Claude Code Context for Duckgres
August 8, 2026 · View on GitHub
This file provides context for Claude Code sessions working on this codebase.
Project Overview
Duckgres is a PostgreSQL wire protocol server backed by DuckDB. It allows any PostgreSQL client (psql, pgAdmin, lib/pq, psycopg2, JDBC, etc.) to connect and execute queries against DuckDB databases.
Architecture
Duckgres has three deployment topologies, built from three primary run modes (standalone, control-plane, duckdb-service; a fourth utility mode, reshard-runner, is the entrypoint of the dedicated per-operation reshard pods the control plane spawns — see the Resharding section):
1. Standalone — single process. One binary running in standalone mode handles the PG wire protocol, auth, TLS, transpilation, and DuckDB execution itself. Each user gets their own DuckDB database in-process.
PG Client → TLS → Server (standalone) → DuckDB
2. Control plane + local process workers — single host, multiple processes. A control-plane parent process owns client connections (TLS, auth, PG wire, transpilation) and spawns child duckdb-service worker processes, communicating via Arrow Flight SQL over Unix sockets. Used for stronger isolation between sessions on a single host. Selected with --worker-backend process (the default).
PG Client → TLS/Auth/PG Protocol → Control Plane (process)
→ Flight SQL (UDS) → local Worker process (DuckDB)
3. Control plane + remote workers on Kubernetes — multitenant cluster deployment. The control-plane runs as its own pod and routes per-org traffic to dedicated duckdb-service worker pods over TCP+TLS. Worker pods are scheduled by the control plane via the K8s API; org config and worker state are persisted in a Postgres-backed config store. Selected with --worker-backend remote; requires a binary built with -tags kubernetes.
PG Client → TLS/Auth/PG Protocol → Control Plane pod
→ Flight SQL (TCP+TLS) → per-org Worker pod (DuckDB)
Native metadata Postgres proxy
The Kubernetes control plane can expose explicitly opted-in CNPG-backed
warehouse metadata databases on a separate SNI suffix
(DUCKGRES_METADATA_HOSTNAME_SUFFIXES; managed-warehouse deployments use
.md.dev.postwh.com, .md.us.postwh.com, or .md.eu.postwh.com). This is an
early connection branch, not a DuckDB
executor: the existing org root password authenticates at Duckgres, the
startup database MUST be exactly metadata, and the control plane resolves
the real endpoint/database/tenant role/password internally through
SharedWorkerActivator.MetadataPostgresURL. After authenticating upstream
with that internal credential, protocol traffic is relayed byte-for-byte. The
endpoint and password remain internal; the upstream role and database can be
visible to the fully privileged client through normal PostgreSQL introspection
such as current_user and current_database().
Access is fail-closed on the warehouse row's metadata_proxy_enabled flag,
state=ready, and metadata_store_kind=cnpg-shard. Never infer publication
from shard placement and never pass a client-supplied upstream database,
username, host, or password. DUCKGRES_METADATA_PROXY_MAX_CONNECTIONS_PER_ORG
(default 20 per control-plane replica) bounds public sessions so they cannot
exhaust the internal PgBouncer pool. These sessions participate in the
existing per-user kill / disable fan-out, and an established session is closed
if the warehouse gate stops being eligible. An admin warehouse PUT that
explicitly includes metadata_proxy_enabled reloads the local config snapshot
and notifies peer replicas; established sessions observe the new gate on their
next five-second recheck after snapshot propagation.
The initial scope is dedicated, single-customer CNPG shards only. Do not enable
an org on a shared shard until upstream CONNECT ACLs and role hardening are in
place. The exact virtual database check prevents selecting a different startup
database, but after connection the customer root credential has the full
access of the internally resolved metadata role.
Observability stays explicit at the branch boundary:
duckgres_connections_open includes these client sockets because it is the
process-wide accepted-connection gauge, while
duckgres_metadata_proxy_connections_open,
duckgres_metadata_proxy_connection_attempts_total,
duckgres_metadata_proxy_connection_duration_seconds,
duckgres_metadata_proxy_upstream_connect_duration_seconds, and
duckgres_metadata_proxy_bytes_total isolate proxy load and failures. The
relay is intentionally opaque, so DuckDB query metrics, query logs, and query
traces do not include SQL executed through it. Use the CNPG/PgBouncer metrics
and the fixed upstream application_name=duckgres-metadata-proxy for
database-side attribution; never make one org's metadata target part of the
control-plane health check. Target resolution plus upstream
connect/auth/synchronization has a fixed 10-second bootstrap deadline; the
deadline is canceled after hijack and never applies to established relay
traffic. duckgres_auth_failures_total includes wrong-password proxy attempts;
the dedicated attempt counter with outcome="auth_failed" is the proxy split.
Pre-TLS duckgres_rate_limit_rejects_total events cannot be assigned to either
endpoint because SNI has not yet been observed.
Metadata-proxy CancelRequest handling is session-terminating. Synthetic
backend keys map to the exact established frontend/upstream connection pair on
the owning control-plane replica, which closes both rather than redialing a
PgBouncer Service where instance-local cancel keys could reach the wrong pod.
Raw cancel connections remain control-plane-local behind the NLB, matching the
existing cancellation locality: a synthetic-key miss on another replica is
absorbed and counted by
duckgres_metadata_proxy_cancel_requests_total{outcome="not_local"}.
In topologies 2 and 3, the control plane exposes only PostgreSQL wire protocol to clients. Arrow Flight SQL is internal transport between the control plane and workers.
Key Components
- main.go / config_resolution.go: CLI flags; effective config resolution (CLI > env > YAML > defaults), including env-only K8s knobs.
- server/ — PG wire protocol server and DuckDB execution
- Wire protocol & connections:
server.go,conn.go,conn_errors.go,conn_query_exec.go,conn_results.go,conn_copy.go,conn_extended_query.go,conn_pg_stat_activity.go,conn_cursor.go,protocol.go,exports.go - Execution:
executor.go,flight_executor.go,chsql.go,transient.go - Catalog & types:
catalog.go,types.go,session_database_metadata.go - Auth, TLS, rate limiting:
auth_policy.go,ratelimit.go,certs.go,acme.go - DuckLake:
ducklake_migration.go,checkpoint.go - Observability:
querylog.go,tracing.go - ProcessIsolation child workers:
parent.go,worker.go,worker_activation.go,worker_control.go
- Wire protocol & connections:
- controlplane/ — Multi-process / multi-tenant control plane
- Core:
control.go,session_mgr.go,worker_mgr.go,worker_pool.go(process/k8s abstraction),validation.go,sdnotify.go - Runtime loops:
janitor.go,leader_loop.go,memory_rebalancer.go,runtime_tracker.go - K8s / multitenant under build tag
kubernetes(including:multitenant.go,k8s_pool.go,k8s_pool_acquire.go,k8s_pool_spawn.go,k8s_pool_lifecycle.go,k8s_pool_reconcile.go,k8s_pool_helpers.go,k8s_factory.go,org_router.go,org_reserved_pool.go,sts_broker.go,shared_worker_activator.go,worker_rpc_security.go,janitor_leader_k8s.go) - Subpackages:
admin/(HTTP admin API + dashboard,kubernetestag; includes the models explorer UIstatic/models.html+models_api.go, anddevserver/for local UI dev against a port-forwarded CP — seeadmin/README.md),provisioner/(k8s controller,kubernetestag),provisioning/(HTTP API),configstore/(Postgres-backed config)
- Core:
- duckdbservice/ — DuckDB Arrow Flight SQL service
- Core:
service.go,flight_handler.go,arrow_helpers.go,auth.go,config.go - Lifecycle, caching, profiling, metrics:
activation.go,transient.go,cache_proxy.go,profiling.go,progress.go,metrics.go
- Core:
- transpiler/ — AST-based PostgreSQL → DuckDB SQL transpiler
- Top-level:
transpiler.go,config.go,boolpredicates.go,show_create.go transform/: individual transforms; see registered pipeline intranspiler.goNew()
- Top-level:
Run Modes
- standalone (default): Single process, handles everything including TLS, auth, PG protocol, and DuckDB execution.
- control-plane: Multi-process. Owns pgwire client connections end-to-end (TLS, auth, protocol handling, SQL transpilation) and routes queries to a worker pool.
- Process backend (default,
--worker-backend process): local Flight SQL workers over Unix sockets. - Remote backend (
--worker-backend remote): per-org Kubernetes worker pods over TCP+TLS. Multitenant; requires-tags kubernetesand a Postgres-backed config store. Adds config store, org router, runtime tracker, janitor/leader election, and a provisioning/admin HTTP API.
- Process backend (default,
- duckdb-service: Thin DuckDB execution engine exposed via Arrow Flight SQL. Spawned automatically by the control plane as worker processes, or run standalone for testing.
- reshard-runner (
-tags kubernetesonly): entrypoint of the dedicated per-operation reshard pod (duckgres-reshard-op-<id>), spawned by the control plane when an operator starts a reshard. Claims ONE op (DUCKGRES_RESHARD_OP_ID), executes the reshard step machine to a terminal state, exits (0 unless an infrastructure error). See "Resharding" below.
Key CLI flags for control-plane mode:
--mode control-plane|duckdb-service|standalone|reshard-runner--worker-backend process|remote--process-min-workers N/--process-max-workers N--process-retire-on-session-end--worker-queue-timeout DURATION/--worker-idle-timeout DURATION--idle-timeout DURATION— connection idle timeout: a client connection with no traffic for this long is closed and its worker released to hot-idle (in control-plane mode an idle connection otherwise pins a worker forever). Control-plane default is60s(server.DefaultControlPlaneIdleTimeout; standalone defaults to24h); a negative value disables it.server.Newapplies the standalone default, so the control plane sets it explicitly beforeInitMinimalServer(which skips that defaulting).--memory-budget SIZE(default 75% RAM) /--memory-rebalance--socket-dir /path(process backend)--handover-drain-timeout DURATION(default24hprocess; remote default is0= unbounded — the CP waits for active sessions for as long as it takes and the pod's k8sterminationGracePeriodSecondsis the only hard wall. cloudflare/tableflip FD passing applies to process/standalone single-host upgrades, not k8s pod replacement.)--ducklake-delta-catalog-enabled/--ducklake-delta-catalog-path- Remote backend (requires
--config-store;-tags kubernetesfor K8s pool):- Config store:
--config-store,--config-poll-interval,--internal-secret - K8s pool:
--k8s-worker-image,--k8s-worker-namespace,--k8s-control-plane-id,--k8s-worker-port,--k8s-worker-secret,--k8s-worker-configmap,--k8s-worker-image-pull-policy,--k8s-worker-service-account(no global worker cap — per-orgOrg.MaxWorkers, 0=unbounded, is the only cap) - AWS / STS:
--aws-region - Compute-usage billing needs no config: metering is always on for the remote backend and billing PULLS usage over the internal-secret-authed HTTP API (
GET /api/v1/billing/usage+POST /api/v1/billing/ack). Seedocs/design/billing-pull-api.mdand "Compute-Usage Billing" below. - Pod scheduling knobs (CPU/memory requests, node selector, tolerations) are env-only — see
config_resolution.go.
- Config store:
Key CLI flags for duckdb-service mode:
--duckdb-listen(e.g.,unix:///...or:8816)--duckdb-listen-fd(internal; set by control plane)--duckdb-token(bearer auth)--duckdb-max-sessions(0=unlimited)
Configuration
Configuration is resolved in config_resolution.go with the following precedence (highest to lowest):
- CLI flags (
--port,--config, etc.) - Environment variables (
DUCKGRES_PORT, etc.) - YAML config file
- Built-in defaults
Note: --mode is CLI-only (not loadable from YAML/env). A handful of K8s pod-scheduling knobs are env-only (no CLI flag).
Keep docs in sync with behavior
When you change a behavior, default, flag, or invariant that is documented
anywhere in the repo, update that documentation in the same PR. Stale docs
are worse than no docs — they actively mislead the next reader (human or agent).
This applies to, at least: this CLAUDE.md, README.md, docs/, CLI flag help
text (main.go / cliflags.go), and any design/plan docs that pin the changed
behavior. Concretely: if you change a default value, a flag's meaning, a drain or
shutdown semantic, an activation/routing/teardown order, or any of the
LOAD-BEARING CONTRACT sections below, grep for the old value/term across *.md
and help strings and fix every mention. A behavior change that leaves a doc
asserting the old behavior is incomplete, the same way a behavior change without
a test is incomplete.
Development
The project uses just as a command runner. Run just to see all available recipes for building, testing, running, metrics, and scripts.
Testing
Every feature, behavior change, bugfix, AND refactor that affects runtime or
cluster behavior MUST ship with a solid end-to-end test case in
tests/e2e-mw-dev/ (harness.sh). This is not just for new features — any
change to how the system behaves at runtime (new capability, changed semantics,
a fixed bug, a new config knob, an activation/routing/teardown tweak) extends or
adds a harness assertion in the same PR. Refactors count too: when you move or
rewrite a code path the harness covers, confirm the relevant assertion still
exercises it (and update it if the path moved) — a refactor that quietly drops
e2e coverage is a regression in the test suite even if behavior is unchanged. Unit/package tests are necessary but not sufficient: a
change is only "done" once it is exercised against the real mw-dev cluster —
real worker pods, real Crossplane ducklings, real cnpg/RDS metadata, real
S3/STS. "Solid" means a deterministic pass/fail
assertion of the actual user-visible behavior (not just "it didn't error"), with
transient/cold-pool conditions handled, on both metadata backends (cnpg + ext)
where it touches metadata. A bugfix gets a regression assertion that would have
caught the bug. If a change genuinely cannot be asserted in-Job (e.g. it needs
cnpg-shards exec, or warm-pool-only state), say so explicitly in the
harness/README with the reason — don't silently skip. The harness is the gate
that catches what unit tests fake.
Three test lanes worth knowing about, in increasing order of blast radius:
- Unit / package tests (
go test ./...): in-process, no external deps. Where most coverage lives. Includestests/manifests/(static-manifest artifact asserts fork8s/rbac.yaml+k8s/networkpolicy.yaml). tests/integration/(just test-integration): spins up the standalone server binary against a real MinIO + Postgres metadata store via docker compose. Covers wire protocol, DuckLake on real S3-compatible storage, transpilation against a live server.tests/e2e-mw-dev/(per-PR GitHub workflowe2e-mw-dev.yml): the full multi-tenant activation pipeline against the real posthog-mw-dev EKS cluster — real Cilium, real Crossplane ducklings, real cnpg-shard + external-RDS metadata, real AWS S3. A shell harness (harness.sh) runs as an in-cluster Job per PR;run.shorchestrates deploy/test/teardown/e2e-cleanup. Replaces the retired kind suite (tests/k8s/) — that suite'sk8s-integration-testsCI job and its Go tests are gone; the supportingk8s/scripts/manifests + Dockerfiles are kept for now. Seetests/e2e-mw-dev/README.md.
When code changes obligate test changes
tests/e2e-mw-dev/ is the only place we exercise the full activation pipeline (control plane → STS broker → worker pod → DuckDB → ATTACH against real cloud storage). If your change touches any of the following, treat updating the harness as part of the change, not a follow-up:
controlplane/shared_worker_activator.go,controlplane/sts_broker.go, anything in the activation payload shape (TenantActivationPayload,server.DuckLakeConfig)server/server.go::AttachDeltaCatalog,server.attachDuckLake*,server.refresh*Secretcontrolplane/configstore/models.go— new columns flow through the provisioning API the harness calls; exercise them via a provision body fieldduckdbservice/activation.go,worker_activation.go— worker-side activation order- Any code path that wires AWS credentials through to DuckDB SECRETs
The contract: if the harness no longer exercises a path you changed, update harness.sh; if your change removes a path it asserts against, delete the assertion. The DuckLake round-trip / durability / concurrent-writers checks in harness.sh are the load-bearing ones for catalog wiring — keep them honest.
Dependencies
github.com/duckdb/duckdb-go/v2- DuckDB Go drivergithub.com/pganalyze/pg_query_go/v6- PostgreSQL SQL parser (CGO, uses libpg_query)gopkg.in/yaml.v3- YAML config parsing
Known Limitations
- No replication
- Some pg_catalog tables are stubs (return empty)
- Unmapped DuckDB types (MAP, STRUCT, UNION, ENUM, BIT) fall back to OidText
- DML RETURNING is not supported via extended query protocol (see below)
DML RETURNING Detection
DML with RETURNING is rejected at extended-query Describe time with SQLSTATE 0A000 — the Describe path probes schema by executing the query, which would cause an unintended mutation. Detection lives in isDMLReturning and friends in server/conn.go (heuristic SQL-aware lexer, with any-depth scanning for WITH-prefixed writable CTEs). Invariants for anyone editing this code:
- False negatives are dangerous — they cause silent mutations during Describe. False positives are safe (just an error to the client). Err toward false positives.
- All detection is heuristic string scanning. If precision becomes critical, switch to
pg_query_goAST parsing. - LIMIT 0 does NOT prevent CTE side effects — Postgres CTEs are optimization fences, so writable CTEs execute even with LIMIT 0.
- DuckDB does not currently support MERGE. If it adds MERGE RETURNING, add
MERGEto the prefix check inisDMLReturning.
Worker Session Model (k8s / remote backend) — LOAD-BEARING CONTRACT
In the control-plane remote/k8s backend a worker pod serves exactly one
client query session at a time. This is deliberate: workerDuckDBLimits
(controlplane/control.go) gives the single session ~75% of the whole pod's
RAM + all CPU cores — it does NOT divide by session count. Two sessions on one
pod would each believe they own 75% → ~150% overcommit → nondeterministic OOM /
a heavy query killed by a co-resident one. Do not break the following:
-
One session per worker is enforced, not emergent. The CP spawns remote worker pods with
DUCKGRES_DUCKDB_MAX_SESSIONS=1(k8s_pool.go::spawnWorker). A 2nd concurrentCreateSessionon a worker is rejected, not silently overcommitted. Internal control/maintenance work uses the worker's side connections (controlDB/warmupDB), which are NOT counted sessions — so cap=1 does not starve them. Do not raise this to >1 for k8s workers, and do not route internal work throughCreateSession. -
OrgReservedPool(remote/multitenant) must never co-assign. It reuses only idle (activeSessions==0, Hot, org-owned) workers viafindIdleAssignedWorkerLocked, or claims/spawns a fresh one. There is NO least-loaded "share onto a busy worker" path (that exists only in the single-tenant flatK8sWorkerPool.AcquireWorker, which is not used in remote mode). Do NOT add one, and do not resurrect aleastLoaded*helper here. -
At org max workers + all busy → fail fast with the clear org-cap message (
WorkerClaimMissReasonOrgCap, seecapacity_policy.go). Never busy-wait at cap. -
Under cap → spawn a worker on demand (
spawnReservedWorkerForSlot). There is no warm pool to wait on; the cap is re-checked authoritatively cross-CP inCreateSpawningWorkerSlot. The spawn+activate runs DETACHED from the request ctx (context.WithoutCancel+workerSpawnActivateTimeout): the requester waits for the result or its own ctx, but a requester that gives up must NOT kill the in-flight pod (doomed-spawn thrash). An abandoned spawn that succeeds is parked hot-idle (ReleaseWorker/TransitionToHotIdleIfNoSessions, record persisted) for the org's next connection; one that fails is retired. Nothing may leak in Reserved/Activating. -
FIFO anti-snatch: the slow acquisition path's DECISION section (idle-reuse re-check → hot-idle claim → spawning-slot creation;
acquireDecisioninorg_reserved_pool.go) is serialized per org byorgAcquireGate(org_acquire_gate.go) so a worker the CP scaled up for an earlier waiter cannot be snatched by a later connection. The multi-minute spawn+activate runs OUTSIDE the gate — each waiter is 1:1 bound to the claim/slot it owns and the session is pre-claimed before the worker becomes Hot, so a cold burst ramps N spawns in parallel without breaking anti-snatch. Keep the gate cancel-safe (a queued waiter whose ctx is cancelled must be skipped, not deadlock the gate). -
Destroy-before-reuse ordering:
SessionManager.DestroySession(session_mgr.go) MUST await the worker-sideDestroySessionRPC beforeReleaseWorker, so a reused (hot-idle) worker's prior session is gone before the next one is assigned (otherwise cap=1 spuriously rejects the reuse). -
Cap-drift is recovered, not fatal: if a worker still rejects a CP-scheduled session at its cap (CP↔worker accounting drift — should never happen),
SessionManager.CreateSessionWithProtocoldoes NOT fail the client: it logs loudly (ERROR), bumpsduckgres_control_plane_worker_session_cap_drift_total, retires (recycles) the inconsistent worker, and re-acquires a fresh one (bounded bymaxWorkerSessionCapDriftRetries). Detection isisWorkerSessionCapError(matches the worker's "max sessions reached" message). A nonzero drift metric means the scheduling invariant is broken — fix the root cause, don't just lean on the retry. -
An invalidated DuckDB instance retires the worker; it is NEVER reused. A DuckDB Internal- or Fatal-class exception does not just fail one statement, it poisons the whole instance: every later statement on ANY connection to it — including a brand new session — fails with "database has been invalidated because of a previous fatal error" until the process restarts. DuckLake's commit path is a known source (an InternalException inside the commit retry loop is rethrown by
ErrorData::Throwwith its original type). Detection isisInstanceFatalError(duckdbservice/instance_fatal.go). The error TYPE is authoritative: a typed*duckdb.ErrorofErrorTypeInternal/ErrorTypeFatal. Never add substring matches for"INTERNAL Error"/"FATAL Error"— DuckDB echoes the offending SQL back in its error text (LINE 1: <query>), so those matched the USER'S OWN QUERY and handed every tenant a one-statement worker kill viaSELECT 'INTERNAL Error' + 1(regression:TestInstanceFatalIgnoresEchoedQueryText). The one string fallback is thedatabase has been invalidated…marker, for an error that arrives already flattened. OOM and DuckLake transaction conflicts are distinct error types and must never classify here; they retry in place. The flag is sticky (invalidation is permanent) and is set from the statement paths, theCreateSessionpath, and an asyncSELECT 1liveness probe kicked by each health check — the probe is what catches a fatal thrown on a session that has since been destroyed. The stored reason MUST be redacted before it is kept (usersecrets.RedactErrorForLog, ornoteInstanceErrorOpaquewhere no statement is available to classify, as on the secret-replayingCreateSessionpath): it is logged on the worker, shipped to the CP asinstance_invalid_reason, and logged again on retire, so an un-redacted engine error leaks a failedCREATE SECRET's credential into three sinks. The probe must stay OFF the health check's critical path: the CP's health-check budget is 3s and already shared with progress polling, so a blocking probe would get healthy workers killed for unresponsiveness. It is single-flight and a wedged CGO call can outlive its context, so a probe stuck past the threshold raisesduckgres_worker_instance_probe_stuckrather than silently disabling detection. The worker reportsinstance_invalidated+healthy:false; the CP retires it on the FIRST report, bypassingmaxConsecutiveHealthFailures(the process answers RPCs fine, so the failure counter would never fire) and rejects it for reuse invalidateReservedWorkerHealth— the hot-idle reuse path is what previously turned one bad statement into "the warehouse is down until someone restarts it".duckgres_control_plane_worker_instance_invalidated_totalandduckgres_worker_instance_invalidated_total/duckgres_worker_instance_invalidated_stateare the signals; nonzero means a tenant hit an engine bug and lost a worker — contained, but chase the root cause. This is blast-radius containment ONLY: it does not fix the engine bug, which is fixed by shipping a DuckLake extension build that guards the read (seeDUCKLAKE_EXTENSION_TAGinDockerfile/Dockerfile.worker).
Touching any of: controlplane/org_reserved_pool.go, org_acquire_gate.go,
k8s_pool.go::spawnWorker/AcquireWorker, control.go::workerDuckDBLimits,
duckdbservice/instance_fatal.go, or
duckdbservice session counting → update the unit tests
(org_reserved_pool_test.go, org_acquire_gate_test.go,
duckdbservice/service_test.go, duckdbservice/instance_fatal_test.go,
controlplane/instance_invalidated_test.go) AND the
one_session_per_worker + cold_burst_parallel_spawns assertions in
tests/mw-dev/e2e/harness.sh.
Exploratory Worker Tier (small-first routing) — LOAD-BEARING CONTRACT
Design: docs/superpowers/specs/2026-08-04-exploratory-worker-tier-design.md.
On the remote/k8s backend only, a connection that does not ask for a worker
shape starts on a small "exploratory" pod and grows into a normal one only when
it proves it needs to. Two mechanisms: lazy acquisition (no worker at
connect) and escalation (small → standard, one-way). Env-only knobs:
DUCKGRES_EXPLORATORY_TIER_ENABLED / _WORKER_CPU / _WORKER_MEMORY /
_WORKER_TTL (default 48h), resolved by exploratoryWorkerProfile
(controlplane/worker_profile.go). Server side lives in server/conn_tier.go
tier_classify.go; control-plane side incontrolplane/session_activation.go- the activator/switcher closures in
control.go::handleConnection.
- Eligibility is decided once, at connect (
useExploratoryTier): remote backend AND a usable exploratory profile AND not a passthrough user AND no clientduckgres.worker_*startup option (clientSuppliedWorkerGUCs). A half-configured tier (missing/invalid size) resolves to nil and degrades to today's eager behavior — never to a BestEffort pod. Passthrough users are excluded (they bypass the compat layer the classification is built on), and a GUC-sized connection bypasses the tier entirely — it acquires the requested shape eagerly at connect, as before. - Nothing is acquired until a statement needs an engine. The connection
reaches the message loop with
c.executor == niland aSessionActivator;activateForStatementacquires on first need. Statements the control plane answers itself —SET/SHOW duckgres.query_source, ignored SETs, no-ops,pg_stat_activity, the empty query — MUST NOT acquire. That is the point of the whole feature; adding an acquire to an engine-free path silently deletes the benefit. Theduckgres.s3_cacheGUC is the exception, on all three protocol paths, because unlike the other duckgres GUCs it is WORKER state (it rebuilds the worker'sducklake_s3secret):SETalways acquires — the swap needs a worker to apply to — andSHOWacquires only whenc.hasPendingS3Cache, i.e. when a connect-time option has not been applied yet and answering first would report a transport the session is about to leave (see the s3_cache section below). - A pinning FIRST statement acquires the standard profile directly (one
acquire,
pinned=true→MarkConnectionPinned), never small-then-escalate. - The pin set is the state boundary, and every member is load-bearing: DML,
DDL,
COPY(BOTH directions — COPY TO can reference session state and is routed above the transpile-time hook),SET,BEGIN(so an open transaction can never exist on a worker that is about to be replaced),DECLARE(simple, batched AND extended —FETCH/CLOSEare unhooked and rely entirely on the DECLARE pin, because a cursor's worker-side RowSet must not open on a session about to be destroyed), secret DDL before the user-secrets interception (that interception owns its own execution and sits ABOVE the general hook, so a plain/TEMPORARYCREATE SECRETwould otherwise land on the small worker and be silently dropped), and the extended-protocol Describe probe of a pinning statement (Describe really executes it). A parse failure pins by default — false pins are free, a missed pin loses state. - Escalation is one-way and sticky.
escalateWorkerdestroys the small session BEFORE acquiring the standard one, so once it is entered there is no session to fall back to. Both reason and outcome are closed sets:duckgres_exploratory_escalations_total{reason="state"|"oom"|"heuristic", outcome="ok"|"canceled"|"capacity"|"draining"|"disabled"|"error"}— every ATTEMPT is counted, so a cluster that cannot escalate anything is distinguishable from one nobody escalates on (v1 ships no heuristic tier; the constant marks the hook point). The failure classes come from the CLASSIFIED SQLSTATE viaserver.AcquisitionFailureOutcome, the same helperduckgres_session_activation_total{org,outcome}uses — one helper, so the two acquisition metrics can never drift into different or unbounded label sets. - A failed acquisition — activation OR escalation — is CONNECTION-FATAL
(the one exception is the post-escalation s3_cache re-apply, below).
failWorkerAcquisitionsends a FATAL ErrorResponse, suppresses ReadyForQuery, and unwinds the message loop; there is no session left to resynchronize to. SQLSTATE comes fromescalationErrorSQLState: a*server.SessionAcquireError(classified by the CP with the SAME logic the eager connect path uses,sessionCreationErrorResponse) is authoritative — 28000 disabled / 53300 capacity / 57P03 draining / 57014 cancel / 3D000 catalog / XX000 s3_cache apply / 53400 other; the substring fallback covers only the paths that still return a plain error. Keep the client message the classified one, never the wrapped internal chain. Extended-protocol handlers are void, so the error is ALSO parked onc.fatalErr, whichrunExtendedQueryMessagehands to the message loop.fatalErris one-shot by construction — it is set on the way out and never cleared, because the connection is terminating; do not add a path that sets it and then continues. - OOM re-execute is transparent only under all three conditions: the
statement is a READ, ZERO DataRows have been sent, and
txStatusis idle. A wire stream cannot be restarted, so a partial result must surface the error instead. RowDescription is NOT resent on the retry (same query, same engine version → identical schema), and the retry runs on the escalated worker only. Detection isisWorkerOutOfMemoryError(server/conn_errors.go) — DuckDB's engine OOM only; a pod-level OOMKill isErrWorkerDeadand is deliberately never re-executed. duckgres.s3_cacheinterplay (both directions). A connect-time-c duckgres.s3_cache=...cannot be applied at connect on this path (no worker), so the CP parks it (SetPendingS3CacheOption) andensureSessionActiveapplies it AFTER installing the executor — applying it inside the activator would find a nil executor, no-op the worker swap, and still flip the session flag, the exact divergenceapplyS3CacheSettingexists to prevent. A failed apply fails the activation (XX000, fatal). On escalation the bypass is RE-APPLIED to the new worker (reapplyS3CacheAfterWorkerSwitch); that failure is the ONE non-connection-fatal escalation outcome, because the swap already succeeded: the statement fails with a normalERROR(XX000, naming the re-apply), the session flag is reset to the worker's REAL transport (proxied — a fresh session always starts on the cache proxy), the connection stays alive (a ReadyForQuery on the simple protocol; Sync's on the extended one), and the pin is deliberately NOT rolled back.escalateWorkertags iterrS3CacheReapplyFailedandfailEscalationroutes it — every call site goes through that dispatcher so the two severities can never be confused at one of them.SHOWmust never lie: it activates iff an option is still pending.- Billing is largest-size-wins over the whole connection (v1). The size is stamped at activation and re-stamped at escalation with the target profile; escalation only ever goes small→standard, so that stamp IS the maximum. The pre-activation idle prefix bills at the first acquired size.
- Accepted gaps (do not "fix" silently — they are decisions): per-org
connection admission and the vCPU lease now happen at the FIRST STATEMENT,
not at connect (the connect-time reshard/migration/draining gates are
unchanged); a one-shot per-user
killlanding inside the switcher's destroy→create window is missed, and so is akillagainst a connection that authenticated but has never activated (no session forDestroySessionsForUserto iterate and no registered conn-closer until the first statement) —disablecovers BOTH, because the activation/escalation re-check refuses the session outright (28000); extendingkillto authed-but-unactivated connections is a named follow-up, not implemented; and a client that sends TCP FIN mid-activation does not abort the in-flight spawn (the message loop is blocked in the acquire) — the completed worker parks hot-idle for the org's next connection. - Touching classification, the pin hooks, activation, escalation, the OOM
retry, or the profile resolution → update
server/tier_classify_test.go,server/conn_tier_test.go,server/conn_tier_exec_test.go,server/conn_lazy_activation_test.go,server/s3_cache_test.go,controlplane/session_activation_test.go,controlplane/worker_profile_test.go,controlplane/compute_size_test.go, AND theexploratory_tier/exploratory_lazy_activation/exploratory_state_pin/exploratory_oom_escalation/org_default_profile/sized_worker(GUC bypass) /assert_worker_podassertions intests/mw-dev/e2e/harness.sh. The harness header aboveexploratory_tierrecords which existing assertions the tier's connect semantics changed and why — keep that audit current.
Worker Drain Protocol (graceful shutdown, #690)
Remote worker pods drain on SIGTERM (pod deletion): they reject new work, keep
in-flight work alive, then exit; the CP marks them Draining (not crashed) and
retires them cleanly. Drain readiness is tracked by a refcount (activeWork in
duckdbservice/service.go) of "drain tokens" — one taken per unit of in-flight
work (query, txn, metadata stream, COPY, activation), released when it finishes.
Invariants: take exactly one token when work starts and release exactly one when
it ends on every path (a leak hangs drain to the shutdown timeout, an early
release lets shutdown kill live work); reapIdle releases tokens stranded by a
GetFlightInfo whose DoGet never arrived. terminationGracePeriodSeconds=3600
(k8s_pool.go) must stay above workerShutdownDrainTime (55m).
Per-Session S3 Cache Bypass (duckgres.s3_cache, remote backend)
SET duckgres.s3_cache = on|off (default on; also a -c startup option)
lets a session bypass the node-local S3 cache-proxy DaemonSet — for cold-read
benchmarking and ruling the cache out of correctness/staleness questions.
Mechanism: the CP intercepts the duckgres-namespaced GUC (never forwarded to
DuckDB) and, on every state flip, calls the worker's SetSessionS3Cache
action (flightclient.FlightExecutor.SetS3CacheEnabled →
SessionPool.SetS3CacheEnabled), which rebuilds the ducklake_s3 secret:
off = the org's native HTTPS transport, so httpfs CONNECT-tunnels through
the proxy as opaque TLS (no cache reads/fills — the deliberate, reversible
form of the mw-prod-us 2026-07-17 incident); on = the
overrideS3EndpointForCacheProxy transport. Global http_proxy is never
touched (post-attach propagation to DuckLake subcatalogs is unreliable;
secrets are consulted per request). Instance-global secret + one session per
worker = session-scoped in effect. Invariants:
- State follows the worker, never leads it. The session flag
(
clientConn.s3CacheOff) flips only after the worker swap succeeds; a failed swap fails the SET (XX000) / the batch / the connect (startup option), soSHOWcan never report a transport the worker isn't using. - A bypass must never leak into the org's next session.
CreateSessionrestores the proxy transport before the session starts and a restore failure fails the create (CP retries);DestroySessionrestores best-effort. Both no-op unless a bypass is actually in effect. - Credential rotation respects the flag. The hot-idle/mid-session refresh
(
reuseExistingActivation) rebuilds the secret with or without the proxy transport according tos3CacheBypassed; all secret rebuilds serialize onsecretSwapMuso the last write always matches the flag. (Without this, an hourly STS rotation silently re-enables the cache mid-benchmark — the inverse of the 2026-07-17 incident.) - On an exploratory-tier connection the startup option is applied at
ACTIVATION, not at connect (there is no worker at connect). The CP parks it
(
SetPendingS3CacheOption) andensureSessionActiveapplies it right after installing the executor — never inside the activator, where the executor is still nil and the swap would silently no-op while the session flag flipped. A failed apply is a fatalXX000activation failure, and a tier escalation re-applies the bypass on the new worker.SHOWactivates iff an option is still pending, so it can neither lie nor spend a pod needlessly. See the Exploratory Worker Tier section. - Closed enum, validated on every set path (
transform.NormalizeS3Cache): PostgreSQL boolean spellings, normalized to on/off; anything else is22023(simple/batched SET, extended Parse, and the startup option — which the CP validates BEFORE acquiring a worker). The rejection never echoes the value. - Scope: remote/k8s shared-warm workers with a cache proxy. Elsewhere
(standalone, process backend, no
DUCKGRES_CACHE_ENABLED) the worker swap is a no-op and the GUC is session-state only — there is no cache to bypass. The query-log sink's activation stays proxied unconditionally. - Touching the interception, swap, restore, or refresh interplay → update
transpiler/s3_cache_test.go,server/s3_cache_test.go,duckdbservice/s3_cache_test.go, AND thes3_cache_gucassertion intests/mw-dev/e2e/harness.sh(mw-dev has no cache proxy, so the e2e covers the client-visible plumbing incl. the worker action round-trip; the swap itself is unit-only — see the harness header note).
User Persistent Secrets (multitenant remote backend)
CREATE PERSISTENT SECRET from a client survives across sessions and worker
pods: the CP intercepts it (server/conn_user_secrets.go, classification in
server/usersecrets/), executes it on the live session first (DuckDB
validates), then stores the statement AES-GCM-encrypted in the config store
(duckgres_org_user_secrets, keyed org/user/name) and replays it in the
CreateSession payload on the user's future sessions
(duckdbservice/user_secrets.go). Enabled by the env-only
DUCKGRES_USER_SECRET_KEY (base64 32-byte AES key); unset → clear 0A000
error. Plain/TEMPORARY CREATE SECRET stays session-scoped passthrough.
Invariants for anyone touching this path:
- Cross-user isolation is the wipe at session create, not the destroy-time
cleanup. DuckDB secrets are instance-global, and a hot-idle worker is
reused across users of an org:
wipeUserSecretsdrops ALL user-created secrets — persistent ones AND non-persistent (plain/TEMPORARYCREATE SECRET) ones, which pass through to the worker and would otherwise leak to the next user. It preserves only the system-managed allowlist (usersecrets.IsReservedName:ducklake_s3- the
__default_*/duckgres_*prefixes, which activation re-creates). It MUST run before replay on every CreateSession in shared-warm mode, and a wipe failure MUST fail the session.
- the
- Execute-then-persist ordering. Persist only statements DuckDB accepted; a store failure after a successful exec is an ERROR telling the user the secret will NOT survive the session. Replay failures at session create are warnings, never connection refusals.
- No silent non-persistence. Any path where persistent-secret DDL would execute but not persist must REJECT instead, including multi-statement batches and parameterized statements in the control-plane interception path. Otherwise the secret works for one session and is silently deleted by the next session's wipe.
- DROP's store-fallback is gated on DuckDB's not-found error only
(
isSecretNotFoundError). Any other exec failure (cancel, RPC error, ambiguity, aborted txn) must surface and leave the store untouched — a false "DROP succeeded" is fatal for a credential revocation. - Never log/store secret statement text.
usersecrets.RedactForLogguards client-query and worker-statement logs,logQueryError, the query log, spans, and pg_stat_activity (currentQuery); keep new logging of query text behind it. Engine error messages echo the offending SQL (DuckDB emitsLINE 1: ... SECRET '...'), so a failed CREATE SECRET leaks the credential via theerrorattribute / query-logExceptioneven when the query attribute is redacted —usersecrets.RedactErrorForLog(query, errMsg)guards those error sinks (logQueryError,logQuery); keep new error logging behind it too, and pass the original (un-redacted) query so it can classify. - Touching the interception, wipe/replay, or payload shape → update
server/conn_user_secrets_test.go,duckdbservice/user_secrets_test.go, and thepersistent_user_secret(+_isolation) assertions intests/mw-dev/e2e/harness.sh.
Admin Console (VPC-private web UI, kubernetes tag)
controlplane/admin/ serves a React admin console + REST API on :8080 — the
operate-everything surface (metrics, live queries/sessions/connections, recent
errors, worker fleet, live cluster node/pod topology, full config store, user
impersonation, audit log; sliceable by org + user). Design + decisions:
docs/design/admin-ui.md; package details:
controlplane/admin/README.md. Exposed VPC-privately via an internal-scheme ALB
- Cognito (Google SSO) behind Tailscale (charts:
ingress-admin.yaml). Invariants:
- Frontend is an embedded React/Vite SPA (
ui/, built toui/dist/,//go:embed all:ui/distinembed_ui.go, SPA-fallback served by Gin; the SPA owns/).ui/distis a gitignored build artifact — onlyui/dist/.gitkeepis tracked, so the embed has a target andgo buildcompiles without node (the server then serves a "UI not built" notice).just ui-buildbuilds it locally; bothDockerfileandDockerfile.controlplanerunnpm run buildbeforego build. Do not delete.gitkeepand do not commitui/dist. - Two-tier authz (
authz.go):AuthMiddlewareresolves every/api/v1request to admin (validTokenSetinternal secret — service/break-glass) or to an SSO identity from the ALBX-Amzn-Oidc-DataJWT. The SSO email (@posthog.com+email_verified != false, else 401) is mapped to a role per-request by aRoleResolverbacked by theduckgres_operatorsconfig-schema table (goose migration000006_create_operators.sql) —adminrow → admin, else viewer. Admins manage operators in the admin-only Operators page (ui/src/pages/Operators.tsx→/api/v1/operators,operators_api.go) — a deliberately distinct surface from the Org Users page (ui/src/pages/Users.tsx,/users, per-org database logins). The two are labelled + cross-linked in the UI so "users" is never ambiguous: Operators = who can sign in to this console; Org Users = customer DB accounts. The operators GET isRequireAdmin, souseOperatorsonly fires for admins (viewers see an "admin only" notice). The first SSO login auto-provisions a create-only viewer row, and the first admin is minted by logging in over the break-glass internal token and patching that row toadmin. The last-admin guard (operators_api.go) refuses to demote/delete the final admin (409) so the console can't be locked out.RoleGaterequires admin for all mutating verbs + the audit GET.AuditMiddlewarerecords every mutation. Keep new mutating routes under this gate; never add a write path that bypasses RoleGate/audit. - Impersonation is a real session (
impersonate.go+admin_providers.go): it reusesSessionManager.CreateSessionWithProtocol(workers trust the CP — no password) and alwaysDestroySessionin a defer. Admin-only, every statement audited with the admin actor +usersecrets.RedactForLogSQL; writes requireallow_write=true(conservative classifier — WITH/CTEs count as writes). It consumes a worker under one-session-per-worker and counts against the org's connection limits — do not silently exempt it. - Metrics proxy is allow-listed (
metrics_proxy.go): the client passes a panel KEY, PromQL is built server-side fromrangePanels(never an open PromQL relay) and forwarded toDUCKGRES_PROMETHEUS_URL. Org-labelled panels keep slicing enforced. - Env-only knobs:
DUCKGRES_PROMETHEUS_URL(read inmultitenant.go; set by the chart). The audit tableduckgres_admin_auditis AutoMigrated at startup (operational state, not goose-migrated tenant config). Theduckgres_operatorstable is authoritative access-control data, so it lives in the config schema via goose migration000006_create_operators.sql, not AutoMigrate. ManagedSession.Usernameis populated at session create so the console can slice live sessions/queries by user; keep it set on every create path.- Errors page is a redacted, in-memory live-triage buffer — NOT durable
history. Every failed query is captured into a bounded per-server ring
(
server/recent_errors.go,DefaultRecentErrorCap=500) at the singlelogQueryErrortap (server/conn.go), surfaced atGET /api/v1/errorsand merged across CP replicas byPeerFetcher.FetchPeers(each error belongs to exactly one CP — disjoint union, no worker-id dedup; sorted newest-first, then capped). The ring stores ONLY the redacted forms:QueryviaRedactForLog,MessageviaRedactErrorForLog— a failed CREATE SECRET must never leak its credential into the ring. Keep the capture behind those redactors; long-term error history lives in the external query-log pipeline (Kafka sink), not here. - Per-user kill switch (
live.goroutes +admin_providers.go+session_mgr.go::DestroySessionsForUser+configstoredisabledcolumn):POST …/users/:username/killis a one-shot terminate — it tears down all of a user's sessions + in-flight queries but does NOT block reconnects. It reaches only connections that HAVE a session: on the exploratory worker tier a connection that authenticated but has not yet run its first statement has no session (and no registered conn-closer) forDestroySessionsForUserto iterate, sokillmisses it. Onlydisablecovers that connection — its first statement's activation re-check refuses the session with 28000. Extendingkillto authed-but-unactivated connections is a follow-up.POST …/users/:username/disableis the persistent block: it sets theduckgres_org_users.disabledcolumn (goose migration000011_add_org_user_disabled.sql), kills the user's live sessions, AND refuses the user's NEW pgwire connections at auth time (control.go, distinct28000"account is disabled" error, emitted only after the password checks out so it never leaks account existence).enablereverses it. The disabled state is read from the in-memory snapshot, so disable/enable callConfigStore.ReloadSnapshot()to make the flip effective immediately instead of one config-poll later.- These are cluster-wide: a user's sessions live on whichever CP replica
owns each connection, so the handlers fan out the kill/disable/enable to peers
via
PeerFetcher.PostPeers(POST sibling of the read fan-out, same?scope=localrecursion guard) and sum the per-CPkilledcounts. The snapshot reload is fanned out too so every replica enforces the block at once. - Kill must be scoped to the target user — never tear down another user's sessions on the shared org stack (the regression the e2e asserts with a concurrent root query that must survive).
- Live Nodes view (
ui/src/pages/Nodes.tsx+pages/nodes/peepernetes.{ts,css}, a port of the standalone peepernetes visualizer): a full-bleed, animated cluster node/pod TV — nodes grouped by karpenter nodepool (or by namespace / deployment), CPU/MEM request bars, pod chips colored per deployment, placeholder/system-pod classification, Karpenter empty-node reclaim countdown, draining-duration on nodes (deletion-timestamp or client-tracked first-seen) and terminating-duration on pods, each pod's running image, unscheduled tray, and a synthesized event ticker. Its header carries only the filters; the cluster counters live in the shared admin Topbar and show on EVERY page — the Topbar pollsGET /cluster/summary(useClusterSummary), a server-side aggregate (cluster.go) of nodes (duckgres nodepools) · CP replicas · running workers (labelapp=duckgres-worker, NOT every app pod — so it matches the worker chips + the CP's own worker accounting) with their vCPU/GiB request totals as a sub-line · placeholders with their vCPU/GiB and the cpu%/mem% those headroom pods are OF the worker totals · pending. Computing it server-side (not from the Nodes view's pushed counts) is what lets the totals appear on every page, not just while the view is mounted. The view has no separate live indicator — the Topbar's "Connected" dot (admin-API reachability) is the single green pulsing live signal. It's imperative DOM (mounted by the React page into a.peeperroot, scoped CSS +pn--prefixed keyframes) and does NOT use native K8s watch — the browser can't reach the API, so it POLLS four read-only projected endpoints (server/-free;cluster.go):GET /cluster/{nodes,pods,events}project the in-cluster objects down to the minimal K8s-shaped subset the view reads (annotations trimmed tokubernetes.io/config.mirror; no raw objects), andGET /cluster/nodepoolsproxies the karpenter NodePool CRD (v1→v1beta1, degrading to an empty list when karpenter is absent). Backed by the shared K8s pool's clientset (Extras.ClusterClient, nil on non-k8s backends → routes unregistered). All four are GETs so RoleGate admits viewers; there is no mutation path. RBAC: these reads are cluster-scoped / cross-namespace, which the CP's in-namespace Role doesn't cover — the grant lives on its ownduckgres-control-plane-cluster-topologyClusterRole in thechartsrepo (charts/duckgres/templates/rbac.yaml), bound to the CP SA. It's a separate role (not folded intoduckgres-duckling-reader) so binding duckling-reader elsewhere doesn't drag these broader reads along and trip RBAC escalation-prevention. When the ClusterRole is absent the handlers degrade a Forbidden to an empty{items:[]}(200) and log a warning, so the view shows nothing rather than 500ing — the e2e CP hits exactly this path (its SA can't be granted cluster-scoped RBAC from CI), soadmin_console_apionly asserts the{items:[...]}envelope; projection shape is covered bycluster_test.go. Touching the projection/endpoints or the view → updatecontrolplane/admin/cluster_test.goand the/cluster/{nodes,pods,events,nodepools}checks inadmin_console_api(tests/mw-dev/e2e/harness.sh). - Touching any of the above → update
controlplane/admin/*_test.go(espauthz_test.go,kill_switch_test.go,operators_api_test.go),controlplane/session_mgr_test.go(TestDestroySessionsForUser),controlplane/configstore/store_test.go(TestDisabledUserEnforcement), theOperators/Org UsersUI pages +ui/src/pages/Operators.test.tsx, AND theadmin_*/admin_operators/impersonation_*/user_kill_switch/user_disable_blockassertions intests/mw-dev/e2e/harness.sh.
Project-Scoped Logins (project_reader / project_user) — LOAD-BEARING CONTRACT
A team can hold two generated logins, both bound to one duckgres_org_teams
row via duckgres_org_users.access_mode + team_id (migrations 000026,
000031): project_reader (posthog_team_<id>, read-only — the PostHog SQL
editor's login) and project_user (posthog_team_<id>_rw, read/write). Minted
by PUT /api/v1/orgs/:id/teams/:team_id/{project-reader,project-user} (admin
API only). Both are enforced by the query gateway, not PostgreSQL GRANT.
Invariants:
- The two modes derive the SAME namespaces.
ConfigStore.OrgUserQueryAccesscomputesAllowedSchemas(<schema>,<schema>_data_imports,shadow_<team>_models) +AllowedRelations(the legacyposthog.<events| persons>overrides — a non-NULL override grants it even when it spells the derived default) identically for both;ReadOnlyis the only difference. Write authorization must NEVER widen the reachable relation set — a project user is a project reader that may also mutate what it can already see. The e2e asserts cross-project denial in read AND write shapes for this reason. - Fail closed on an unresolvable team. A scoped user whose team row is
missing or disabled gets an empty,
ReadOnlypolicy — a project user is DOWNGRADED, never left writing into a scope nothing can confirm. QueryAccessPolicy.Authorize(server/query_access.go) is the single tap, called on simple query and extended Parse. It is default-deny: the parser is the authorization boundary, so anythingpg_querycannot describe is rejected (this is why DuckDB-only spellings fail).CREATE SCHEMA/ALTER … SET SCHEMAstay denied: the schema set IS the project boundary.- A WRITE TARGET is checked by
authorizeWriteTarget, never by the walk'sauthorizeRangeVar. The walk's check is the READ-position one: it accepts a bare name that is a visible CTE (sound, because a defined CTE provably shadows a base relation of the same name) or an unqualified pg_catalog compat name. Neither is sound for a target — a write target does not bind to the CTE, so it resolves against the sessionsearch_path(sessionmetaleaves it atmain,memory.main) and reaches a real relation outside the grant;WITH shared AS (…) INSERT INTO shared VALUES (1)was a working escape intoducklake.main.sharedbefore this split.authorizeWriteTargettherefore requires schema qualification unconditionally. Do NOT infer target-ness from proto position:walk()descends into a Node's own oneof, so a bare*RangeVarmessage reacheswalkMessagefor reads too. Adding a case toauthorizeWriteStatementREQUIRES enumerating that statement's target field(s) there by name (DropStmtscopes its dotted-name lists itself); thewalkMessageRangeVarbranch is only the lenient defense-in-depth net for positions that enumeration does not cover (e.g.Constraint.pktable).TestQueryAccessPolicyRejectsUnqualifiedWriteTargetsis the tripwire. - Neither mode may carry passthrough (it bypasses the compat layer that enforces the scope) — DB check constraint + admin API guard.
- A policy change tears down live sessions.
changedProjectScopedUsers(org_router.go) fires on credential, team, disable AND mode flips; the demotion direction is load-bearing (a demoted user must not keep a write-authorized session). Deleting a team deletes BOTH of its logins. - One of each per team, via two SEPARATE partial unique indexes on
(org_id, team_id)— deliberately not one index across both modes, so a reader and a writer coexist. - Touching any of this → update
server/query_access_test.go,controlplane/configstore/query_access_test.go,controlplane/admin/api_test.go,controlplane/org_router_test.go,tests/configstore/org_teams_postgres_test.go+migrations_postgres_test.go,docs/postgres-compatibility.md, AND theproject_reader_isolation/project_user_isolationassertions intests/mw-dev/e2e/harness.sh.
Compute-Usage Billing (managed-warehouse, remote backend only)
duckgres meters per-org compute usage of worker pods into 60s buckets in the
config store; the billing service pulls the accumulated usage over an HTTP
API and acks a watermark, at which point duckgres deletes the acked buckets.
Full design + decisions: docs/design/billing-pull-api.md (supersedes the
push/capture reporting hop of billing-compute-seconds-plan.md; the metering
side of that doc still applies). Scope is only the remote/k8s backend
(per-org worker pod with a known WorkerProfile size). Pipeline:
compute: conn end → in-proc counter keyed (org, informational team, query_source, worker size)
│ flusher (~15s) UPSERT-increment → config-store buffer (cross-CP sum)
▼ duckgres_org_compute_usage (+ duckgres_compute_billing_cursor)
storage: leader sampler (~30m) → org's DuckLake metadata Postgres
SUM(data+delete file sizes) × interval → duckgres_org_storage_usage
billing: GET /api/v1/billing/usage (usage + storage arrays, per key per UTC day, watermarks)
→ POST /api/v1/billing/ack {watermark_high} → cursor advance + delete ≤ it (BOTH tables)
safety: leader-only GC hard-deletes buckets older than 30 days (WARN, alertable)
Two raw metrics per connection over its full lifetime, using the provisioned
worker size: cpu_seconds = vCPU × ceil(conn_secs), memory_seconds = GiB × ceil(conn_secs). Counted internally in integer millicore-seconds /
MiB-seconds (compute_meter.go) to avoid truncating a fractional-core /
sub-GiB worker; worker size is stored in the bucket key as exact NUMERIC
decimals (vCPU / GiB). team_id is informational only (an integer —
PostHog's Team.id; a JSON NUMBER on every API surface): duckgres does NOT
own team-level billing attribution — the external billing service maps
org → team(s) itself. The stamp is resolved from the config snapshot at
record time: compute buckets get the CONNECTING USER's team
(duckgres_org_users.team_id, e.g. a project-reader login) when it has one,
else the org's OLDEST team (min created_at, ties broken by the smaller
team_id — in practice the provision-time first team; ConfigStore.OrgUsageTeamID);
storage buckets always get the oldest team (OrgOldestTeamID). 0 appears
only defensively (unknown org / stale snapshot — a committed org always has
at least one team). Team changes/deletions NEVER re-attribute existing
buckets; query_source is the
duckgres.query_source session GUC (standard unless set; a mid-connection
change bills the whole connection under the final value). The GUC is a closed
enum validated at SET time (transform.NormalizeQuerySource): only
standard | endpoints (case-insensitive, normalized to lowercase; empty =
reset to default) — anything else is rejected with 22023 on every set path
(simple/batched SET, extended Parse, and the -c startup option, which rejects
the connection like invalid duckgres.worker_* options), and
server.ConnectionBilling clamps a non-canonical value to standard as
defense in depth so client junk can never become a billing bucket key.
Invariants for anyone
touching this path:
- Metering is strictly best-effort and off the hot path. A metering error
(counter, flush) must NEVER block or fail a query or connection teardown. The
connection-end record is added to an in-process counter (map+mutex,
microseconds, no I/O); the flush is async.
cp.computeMeteris nil outside the remote backend — every call site is nil-safe. There is no enable knob: the remote backend always meters. - Worker size is plumbed onto the connection (
server.SetConnectionWorkerSize→clientConn.workerMillicores/workerMiB, set incontrol.go::handleConnectionfromworkerBillingSize(workerProfile), remote-only).workerMillicores==0(non-remote / unknown) → metering skipped. The metric is computed once at the SAME teardown point asCloseConnectionMetrics(the#841lifetime defer), viaserver.ConnectionBilling(which also carries the query source). - Bucket = connection-end time floored to 60s. Flush carries the sub-unit remainder forward so rounding never loses counts across flushes. Buffer flush is UPSERT-increment so all CP pods sum into one row per key.
- Serve only closed buckets.
watermark_high= the newest bucket withbucket_start ≤ now − 60s − 30s grace(grace > flush interval, so every CP's contribution has landed before a minute is served). The GET aggregates the window(cursor, watermark_high]into one row per(org, team, query_source, cpu, mem_gib)per UTC day — response size is bounded by active keys × days, so billing downtime can't make it explode. - Ack is the only deletion path (plus the 30d GC).
POST /billing/ackadvances the single global cursor monotonically and deletes buckets≤ watermark_highin one TXN (AckComputeUsage). Idempotent — re-acks and stale acks are no-ops. An ack beyond the latest closed bucket is rejected (400) so it can never delete buckets that were never served. Auth is the admin internal secret (RequireAdminon both routes, registered inside the audited/api/v1group inmultitenant.go). - Safety GC is leader-only (
runComputeUsageGC, attached under the janitor lease): hard-deletes buckets older than 30 days regardless of ack and logs a WARN with the dropped count — nonzero means billing stopped pulling (alert). - Graceful shutdown does a final flush after connections drain to their
natural end (
shutdown/drainAndShutdown), so a departing CP pod lands its last interval before exit. - Org team CRUD (
duckgres_org_teams): the PostHog backend manages an org's team rows viaGET/POST /api/v1/orgs/:id/teams+DELETE /api/v1/orgs/:id/teams/:team_id(internal secret,controlplane/provisioning). The POST is the grandfather upsert: it MAY overwrite an existing row'sschema_nameand the legacyevents_table_name/persons_table_name/schema_data_imports_nameoverrides (NULL = derive fromschema_name:<schema>.events,<schema>.persons,<schema>_data_imports), because the PostHog backfill replaces migration 000024'steam_<id>placeholder through it. Two teams in one org can never share a schema (unique(org_id, schema_name), migration 000025 → 409). Provisioning a warehouse for a NEW org REQUIRESteam_id(ErrProvisionTeamRequired→ 400;default_team_idis accepted as a transitional alias) and creates the org's first plain team row — a warehouse cannot exist without a team. DELETE removes CONFIG only (never warehouse data) and never touches usage buckets; the org's LAST team is undeletable (409 — an org must always have at least one team; delete the org instead). The admin console mirrors this on a user-facing surface (GET /teams,POST /teams,PUT /orgs/:id/teams/:team_id) whereschema_nameis immutable. Shared rules live inconfigstore.UpsertOrgTeamTx/DeleteOrgTeamTx; tests:tests/configstore/org_teams_postgres_test.go, the provisioning/admin API tests, andorg_teams_crudin the e2e harness. - Storage metric (
managed_warehouse_storage_gib_seconds,storage_meter.go): a LEADER-ONLY sampler (double writers would double-bill — the UPSERT is additive) visits each Ready warehouse's DuckLake metadata Postgres every 30m (env-onlyDUCKGRES_STORAGE_SAMPLE_INTERVAL; e2e uses 60s) and credits exactlytracked_bytes × intervalbyte-seconds — no elapsed-time tracking, a missed sample under-bills one interval. The SUM is overducklake_data_file+ducklake_delete_filewith NO snapshot filter (neverducklake_table_info()/ducklake_table_stats— current- snapshot-only / approximate). byte-seconds are NUMERIC (BIGINT overflows); served as exact-decimal GiB-seconds (÷2³⁰ terminates;byteSecondsToGiBSecondsbig-int math). Connection resolution reuses the cross-org activator (MetadataPostgresURL: duckling pgbouncer → sslmode disable, direct RDS → require). Drift gauges:duckgres_org_storage_pending_delete_files(alert on sustained nonzero) +duckgres_org_storage_tracked_bytes. - Touching the meter/flush/API/GC, the worker-size or query-source plumbing,
the storage sampler, or the bucket keys → update
controlplane/compute_meter_test.go,compute_billing_api_test.go,compute_size_test.go,storage_meter_test.go,configstore/storage_usage_test.go, the migration assertion intests/configstore/migrations_postgres_test.go, and thecompute_usage_pull_apiassertion (compute + storage) intests/mw-dev/e2e/harness.sh.
Discovery Endpoints (external-writer tenant listing)
GET /api/v1/warehouses + GET /api/v1/warehouse-team-ids on the internal
provisioning API (controlplane/provisioning/discovery.go) are the read-only
"which tenants exist and where do I write" surface for EXTERNAL writers
(viaduck destination discovery; millpond's include-values poller). Semantics
are load-bearing for those consumers:
- Warehouse set = ready + resharding (
discoveryStates). Resharding is listed withwritable=false— vanishing would read as tenant REMOVAL downstream, not a pause. The state enum is open: a new state MUST be classified intodiscoveryStates/discoveryExcludedStates(TestDiscoveryStateClassificationis the tripwire; an unclassified state reads as fleet-wide removal to every consumer). - Teams come from
duckgres_org_teams, with RESOLVED table locations:events_table/persons_table=<schema_name>.<override-or-derived>,data_imports_schema= override-or-<schema>_data_imports. The derivation lives ONCE, inresolveTeamTables; the legacy overrides are BARE identifiers (never schema-qualified), enforced at every write surface byconfigstore.ValidateOrgTeamTableName. enabledis passed through as information only — it is the per-team query-serving switch (migration 000024, not yet enforced), NOT an ingestion signal. Disabled teams stay in BOTH endpoints; deriving "stop ingesting" from it would turn a serving hold into permanent event loss. The only ingestion-stop signal is row absence.- Error contract: transient store failures fail the WHOLE request (a
polling consumer keeps last-known-good — the safe direction); only a
warehouse with zero team rows degrades, per-warehouse, to an empty teams
array (
duckgres_discovery_broken_team_rows_total{reason}counts it — a sustained nonzero means a live tenant is silently unroutable). config_generationis an opaque change token: maxupdated_atover ALL warehouse+org+org-team rows regardless of state, read BEFORE the data queries. Its delete-visibility depends onDeleteOrgTeamTxtouching the parent org row (a bare team DELETE leaves no timestamp behind) —TestLatestConfigChangeCoversTeamsPostgrespins that pair; keep them in sync. Compare for equality only.- No plaintext credentials in the payload — metadata-store passwords are
k8s SecretRefs. cnpg connection details are MIRRORED from the Duckling CR
status into the row by the provisioner's ready-reconcile
(
reconcileMetadataStoreRow; drift-only writes so steady-state ticks never bumpupdated_at/the change token; the state-CAS write is the reshard fence — the runner owns those columns mid-flip). External rows are provision-time inputs; only their credential Secret ref is mirrored. - Auth is a SEPARATE, scoped surface (
RegisterDiscoveryAPI+ its own group inmultitenant.gobehindadmin.AnyTokenAuthMiddleware): the read-only discovery secret (--read-only-secret/DUCKGRES_READ_ONLY_SECRET, sent inX-Duckgres-Internal-Secret, same fallback-rotation semantics as the internal secret) works ONLY on these two GETs; the admin internal secret also works here (operator/debug + rotation window). Never register discovery routes inside the adminapigroup and never acceptreadOnlyTokensanywhere else — external writer pods carry this credential, and its blast radius must stay "read the tenant list and its connection topology (RDS endpoints, bucket names, k8s Secret names — never values)". Tripwires:TestAnyTokenAuthMiddlewareScoping(token matrix incl. cross-surface rejection) andTestReadOnlyGroupTopology(the group's exact route set, against the realregisterReadOnlyGroupwiring). A discovery value equal to the internal secret (or any fallback) FAILS STARTUP —validateDistinctReadOnlySecret— because a shared value silently un-scopes the credential. - Touching the payload shape, states, team derivation, or the generation →
update
controlplane/provisioning/discovery_test.go,tests/configstore/org_teams_postgres_test.go, AND thediscovery_endpointsassertion intests/mw-dev/e2e/harness.sh.
Resharding (metadata-store migrations) — LOAD-BEARING CONTRACT
Operator-driven moves of an org's DuckLake catalog between metadata stores
(cnpg↔cnpg, ext→cnpg, cnpg→ext escape hatch), admin-console-driven with a
verbose op log. Full design: docs/design/resharding.md. Pieces:
configstore/reshard.go (+ migrations 000018/000021/000022),
provisioner/reshard_runner.go + catalog_copy.go + catalog_backup.go
(the step machine, executed in a DEDICATED per-op pod — see below),
controlplane/reshard_runner_mode.go (--mode reshard-runner, the pod's
entrypoint), controlplane/reshard_pod.go (spawner) +
reshard_reconciler.go (leader-only pod janitor), admin/reshard.go, UI
ReshardForm.tsx/ReshardOperation.tsx. Invariants:
-
Reshards execute in a dedicated per-operation pod, NEVER in a CP process (
duckgres-reshard-op-<id>, labelsapp=duckgres-reshard+duckgres-op-id, restartPolicy Never, TGPS 600, requests=limits from the env-onlyDUCKGRES_RESHARD_POD_CPU/DUCKGRES_RESHARD_POD_MEMORY, default 2/8Gi): a catalog pg_dump/copy must not compete with live traffic for CP resources (a ~20k-table dump OOM-killed a 512Mi CP pod). The pod runs the CP's OWN image + ServiceAccount (no new RBAC) and inherits an ALLOWLIST of the CP's env spec verbatim (reshardPodEnvAllowlist— secretKeyRefs stay refs; nothing secret lands in a pod spec). The start handler creates the op PENDING and spawns the pod; the pod claims the row via the standard CAS, runs the step machine to a terminal state, and exits 0 — the op row is the OUTCOME's source of truth (failed/rolled-back still exits 0; only infra errors — store unreachable, claim lost/fenced — exit nonzero). A leader-only reconciler (attached under the janitor lease) respawns the pod of a pending-past-grace or stale-heartbeat op (bounded, 3 attempts, then force-fail with an operator-facing error) and reaps pods of terminal ops. The e2e reshard pod netpol lives ink8s/networkpolicy.yaml(duckgres-reshard-runner-boundaries, egress-only: DNS/5432/443/8080); the production chart needs the equivalent (charts repo). -
The sound connection barrier is the lease-GRANT check, not the connect-time 57P03 gates: the grant transaction refuses
reshardingorgs under the same per-org advisory lockSetWarehouseReshardingtakes for theready→reshardingCAS. Never rely on the snapshot-polled connect gate alone — a lease can be granted up to a queue-timeout after it ran. -
Drain, never kill: live queries always finish. Drain = leases==0 AND queue==0 (one tx) AND zero live org workers (each runs a catalog-writing
DuckLakeCheckpointer). Lingering hot-idle workers are retired via the standard CAS retire path only — never raw pod deletes. -
Flip semantics differ by direction: a
cnpgShardchange re-points role/DB in place (source ORPHANED — explicit cleanup after verify); a TYPE flip to external un-renders the cnpg MRs, and whether Crossplane then DELETES or ORPHANS the role/DB depends onspec.metadataStore.retainCnpgOnFlip(charts). External stores are never modified/deleted. -
cnpg→ext escape hatch = orphan-adopt then verified-delete (charts
retainCnpgOnFlip): copy → verify source → setretainCnpgOnFlip=trueAND poll the cnpg Role/Database MRs until they carry the no-Delete policy (two-step flip, closes the un-render-before-policy race; this MR read needs an explicit get grant on roles+databases inpostgresql.sql.m.crossplane.io— NOT covered by duckling-reader; a Forbidden fails the wait immediately with the missing grant named, and the periodic wait log + timeout error carry the observedmanagementPolicies) → flip type to external (now ORPHANS, not deletes, the cnpg role/DB) → verify the external catalog row counts match the copy EXACTLY → only THENDROP DATABASEthe retained source + clear the flag. ANY failure before that drop → flip back to cnpg-shard + clear flag in one patch (SetMetadataStoreCnpgAdopt), provider-sql re-ADOPTS the still-present role/DB by external-name — NO copy-back, NO empty-recreate (replaces the old recreate+copy-back recovery that caused a data-loss incident). XRD-compat: if the read-back shows the cluster's XRD lacksretainCnpgOnFlip(patch pruned), REFUSE the reshard ("deploy charts first") — safer than the destructive delete-on-flip. -
DEPROVISION-UNAFFECTED (non-negotiable):
retainCnpgOnFlipdefaults false and is true only transiently mid-reshard; a normal never-resharded cnpg tenant always has it false → its cnpg Role/DB render with full lifecycle["*"](Delete) → deprovision (Duckling delete → finalizer) drops them exactly as before. The orphan is bound to the reshard type-flip, NEVER to Duckling deletion.lifecycle_teardown_cnpgin the e2e is the regression net. -
Pre-flip catalog backup (safety net,
backup_catalogstep,catalog_backup.go): after drain + pause-compaction +recordSourceand BEFORE any flip (for EVERY direction), the runnerpg_dumps the SOURCE catalog to the org's OWN S3 data bucket unders3://<bucket>/_reshard_catalog_backups/op-<id>-<ts>.dump(custom format →pg_restore; password viaPGPASSWORD, never argv; bucket/region/IAM-role from the duckling CR status; upload creds via STS AssumeRole of the org's own role, injected as anAssumeRoleFuncfrom the reshard-runner mode (controlplane/reshard_runner_mode.go) to avoid the import cycle). The dump is STREAMED: pg_dump stdout flows straight into an S3 multipart upload (16MiB parts × concurrency 2 bounds memory regardless of catalog size — no temp file, no whole-dump buffering; a nonzero pg_dump exit or empty dump deletes the partial object). Gate by direction: the destructive cnpg→external direction (its verified-delete drops the source) HARD-FAILS the op before the flip if the backup fails; non-destructive directions (source survives) log a warning and continue. The URI is recorded onbackup_s3_uri(migration000021) + the op log, with the exactpg_restorerecovery command. Retention is an S3 lifecycle rule on the reserved key prefix (30d suggested) — no in-app GC; backups are kept on success. The objects carry NO object tag: PutObject with x-amz-tagging needss3:PutObjectTagging, which the org duckling roles do not grant (a tagged upload 403s on the real cluster — mw-dev e2e regression). pg_dump/pg_restore ship in the CP image (Wolfi'spostgresql-18-clientapk, in BOTHDockerfileandDockerfile.controlplane). Full design + restore procedure:docs/design/resharding.md. -
Source identity: the duckling STATUS is authoritative, validated at submit. The start handler derives source_kind/from_shard/the external source block from the duckling STATUS (where the catalog actually lives) and 400s an op whose identity is incomplete or contradicts the config-store row (kind drift, cnpg source with unresolvable shard, external source with an incomplete row block). The runner re-checks at recordSource (pre-flip). Never re-introduce the old "empty row kind defaults to cnpg-shard" fallback — it pointed a flip-before-copy reshard at a phantom cnpg source while the org lived on an external RDS (prod incident: org re-pointed onto an empty catalog, rollback patch rejected).
-
Metadata credentials come from a referenced Secret, never CR status.
status.metadataStore.credentialSecretRefnames one key in a Secret in theducklingsnamespace;DucklingClient.Getresolves it before returning the status used by activation, probes, metering, and resharding. References to any other namespace are rejected. The name, namespace, and key are all mandatory; an incomplete reference or an unreadable/missing Secret is a hard error. Duckgres never reads credential material from Duckling CR status. -
Rollback patches the source shard VALUE back — never removes the key (precedence would fall through to the freshly-stamped bogus status pin); ext→cnpg rollback must null
cnpgShard(XRD CEL forbids it on external). Never emit a patch the XRD would reject: an empty/invalid recorded from_shard (isValidCnpgShardName, mirrors the XRD pattern) or an incomplete external source block skips the flip-back/adopt patch, logs ERROR operator instructions, and intentionally leaves the warehouse blocked inresharding— unblocking onto the wrong store is worse than a blocked org (see docs/design/resharding.md's never-stranded carve-out). Takeover/endpoint reconstructions derive sslmode from the metadata-store KIND viasslModeFor(cnpg → disable, external → require) — never hardcode an sslmode. -
The compaction pause must never let XRD defaulting disable DuckLake:
SetCompactionEnabledpins the org's effectivespec.ducklake.enabled(legacy type coupling, as the activator derives it) into the patch when the CR has nospec.ducklakeobject — otherwise materializing the object getsenabled: falsestamped by the XRD default and every later activation fails ("tenant activation requires a ducklake metadata_store"). The pinned value is never removed afterwards (deliberate — seeTestSetCompactionEnabledPinsDuckLakeEnablement). -
The ext target password is ephemeral: request → creating replica's in-memory stash → one-shot pull by the runner pod → runner memory; never in the op row, log, audit, k8s Secret, or any pod spec. The runner pod fetches it at startup from
GET /api/v1/reshards/:id/passwordon the CREATING replica's pod IP (the URL — never the password — is persisted onpassword_url, migration000022, so a reconciler respawn re-wires the same handoff). The endpoint is INTERNAL-SECRET identity ONLY (an SSO admin is 403'd — operators must never read tenant credentials); the stash is pruned once the op turns terminal. A pull that 404s (creating replica gone) is not an infra error: the runner proceeds stashless and the step machine fails the op with the clear "password is not available … cancel and re-run" message, then rolls back. (This replaces the old claim-on-create /CreateReshardOperationClaimed+AdoptClaimedOperationpath — with the op no longer executing in a CP process, pinning it to the creating replica is neither possible nor needed.) -
The ext SM secret must be ESO-readable and a raw string: the ESO IAM policy only allows
posthog-*/duckling-*names, so the start handler enforces a POSITIVE allowlist (esoReadableSecretPrefixes) — a name outside it is 400'd, with RDS-managedrds!…/rds/…/masternames (rdsManagedSecretNamePattern) detected first for a more specific message. The composition's ExternalSecret copies the whole value verbatim (no JSON property). An unreadable name that slips through just hangs the cutover wait until the per-op timeout, then recovers (flip-back + copy-back). The form teaches the same rules (ui/src/lib/reshard.ts::classifySecretName). -
cnpg→ext fails fast at submit, before the destructive flip: the flip DELETEs the cnpg source, so both submit-time gates (the ESO-name allowlist above + a bounded
SELECT 1pre-flight connection check to the external target,admin.ExternalTargetProberoverprovisioner.PGCatalogCopier.Probe,sslmode=require, wired inmultitenant.go) 400 a doomed op before anything is created. The prober nil-degrades (tests / non-k8s → check skipped; the runner's copy still catches a bad credential); when present and the connect fails, the op is refused. The 400 never echoes the password. -
Runner fencing: claim bumps
runner_epoch; every runner write is CAS-fenced on (runner, epoch); stale-heartbeat (>5m) ops are takeover-able; the copy holds a target-DB advisory lock. -
Takeover rollback reconstructs progress from the persisted row. The in-process rollback flags (
blocked,compactionPaused,flipped,retainRequested) start false on a freshopRun. A runner that CLAIMS an op a prior epoch advanced (crash-takeover or replica switch) MUST callreconstructProgress()beforerun()— otherwise a cancel/failure that short-circuits before the steps re-execute (e.g.run()'s firstcancelRequested()check) rolls back with all-false flags and silently skips unblocking the warehouse (org stuck inresharding) and restoring compaction. Reconstruction is conservative:blocked←blocked_atset OR step pastblocking;compactionPaused← step pastpausing_compaction(proves the prior setting was recorded, else restore could wrongly re-enable compaction);flipped/retainRequested← step reachedcutover/orphaning_source(over-marking is safe — the flip-back/adopt patches are idempotent no-ops when the store never moved). This was a real mw-dev incident: the blocking runner OOM-crashed mid-backup, a sibling replica took over, saw the cancel flag, and marked the opcancelledwhile leaving the warehouse blocked. -
Touching any of this → update
tests/configstore/reshard_postgres_test.go,provisioner/reshard_runner_test.go(incl. theTestReshardBackup*cases),provisioner/k8s_client_test.go(the compaction/ducklake-pin cases),admin/reshard_test.go,controlplane/reshard_pod_test.go+reshard_reconciler_test.go+reshard_runner_mode_test.go, the migration asserts intests/configstore/migrations_postgres_test.go, the netpol assert intests/manifests/manifests_test.go, AND thereshard_*+lifecycle_teardown_cnpgassertions intests/mw-dev/e2e/harness.sh(validation, cancel-during-drain, bogus-shard-rollback, ext→cnpg positive path incl. the pre-flip backup assertion + the runner-pod appear/reap assertions, and the deprovision-unaffected net). The cnpg→ext orphan-adopt charts side (composition managementPolicies +retainCnpgOnFlipXRD field) lives in thechartsrepo and is covered bycharts/charts/crossplane-config/tests/composition_retain_cnpg_test.sh. cnpg→ext positive path is unit-only (harness lacks the RDS password); cnpg→cnpg positive path needs a second mw-dev shard (follow-up).
TODO Reference
TODO.md is a lightweight backlog for ideas that do not yet have a better
home. It is not the PostgreSQL compatibility source of truth; use
docs/postgres-compatibility.md for compatibility status, test citations, and
known gaps.