duckgres
PostHog/duckgres/CLAUDE.md
This file provides context for Claude Code sessions working on this codebase. 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. 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…
- Reads credentials
- Deletes or force-pushes
# Claude Code Context for Duckgres
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`
- **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, `kubernetes` tag; includes the models explorer UI `static/models.html` + `models_api.go`, and `devserver/` for local UI dev against a port-forwarded CP — see `admin/README.md`), `provisioner/` (k8s controller, `kubernetes` tag), `provisioning/` (HTTP API), `configstore/` (Postgres-backed config)
- **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`
- **transpiler/** — AST-based PostgreSQL → DuckDB SQL transpiler
- Top-level: `transpiler.go`, `config.go`, `boolpredicates.go`, `show_create.go`
- `transform/`: individual transforms; see registered pipeline in `transpiler.go` `New()`
## 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 kubernetes` and a Postgres-backed config store. Adds config store, org router, runtime tracker, janitor/leader election, and a provisioning/admin HTTP API.
- **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 kubernetes` only): 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`
- `--statement-timeout DURATION` — cancels any single statement that runs longer than this; `0`/empty (the default) leaves statements **unbounded**, which is today's behavior. Blast-radius containment only: nothing else in the stack bounds a wedged statement (DuckDB has no statement timeout, client `statement_timeout` is an ignored SET, the cnpg shards run `idle_in_transaction_session_timeout=0`, pgwire clients wait forever), so one engine deadlock cost a tenant 5h33m of their hourly refresh pipeline on 2026-09-16. The deadline rides on the STATEMENT context, never the connection context — a timeout ends one statement, not the session — and surfaces as `57014` with PostgreSQL's exact wording `canceling statement due to statement timeout` (drivers string-match it). The statement context comes from `queryContextInner`, the single registration point every execution path passes through on its way to an engine: simple, batched, extended Execute, the writable-CTE rewrites on both protocols, the extended Describe schema probes (they really execute at `LIMIT 0`), COPY in both directions, and cursors. Classification is error-driven and feature-gated (`statementTimedOut(err)`): an expired deadline only classifies as the timeout when the knob is on, so deployments that never set it keep byte-for-byte behavior for internal deadlines. Two lifetime notes: a **cursor is bounded over its whole lifetime, not per `FETCH`** (one context, taken when the cursor opens at first `FETCH`), and a **suspended portal is bounded over its whole lifetime** too (its open rowset dies with the context, so the teardown moves to `closeExec`) — both stricter than PostgreSQL, deliberate (an unbounded cursor or suspended portal pins a worker).
- **Every execution path must acquire the statement context through `queryContext`/`queryContextInner`.** That is the ONLY place `RegisterQuery` is called, so a path that skips it is reachable by neither the timeout nor a pgwire `CancelRequest`. Extended-protocol `Execute` used to skip it and called the context-LESS `executor.Query`/`Exec` (which run on `context.Background()` — see `PinnedExecutor`), leaving prepared statements — what pgx, psycopg3 and JDBC send — unbounded and uncancellable. Regression: `TestStatementTimeoutReachesExtendedProtocolExecute`. If you add an execution path, route it through `queryContext` and the `*Context` executor methods.
- **Timeout classification is driven by the ERROR and gated on the timeout being configured** (`statementTimedOut(err)`), never by stored per-connection state. An earlier design recorded the live statement context on the connection; it went sticky — once any statement timed out, later unrelated failures were reported as timeouts and their logging skipped. The gate also keeps a deployment with the feature OFF byte-for-byte identical, so internal deadlines (attach, exec, worker gRPC) do not start surfacing as `57014`. With the feature ON an internal deadline racing a statement can still be labelled a statement timeout — an accepted narrowing.
- **A cursor is bounded over its whole lifetime, not per `FETCH`**: `queryContextForCursor` takes ONE context at `DECLARE` and holds it across every `FETCH`, so a cursor open longer than the timeout dies — stricter than PostgreSQL. Deliberate (an unbounded cursor also pins a worker), but size the timeout with long-lived cursors in mind.
- Caveat: an engine that ignores cancellation (duckdb/duckdb#24961) will not stop work; the client is freed with a clear error but the worker can stay wedged — retiring a timed-out worker is a follow-up that must respect the destroy-before-reuse ordering in `SessionManager.DestroySession`.
- `--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). The close is preceded by a `FATAL` 57P05 ErrorResponse naming the effective timeout (matching PostgreSQL's `idle_session_timeout`), so clients see a reap, not a network fault. **Control-plane default is `5m`** (`server.DefaultControlPlaneIdleTimeout`; standalone defaults to `24h`); a negative value disables it. `server.New` applies the standalone default, so the control plane sets it explicitly before `InitMinimalServer` (which skips that defaulting).
- `--memory-budget SIZE` (default 75% RAM) / `--memory-rebalance`
- `--socket-dir /path` (process backend)
- `--handover-drain-timeout DURATION` (default `24h` process; **remote default is `0` = unbounded** — the CP waits for active sessions for as long as it takes and the pod's k8s `terminationGracePeriodSeconds` is 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 kubernetes` for 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-org `Org.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`). See `docs/design/billing-pull-api.md` and "Compute-Usage Billing" below.
- Pod scheduling knobs (CPU/memory requests, node selector, tolerations) are env-only — see `config_resolution.go`.
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):
1. CLI flags (`--port`, `--config`, etc.)
2. Environment variables (`DUCKGRES_PORT`, etc.)
3. YAML config file
4. 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](https://github.com/casey/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/mw-dev/e2e/` (`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. Includes `tests/manifests/` (static-manifest artifact asserts for `k8s/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/mw-dev/e2e/`** (per-PR GitHub workflow `e2e-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.sh` orchestrates deploy/test/teardown/e2e-cleanup. **Replaces the retired kind suite** (`tests/k8s/`) — that suite's `k8s-integration-tests` CI job and its Go tests are gone; the supporting `k8s/` scripts/manifests + Dockerfiles are kept for now. See `tests/mw-dev/README.md`.
### When code changes obligate test changes
`tests/mw-dev/e2e/` 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*Secret`
- `controlplane/configstore/models.go` — new columns flow through the provisioning API the harness calls; exercise them via a provision body field
- `duckdbservice/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 driver
- `github.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_go` AST 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 `MERGE` to the prefix check in `isDMLReturning`.
## 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 the *whole pod's* RAM
less a headroom reserve, plus 2.5 DuckDB threads per requested CPU, rounded
up. It does NOT divide by session count. Two sessions on one pod would each
believe they own the whole budget → 125-150% of the pod's RAM committed →
nondeterministic OOM / a heavy query killed by a co-resident one. Do not break
the following:
Headroom is `max(25% of pod, min(6GiB, 40% of pod))`
(`workerMemoryHeadroomBytes`), not a flat percentage — 40% below 15Gi, exactly
6GiB in `[15Gi, 24Gi)`, 25% at and above 24Gi. The 24Gi region is the old
flat-75% rule exactly, so the 120Gi pool default and the 360Gi client-profile
ceiling keep the sizing they have always had.
The reserve covers what `memory_limit` does not govern — DuckDB C++ catalog
objects, postgres_scanner/libpq result buffers, Arrow Flight batches, the Go
runtime, page cache — and it is CUMULATIVE, not a per-query peak: DuckDB does
not return buffer-pool pages to the OS when a session ends, so a hot-idle
worker's RSS ratchets across the sequential sessions it serves and settles near
`memory_limit` + overhead. In mw-prod-us a 16Gi worker ratcheted to 14.8GiB —
2.8GiB past its 12GiB limit, 1.2GiB of pod left — and was OOMKilled, while
120Gi workers ran 15.6GiB past a 90GiB limit inside 14.4GiB of remaining pod
and were not. The overhead is not constant across pod sizes, but it grows
sub-linearly, so a flat fraction leaves proportionally less usable margin the
smaller the pod. Size headroom by the margin it must absorb, not by percentage
alone.
DuckDB budgets below the crossover are formatted in MB, not GB: truncating a
fractional-GiB budget (8Gi - 40% = 4.8GiB) to whole GB would hand back the
very margin the cap exists to reserve.
- **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 concurrent `CreateSession` on 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 through `CreateSession`.
- **`OrgReservedPool` (remote/multitenant) must never co-assign.** It reuses only
idle (`activeSessions==0`, Hot, org-owned) workers via
`findIdleAssignedWorkerLocked`, or claims/spawns a fresh one. There is NO
least-loaded "share onto a busy worker" path (that exists only in the
single-tenant flat `K8sWorkerPool.AcquireWorker`, which is not used in remote
mode). Do NOT add one, and do not resurrect a `leastLoaded*` helper here.
- **At org max workers + all busy → fail fast with the clear org-cap message**
(`WorkerClaimMissReasonOrgCap`, see `capacity_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 in
`CreateSpawningWorkerSlot`. 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; `acquireDecision` in
`org_reserved_pool.go`) is serialized per org by `orgAcquireGate`
(`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-side `DestroySession` RPC *before*
`ReleaseWorker`, so a reused (hot-idle) worker's prior session is gone before
the next one is assigned (otherwise cap=1 spuriously rejects the reuse).
- **A hot-idle claim refuses a pod that is already being disrupted.**
Karpenter honours `karpenter.sh/do-not-disrupt` only while a session is
assigned (#757, `k8s_pool_disruption.go`), so an idle worker can be
SIGTERM'd between the durable `ClaimHotIdleWorker` CAS and adoption; a
session created on it fails on its first RPC. `adoptClaimedWorker` therefore
checks the pod it just read (`claimedPodDoomReason`,
`k8s_pool_doomed_claim.go`): a `deletionTimestamp`, a non-Running phase, or
a node carrying Karpenter's `karpenter.sh/disrupted` taint → the claim is
retired (`retired`, reason `pod_doomed`, origin `reserve_doomed_pod` — not
`lost`/crash), counted on
`duckgres_control_plane_hot_idle_claim_skipped_total{reason}`, and the
acquisition re-runs its decision (next candidate or a fresh spawn) instead
of failing the client. A node lookup failure is "unknown", never "doomed":
refusing every claim during an API-server blip would turn a transient into
a spawn storm. The local idle-reuse path is unaffected — it already reuses
only `Hot` workers, and the lifecycle state machine forbids
`Draining → Reserved`.
- **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.CreateSessionWithProtocol` does NOT fail the client: it logs
loudly (ERROR), bumps `duckgres_control_plane_worker_session_cap_drift_total`,
retires (recycles) the inconsistent worker, and re-acquires a fresh one
(bounded by `maxWorkerSessionCapDriftRetries`). Detection is
`isWorkerSessionCapError` (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::Throw` with its original type). Detection is
`isInstanceFatalError` (`duckdbservice/instance_fatal.go`). The **error TYPE
is authoritative**: a typed `*duckdb.Error` of
`ErrorTypeInternal`/`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 via
`SELECT 'INTERNAL Error' + 1` (regression:
`TestInstanceFatalIgnoresEchoedQueryText`). The one string fallback is the
`database 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, the
`CreateSession` path, and an async `SELECT 1` liveness 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`, or `noteInstanceErrorOpaque` where no
statement is available to classify, as on the secret-replaying `CreateSession`
path): it is logged on the worker, shipped to the CP as
`instance_invalid_reason`, and logged again on retire, so an un-redacted
engine error leaks a failed `CREATE 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 raises `duckgres_worker_instance_probe_stuck` rather than
silently disabling detection. The worker reports
`instance_invalidated` + `healthy:false`; the CP retires it on the FIRST
report, bypassing `maxConsecutiveHealthFailures` (the process answers RPCs
fine, so the failure counter would never fire) and rejects it for reuse in
`validateReservedWorkerHealth` — 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_total` and
`duckgres_worker_instance_invalidated_total` /
`duckgres_worker_instance_invalidated_state` are 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
(see `DUCKLAKE_EXTENSION_TAG` in `Dockerfile`/`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`,
`k8s_pool_doomed_claim_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 in `controlplane/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
client `duckgres.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 == nil` and a `SessionActivator`;
`activateForStatement` acquires 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. The `duckgres.s3_cache` and `duckgres.worker_ttl` GUCs are the
exception, on all three protocol paths, because unlike the other duckgres
GUCs they are WORKER state (the s3_cache secret swap; the worker_ttl
pool-side hot-idle TTL): **`SET` always acquires** — the apply needs a
worker to land on — and **`SHOW duckgres.s3_cache` acquires only when
`c.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). `SHOW duckgres.worker_ttl` never
acquires: until a worker exists, the connect-time baseline is the truthful
answer (there is no pending worker-side TTL state).
- **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`/`CLOSE` are 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/TEMPORARY `CREATE SECRET` would 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.** `escalateWorker` destroys 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 via `server.AcquisitionFailureOutcome`, the same helper
`duckgres_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).
`failWorkerAcquisition` sends a FATAL ErrorResponse, suppresses
ReadyForQuery, and unwinds the message loop; there is no session left to
resynchronize to. SQLSTATE comes from `escalationErrorSQLState`: 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 on `c.fatalErr`, which
`runExtendedQueryMessage` hands to the message loop. **`fatalErr` is 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 `txStatus` is 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 is `isWorkerOutOfMemoryError` (`server/conn_errors.go`) — DuckDB's
engine OOM only; a pod-level OOMKill is `ErrWorkerDead` and is deliberately
never re-executed.
- **`duckgres.s3_cache` interplay (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`) and
`ensureSessionActive` applies 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 divergence `applyS3CacheSetting`
exists 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 normal `ERROR` (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. `escalateWorker` tags it
`errS3CacheReapplyFailed` and `failEscalation` routes it — every call site
goes through that dispatcher so the two severities can never be confused at
one of them. **`SHOW` must 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 `kill` landing inside the switcher's
destroy→create window is missed, and so is a `kill` against a connection that
authenticated but has never activated (no session for
`DestroySessionsForUser` to iterate and no registered conn-closer until the
first statement) — `disable` covers BOTH, because the activation/escalation
re-check refuses the session outright (28000); extending `kill` to
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 the `exploratory_tier` / `exploratory_lazy_activation` /
`exploratory_state_pin` / `exploratory_oom_escalation` /
`org_default_profile` / `sized_worker` (GUC bypass) / `assert_worker_pod`
assertions in `tests/mw-dev/e2e/harness.sh`. The harness header above
`exploratory_tier` records which existing assertions the tier's connect
semantics changed and why — keep that audit current.
## Hot-Idle Pool Reporting + Per-Org Caps (remote backend)
Two operator surfaces for the hot-idle pool, both over the durable runtime
store (`worker_records`, the only source that sees parked workers — they hold
no session):
- **Reporting**: `configstore.ListHotIdleByOrg` aggregates `hot_idle` rows
per org (count, summed vCPU/memory, oldest park) and
`GET /api/v1/workers/hot-idle` (`controlplane/admin/live.go`) joins each
org's configured caps. Backs the Workers page "Hot idle by org" card
(sortable, default memory-pinned desc). **Worker shape resolution is a
three-step chain, everywhere**: the worker's explicit profile wins, else
the org's default worker profile, else the CP-global default worker shape
(`cfg.K8s.WorkerCPURequest`/`MemoryRequest`, else the 8/16Gi constants) —
an unsized worker on a default-less org must never report zero cpu/memory
(it pins real pod requests). The same chain backs the fleet rollup
(`ListWorkerLifecycleStats`) and the cap sweep's shape math
(`orgHotIdleLimitsFromSnapshot`); unparseable quantities still contribute 0.
- **Caps**: `max_hot_idle_workers` (count), `max_hot_idle_cpu` and
`max_hot_idle_memory` (k8s quantity strings, e.g. `"16"` / `"64Gi"`) on
`duckgres_orgs` (migration 000037; 0/"" = unlimited). Editable via the
admin org PUT + the org detail form. Invariants:
- **Enforcement is a convergent janitor sweep** (`reapHotIdleCaps`), NOT a
park-time check: on each tick it retires the OLDEST parked workers
(oldest-first listing) until the org is within ALL configured limits.
This uniformly covers every park path AND cap decreases — lowering a cap
drains the excess on the next ticks (config-poll reload, then the 5s
janitor tick). Retires go through the fenced `RetireFromSnapshot` CAS
with origin `janitor_hot_idle_cap` (a distinct metric origin — a nonzero
rate means an operator's cost ceiling is biting, not stale capacity).
On a retire error the sweep STOPS that org for the tick (never marches
on and over-reaps on a transient failure).
- **Cap wins over the floor** (`DefaultWorkerMinHotIdle`): the floor only
guards the TTL reaper; the cap is the explicit operator intent. An org
with floor > cap is a contradictory config that resolves to the cap.
- **Validation is fail-closed on the write path**: a cap quantity that
doesn't parse (or is zero/negative) is 400'd by the admin org PUT,
because the sweep reads those as UNLIMITED — accepting them would
silently mean "no cap".
- The sweep runs even when the TTL reaper is disabled (`hotIdleTTL == 0`)
— a cap is a hard ceiling, not a freshness rule.
- Touching any of this → update `tests/configstore/hot_idle_reporting_postgres_test.go`
(+ the migration asserts in `migrations_postgres_test.go`),
`controlplane/janitor_test.go` (the cap sweep cases +
`TestOrgHotIdleLimitsFromSnapshot` glue test),
`controlplane/admin/api_test.go` (`TestUpdateOrgHotIdleCaps*`) +
`live_test.go` (`TestHotIdleRoute`), `ui/src/pages/Workers.test.tsx` +
`OrgDetail.test.tsx`, AND the `/workers/hot-idle` envelope +
`hot_idle_reporting_and_cap` assertions in `tests/mw-dev/e2e/harness.sh`.
## 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).
**An adopted worker gets NO informer event on its owning CP.** Each CP's pod
informer (`startInformer`, `k8s_pool_reconcile.go`) is label-scoped to pods
THAT CP spawned (`duckgres/control-plane=<POD_NAME>`), so a worker claimed
from another CP's hot-idle pool (`k8s_pool_acquire.go`, fresh `done`
channel) never has `w.done` closed when its pod exits. The `HealthCheckLoop`
draining branch therefore MUST NOT rely on the informer as its only exit:
after `drainingPodVerifyThreshold` consecutive failed probes it `Get`s the
pod itself and, on NotFound / terminal phase, takes the same drained-lease
path the informer would (`handleTerminatedWorkerLease`, origin
`health_check_drained_pod_gone`). Without this, an adopted worker that was
SIGTERM'd (node disruption) and exited is probed every 2s tick forever —
one zombie is ~300 failed health checks per 10m, and four of them leaked for
three days in a production cluster and tripped the health-check-ratio alert.
The leader janitor's `reapOrphanedDrainingWorkers` (origin
`janitor_draining_orphan`, grace `defaultDrainingOrphanGrace`) is the
cluster-wide backstop: a durable `draining` row past its grace whose pod is
gone is retired via the fenced `RetireFromSnapshot` CAS, which also unsticks
the owner's local object (its next probe sees a non-draining row and drops
it). Both are pod-verified — a pod that still exists is left to drain, however
many probes fail. A local object whose durable row is draining under ANOTHER
owner is not waited on: the durable check fails and the ordinary 3-failure
mark-lost path drops it as a stale lease. Expect a steady trickle of
`duckgres_worker_lifecycle_transitions_total{operation="drain",origin="worker_drain",outcome="fence_miss_lease"}`:
a SIGTERM'd worker reports `draining` to EVERY CP still holding a local object
for it (previous owners keep theirs after a sibling claims the worker from
hot-idle), and only the current owner's lease can land the CAS — the misses
are the fence working, not the owner's drain failing. Touching the informer
selector, the draining branch, or the
sweep → update `controlplane/k8s_pool_draining_orphan_test.go` +
`janitor_draining_orphan_test.go` (unit-only; see `tests/mw-dev/README.md`
for why the e2e cannot stage a cross-CP adoption + pod exit).
## PostHog Logs (OTLP) — LOAD-BEARING CONTRACT
Process-level slog → PostHog Logs via OTLP (`internal/cliboot.InitLogging`).
This is **not** a replacement for `ducklake.system.query_log`. Product-analytics
events (`POSTHOG_ANALYTICS_API_KEY`) stay on the capture API.
- **`service.name` is the process role**, not `duckgres-<identifier>`:
`duckgres-control-plane` / `duckgres-worker` / `duckgres-reshard` /
`duckgres` (standalone). `DUCKGRES_IDENTIFIER` is resource attr
`duckgres.deployment`. Logs and traces share `otelResource(bi)`.
- **Default PostHog level is WARN** (`DUCKGRES_POSTHOG_LOG_LEVEL`); stderr
stays at `DUCKGRES_LOG_LEVEL`. User-class `Query execution failed.` stays
Info and does not export at the default.
- **Query text default is `redacted`** (`RedactForLog`+4096). Secret DDL is a
placeholder; ordinary SELECT text still leaves. Never redact on `LINE 1:`.
- **Public connection key is `pid`**, not `connection_id`. Org/worker are
process-scoped on workers (`stampWorkerLogIdentity`); `user`/`pid` live on
the session logger and must never be `SetDefault`'d.
- **Worker `POSTHOG_API_KEY` is a named `env:` `secretKeyRef`** copied from
the CP pod spec (`controlplane/pod_env.go`, `k8s_pool_spawn.go`). Never
`envFrom`. Never `os.Getenv` → `value:`. If Get/`POD_NAME`/named env misses:
one WARN and omit — **never fail spawn**. Do not forward
`ADDITIONAL_POSTHOG_API_KEYS` or `POSTHOG_ANALYTICS_API_KEY`.
- **`FlushLogging()`** only on the listed drain `os.Exit` sites (worker
success/timeout; CP SIGTERM after drain; `drainAfterUpgrade`). Do not wrap
every startup `os.Exit(1)`.
- **Exporter health** is scraped on the CP:
`duckgres_otlp_log_export_failures_total{source,reason}` (no `{org}`).
Workers report `otlp_export_enabled` / `otlp_export_failures` on the
health-check JSON; the CP `Add`s last-seen **deltas** only. Workers do not
call `InitMetrics`.
- Touching spawn env, health JSON, or the handler stack → update
`controlplane/k8s_pool_spawn_test.go`, `controlplane/otlp_export_test.go`,
`internal/cliboot/*_test.go`, AND `assert_worker_pod` in
`tests/mw-dev/e2e/harness.sh` (plaintext-key assert; secretKeyRef copy when
the CP named env has one).
## Per-Session S3 Cache Bypass (`duckgres.s3_cache`, remote backend)
`SET duckgres.s3_cache = on|off|passthrough` (default `on`; also a `-c`
startup option) controls the node-local S3 cache-proxy DaemonSet. `off`
bypasses its cache transport for cold-read benchmarking; `passthrough` keeps
requests flowing through cache-proxy while skipping its cache, so cache-off
workloads retain per-request proxy instrumentation.
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.SetS3CacheMode` →
`SessionPool.SetS3CacheMode`), 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); `passthrough` = the same
`overrideS3EndpointForCacheProxy` transport as `on`, with a worker-local
marker that makes cache-proxy use `forwardUncached` and strips that marker
before it reaches S3; `on` = the normal caching 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.s3CacheMode`) flips only after the worker swap succeeds; a
failed swap fails the SET (`XX000`) / the batch / the connect (startup
option), so `SHOW` can never report a transport the worker isn't using.
- **A bypass must never leak into the org's next session.** `CreateSession`
restores the proxy transport before the session starts and a restore
failure fails the create (CP retries); `DestroySession` restores
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 to `s3CacheMode`; all secret rebuilds serialize on
`secretSwapMu` so 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`) and `ensureSessionActive` applies 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 fatal `XX000` activation failure, and a tier escalation
re-applies the bypass on the new worker. `SHOW` activates 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, plus `passthrough`; anything else is `22023`
(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 the `s3_cache_guc` assertion in
`tests/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).
## Mid-Session Worker TTL (`duckgres.worker_ttl`, remote backend)
`SET duckgres.worker_ttl = '20m'` / `SHOW` / `RESET` let a client that cannot
set startup options change its bound worker's pool-side hot-idle TTL
mid-session (transpiler interception in `transform/setshow.go`, connection
apply in `server/conn_worker_ttl.go`, control-plane hook
`controlplane/worker_ttl.go::workerTTLControlFor` → pool
`SetWorkerTTLForPID`). Full design: `docs/design/worker-ttl-pool.md`.
Invariants:
- **Same trust boundary as the `duckgres.worker_*` startup options**: gated
on `DUCKGRES_K8S_ALLOW_CLIENT_WORKER_PROFILE` (SET rejected 22023 when off)
and clamped to `DUCKGRES_K8S_WORKER_MAX_TTL`; a clamped value is stored
clamped.
- **State follows the worker, never leads it** (same rule as s3_cache): the
session override flips only after the apply hook succeeds, and SHOW falls
back to the bound worker's CURRENT pool TTL (`Current`), so SHOW never
reports a TTL the worker won't park with.
- **Whole-minute granularity, enforced at validation.** The parked TTL is
persisted as `ttl_minutes` (integer; 0 = "reaper applies the deployment
default"), so `NormalizeWorkerTTL` REJECTS zero and sub-minute values with
22023 — accepting them would park the worker for the deployment default
while SHOW reported the shorter value. (Sub-minute STARTUP options still
truncate at park — pre-existing.)
- **Both reapers read the persisted override** (`ttl_minutes` stamped at the
hot→hot_idle park): the leader janitor's expiry query and the per-CP
fallback.
- **Exploratory tier**: SET on a lazily-activated connection acquires a
worker first (the TTL is pool-side per-worker state — there is nothing to
apply to otherwise); SHOW never acquires (the connect-time baseline is the
truthful answer pre-worker). Escalation re-applies the override on the new
worker; on failure the override is RESET (statement error, not
connection-fatal) — and BOTH session-worker-state re-applies (s3_cache +
worker_ttl) always run (an s3 failure must not skip the TTL re-apply).
- **Describe must cover BOTH 'S' and 'P'** for the intercepted SET/SHOW — a
portal-Describe miss returns NoData and probes DuckDB (acquiring a worker
on lazy connections) for a GUC it does not know.
- **Standalone/process backends** accept SET/SHOW as session state only (no
per-worker hot-idle TTL exists there).
- Touching the interception, apply hook, park persistence, or the reapers →
update `transpiler/worker_ttl_test.go`, `server/worker_ttl_test.go`,
`controlplane/worker_ttl_test.go` + `k8s_pool_worker_ttl_test.go`, AND the
`worker_ttl_guc` assertion in `tests/mw-dev/e2e/harness.sh`.
## 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: `wipeUserSecrets` drops ALL user-created
secrets — persistent ones AND non-persistent (plain/TEMPORARY `CREATE
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.
- **Client-ATTACHed catalogs get the same treatment, in the same place.**
`detachUserCatalogs` (`duckdbservice/user_catalogs.go`) runs right after the
secrets wipe on every shared-warm CreateSession and a failure MUST fail the
session (best-effort again at DestroySession). Attached databases are
instance-global exactly like secrets, and an attached catalog freezes its
connection string — credentials included — at ATTACH time, so it outlives
the wipe of the secret it was built from: without the detach, user B can
query user A's attached Postgres without ever holding A's credential. It is
also a correctness bug, not just isolation: clients attach with
`ATTACH IF NOT EXISTS ... AS db`, a no-op when the previous session left a
`db` behind, so the new session silently reads the PREVIOUS session's target
(2026-09-18: a tenant's sqlmesh run inherited an analyst's `db` endpoint and
its parallel postgres scans died with `SET TRANSACTION SNAPSHOT ... snapshot
does not exist`). Preserved: DuckDB-internal catalogs, the instance's primary
database (resolved by lowest oid — it is a file stem, not `memory`, when
DataDir is set), and the `isSystemCatalog` allowlist (`ducklake`, `delta`,
`__ducklake_metadata_*`). **A new worker-managed ATTACH must be added to that
allowlist** or the next session create will detach it.
- **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.RedactForLog` guards
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 emits `LINE 1: ... SECRET '...'`),
so a failed CREATE SECRET leaks the credential via the `error` attribute /
query-log `Exception` even 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, catalog detach, or payload shape →
update `server/conn_user_secrets_test.go`,
`duckdbservice/user_secrets_test.go`, `duckdbservice/user_catalogs_test.go`,
and the `persistent_user_secret`(+`_isolation`, which also carries the
attached-catalog leak check) assertions in `tests/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 to `ui/dist/`,
`//go:embed all:ui/dist` in `embed_ui.go`, SPA-fallback served by Gin; the SPA
owns `/`). `ui/dist` is a **gitignored build artifact** — only `ui/dist/.gitkeep`
is tracked, so the embed has a target and `go build` compiles without node
(the server then serves a "UI not built" notice). `just ui-build` builds it
locally; both `Dockerfile` and `Dockerfile.controlplane` run `npm run build`
**before** `go build`. Do not delete `.gitkeep` and do not commit `ui/dist`.
- **Two-tier authz** (`authz.go`): `AuthMiddleware` resolves every `/api/v1`
request to admin (valid `TokenSet` internal secret — service/break-glass) or to
an SSO identity from the ALB `X-Amzn-Oidc-Data` JWT. The SSO email
(`@posthog.com` + `email_verified != false`, else 401) is mapped to a role
**per-request** by a `RoleResolver` backed by the `duckgres_operators` config-schema
table (goose migration `000006_create_operators.sql`) — `admin` row → 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 is `RequireAdmin`, so
`useOperators` only 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 to `admin`. The **last-admin guard** (`operators_api.go`) refuses to
demote/delete the final admin (409) so the console can't be locked out.
`RoleGate` requires admin for all mutating verbs + the audit GET.
`AuditMiddleware` records 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 reuses `SessionManager.CreateSessionWithProtocol` (workers trust the CP — no
password) and **always** `DestroySession` in a defer. Admin-only, every
statement audited with the admin actor + `usersecrets.RedactForLog` SQL; writes
require `allow_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 from `rangePanels` (never an open PromQL
relay) and forwarded to `DUCKGRES_PROMETHEUS_URL`. Org-labelled panels keep
slicing enforced.
- **Product monitoring API is tenant-safe** (`monitoring.go`):
`GET /api/v1/orgs/:id/monitoring/{snapshot,series}` is internal-secret-only
for the PostHog backend. It fixes tenant identity from the path, uses only
org-scoped runtime-store reads and Prometheus selectors, returns CP coverage
for partial live-state fan-out, and strips usernames, PIDs, pod/image names,
CP ownership, SQL, client/trace data, and secrets. The series metric and
window are closed enums; never turn it into arbitrary PromQL or reuse the
operator payloads wholesale.
- **Env-only knobs**: `DUCKGRES_PROMETHEUS_URL` (read in
`multitenant.go`; set by the chart). The audit table `duckgres_admin_audit` is
AutoMigrated at startup (operational state, not goose-migrated tenant config).
The `duckgres_operators` table is authoritative access-control data, so it lives
in the config schema via goose migration `000006_create_operators.sql`, not
AutoMigrate.
- `ManagedSession.Username` is 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 single
`logQueryError` tap (`server/conn.go`), surfaced at `GET /api/v1/errors` and
merged across CP replicas by `PeerFetcher.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: `Query` via
`RedactForLog`, `Message` via `RedactErrorForLog` — 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.go` routes + `admin_providers.go` +
`session_mgr.go::DestroySessionsForUser` + `configstore` `disabled` column):
- `POST …/users/:username/kill` is 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) for `DestroySessionsForUser` to
iterate, so `kill` misses it. Only `disable` covers that connection — its
first statement's activation re-check refuses the session with 28000.
Extending `kill` to authed-but-unactivated connections is a follow-up.
- `POST …/users/:username/disable` is the **persistent block**: it sets the
`duckgres_org_users.disabled` column (goose migration
`000011_add_org_user_disabled.sql`), kills the user's live sessions, AND
refuses the user's NEW pgwire connections at auth time (`control.go`,
distinct `28000` "account is disabled" error, emitted only after the
password checks out so it never leaks account existence). `enable` reverses
it. The disabled state is read from the in-memory
snapshot, so disable/enable call `ConfigStore.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=local` recursion guard) and sum the per-CP `killed` counts. 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 polls `GET /cluster/summary` (`useClusterSummary`), a server-side
aggregate (`cluster.go`) of nodes (duckgres nodepools) · CP replicas · running
**workers** (label `app=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 `.peeper` root, 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 to
`kubernetes.io/config.mirror`; no raw objects), and `GET /cluster/nodepools`
proxies 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 own `duckgres-control-plane-cluster-topology`
ClusterRole in the `charts` repo (`charts/duckgres/templates/rbac.yaml`), bound
to the CP SA. It's a *separate* role (not folded into `duckgres-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), so `admin_console_api` only
asserts the `{items:[...]}` envelope; projection shape is covered by
`cluster_test.go`. Touching
the projection/endpoints or the view → update `controlplane/admin/cluster_test.go`
and the `/cluster/{nodes,pods,events,nodepools}` checks in `admin_console_api`
(`tests/mw-dev/e2e/harness.sh`).
- **Trino cell views** (`trino.go` + `trino_client.go`, UI
`pages/Trino{Cluster,Queries}.tsx` + `pages/OrgTrinoCard.tsx`, derivations
in `lib/trino.ts`): live queries with an admin-only, audited kill; the
cell's coordinator/node health; per-org Trino provisioning state (the
`duckgres_managed_warehouse_trino` state/status_message/ready_at/failed_at
columns, which were surfaced nowhere before, so a failed Trino provision
was silent). Read as the OBSERVER principal, never the provisioner's
admin — see the Trino Cells section and `admin/README.md`. Payloads are
redacted (SQL) and projected (never `TrinoEnabledOrg.RootPasswordHash`). A
ready, reachable org includes non-secret `status.connection` coordinates;
passwords remain write-only provision/reset results.
Every read is cached + timeout-bounded and degrades to `available:false`
plus a reason rather than erroring the page: the console must render
during exactly the incident it exists for. A shared-pool cell has no fixed
coordinator, so its observer (`trino_pool_observer.go`) lists the pool's
live instances on every call and fans out to each instance's Service over
forwarded HTTPS; usage metering polls the same observer. Never build a
pool observer from `CoordinatorURL` - it is empty (#1216). Unset
legacy URL and registry configuration leaves the routes unregistered.
- Touching any of the above → update `controlplane/admin/*_test.go` (esp
`authz_test.go`, `kill_switch_test.go`, `operators_api_test.go`,
`trino_test.go`, `trino_client_test.go`),
`controlplane/session_mgr_test.go`
(`TestDestroySessionsForUser`), `controlplane/configstore/store_test.go`
(`TestDisabledUserEnforcement`), the `Operators`/`Org Users` UI pages +
`ui/src/pages/Operators.test.tsx`, AND the `admin_*` / `admin_operators` /
`impersonation_*` / `user_kill_switch` / `user_disable_block` assertions in
`tests/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.OrgUserQueryAccess`
computes `AllowedSchemas` (`<schema>`, `<schema>_data_imports`,
`shadow_<team>_models`) + `AllowedRelations` (the legacy `posthog.<events|
persons>` overrides — a non-NULL override grants it even when it spells the
derived default) identically for both; **`ReadOnly` is 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, `ReadOnly` policy — 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 anything
`pg_query` cannot describe is rejected (this is why DuckDB-only spellings
fail). `CREATE SCHEMA` / `ALTER … SET SCHEMA` stay denied: the schema set IS
the project boundary.
- **A WRITE TARGET is checked by `authorizeWriteTarget`, never by the walk's
`authorizeRangeVar`.** 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 session `search_path` (`sessionmeta` leaves it at
`main,memory.main`) and reaches a real relation outside the grant;
`WITH shared AS (…) INSERT INTO shared VALUES (1)` was a working escape into
`ducklake.main.shared` before this split. `authorizeWriteTarget` therefore
requires schema qualification unconditionally. Do NOT infer target-ness from
proto position: `walk()` descends into a Node's own oneof, so a bare
`*RangeVar` message reaches `walkMessage` for reads too. **Adding a case to
`authorizeWriteStatement` REQUIRES enumerating that statement's target
field(s) there by name** (`DropStmt` scopes its dotted-name lists itself); the
`walkMessage` `RangeVar` branch is only the lenient defense-in-depth net for
positions that enumeration does not cover (e.g. `Constraint.pktable`).
`TestQueryAccessPolicyRejectsUnqualifiedWriteTargets` is 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.
- **Service credentials are NOT project-scoped and never touch
`duckgres_org_users`.** `POST /api/v1/orgs/:id/service-credentials` is how a
PostHog backend job (dagster) fetches a short-lived credential: a
per-credential grant row with its own `svc_` identity, root-shaped
(unrestricted) at the org level. See "Service Credentials" below for the
full contract.
- 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 the
`project_reader_isolation` / `project_user_isolation` assertions in
`tests/mw-dev/e2e/harness.sh`.
## Service Credentials (`POST /orgs/:id/service-credentials`) — LOAD-BEARING CONTRACT
How PostHog backend jobs (dagster today) authenticate to duckgres WITHOUT a
long-lived password living in Django. Replaces the org-root credential read
from a `DuckgresServer` row with a per-credential grant minted on demand by
the CP — AWS AccessKey/Secret style: each minted credential is its own
`duckgres_service_grants` row with its OWN identity (`credential_id`), its
own TTL, and its own rotation clock. Storage NEVER touches
`duckgres_org_users` — there is no shared login row for an operator rotation
or password update to clobber mid-run, and service credentials are not
project-scoped (root-shaped: unrestricted at the org level).
**Caller contract:**
`POST /api/v1/orgs/:id/service-credentials` with
`{principal, ttl_seconds?}`. `principal` is audit attribution
(`"dagster:events-backfill"`) only: every request creates an independent grant
with a new `credential_id` and secret, even when principals are identical.
`ttl_seconds` is clamped to [1 min, 1 h]
(default 15 min, the RDS-IAM precedent). Response is
`{credential_id, credential_secret, expires_at, connect}`; all fields are
always present. `POST /api/v1/orgs/:id/service-credentials/refresh` with
`{credential_id, ttl_seconds?}` rotates the named grant's secret by default.
Explicit `rotate_secret: false` renews only a live, non-revoked grant without changing its secret, preserving Trino gateway query ownership.
Mint and default refresh return `secret_rotated: true`; non-rotating renewal returns `secret_rotated: false` and omits `credential_secret`.
Clients must require the explicit false marker before retaining their in-memory secret, because an old server may ignore the renewal option and rotate.
When Trino service authentication is configured for the assigned cell and that cell is ready, responses also include `trino_connect` from its authoritative client endpoint.
`DUCKGRES_TRINO_SERVICE_AUTH_SECRET_FILE` is an opt-in JSON map of configured immutable stored cell IDs to dedicated token rotation sets (see the Trino service credential runbook).
The validation endpoint derives the calling coordinator cell from its bearer token and checks the org's current persisted cell assignment on every request; empty assignments and cross-cell grants are denied.
The caller is the internal-secret-authed PostHog backend — the routes sit
next to the other provisioning routes for exactly that trust class, NOT on
the admin/console side.
The `connect` block tells the caller WHERE to use the credential from the same
authoritative CP response that issued it, so nothing downstream re-derives its
own idea of the warehouse endpoint out of band (a Django `DuckgresServer` row
is exactly the drift this kills). It is
`{host, port: 5432, database: "ducklake", sslmode: "require"}`.
**`connect.host` is ALWAYS the org's canonical ingress name
`<org-id>` + the CP's configured managed-ingress suffix** (e.g.
`<org-id>.dw.us.postwh.com`) — the very value the pgwire TLS `server_name`
pins (the wildcard cert is `*<suffix>` and the SNI router resolves the
single-label prefix as the org; see "Native metadata Postgres proxy" /
`controlplane/sni_kubernetes.go`). It is one logical name handed back verbatim
for every caller: NEVER a pod IP, NEVER a ClusterIP, NEVER resolved per caller
network. How that name resolves for a given caller — public ingress vs an AWS
PrivateLink endpoint for dagster workers — is the caller network's business,
not the CP's. The CP wires the suffix from its first configured
`ManagedHostnameSuffixes` entry (`DUCKGRES_MANAGED_HOSTNAME_SUFFIXES`) at the
`RegisterAPI` site (`controlplane/multitenant.go`), falling back to
`provisioning.DefaultManagedIngressSuffix` when unwired.
**The load-bearing invariants:**
- **The credential IS its own grant row.** `credential_id` is
`svc_<24 random hex>`, generated server-side, never caller-supplied; the
client presents it as the pgwire username and the plaintext secret as the
password. The bcrypt hash lives on the `duckgres_service_grants` row —
`duckgres_org_users` holds NO service-credential material, so operator
writes to the org's users table can never invalidate (or be depended on by)
a minted credential.
- **Auth resolves `svc_`-prefixed usernames against the grants snapshot map
ONLY** (`Snapshot.OrgServiceGrant`, loaded alongside `OrgUserPassword`).
Expiry and revocation are enforced at that lookup: unknown, revoked,
expired, and hash-blanked credentials all fail identically (with equalized
bcrypt time — which state a credential_id is in is not probeable). A live
grant is `revoked_at IS NULL AND expires_at > now()`.
- **Mint always creates.** `principal` is non-unique audit metadata, never an
identity or reuse key. Every mint inserts a new row, returns its new ID and
plaintext once, and leaves every same-principal credential untouched. This
lets concurrent jobs keep a stable principal without sharing secrets.
- **Management is by `credential_id`.** A caller keeps the ID returned by its
mint and supplies it to refresh/revoke. Losing the plaintext means minting a
new credential; plaintext cannot be recovered from the stored bcrypt hash.
New Duckgres servers ignore the removed `force_rotate` JSON field so an old
caller can still reach the always-create endpoint, but that compatibility
is one-way: deploy the always-create Duckgres version to the whole fleet
BEFORE removing PostHog's `force_rotate`/reuse fallback. A new caller talking
to an old server could otherwise receive a reused ID without plaintext.
- **Refresh always rotates and never creates.** Unknown `credential_id` →
404; a REVOKED grant → 410 (revocation is terminal: refresh never
resurrects). An EXPIRED (but unrevoked)
grant MAY be refreshed — expiry only refuses NEW handshakes, so refresh is
how a caller that missed the window recovers without minting a second
identity for the same principal.
- **Established sessions are NEVER torn down** — not on expiry, not on
rotation, not on refresh (the mint plane is separate from connection
scheduling). Freshness is enforced only at the pgwire handshake — the
RDS-IAM / Cloud-SQL-IAM contract. A long job's existing connection rides
to completion; each NEW connection mints or refreshes afresh.
- **Revoke keeps the row** (`DELETE /api/v1/orgs/:id/service-grants/:credential_id`):
`revoked_at` is stamped and the hash is BLANKED server-side, so a leaked
credential can never authenticate again and the provenance (principal,
mint/rotation times) survives for investigation. `GET
/api/v1/orgs/:id/service-grants` is the flat, all-statuses list — no
plaintext, no hashes (`PasswordHash` is `json:"-"`). Durable history is
intentional because expired, unrevoked IDs remain refreshable and audit
provenance must survive; do not add expiry deletion. If grant volume makes
the operator list expensive, add pagination/archival as a separate API
change rather than weakening lifecycle semantics.
- **Org deletion deletes all of that org's grant rows.** This is the explicit
lifecycle boundary to the normal durable-history rule: leaving a grant
behind would let its old secret authenticate if the org name were reused.
Org creation takes the same advisory lock and clears pre-existing orphan
grants only when the locked lookup confirms no current org row; duplicate
creation and re-provisioning an existing org must preserve live grants.
- **Concurrency**: each mint owns a new row. Mint/refresh/revoke still take
`LockOrgConnectionAdmissionTx` so org lifecycle changes cannot leave orphan
grants and management of a named ID remains serialized.
- **Admission recognizes authenticated `svc_` identities as root-shaped.**
They consume the org vCPU budget but do not require an org-user row or carry
a normal per-user cap. Admission and post-acquisition worker switching do
not reapply expiry/revocation: those are handshake-only checks, so an
established session rides to completion.
- **After the write, THIS replica's snapshot is reloaded immediately**
(`ReloadSnapshot`), then a best-effort `/api/v1/internal/reload-snapshot`
fan-out to peer replicas (`PeerFanout`, wired from the same
`clusterPeerFetcher` the admin kill-switch uses). Without both, a freshly
minted credential routinely fails its first auth on whichever replica the
pgwire connection lands on.
- Touching any of this → update `controlplane/provisioning/service_credential.go` +
`_test.go`, `controlplane/configstore/service_credential.go` +
`_postgres_test.go`, `controlplane/admin/api.go` (list/revoke), and the
caller-side minter in the PostHog repo
(`products/managed_warehouse/backend/service_credentials.py`).
## 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.computeMeter` is 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 in `control.go::handleConnection`
from `workerBillingSize(workerProfile)`, remote-only). `workerMillicores==0`
(non-remote / unknown) → metering skipped. The metric is computed once at the
SAME teardown point as `CloseConnectionMetrics` (the `#841` lifetime defer),
via `server.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 with
`bucket_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/ack`
advances the single global cursor monotonically and deletes buckets
`≤ watermark_high` in 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 (`RequireAdmin` on both routes, registered inside the
audited `/api/v1` group in `multitenant.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 via `GET/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's `schema_name` and the legacy
`events_table_name`/`persons_table_name`/`schema_data_imports_name`
overrides (NULL = derive from `schema_name`: `<schema>.events`,
`<schema>.persons`, `<schema>_data_imports`), because the PostHog backfill
replaces migration 000024's `team_<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 REQUIRES `team_id`
(`ErrProvisionTeamRequired` → 400; `default_team_id` is 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`) where
`schema_name` is immutable. Shared rules live in
`configstore.UpsertOrgTeamTx` / `DeleteOrgTeamTx`; tests:
`tests/configstore/org_teams_postgres_test.go`, the provisioning/admin API
tests, and `org_teams_crud` in 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-only `DUCKGRES_STORAGE_SAMPLE_INTERVAL`;
e2e uses 60s) and credits exactly `tracked_bytes × interval` byte-seconds —
no elapsed-time tracking, a missed sample under-bills one interval. The SUM
is over `ducklake_data_file` + `ducklake_delete_file` with NO snapshot
filter (never `ducklake_table_info()`/`ducklake_table_stats` — current-
snapshot-only / approximate). byte-seconds are NUMERIC (BIGINT overflows);
served as exact-decimal GiB-seconds (÷2³⁰ terminates;
`byteSecondsToGiBSeconds` big-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`.
- **The admin console usage views read the SAME buffer** —
`GET /api/v1/usage/monthly` (the **Usage** page) and
`GET /api/v1/orgs/:id/usage/daily` (the org detail page's **Usage** charts)
in `controlplane/admin/usage_api.go`, backed by
`configstore.Aggregate{Compute,Storage}Usage{Monthly,Daily}`, sum retained
buckets per UTC month / per UTC day per (org, team), merging the compute and
storage families and joining the team schema name for display. Both
self-gate with `RequireAdmin` (per-team cost data across all orgs is as
sensitive as the raw billing families — viewers get 403, and the UI hides
the nav item / fires no query for them). The daily endpoint's org scope is
the `:id` path segment flowing into the queries' WHERE clause — one org's
usage must never leak into another org's page (the e2e asserts
`.org_id == $o` on the response). These are operations views, NOT invoices:
acked buckets are already deleted and >30d buckets are
GC'd, so responses carry the ack cursor as `watermark_low` and the UI
shows the retention caveat instead of implying all-time totals. They add NO
second accounting pipeline — keep them pure reads over the buffer.
The Usage page also carries a **client-side pricing-sensitivity calculator**
(`ui/src/pages/UsagePricing.tsx` + `lib/pricing.ts`): named unit-price
scenarios ($/CPU-min, $/GiB·min, $/GiB·h) priced against each org's month
totals. It is pure browser math over the monthly rows — no endpoint, no
persistence beyond the operator's own localStorage — so it inherits the
page's admin-only gate and needs no server-side access control of its own
(a PM gets it by holding the console admin role; a lighter pricing-viewer
role is a named follow-up, not implemented).
- 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 in
`tests/configstore/migrations_postgres_test.go`, and the
`compute_usage_pull_api` assertion (compute + storage, incl. the
`usage-monthly` checks) in
`tests/mw-dev/e2e/harness.sh`. Touching the monthly/daily aggregation or
the usage views → update `controlplane/admin/usage_api_test.go`,
`tests/configstore/usage_monthly_postgres_test.go` +
`usage_daily_postgres_test.go`,
`ui/src/pages/Usage.test.tsx` + `OrgUsage.test.tsx`, and the
`usage-monthly` / `usage-daily` harness checks.
## 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 with `writable=false` — vanishing would read as tenant REMOVAL
downstream, not a pause. The state enum is open: a new state MUST be
classified into `discoveryStates`/`discoveryExcludedStates`
(`TestDiscoveryStateClassification` is 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, in `resolveTeamTables`; the legacy overrides are
BARE identifiers (never schema-qualified), enforced at every write surface
by `configstore.ValidateOrgTeamTableName`.
- **`enabled` is 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_generation` is an opaque change token**: max `updated_at` over
ALL warehouse+org+org-team rows regardless of state, read BEFORE the data
queries. Its delete-visibility depends on `DeleteOrgTeamTx` touching the
parent org row (a bare team DELETE leaves no timestamp behind) —
`TestLatestConfigChangeCoversTeamsPostgres` pins 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 bump `updated_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 in `multitenant.go` behind `admin.AnyTokenAuthMiddleware`): the
read-only discovery secret (`--read-only-secret` /
`DUCKGRES_READ_ONLY_SECRET`, sent in `X-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 admin
`api` group and never accept `readOnlyTokens` anywhere 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) and `TestReadOnlyGroupTopology` (the group's exact route
set, against the real `registerReadOnlyGroup` wiring). 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 the
`discovery_endpoints` assertion in `tests/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>`, labels `app=duckgres-reshard` +
`duckgres-op-id`, restartPolicy Never, TGPS 600, requests=limits from the
env-only `DUCKGRES_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 in `k8s/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 `resharding` orgs
under the same per-org advisory lock `SetWarehouseResharding` takes for the
`ready→resharding` CAS. 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 `cnpgShard` change 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 on `spec.metadataStore.retainCnpgOnFlip`
(charts). **External stores are never modified/deleted.**
- **cnpg→ext escape hatch = orphan-adopt then verified-delete** (charts
`retainCnpgOnFlip`): copy → verify source → set `retainCnpgOnFlip=true` AND
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 in `postgresql.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 observed
`managementPolicies`) → flip type to external (now
ORPHANS, not deletes, the cnpg role/DB) → verify the external catalog row
counts match the copy EXACTLY → only THEN `DROP DATABASE` the 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 lacks
`retainCnpgOnFlip` (patch pruned), REFUSE the reshard ("deploy charts first")
— safer than the destructive delete-on-flip.
- **DEPROVISION-UNAFFECTED (non-negotiable)**: `retainCnpgOnFlip` defaults 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_cnpg` in the e2e is the regression net.
- **Pre-flip catalog backup (safety net, `backup_catalog` step,
`catalog_backup.go`)**: after drain + pause-compaction + `recordSource` and
BEFORE any flip (for EVERY direction), the runner `pg_dump`s the SOURCE catalog
to the org's OWN S3 data bucket under
`s3://<bucket>/_reshard_catalog_backups/op-<id>-<ts>.dump` (custom format →
`pg_restore`; password via `PGPASSWORD`, never argv; bucket/region/IAM-role
from the duckling CR status; upload creds via STS AssumeRole of the org's own
role, injected as an `AssumeRoleFunc` from 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 on `backup_s3_uri` (migration `000021`) + the op
log, with the exact `pg_restore` recovery 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
needs `s3: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's `postgresql-18-client` apk, in BOTH `Dockerfile` and
`Dockerfile.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.credentialSecretRef` names one key in a Secret in the
`ducklings` namespace; `DucklingClient.Get` resolves 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 in `resharding`** — 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 via `sslModeFor` (cnpg → disable, external → require) — never
hardcode an sslmode.
- **The compaction pause must never let XRD defaulting disable DuckLake**:
`SetCompactionEnabled` pins the org's effective `spec.ducklake.enabled`
(legacy type coupling, as the activator derives it) into the patch when the
CR has no `spec.ducklake` object — otherwise materializing the object gets
`enabled: false` stamped by the XRD default and every later activation
fails ("tenant activation requires a ducklake metadata_store"). The pinned
value is never removed afterwards (deliberate — see
`TestSetCompactionEnabledPinsDuckLakeEnablement`).
- **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/password` on the CREATING
replica's pod IP (the URL — never the password — is persisted on
`password_url`, migration `000022`, 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`+`AdoptClaimedOperation` path — 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-managed `rds!…`/`rds/…/master` names
(`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 1` pre-flight connection check to the external
target, `admin.ExternalTargetProber` over `provisioner.PGCatalogCopier.Probe`,
`sslmode=require`, wired in `multitenant.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 fresh `opRun`. A runner that CLAIMS an op a
prior epoch advanced (crash-takeover or replica switch) MUST call
`reconstructProgress()` before `run()` — otherwise a cancel/failure that
short-circuits before the steps re-execute (e.g. `run()`'s first
`cancelRequested()` check) rolls back with all-false flags and silently skips
unblocking the warehouse (org stuck in `resharding`) and restoring compaction.
Reconstruction is conservative: `blocked` ← `blocked_at` set OR step past
`blocking`; `compactionPaused` ← step past `pausing_compaction` (proves the
prior setting was recorded, else restore could wrongly re-enable compaction);
`flipped`/`retainRequested` ← step reached `cutover`/`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 op `cancelled` while leaving the warehouse blocked.
- Touching any of this → update `tests/configstore/reshard_postgres_test.go`,
`provisioner/reshard_runner_test.go` (incl. the `TestReshardBackup*` 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 in `tests/configstore/migrations_postgres_test.go`, the
netpol assert in `tests/manifests/manifests_test.go`, AND
the `reshard_*` + `lifecycle_teardown_cnpg` assertions in
`tests/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 + `retainCnpgOnFlip` XRD field)
lives in the `charts` repo and is covered by
`charts/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).
## Trino Cells (customer-facing SQL over DuckLake, `kubernetes` tag)
An org enabled for Trino gets a catalog, a login, authorization and resource
limits on a shared multi-tenant Trino cluster called a **cell**. The control
plane is the only writer of that state: `provisioner/trino_provisioner.go`
projects it every controller tick from `duckgres_managed_warehouse_trino` +
the org's warehouse row + its Duckling CR. Enablement is env-inferred —
`DUCKGRES_TRINO_COORDINATOR_URL` identifies legacy (`controlplane/trino_inputs.go`).
The optional `DUCKGRES_TRINO_CELLS_FILE` adds namespace-isolated logical cells
with blue/green backends. It requires legacy unless
`DUCKGRES_TRINO_REGISTRY_ONLY=true` explicitly selects a registry-only deployment.
That mode requires a valid registry and forbids a legacy coordinator URL.
Registered reconcilers never claim unassigned warehouses. The optional
`DUCKGRES_TRINO_DEFAULT_CELL` assigns unowned tenants atomically at enablement;
see [the placement runbook](docs/trino-cells.md#automatic-placement-runbook).
With no Trino configuration, the branch never wires. **Trino is binary: if
you asked for it, a wiring failure is fatal at startup**, because silently
skipping leaves the cell's OPA sidecar serving a last-good bundle while
password/tenant/catalog changes never propagate.
- **The catalog is DuckLake and carries NO secret.** Per org:
`connector.name=ducklake`, `ducklake.metadata.connection-url` (a JDBC URL
whose `sslmode` follows the store kind — `disable` for in-cluster
`cnpg-shard`, `require` for `external` and any future kind),
`ducklake.metadata.connection-user`,
**`ducklake.metadata.connection-password-file`**, `ducklake.data-path`,
`fs.s3.enabled` (NOT `fs.native-s3.enabled` — that spelling was rejected at
CREATE CATALOG and cost a release), `s3.region`, `s3.auth-type=IAM_ROLE`,
`s3.iam-role` (the per-org duckling role: the tenant S3 boundary), and a
small `s3.max-connections`. Every value comes from the `ManagedWarehouse`
row's `metadata_store_*` / `s3_*` / `worker_identity_*` blocks. **Trino logs
the full `CREATE CATALOG` statement, renders catalog properties in its web
UI, and ships them to workers — a password in a property is readable by
anyone who can see a query listing.** Hence the file indirection; never add
`ducklake.metadata.connection-password`.
- **Tenant passwords live in one Secret, keyed by org id**
(`TrinoTenantSecretName`, mounted at `TenantSecretMountPath`). The value is
read from the org's Duckling CR status (`credentialSecretRef`) through the
SAME resolver the worker activation path uses, so Trino and the DuckDB
workers can never authenticate a tenant's metadata store with two different
credentials. The projection is AUTHORITATIVE, not additive: a disabled org's
key is removed on the next tick.
- **Tenant client coordinates are non-secret and readiness-gated.** The org
detail endpoint returns `status.connection` only while the catalog is ready
and the coordinator is reachable. `DUCKGRES_TRINO_CLIENT_URL` names the HTTPS
endpoint clients should dial; when unset, the control plane falls back to its
coordinator URL and TLS server name. The response contains host, port and the
tenant principal, never a password or password hash. A client URL whose
leading host label is `{database_name}` (registry `client_url` or the env
var; `ResolveTrinoClientURL`) advertises each org's own
`<database_name>.<domain>` with username `root`: the Trino fork qualifies a
login with the org its host names
(`http-server.authentication.password.host-qualified-user.domains`), so a
user types the same username and host on both engines. Set it only once
that host actually reaches Trino — advertising an unrouted host is worse
than advertising the shared one.
- **`Reconcile` order is load-bearing**: cluster secrets → auth files →
resource groups → OPA bundle → tenant passwords → catalogs, and the
`globalErr` gate SKIPS the catalog step if any projection failed. A
coordinator that just lost its `password.db` keys 401s every catalog REST
call, which would surface as a misleading "catalog reconcile failed" masking
the real problem.
- **`ensureClusterSecrets` is write-once with a sentinel.** The K8s Secret is
the source of truth for each cluster credential; the configstore holds only
a one-bit `duckgres_trino_cluster_bootstrap` row per namespace. Missing
Secret + not bootstrapped ⇒ generate; missing Secret + already bootstrapped
⇒ **fail loud**, because regenerating the env-projected
internal-communication shared secret would split-brain a running cluster.
Admin and observer password/hash pairs are the deliberate exceptions:
missing or incomplete pairs regenerate together. Each attempt reads both
keys from one Secret snapshot and updates with that snapshot's resource
version. A create/update conflict reloads and adopts the winner, with five
attempts per call. Complete but invalid pairs fail without writing; transient
API errors also fail without attempting repair. No advisory lock serializes
these Kubernetes writes. Never overwrite an existing complete pair to repair
a suspected bootstrap race. Confirm stored corruption before operator recovery.
- **Static catalog reconcile is `SHOW CATALOGS` first**: create only what's missing,
drop only names matching `opa.ManagedCatalogPattern` that aren't wanted, so
`system`, `jmx` and hand-made catalogs survive. An org whose password is
momentarily unresolvable keeps its existing catalog (never dropped) but is
NOT reported ready.
- **Shared catalog mode is explicit and fenced.** Registered cells can use
`catalog_management: paused` for compatibility rollout or `gateway-shared`
for sole-active-backend catalog writes. Freeze new admissions and DDL before
warming the standby; bulk-check catalog states and admitted tenant credentials,
cut over, then release before draining. See
[docs/runbooks/trino-shared-catalogs.md](docs/runbooks/trino-shared-catalogs.md).
Never replay CREATE across both slots. A remote FAILED query is not proof
that synchronous DDL stopped; retain the durable intent until fenced recovery.
Paused and non-owner replicas still refresh auth and OPA projections.
- **Provisioner catalog inventory is narrowly authorized.** The admin can read
`catalog_name` and `state` from `system.metadata.catalogs`, in addition to
the existing node inventory. The observer and customer principals cannot.
`FAILING` startup placeholders are not usable catalogs.
- **Catalog naming is a THREE-way contract**: `TrinoCatalogName` (`org_` +
sanitized org id, no `_iceberg` suffix — warehouses are DuckLake),
`opa.ManagedCatalogPattern`, and the regex literal inside `policy.rego`.
`TestTrinoCatalogNameMatchesManagedNamePattern` +
`TestPolicyRegoContainsManagedNamePattern` fail if any one moves alone.
- **A Trino principal resolves to its org by EXACT lookup, never by splitting.**
`configstore.NewTrinoPrincipalOwners` indexes the principals the auth files
project (bare `<database_name>` → root, `<database_name>.<username>` → that
login). Usage metering (`trino_usage_collector.go`, which also passes the
duckgres username — not the principal — to the team resolver) and the admin
console both use it, so a per-user login's queries are its org's usage and a
principal outside the password file is never attributed to anyone.
- **Every duckgres login authenticates to Trino, under `<database_name>.<username>`.**
`ListTrinoEnabledOrgs` returns the org's logins in `Users`, and
`BuildTrinoAuthFiles` writes one `password.db` line per login with the
bcrypt hash **copied through unchanged** — it is the same hash pgwire
verifies, so one password works on both engines and nothing is re-hashed or
minted. The bare `<database_name>` principal survives alongside them and
authenticates with root's hash, so it follows root's login row exactly: no
`root` row, a disabled root or a blank password leaves `RootPasswordHash`
empty and drops ONLY the bare principal — from `password.db`, the pool's
Gateway binding, `TrinoPrincipalOwners` and the advertised
`status.connection` alike. (`TrinoPrincipalOwners` separately keeps a
root-independent database -> org index, so minted `<db>.svc_…` service
credentials are attributed whether or not root exists.) The org is still listed and its other logins
still work; a root-less org is enabled normally (no root preflight). Three
rules that are load-bearing rather than cosmetic: (1) usernames are
projected through an **allowlist** (`trinoUsernamePattern`) because
duckgres barely validates them and a `:`, `,` or newline would let whoever
can create org users append lines to `password.db` — including an admin
line; (2) `rejectPrincipalCollisions` now also holds back orgs that derive
the same Trino username, since the password file is ONE flat namespace per
cell and a duplicate line is a cross-tenant auth bug; (3) the resource-group
selector's `orgCaptureRegex` captures only up to the first `.`, or every
login gets a private leaf with the full per-tenant limits and an org with
ten logins holds ten times its budget.
- **A project-scoped login (`project_reader` / `project_user`) joins
`scope_<org>_team_<id>`, NOT the org group.** The scope group owns the same
catalog in `group_catalogs` — so the cross-tenant check is the unchanged
check — and carries a `group_scopes` document that narrows it to that
team's schemas. Scopes only ever SUBTRACT; keep it that way if the rules are
restructured. The scope comes from `OrgUserQueryAccess`, the same derivation
the pgwire path uses, so Trino and DuckDB cannot disagree about it, and a
scoped row whose scope will not resolve is DROPPED rather than projected
unscoped. Scoped logins get **no write authority at all** — `project_user`
is read/write on pgwire and read-only here, a narrowing; making it writable
means gating writes per-schema, not per-catalog.
- **The Rego policy is the tenant-isolation boundary.** The cell can assume
every per-org duckling role, so nothing below OPA stops org A reading org
B's catalog. Treat `provisioner/opa/policy.rego` as security review.
**Query visibility is same-org only**: `ViewQueryOwnedBy`,
`FilterViewQueryOwnedBy` and `KillQueryOwnedBy` (the plugin's exact
operation strings — the filter one is NOT `FilterViewQuery`) are allowed
only when the requester and the query OWNER share a bundle-known
`org_<sanitized>` group, derived from the same `data.group_catalogs`
ownership map every other decision uses. This matters because
`ExecuteQuery` is unconditionally allowed, so without it org A reads org
B's SQL text — table names, filter literals, customer identifiers — via
`system.runtime.queries` and the web UI, and can kill B's queries. The
owner arrives as `input.action.resource.user.{user,groups}` (an
`@JsonUnwrapped` `TrinoIdentity`); `ImpersonateUser` uses a different,
groups-less shape for the same field — do not conflate them. **The admin
principal deliberately gets NO cross-tenant query visibility** (only its
own queries): the reconcile loop issues only `SHOW`/`CREATE`/`DROP
CATALOG` and never reads `system.runtime.queries`, so the grant would buy
nothing and leak every tenant's SQL. **Every filter op needs a `batch`
rule as well as an `allow` rule.** `opa.policy.batched-uri` is enabled
(without it, filtering a catalog with >1024 tables overruns the OPA
client's queue), and `filterViewQueryOwnedBy` goes through it like the
catalog filters do. A missing `batch` rule fails closed, which for query
visibility looked like "org-mates' queries are missing" rather than an
error — Trino short-circuits self-ownership before OPA, so each tenant
still saw its own query. `TestBatchedQueryFilteringMatchesNonBatched`
pins the two shapes to the same decision.
- **The observer principal (`__duckgres_observer`) is the admin console's
read-only identity**, and is deliberately NOT the provisioner's admin.
Split authority: admin does CREATE/DROP CATALOG and sees only its own
queries; the observer gets cluster-wide `ViewQueryOwnedBy` /
`FilterViewQueryOwnedBy` / `KillQueryOwnedBy` plus `ReadSystemInformation`
(one op gating EVERY MANAGEMENT_READ resource — `/v1/node` and
`/v1/resourceGroupState`, which the console reads, plus `/v1/thread`,
`/v1/announce` GET, `/v1/maxActiveSplits`, `/v1/integrations/gateway`; all
GETs of operational state, enumerated in policy.rego) and
holds NO TENANT catalog — no `data.group_catalogs` entry, and the observer
group is excluded from `tenant_owns_catalog` and from the same-org query
match, so even a mistaken bundle entry grants no tenant data access. Its
ONE data grant is `system.runtime.nodes`: `AccessCatalog` on `system` plus
`SelectFromColumns` pinned to that single table, because a cell on the
default `discovery.type` serves no `/v1/node` and `/v1/announce` carries
neither health nor version. `AccessCatalog` alone opens nothing — every
read still passes `SelectFromColumns` — so `system.runtime.queries`
(tenant SQL), `system.metadata.*` and `system.jdbc.*` (which would
enumerate every tenant catalog, schema, table and column) all stay denied;
`TestObserverSystemGrantIsPinnedToTheNodesTable` pins that. `is_observer` is
the same username-AND-group conjunction as `is_admin`. Its credential is
a second regenerate-if-missing pair on `trino-auth`
(`ensureCredentialPair`), projected into password.db/group.db and read by
the console through `TrinoProvisioner.ObserverCredential` on every call
(never captured — a self-heal would otherwise 401 forever). Console reads
redact SQL before it leaves the CP. See `controlplane/admin/README.md`.
- **Resource groups must keep a selector for BOTH operational principals**
(`root.admin.__admin_provisioner` and `root.admin.__duckgres_observer`).
The final selector matches user `(?<org>.*)`, which matches anything, so a
principal without its own lane is admitted as a tenant into
`root.tenants.free.<principal>` — and those leaves are `JmxExport: true`,
so it would appear as a phantom tenant in the per-tenant resource-group
metrics.
- **Resource groups must keep the `root.admin.__admin_provisioner` selector.**
Trino rejects a query matching no resource group, so dropping it silently
breaks every reconcile tick's own DDL.
- **Cells, minimally**: `trino_cell_id` on the row names the owning cell
(`DUCKGRES_TRINO_CELL_ID`, default `configstore.DefaultTrinoCellID`). A
provisioner claims unassigned orgs (`ClaimTrinoCell`, conditional in SQL so
no cell can steal another's tenant), reconciles its own, and ignores the
rest — including writing NO state for them. Claims return an authoritative
boolean: a lost claim must never project the losing cell's tenant. Only
legacy claims unassigned warehouses. Registered logical IDs have the reserved
storage prefix `registered:`; the old stored `cell-001` remains legacy.
Admin-only initial selection runs before first enablement and refuses changes
to any already owned warehouse, including a disabled one. An owned org moves
only through `POST /orgs/:id/trino/cell/move` (`ConfigStore.MoveTrinoCell`): a
compare-and-swap on the named source under the admission lock that resets
the row to pending. The source's authoritative projection then cleans the org
up and the destination provisions it, with NO overlap - the org has no Trino
in between. Per-org state writes carry `TrinoStateUpdate.CellID` so a stale
source tick cannot mark a moved row ready; keep that fence. No live
migration, capacity model, rebalancer, drain, or Gateway routing controller
is included.
See [docs/trino-cells.md](docs/trino-cells.md) for configuration and recovery.
- **Blue/green projections share one logical cell's namespace**, but internal
communication Secrets remain distinct, chart-owned read-only references.
Stopped backends do not receive catalog calls or observer polling. Every
running backend must reconcile its independent catalog set before the org is
ready. Cell failures do not block sibling-cell projections. Configuration is
startup-loaded and must agree across control-plane replicas.
- **The existing deployment's API identity is `legacy`.** The Trino console
exposes this name in `cell.id` and in owned orgs' `status.cell` / `orgs[].cell`.
`TrinoCell.StoredID` keeps the configured ownership ID private to the adapter.
Match connection readiness against the raw persisted ID, never the alias.
Do not rename org assignments, catalog-store keys or environment settings.
The general org endpoint still exposes the original `trino.trino_cell_id`.
Unknown and unassigned ownership values are not relabeled.
- **The bundle endpoint is mounted OUTSIDE `/api/v1`** (`/bundles/trino`) with
its own bearer auth, and `buildTrinoWiring` bootstraps SYNCHRONOUSLY so the
handler is constructed with the real token — there is no window where it
serves under a placeholder. Registered cells use `/bundles/trino/<cell-id>`;
each cell's token can read only its own bundle. Legacy retains the old path.
- Touching any of this → update `provisioner/trino_provisioner_test.go`,
`provisioner/trino_cluster_secrets_test.go`, `provisioner/opa/*_test.go`,
`provisioning/api_test.go`, `tests/configstore/trino_postgres_test.go` +
`trino_cluster_secrets_test.go`, the migration asserts in
`tests/configstore/migrations_postgres_test.go`, and — for anything the
admin console reads — `controlplane/admin/trino{,_client}_test.go` plus
the `ui/src/lib/trino.test.ts` derivations and
`tests/mw-dev/e2e/trino.sh`. Cell moves → also
`tests/configstore/trino_selection_postgres_test.go`,
`controlplane/admin/trino_fleet_test.go` and the `trino_cell_move`
assertion in `tests/mw-dev/e2e/harness.sh`.
## Shared Trino Compute Pool (`mode: "shared-pool"`, `kubernetes` tag) — LOAD-BEARING CONTRACT
Operator-managed pool of immutable Trino instances, replacing fixed blue/green
for a cell that opts in. **Ships disabled**: a cell without `mode:
"shared-pool"` in `DUCKGRES_TRINO_CELLS_FILE` behaves byte for byte as today.
Code: `controlplane/trinopool/` (pure: blueprint, phases, planner, catalog-version
port), `controlplane/trinocatalog/` (fenced catalog publisher),
`controlplane/trinogateway/` (Gateway protocol v1 client),
`controlplane/trino_pool_*.go` (config, effects, validation, operator, wiring,
publication barrier), migration `000041` (upstream owns `000040`,
the Trino backend selection).
- **Env, all default-off**: `DUCKGRES_TRINO_POOL_ENABLED` (a pooled cell with
this off FAILS startup — falling back to blue/green would hand the operator a
shape they did not ask for), `DUCKGRES_TRINO_POOL_OPERATOR_ENABLED` (off =
desired state is recorded, nothing external is touched),
`DUCKGRES_TRINO_POOL_GATEWAY_URL`, `DUCKGRES_TRINO_POOL_CATALOG_WRITER_ENABLED`
/ `_BOOTSTRAP` / `_DSN_FILE` / `_WRITER_IDENTITY`, and
`DUCKGRES_TRINO_POOL_CONFIG_CONFIGMAP` / `_NAMESPACE` / `_REGISTRY_KEY` (the
desired-state source, below — REQUIRED for a pooled cell), and
`DUCKGRES_TRINO_POOL_CATALOG_SCHEMA` (REQUIRED with the catalog writer: the
publisher credential names a database only, and the role's privileges are on
the cell's schema, so unqualified SQL would resolve against `public` where it
can neither create nor read), `DUCKGRES_TRINO_POOL_CATALOG_CELL_ID` (also
REQUIRED with the catalog writer: the catalog store's PARTITION — see the
identity bullet below; there is no default), and
`DUCKGRES_TRINO_POOL_PUBLISHER_IMAGE` (this
process's own digest-pinned image, rendered from the same chart helper as the
container and as the ConfigMap's `publisher-image` key — the projection fence
below compares the two).
- **The pool keeps the cell's identity**: routing group and namespace are
unchanged. Replacing compute never rewrites which warehouse lives where.
- **The catalog store's partition is its OWN identity**
(`DUCKGRES_TRINO_POOL_CATALOG_CELL_ID`, resolved by
`trinoPoolCatalogCellID`): it is the value every coordinator of the cell
carries in `catalog-store.cell-id`, and the rows the publisher writes must
carry exactly it or each coordinator reconciles an EMPTY catalog set. It is
not derived from the pool id — that id carries duckgres's reserved
`registered:` prefix and doubles as the warehouse-ASSIGNMENT key, so deriving
one from the other would force a cluster to spell its partition
`registered:<cell>` and would silently re-key every coordinator's read the day
a pool's logical id changes. It is **REQUIRED with the catalog writer and
fails closed** — unset, blank and unusable are one refusal, and there is
deliberately NO fallback to the pool id: a missing setting would otherwise
publish a full catalog set under a partition nobody reads, which is
indistinguishable from a store nothing has been published to yet. The chart
renders it from the same value it renders the coordinators'
`catalog-store.cell-id` from, and guards the pair the way it already guards
the schema pair. The pool row's
`publication_revision` checkpoint addresses the POOL
(`RecordTrinoPoolPublicationRevision` refuses a pool id the held lease does
not name), never the partition. Regressions:
`TestCatalogWriterPublishesUnderTheConfiguredStorePartition` and
`TestCatalogWriterRequiresAStorePartition`.
- **Missing/invalid desired config FREEZES the pool** at last-good state. It is
never a desired count of zero (that would delete the fleet) and never a
startup abort (a ConfigMap problem must not take the CP down). A pool that
vanished from the registry is an error (⇒ freeze), never an empty desired
state.
- **Desired state is published from the API OBJECT, not from the mounted copy
of it.** The registry and blueprint files are the BOOT source — they are what
tells the process a pool exists. Every publication is then derived from ONE
read of `DUCKGRES_TRINO_POOL_CONFIG_CONFIGMAP`, taken immediately before the
write, because a projected volume refreshes per pod on the kubelet's schedule
and a `subPath` mount never refreshes at all: two replicas can hold different
contents indefinitely, and an idle pod can publish what it read at boot. A
pooled cell that does not name that object REFUSES TO START; a silent
fallback would leave nobody able to say which configuration a live pool is
driven from. The blueprint's key is the last element of `blueprint_file` (the
mapping a ConfigMap volume performs), and both documents come from the SAME
read — two reads can straddle an update and pair a new registry entry with a
release the cluster has already replaced.
- **Fences, not leadership.** The janitor lease decides who executes; the
pool's `authority_epoch` fences every durable write — desired-state
publication, freeze and thaw included — every Kubernetes object (annotation,
compared before any adopt) and every Gateway call (`controllerEpoch`). Seeding
the pool row is the ONLY unfenced write and can only INSERT. A refused fenced
write ENDS the leadership term: re-acquiring would let a superseded controller
fence the valid leader straight back, and the two would trade the pool
forever. The fence owner is per-PROCESS, not per control plane.
- **Desired state is ORDERED as well as fenced.** The blueprint carries a
`generation`; a publication that would move it backwards is refused. Holding
the fence proves who may write, not that what they hold is current — a replica
carrying an older blueprint could otherwise publish it over a newer one as a
legal fenced write. The ordinal must come from the config source; a content
hash is not monotonic and would refuse valid configurations. It is a BACKSTOP,
not the freshness mechanism (a settings-only edit need not move it, and two
configurations can carry the same one — hence the API read above), and a
backwards generation FREEZES the pool while KEEPING the lease: it is a
configuration problem, not a lost fence, and reporting it as one ended the
leadership term every tick and handed the pool to a replica that did the same.
- **The catalog writer's fence IS the pool authority.** It is claimed when the
operator wins the lease, never at startup (where every replica would claim it
and the fence would distinguish nobody), and a takeover needs a strictly
higher epoch — an equal epoch held by another identity is a collision, not a
handover.
- **The published catalog revision is read from the catalog STORE, not from
whatever a publication last managed to write down.** The pool row's
`publication_revision` is a cache of it, and the admission gate certifies
members against that number. A catalog can commit while the follow-up write
of its revision fails — and nothing republishes it, because that catalog now
exists, so no later mutation carries the number forward. The gate would keep
certifying members against a revision predating the tenant, admitting it and
reporting the warehouse ready without its catalog. So: a failed checkpoint is
RETURNED from the publish path (not logged and dropped), the writer's claim
checkpoints the revision the takeover already read, and every tenant-admission
tick reconciles the row against the store's own writer state and fails CLOSED
when it cannot. Holding admissions does not hold the compute lifecycle —
`reconcileOnce` isolates that step — so a pool still repairs and drains.
- **A pooled cell answers the managed-Hoglake connector question from the
store.** Adoption of an existing catalog is refused unless its connector is
confirmed (`CatalogConnectors`), which on a coordinator-mediated cell is a
`system.metadata.catalogs` query. A pooled cell has no fixed coordinator, so
the pool writer answers from the `trino_catalogs` rows the coordinators
reconcile FROM. That is the PUBLISHED definition and is sound only for that
decision — whether any member has applied it stays with pool admission, which
proves it per member. Never read a published row as evidence that a catalog is
operational.
- **Failure is not drain.** SUSPECT excludes a member from new work; LOST
requires positive evidence that the admitted process ENDED and is
reported as failed. A repair NAMES the instance it replaces, so the Gateway
charges the repair budget instead of the single planned surge. There is NO
local path back from SUSPECT: the Gateway excluded the member and only a
fresh certified admission un-excludes it, so flipping the row back to SERVING
would claim a member serves while nothing is routed to it. A suspected member
always leaves — proven dead through the loss claim, or replaced through the
planned drain after `trinoPoolSuspectDrainAfter` when it cannot be proven
dead. A suspect is already excluded from serving capacity, so its drain does
not reduce the Gateway's serving count again. Planned ACTIVE drains retain
the serving-floor guard.
- **Capacity repair counts SERVING, not departing instances.** DRAINING,
SEALED and RETIRING members retain physical capacity and pinned work, but do
not satisfy desired serving capacity. Only one candidate may prepare at a
time. A repair keeps its configured repair slot throughout its nonterminal
lifetime, including SERVING. Its persisted `repair_for` reserves one target;
a second live repair must not target that same instance. When no normal slot
is free, an unreplaced departing member can be a repair target only while
serving capacity is short. Gateway independently validates registration
against active plus preparing capacity and its own repair budget. Deploy
that Gateway support before this planner. No repair permits deletion of the
original member, skips validation, lowers the serving floor, or exceeds the
configured desired + surge + repair capacity. Automatic normalization of
serving repair slots after their targets retire remains a separate TODO;
never clear the local repair flag without matching Gateway semantics.
- **Two observations prove a process ended, and both are statements Kubernetes
makes after the fact** (`processTerminationEvidence`): every recorded object
is verifiably absent, or **the exact container instance that hosted the
admitted process is no longer the one running in the pod that hosted it**.
The second is what a coordinator that restarts IN PLACE produces: it keeps
every object it had, so absence can never arrive, and the member was retained
until the drain timer — where a drain cannot finish either, because the dead
process's transactions stay pinned to it, holding the instance and its repair
slot forever.
**Exactness is the whole contract.** The container id is recorded at
registration (migration `000041`) precisely so a termination record can be
correlated with the admitted process. An earlier version accepted ANY
termination record on the hosting pod plus a differing identity from the
member's endpoint, and that is NOT evidence: a pod restarted once during
startup carries such a record for its whole life, and a second coordinator
pod makes the endpoint's answer ambiguous about which process replied —
together they declared a live, serving member dead and wrote off its pinned
work (regression:
`TestALiveAdmittedProcessIsNotDeclaredLostByAnUncorrelatedTermination`).
Everything ambiguous fails closed: an unreadable cluster, an unenumerated pod
list, more than one live coordinator pod, a member with no recorded container
id, a termination naming a different instance. A label-filtered listing
omission is NOT deletion (labels are mutable), a timeout is never evidence,
and nothing is deleted or restarted to manufacture proof.
**Evidence boundary, stated honestly:** this rests on what the Kubernetes API
reports. A force-deleted pod object, or a node partitioned from the API
server, can leave a process running that the API no longer describes; neither
observation can see that. Within that boundary "proven dead" means
"Kubernetes reported it dead" — which is what the user accepts, as distinct
from a timeout or routing ambiguity being classified as death.
- **A serving member's PROCESS is observed, not just its pod.** A container can
restart inside a Pod and report ready with the same pod UID and a new Trino
incarnation; the Gateway refuses to dispatch to anything but the boot identity
it registered, so a readiness-only check reports SERVING while that capacity
is gone. The identity is re-read against the admitted one, bounded to one
request per member per `trinoPoolIdentityObserveEvery`. A DIFFERENT identity
suspects the member; an unanswered probe is not evidence and changes nothing;
the stored identity is never quietly updated to match.
- **A FAILED_PREPARING candidate is cleaned up, not abandoned.** It is NOT
terminal. The Gateway must grant never-admitted retirement before Duckgres
deletes a registered candidate's UID-bound objects. Verified resource absence
then completes retirement and moves the local row to FAILURE_RETIRED, freeing
its capacity. No loss claim is needed. A PREPARING candidate whose immutable
release or blueprint digest differs from the fresh desired specification uses
this path, including configuration-only changes with the same image. Compare
the snapshot's blueprint digest, not the identity-bound instance SpecDigest.
VALIDATING may have an unknown admission result and must not use this shortcut.
- **Admission is a durable step.** The intent is recorded before the call, so a
lost response is resolved by read-back under the same identity. OK, FAILED
(a decision) and UNKNOWN (no answer) stay distinct. The step identity is the
business intent, never the authority envelope — hashing the whole request made
the retry after a lost response a permanent conflict. A step is recorded
UNKNOWN before the effect and UPDATED with the outcome after it; a decided
outcome is never re-decided. A failed step records `attempts` and
`next_attempt_at` (full jitter, 0.5s→30s) and is not retried until then — the
schedule is durable so a restart cannot reset it to zero — and a step that
succeeds, or an instance that reaches the end of its life, closes its
operation so `terminal_at` is not NULL forever.
- **Instance identity is persisted BEFORE any Kubernetes object exists**, and
names are deterministic, so a lost create is resolved by read-back rather
than by creating a second instance. Identities and live endpoints are never
reused (PK over terminal rows + partial unique index).
New instance names are `cell-<eight random hexadecimal digits>`, independent
of the logical pool ID. The suffix uses four cryptographically random bytes,
not a UUID or sequence. Coordinator and worker Deployments retain their
normal Kubernetes ReplicaSet and pod suffixes. Existing persisted instance
names and endpoints are unchanged; the shorter names appear on new instances.
- **Blueprint is not a template engine.** Argo delivers a validated JSON with
real `corev1.PodTemplateSpec`s; duckgres injects only object names, labels,
selectors, replica counts and the three env vars the `identity_binding`
declares. Images must be digest-pinned (sidecars included). Each instance
keeps its OWN blueprint snapshot, so a new release cannot change what a
running instance reads. Worker anti-affinity is NARROWED to the instance, not
dropped — a pool-wide selector would make instances fight for nodes.
- **Serving instances roll on blueprint changes, not only image changes.**
The planner parses each immutable blueprint snapshot and compares it with the
desired blueprint using the same current representation on both sides.
Worker replicas, pod resources, and configuration files use the existing
surge, admission, drain, and retirement protocol. Stored `SpecDigest` values
remain ownership and operation identities; encoder changes or uppercase hex
do not trigger rollouts or rewrite them. A malformed digest with a readable
snapshot nominates only that instance for guarded replacement. An unreadable
snapshot blocks only that instance's planned replacement and reports an error;
unrelated repairs, scaling, and eligible rollouts can continue. Repair the
unreadable record before attempting its retirement; never infer its namespace
from desired configuration. Registry-only identity changes such as node
environment or service port are not blueprint rollout triggers.
- **Deletion requires an irreversible Gateway retirement claim** for that exact
incarnation; the phase machine permits it only from `RETIRING` onwards (plus
`FAILED_PREPARING`, which provably never admitted work). Delete verifies each
object's UID itself before passing a precondition, and retirement completes
only on verified absence INCLUDING terminating pods. Pool-shared objects
named in the blueprint are refused by name in both apply and delete.
- **Drain has no deadline.** Sealing is driven by the Gateway's obligations
endpoint; a timer would be a decision to lose open transactions. The serving
floor refusal is surfaced, never overridden.
- **Candidate validation uses no canary.** The candidate is probed through its
OWN Service with the existing observer credential: `/v1/catalog/sync` must be
enabled, ready, zero failed catalogs, applied revision at least the pool's
DURABLE `publication_revision`, a process identity that does not change during
the pass, a worker count that agrees with the running pods, and the pods'
RUNNING images equal to the blueprint's pinned release. The `auth-revision`
check is claimed ONLY when every security component reported what it loaded
AND each projected component reports exactly what THIS control plane is
serving: the OPA bundle's revision (`opa.PolicyRevision`, published in the
bundle as `data.trino.revision` and digesting the POLICY BYTES as well as the
data — `policy.rego` lives in the duckgres binary, so the image check says
nothing about it), and the `sha256:<hex>` fingerprints of the projected
`password.db` and `group.db`. Every instance of a required kind must match: a
second password authenticator reads a file duckgres does not write. "The
component answered" is not evidence — a coordinator whose OPA still serves the
projection from before a tenant existed answers perfectly. A coordinator
without `opa.policy.revision-uri` reports OPA as unacknowledged, the check is
absent, and admission fails closed — deliberate, so `opa.policy.revision-uri`
is REQUIRED on a pooled coordinator, pointed at that revision document.
- **The authorization projection is FENCED for a pooled cell.** Every control
plane builds it from its own view and every replica serves it, so two things
are needed together. An ORDER: the config store records the accepted
projection (digest + revision) under the pool's authority, built from source
rows read INSIDE that transaction at `REPEATABLE READ`, with project scopes
derived from the same read rather than the polled snapshot — a revision
allocated for content read at another moment numbers bytes nobody can prove
were current. And an eligible PRODUCER: only a process whose own
`DUCKGRES_TRINO_POOL_PUBLISHER_IMAGE` — captured at STARTUP, from the same
chart helper that renders the container — equals the `publisher-image` key of
the same pool ConfigMap may advance it, compared after the authority is held.
The startup value is used rather than the pod's spec or a runtime image ID: a
pod specification can be edited under a running process, and the runtime
identity is the node's platform-specific digest rather than the manifest the
chart names. Both sides must be pinned by `@sha256:<64 hex>`; a floating tag
on either side is refused, because two equal tags are not evidence of equal
bytes. That is equality, never an ordering
of image identities — an older binary that wins the lease would otherwise
publish its own older `policy.rego` under a HIGHER revision, which no counter
can detect, and a deliberate rollback moves the desired value so the older
binary becomes eligible again. Every uncertainty fails closed, so a partial
rollout PAUSES pooled publication (OPA keeps its last-good bundle) rather than
regressing it. The auth Secret is written under that revision with the
object's own resourceVersion as the CAS and refuses to replace a newer stamp;
the bundle handler gates the bundle it CAPTURED before a 200 AND before a 304
(a 304 preserves exactly the stale bundle the fence exists to retire), and an
unreadable record is a refusal. That gate reads the durable record on EVERY
request and must stay uncached: OPA's periodic downloader activates each
bundle inline as it fetches it, so a cached "still accepted" answer does not
merely delay the truth, it INSTALLS a superseded projection after a newer one
was accepted and a tenant admitted against it. A replica that is not the
advancer still BUILDS and SERVES its projection — refusing would strand every
coordinator polling it on its last-good bundle — but writes the auth Secret
only when what it built equals the accepted digest, and being un-advanceable
is never reported as a reconcile failure (every replica but one is in that
state at any moment, and failing would mark every pooled warehouse Failed).
Candidate admission compares against the
accepted projection, not against what the local process last published.
Legacy cells install no fence and are byte-for-byte unchanged.
- **A retried step repeats the IDENTICAL request — every field, not just the
generation.** The Gateway journals each step under its identity and hashes
the WHOLE request body, minus the authority envelope, so a step whose effect
landed while its response was lost can only be resolved by repeating exactly
what was sent. Anything rebuilt from freshly read state reports a changed
intent, and that member can never finish the transition — not on the next
tick, not after a leader change, never. Three fields did that: the expected
generation (sealing read it back from the obligations the seal had just
moved), the loss claim's `observedAt` (re-stamped per attempt; it now carries
the durable moment the member was excluded), and registration's boot identity
(re-probed per attempt, so an in-place restart changed it). Where the request
genuinely cannot be reproduced — registration's observed identity, a
suspicion whose reason depends on which check fired first — the member is
READ BACK and what the Gateway recorded is adopted, never re-derived. A
read-back that FAILS stays an error: an unresolved operation identity is not
permission to send a body this controller derived meanwhile. The accepted
read-back states are explicit SETS, not a rank — the lifecycle is a graph
(SUSPECT ↔ DRAINING), and a retirement counts as a recorded loss only when
its kind is FAILED, because a DRAINED one completed a different transition.
`observedAt` is omitted from the loss claim: it is optional, the Gateway
stamps its own receipt time, and every value available here is either
re-derived per attempt or not the termination-observation time at all.
Regressions: `TestALostResponseDoesNotChangeTheRetriedRequest` and
`TestALostResponseDoesNotChangeTheRetriedMemberRequest`, against a fake that
journals the whole payload the way the Gateway does.
- **One instance never stops the pool.** `progressInstances` records a failing
instance and carries on, and the planner still runs, so a member that cannot
make progress no longer holds every repair, drain and replacement behind it.
Only a lost fence ends the sweep. This is the isolation the tenant loop
already applies.
- **Tenant admission is a committed publication, not a published binding.**
Publishing principals (PUT `…/tenants/{t}/principals`) tells the Gateway which
logins belong to a tenant; the Gateway dispatches work only for an ADMITTED
one, and only the barrier (open → receipt per active member → commit) admits
it. A receipt is EVIDENCE: each member's own `/v1/catalog/sync` must report
the published catalog revision applied and all three projection revisions
current before duckgres records it. A tenant that leaves the projection is
REVOKED, once (the durable row is kept); with the gate on, a warehouse is held
at Provisioning until its publication commits. Readiness then follows the
COMMITTED target revision, not the state field: a tenant that adds a login
opens a new attempt, and reading the state alone would flap a warehouse that
has served for weeks back to Provisioning because somebody created a user.
All of it reads the durable record (migration `000041`), never the
leader's memory.
- **A refusal settles a tenant's occurrence only on the FIRST request of it.**
The Gateway rolls a refused call back before it journals anything, so the step
identity stays unclaimed, and publishing principals carries no revision
ordering of its own. On a REISSUE an earlier copy of the same request may
still be executing there, so closing the occurrence would let the controller
advance to a newer intent, publish and checkpoint it, and then have that older
copy land and overwrite the Gateway's binding with the superseded principal
set — which duckgres never corrects, because it believes it already published
the newer one. A removed login would stay dispatchable and a current one could
not dispatch. `failTenantStep` therefore takes `firstAttempt`: true from
`publishChangedBindings`/`revokeDepartedTenant`, false from
`reissuePublication`/`reissueRevoke`. The property the tests pin is that what
duckgres has checkpointed always names the set the Gateway actually binds
(`TestACheckpointedBindingNeverDivergesFromTheGateway`), NOT that the newest
desired set wins.
**Known limitation (followup, not recovery):** a tenant whose first request
was UNKNOWN and whose reissue is then refused for a condition that never
clears stays HELD on that occurrence — it publishes no corrected binding and
is not revoked until the refusal stops. That is the conservative direction
(the alternative loses a binding silently), and resolving it needs either a
durable "was this occurrence ever unknown" bit or a Gateway way to burn a step
identity. Both are out of scope here; do not "fix" it by closing the
occurrence on a reissue.
- **One authoritative boot identity.** The coordinator's `processId` is probed
BEFORE member registration and is sent as `bootId` on both register and admit;
the Gateway requires the receipt to carry the pair it recorded. A restart
before admission fails the candidate (`FAILED_PREPARING`) rather than retrying
against an incarnation that no longer exists.
- **Transport is plain internal HTTP** to each instance's ClusterIP Service; TLS
terminates at the Gateway. Probes declare the forwarded HTTPS hop
(`X-Forwarded-Proto`) rather than relaxing auth, and
`allow-insecure-over-http` is never set. There is no pool certificate, no
private CA and no `tls_server_name` for a pooled instance. Credentials and
query data cross the cluster network unencrypted on this hop — accepted, with
coordinator NetworkPolicies a deferred follow-up, so pool admission must NOT
be described as non-bypassable by an in-cluster caller.
- **The Gateway client matches the Java source, not a design doc**
(`GET members` is a bare array; admission is one `admit` call with a nested
receipt; fields are `desiredMembers`/`maxRepair`; the Gateway computes the
guard payload hash itself). Decoding is pinned by fixtures generated from the
real records — regenerate with `tools/gatewaywire`. Pooled registration needs
a Gateway backend record, created INACTIVE and never activated through the
legacy route.
- **Gateway administration has two authentication layers when form auth is enabled.**
`DUCKGRES_TRINO_MANAGED_GATEWAY_USERNAME` selects the existing API-role
identity. The pool client sends Basic authentication with that username and
the existing rollout token as password, plus
`X-Gateway-Transaction-Admin-Token` for the pool capability check. An opaque
token is not a form-session JWT. An unset username preserves the token-only
Bearer mode for Gateways without the outer form-auth requirement. No new
secret or API authorization exception is introduced.
- **Principal binding is duckgres-authoritative.** The tenant's principal set
is derived from the SAME projection that writes `password.db`, asserted
identical in test; the gate would otherwise block real users or admit
principals Trino rejects.
- **Catalog writer**: one fenced transaction per mutation against the
Trino-side catalog store's schema (writer-state row lock, exact epoch AND
identity, journal replay resolution, `catalog_count` recomputed inside the
transaction; the tables are created by `trinocatalog.EnsureSchema` and
asserted against a real PostgreSQL in `tests/trinocatalog/`).
Takeover is explicit; a mutation never claims a higher epoch implicitly. A
lost COMMIT is resolved from the journal, never retried blind.
- Touching any of this → update `controlplane/trinopool/*_test.go`,
`controlplane/trinogateway/*_test.go` (fixtures are generated from the real
Java records — see `tools/gatewaywire/README.md`),
`controlplane/trino_pool_*_test.go`,
`controlplane/trino_pool_member_retries_test.go`,
`controlplane/trino_pool_publication_order_test.go`,
`tests/configstore/trino_pool_postgres_test.go`,
`tests/trinocatalog/*_postgres_test.go`, AND the
`trino_shared_pool_disabled` assertion in `tests/mw-dev/e2e/harness.sh`.
The pool's migrations are numbered ABOVE upstream's, so adding one means
extending `TestMigrationVersionsAreUnique`'s range implicitly (it reads the
embedded directory) and updating the version asserts in
`tests/configstore/migrations_postgres_test.go` — including
`TestConfigStoreMigration40PinsExistingTrinoBackends`, which rewinds every
version at or above 40 so goose can re-apply them in order.
## Logical Catalog Alias (`org_<database_name>` as the startup `database`)
A pgwire session may select its catalog by the name the org has on Trino
(`configstore.TrinoCatalogName`, `org_<sanitized database_name>`) instead of
`ducklake`. The catalog is the same one either way: the alias only renames it
on the wire. This exists so SQLMesh sees ONE catalog name across the Duckgres
and Trino engines and migrating between them needs no state rewrite.
- **What the startup `database` may be**: `""`, `ducklake`, or the
SNI-resolved org's own catalog name. Everything else is 3D000, as before.
- **Identity is still SNI-only, and this must stay true.** The alias is
compared against the catalog name of the org the managed hostname ALREADY
resolved — it is never a key into `DatabaseOrg`, `Orgs`, or any other map,
so it can neither discover nor select an org. A sibling tenant's catalog
name is just an unrecognized string and fails closed. If a change here ever
looks up an org BY the database name, it has reintroduced exactly what PR
#651 removed. See the comment in `ResolvePostgresConnection`.
- **Physical vs. visible**: `EffectiveCatalog` stays `ducklake` and is what
every statement executes against; `LogicalCatalog` (and
`sessionMetadataResult.visibleCatalog`) is the name reported by
`current_database()`, `pg_database`, `information_schema`, and the logs.
`visibleCatalogName` is the one place that chooses between them.
- **SQL written against the alias**: the transpiler's `LogicalCatalogTransform`
rewrites `<alias>.public.t` → `ducklake.main.t` (fed by `clientConn.database`
in `newTranspiler`), and `rewriteDirectQuery` expands `USE <alias>` to
`ducklake.main`.
- Opt-in per connection: a session that connects with `ducklake` or nothing is
byte-for-byte unaffected.
- Touching any of this → update `controlplane/configstore/store_test.go`,
`controlplane/session_search_path_test.go`, `server/direct_query_rewrite_test.go`,
`server/logical_catalog_alias_test.go`, `server/session_database_metadata_test.go`,
and `logical_catalog_alias` in `tests/mw-dev/e2e/harness.sh`.
## 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.
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
No one has posted yet. Be the first.

