An attached VGI catalog can ask the client to also attach a set of companion
catalogs — a DuckLake, an Iceberg REST catalog, a Postgres/MySQL database, or
another DuckDB file — at ATTACH time. Combined with catalog-table branches
(see multi_branch.md), this lets a single logical VGI table
span hot data served by the worker and cold data living in a lakehouse,
without the client hand-attaching anything or writing its own UNION ALL view.
The motivating shape: a worker exposes events, whose recent rows it serves
directly (Kafka, an API, live compute) and whose historical rows live in a
DuckLake on object storage. The worker advertises the DuckLake as a companion
and declares events as a two-branch table (hot VGI arm + cold DuckLake arm)
with a branch_filter time split. A query for the last hour never touches the
lakehouse.
- The worker's
catalog_attachresponse carriesattach_catalogs: list[AttachCatalogInfo]. - At
ATTACH,VgiCatalogAttach(invgi_extension.cpp) provisions each companion viaDatabaseManager::AttachDatabase— the same C++ entry point theATTACHstatement uses, with no SQL parsing. This runs inside the storage attach callback; it is reentrancy-safe because DuckDB does not holddatabases_lockacross the callback. - A multi-branch table's catalog-table branch (
ScanBranchwith an emptyfunction_nameandsource_{catalog,schema,table}set) is then a pure lookup — the optimizer rewriter binds it via the companion table's ownGetScanFunction, honoring the companion's snapshot / pruning semantics. - On
DETACH,VgiCatalog::OnDetachreleases the companions this catalog referenced; a companion is detached only when the last referencing VGI catalog releases it (refcounted).
A worker declares companions on its catalog and returns them from
catalog_attach (handled automatically by ReadOnlyCatalogInterface — set the
attach_catalogs class attribute or override catalog_attach):
from vgi.catalog import AttachCatalogInfo
class MyCatalog(ReadOnlyCatalogInterface):
attach_catalogs = [
AttachCatalogInfo(
alias="acme_lake", # ATTACH alias; the branch's source_catalog
target="ducklake:sqlite:/data/meta.sqlite",
db_type="", # empty ⇒ infer from target scheme
options={}, # extra ATTACH options (e.g. DuckLake DATA_PATH)
hidden=False, # True ⇒ excluded from duckdb_databases()
required=True, # True ⇒ attach failure fails the VGI ATTACH
secret_ref="", # optional named secret to inject as ATTACH options
)
]| Field | Meaning |
|---|---|
alias |
Catalog name to attach as; also the source_catalog a catalog-table branch references. Namespace it by your catalog identity (e.g. acme_lake) so two workers don't both claim lake. |
target |
ATTACH target — a path or DSN (ducklake:sqlite:…, postgres:…, a .duckdb file, …). |
db_type |
DuckDB db type; empty ⇒ inferred from the target scheme prefix. Set duckdb for a bare .duckdb file so the scheme allowlist admits it. |
options |
Extra ATTACH options forwarded verbatim (e.g. DuckLake DATA_PATH). |
hidden |
Attach hidden (excluded from duckdb_databases()); still resolvable by qualified name and by branches. |
required |
On attach failure (unreachable / conflict): true fails the VGI ATTACH loudly; false logs and skips (branches referencing it then error at bind). |
secret_ref |
Name of a DuckDB secret to resolve and inject as ATTACH options — for metadata connections (e.g. a Postgres DSN) that need creds at attach time. |
Companion attach is remote-influenced ATTACH, so it is guarded:
- Opt-out, on by default. Companions attach unless the client passes
attach_companions falseonATTACH. (Attach is largely lazy; the guards below bound the blast radius.) - Scheme allowlist. The scheme (explicit
db_type, else thetargetprefix) must be one ofducklake,iceberg,postgres,mysql,duckdb,sqlite; anything else is rejected with aBinderException. Note the allowlist gates the scheme, not the target host/path — apostgres/mysqlcompanion still connects to a worker-chosen host (see SSRF caveat below), and a whitelisteddb_type(e.g.duckdb) admits a bare file-path target. - Natural access mode. Companions attach at their natural (config /
worker-specified) access mode, so writable federation works (Postgres, a
writable DuckLake, a local DuckDB/SQLite file). Tradeoff: a bare
sqlite/duckdbtarget that doesn't exist is created, so a malicious worker can make the client create empty database files at writable paths — low impact (no data is written on attach; an existing non-database file errors), but a reason to only attach workers you trust. - Never clobber. A companion is never attached over a catalog the VGI
layer did not itself create. If the alias is held by a user catalog (or a
different-target companion), a
requiredcompanion fails the VGI ATTACH with a clear error and the existing catalog is left untouched; an optional one is skipped. - SSRF caveat.
postgres/mysql/ducklake/icebergcompanions connect from the client's network position to a worker-chosen endpoint. Attaching a malicious/compromised worker can therefore probe/reach hosts the client can see. This is inherent to supporting remote companions; only attach workers you trust, and preferattach_companions falsefor untrusted ones.
When the VGI catalog is HTTP and advertises an Orchard secret service
(vgi_secret_service_url), a VgiRemoteSecretStorage is registered as a
catch-all before companions attach. So a companion's credential lookups
(e.g. S3 data files for a DuckLake) resolve through Orchard automatically at
both attach and query time — no CREATE SECRET on the client, and no secret is
named by the worker. This is the recommended path.
secret_ref is a privileged, opt-in alternative for metadata connections
that need creds at attach (e.g. a Postgres DSN): its named secret's key-values
are injected into the companion's ATTACH options. Because the worker chooses
both the secret name and the target host, auto-injecting would let a
malicious worker exfiltrate the user's credentials to an arbitrary host — so
secret_ref injection is off by default and requires
attach_companion_secrets true on the ATTACH. Without opt-in a non-empty
secret_ref is skipped (logged), not injected. Only enable it for a worker you
fully trust to name your secrets.
Subprocess-worker companions (no secret service) use ambient/local credentials — fine for a local SQLite+parquet DuckLake.
-
Companions are refcounted: two VGI catalogs pointing at the same (alias, target) share one attachment; it is detached when the last releases it on
DETACH. This is more reversible than the secret-provider / copy-format seams (which persist for the DB lifetime). -
Companion attach/detach and skips are logged via
VGI_LOG(vgi.companion_attach,vgi.companion_attach.skipped,vgi.companion_detach) — surface withVGI_STDERR_LOG=1orduckdb_logs. -
vgi_companion_catalogs()lists every attached companion —catalog_name(alias),target,db_type,hidden,refcount. This is the way to enumerate a hidden companion, whichduckdb_databases()deliberately omits:SELECT * FROM vgi_companion_catalogs();
- DuckLake companions need
parquet. DuckLake builds its scan fromparquet_scan's bind; if parquet isn't loaded the DuckLake scan segfaults (a DuckLake bug — it should error). Declarerequired_extensions=["parquet"]on the branch so the rewriter auto-loads it. Normal DuckDB/haybarn autoloads parquet; restricted environments (the unittest binary) do not. - Catalog-managed sources like DuckLake require the 3-arg
TableCatalogEntry::GetScanFunction(context, bind_data, EntryLookupInfo)— the 2-arg overload throws "called without entry lookup info".BindCatalogTableArmuses the 3-arg form (no AT clause ⇒ current snapshot).
test/sql/integration/catalog/companion_catalogs.test (run via
make test_companion / test/run_companion_integration.sh) covers companion
auto-attach, catalog-table branches, hot/cold branch_filter pruning, opt-out,
never-clobber conflict (user catalog intact), and refcounted sharing/detach
against a real DuckLake. Fixture: vgi/_test_fixtures/companion.py
(vgi-fixture-companion-worker), configured via VGI_TEST_COMPANION_TARGET.
Deferred: an end-to-end test of Orchard-brokered companion credentials
(DuckLake on mock S3, creds served by Orchard, no client CREATE SECRET). The
mechanism composes from the registered catch-all provider; the harness is a
follow-up.
- C++: companion attach + registry +
InjectCompanionSecretinvgi_extension.cpp; release helper invgi_companion_catalogs.{hpp,cpp-in-ext};VgiCatalog::OnDetachinstorage/vgi_catalog.cpp;BindCatalogTableArminvgi_multi_scan_rewriter.cpp; parse invgi_catalog_api.cpp; structs invgi_catalog_metadata.hpp. - vgi-python:
AttachCatalogInfo+ScanBranch.source_*invgi/catalog/catalog_interface.py;vgi/_test_fixtures/companion.py.