fix(worker): detach client-attached catalogs at the session boundary - #1201
Conversation
Attached databases are instance-global, like secrets, so a client's `ATTACH ... AS db` survived its session on a hot-idle worker and was inherited by whichever session that worker served next. Two failures: - Correctness: clients attach with `ATTACH IF NOT EXISTS ... AS db`, a no-op when a previous session left a `db` behind, so the new session silently reads the previous session's target. On 2026-09-18 a tenant's sqlmesh run inherited an analyst's `db` (a different Postgres endpoint) and its parallel postgres scans failed with `SET TRANSACTION SNAPSHOT ... snapshot does not exist`. Every failing worker had served such an attach beforehand; every worker that had not was clean. - Isolation: an attached catalog freezes its connection string, credentials included, at ATTACH time, so it outlives wipeUserSecrets. Another user of the org could query it without holding the credential. detachUserCatalogs runs next to the secrets wipe: mandatory on every shared-warm CreateSession (a failure fails the session), best-effort at DestroySession. It preserves DuckDB-internal catalogs, the instance's primary database (resolved by lowest oid, since it is a file stem rather than `memory` when DataDir is set) and the worker-managed allowlist (ducklake, delta, __ducklake_metadata_*). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZnZTbiHkGWQ7485DoJB3E
Test Impact PlanDeterministic summary of how this PR changes tests, CI runners, and coverage-risk signals. Summary
Signals
Coverage risk: neutral or increased No coverage-reduction warnings detected. |
bill-ph
left a comment
There was a problem hiding this comment.
Reviewed commit bd13b2f.
Findings:
- No P0 blockers found. The session-boundary detach is mandatory in CreateSession, best-effort in DestroySession, preserves the primary and worker-managed catalogs, quotes identifiers safely, and includes unit/pool/e2e coverage for cross-session isolation.
- Non-blocking: CI unit tests and the MW-dev e2e job were still in progress at review time; please confirm they complete successfully before merge.
— Robo Bill
benben
left a comment
There was a problem hiding this comment.
Automated review generated on behalf of @benben.
Approve. Adds a session-boundary DETACH of client-attached catalogs on shared-warm workers (mandatory at CreateSession, best-effort at DestroySession), mirroring the existing secrets wipe. The mechanism, guard placement, primary-DB resolution, identifier quoting and error unwind all check out; unit tests are real DuckDB round-trips including the "destroy-time detach skipped" case. The main gap is that the preserve-list is name-based and a client can pick a preserved name.
- major
duckdbservice/user_catalogs.go:19Client can persist a catalog by using an allowlisted name. ATTACH 'postgres://…' AS __ducklake_metadata_x (or AS delta when DeltaCatalogEnabled=false) survives every session boundary. - minor
tests/mw-dev/e2e/harness.sh:2563Leak check can pass vacuously on a different worker. Nothing asserts root's and $u2's sessions shared one worker pod. - minor
duckdbservice/service.go:1264Detach shares the 15s secretCtx budget with wipe and replay. A slow postgres catalog teardown can consume the remaining budget and fail the session or skip replay.
Model: opus, PR authored with fable.
…budget Review follow-ups on the session-boundary detach: - Catalog-qualify the listing (system.main.duckdb_databases()). By this point in session create the default catalog is ducklake, and sessionmeta documents that an unqualified duckdb_databases() there starts a DuckLake transaction, which pays a full catalog reload when the schema version has moved. The session-init probe has normally paid for it already, so this is defensive rather than a measured win. - Give the detach its own deadline instead of sharing the secrets wipe's, so a slow detach cannot starve the persistent-secret replay that follows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZnZTbiHkGWQ7485DoJB3E
benben
left a comment
There was a problem hiding this comment.
Automated review generated on behalf of @benben.
Approve (re-review of new commits). The new commits move the catalog detach onto its own 15s deadline and catalog-qualify the database listing to system.main.duckdb_databases(). Both changes hold up: no context is used after the relocated wipeCancel(), and the qualified lookup is covered by the existing new unit tests.
Model: opus, PR authored with fable.
…r side Every `pg` call is its own session, whose session-create detach removes the catalog before a second call could observe it, so the check could only ever pass vacuously. Feed each side's statements to psql on stdin (one Q message per statement, same session), make the catalog name run-unique, assert the marker is visible in root's own session, and assert ducklake + memory survive the detach. Statement shapes verified against a local standalone server. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZnZTbiHkGWQ7485DoJB3E
benben
left a comment
There was a problem hiding this comment.
Automated review generated on behalf of @benben.
Approve (re-review of new commits). The new commit rewrites the e2e attached-catalog leak check to run each side's statements over a single psql session on stdin. The mechanics are right, but it hand-rolls the psql invocation instead of using the existing pg_script helper and so loses the harness-wide transient retry.
- major
tests/mw-dev/e2e/harness.sh:2568Inline psql bypasses pg_script's transient-retry set. A cold spawn ("failed to detect attached catalogs"/"capacity exhausted") on either session aborts the harness. - minor
tests/mw-dev/e2e/harness.sh:2576Leak assertion passes even if CreateSession detach is broken. Root's best-effort destroy-time detach (or a different worker for $u2) already yields count=0.
Model: opus, PR authored with fable.
Problem
On 2026-09-18 a tenant's hourly sqlmesh refresh failed 5
sources.db_*models (plus 1 on the retry an hour later, and 2 ad-hoc analyst queries) with:Failures landed 1–7s into each statement, were confined to a ~1h window, and every 4-hourly run before and after was clean.
Root cause is ours. Attached databases are instance-global, exactly like secrets, and the worker never detaches client-attached ones. So a client's
ATTACH ... AS dbsurvives its session on the hot-idle worker and is inherited by whichever session that worker serves next:psqlsessions doingCREATE OR REPLACE TEMPORARY SECRET analyst_secret (...)+ATTACH IF NOT EXISTS '' AS db (TYPE postgres, SECRET analyst_secret, READ_ONLY)— 23 attaches over 9 workers in the failing hour.ATTACH IF NOT EXISTS '' AS "db" (... SECRET "pipeline_secret")was a no-op, becausedbalready existed. The scanner freezes the connection string at ATTACH time, so sqlmesh's scans silently ran against the analyst's endpoint.pg_export_snapshot()on the main conn,SET TRANSACTION SNAPSHOTon each extra thread) failed.The tenant query log separates cleanly: all 4 workers that failed had served such a
psqlattach beforehand; all 8 workers that had not were clean.It is also a cross-user isolation hole, same class as the secrets wipe: an attached catalog keeps the credentials it was built from even after
wipeUserSecretsdrops the secret. Verified live — a catalog attached as one org user was queryable from a later session as a different user who held no such secret and ran no ATTACH.Fix
detachUserCatalogs(duckdbservice/user_catalogs.go) runs right next to the secrets wipe:CreateSession— a failure fails the session, same semantics as the wipe (the CP's existing create-failure handling applies).DestroySession, so a parked worker doesn't hold a credential-bearing catalog between sessions.Preserved: DuckDB-internal catalogs, the instance's primary database (resolved by lowest non-internal oid, since it is a file stem rather than
memorywhenDataDiris set), and the worker-managed allowlistducklake/delta/__ducklake_metadata_*.The listing is catalog-qualified (
system.main.duckdb_databases()): by this point in session create the default catalog isducklake, andsessionmetadocuments that an unqualifiedduckdb_databases()there starts a DuckLake transaction, which pays a full catalog reload when the schema version has moved. The session-init probe has normally paid for that already, so this is defensive. The detach also runs under its own deadline so it cannot starve the persistent-secret replay that follows.If a detach ever failed persistently the CP retires that worker (
createSessionOnWorkeralways passesretireOnFailure=true), so the org's next connection gets a fresh one rather than looping on a stuck worker.Like the wipe, this is only safe under one-session-per-worker; it sits inside the same
sharedWarmMode && maxSessions == 1guard.Tests
duckdbservice/user_catalogs_test.go: allowlist, identifier quoting, file-backed primary, no-op, and that a post-detachATTACH IF NOT EXISTSbinds the NEW target.TestCreateSessionDetachesPreviousSessionCatalogs: pool-level, two consecutive sessions on one shared-warm worker — including the case where the best-effort destroy-time detach was skipped, so the CreateSession detach is the one that has to hold.persistent_user_secret_isolationgains an attached-catalog leak check modelled on the incident: root leaves a catalog holding a marker table, and a second user'sATTACH IF NOT EXISTSunder the same alias must come up empty. Each side's statements ride ONE connection (fed to psql on stdin) — everypgcall is its own session, whose session-create detach would otherwise remove the catalog before it could be observed. ATTACH parse-fails into the pin set, so both sides land on the org's standard worker rather than being split across the exploratory tier. Also assertsducklake+memorysurvive the detach. Statement shapes verified against a local standalone server.wipeUserSecretsdrops its secret (the bug), is gone afterdetachUserCatalogs, and a re-attach then correctly fails with "secret not found".Supersedes #1200
#1200 is an independent take on the same fix. This one additionally resolves the primary database by oid instead of reserving
memoryby name (a file-backed instance's primary is a file stem), qualifies the listing, gives the detach its own deadline, detaches best-effort at session destroy so a parked worker does not hold a live authenticated pool, and carries the cross-userIF NOT EXISTSe2e check. It adopts #1200's single-connection harness technique, its run-unique catalog name, and its system-catalogs-preserved assertion.Not in this PR
Tables a client creates in the
memorycatalog also survive on a hot-idle worker — same class of stale state, separate change.🤖 Generated with Claude Code
https://claude.ai/code/session_016ZnZTbiHkGWQ7485DoJB3E