Multi-catalog isolation (qod integration)
Goal
Today the proxy serves exactly one DuckLake catalog: a single catalog_name
(“lake”), one pg_database, one S3 bucket + data_prefix, attached once by a
process-global DuckLakeCatalog. The qod integration needs the proxy to serve
many isolated catalogs from one instance, one per tenant, where a tenant’s
data is invisible to every other tenant.
Isolation model (chosen): one Postgres schema + one S3 prefix per catalog, inside the shared Postgres database and shared bucket.
Enabling primitive (verified)
DuckLake’s ATTACH accepts METADATA_SCHEMA:
ATTACH 'ducklake:postgres:<dsn>' AS cat
(DATA_PATH 's3://<bucket>/<tenant>/<catalog>/',
METADATA_SCHEMA '<tenant-schema>',
DATA_INLINING_ROW_LIMIT 0);
This places that catalog’s ducklake_* metadata tables in <tenant-schema> and
its Parquet under a disjoint prefix. Confirmed on the installed extension
(ducklake 415a9ebd): the option is accepted; an unknown option errors with
“Unsupported option”. DATA_INLINING_ROW_LIMIT 0 stays a hard invariant (RLS
coverage depends on no inlined data — see pg_rls.py).
Naming contract
The metadata-schema and data-prefix names are derived deterministically by the
orchestration layer and handed to the proxy per request — the proxy does not
invent them. Contract: metadata_schema = dl_<slug>__<catalog>,
data_prefix = <slug>/<catalog>/, slug/catalog are ^[a-z][a-z0-9_]{1,30}$
with no __. The proxy treats (metadata_schema, data_prefix) as opaque,
validated identifiers.
Where single-catalog is assumed today
| Location | Assumption |
|---|---|
config.py Settings.catalog_name / pg_database / s3.data_prefix | one global catalog |
config.py ducklake_data_path, S3Settings.table_prefix/masked_*_prefix | one data root |
catalog.py DuckLakeCatalog (process singleton) | one DuckDB write conn + read pool + metadata cache, all for one catalog |
catalog.py _build_duckdb_conn (ATTACH at ~L215) | one ATTACH, no METADATA_SCHEMA |
catalog.py _cat() | one catalog alias |
catalog.py metadata cache keyed (ns, table) | not catalog-aware |
server.py /v1/config + ducklake-credentials (ATTACH strings ~L900/937) | one catalog/data path |
pg_rls.py | RLS policies target ducklake_* in the default schema |
masked_export.py / sts.py | export + STS prefixes rooted at the single data_prefix |
Proposed shape
CatalogRef — a small value object
(catalog_id, metadata_schema, data_prefix). Replaces the single global identity. The Iceberg REST{prefix}path segment maps to acatalog_id; the authenticated principal’s account constrains whichcatalog_ids are reachable (cross-account access = 404, the hard boundary).Catalog registry instead of one
DuckLakeCatalog. Options:- (a) one
DuckLakeCataloginstance per active catalog, lazily built and LRU-evicted (bounded number of attached catalogs per process); or - (b) one shared connection that
ATTACHes each catalog under a distinct alias on demand andUSEs the right one per request. Lean (a) for isolation clarity; cap concurrent attachments, evict idle. The metadata cache key gainscatalog_id.
- (a) one
Per-catalog ATTACH adds
METADATA_SCHEMA+ the catalog’sDATA_PATH.S3Settingsgains a per-catalogdata_prefixoverride (or prefix helpers take aCatalogRef).table_prefix/masked_*_prefixcompose under it.pg_rls per-catalog: reader group + RLS policies are created against the catalog’s metadata schema; per-principal LOGIN roles are scoped so a reader can only see its own account’s schema. Account is the outer guard; RLS + masking remain the intra-account guard.
Bootstrap/provisioning: a “create catalog” entry point that creates the Postgres schema, runs the DuckLake init against it, and arms RLS — invoked by the orchestration layer when an account/catalog is created.
Backward compatibility: keep a default
CatalogRefequal to today’s single-catalog config (defaultmetadata_schema = public/current,data_prefixunchanged) so the existing 80-test suite passes unchanged while the multi-catalog path is added alongside.
Phasing
- P1a — introduce
CatalogRef+ thread it through_build_duckdb_connATTACH (addMETADATA_SCHEMA) and the prefix helpers, with a default ref preserving current behavior. No routing change yet; tests green. - P1b — registry of catalogs keyed by
catalog_id; map Iceberg REST{prefix}→ ref; account constrains reachable refs. - P1c — per-catalog provisioning (schema create + DuckLake init + RLS arm)
and per-catalog
pg_rls/masked_export/stsprefixes. - P1d —
ducklake-credentialsand/v1/configemit the per-catalog ATTACH (schema + prefix), so DuckLake-direct and the FlightSQL gateway attach the right isolated catalog.
Open questions
- Attachment cap + eviction policy when an instance serves many tenants.
- Whether per-account reader roles are minted per-catalog or per-account (a principal with several catalogs in one account).
- Connection-pool sizing across catalogs (today: one write conn + 2 read + a PG pool, all single-catalog).