Managed object storage
A DuckLake database normally points at a dataPath you choose, authenticated either by the manager-wide object-store credentials or by that database's own objectStore keys (see Tenants and databases). Managed object storage is the other option: the operator configures one root bucket once, and each database created with managedStorage gets its own prefix carved out of it, provisioned and reclaimed by the manager.
It is off by default. Nothing in this page applies until QOD_MANAGED_STORE_ENABLED=true.
Configuration
The quack-on-demand.managedObjectStore block. As always, prefer the environment variables over editing the bundled application.conf.
| Key | Env var | Default | Meaning |
|---|---|---|---|
enabled | QOD_MANAGED_STORE_ENABLED | false | Master switch. While off, a managed create is refused with 400. |
endpoint | QOD_MANAGED_STORE_ENDPOINT | (empty) | S3-compatible endpoint URL. Empty means AWS default endpoint resolution. |
region | QOD_MANAGED_STORE_REGION | us-east-1 | Region passed to the S3 client. |
bucket | QOD_MANAGED_STORE_BUCKET | qod-managed | The operator root bucket every managed prefix lives in. |
accessKeyId | QOD_MANAGED_STORE_ACCESS_KEY_ID | (empty) | Operator access key id. |
secretAccessKey | QOD_MANAGED_STORE_SECRET_ACCESS_KEY | (empty) | Operator secret access key. Redacted in config output. |
urlStyle | QOD_MANAGED_STORE_URL_STYLE | path | path for S3-compatible stores, vhost for AWS. Any other value is refused at config load. |
retainDays | QOD_MANAGED_STORE_RETAIN_DAYS | 7 | Days a deleted database's objects are retained before the purge worker removes them. 0 makes every delete immediately eligible. |
purgeSweepSec | QOD_MANAGED_STORE_PURGE_SWEEP_SEC | 300 | Purge worker cadence, clamped to a 60 second floor. |
The manager creates the root bucket at boot if it does not exist and logs managed object store ready: bucket '<name>'. The probe is advisory: an unreachable or mis-credentialed store logs managed object store unreachable: managed creates still succeed at the control plane, but their nodes will fail to ATTACH until the store recovers and never blocks boot. It does not gate managed creates either - a database/create with managedStorage: true succeeds at the control plane regardless of store reachability; the failure only surfaces later, when a node tries to ATTACH against the missing bucket.
On a versioned bucket a delete does not remove the object, it writes a delete marker. The purge worker's next listing then comes back empty, so it stamps the prefix as purged while every non-current version survives and keeps costing money, invisible to the inventory query below. Create the root bucket with versioning disabled, and do not enable it later.
Creating a managed database
Send managedStorage: true and no location of your own:
qod database create --tenant acme --name sales --kind ducklake --managed-storage
curl -sS -X POST "$MGR/api/database/create" \
-H "X-API-Key: $QOD_API_KEY" -H 'Content-Type: application/json' \
-d '{"tenant":"acme","name":"sales","kind":"ducklake","managedStorage":true}'
In the admin UI, pick the storage mode Managed (QoD-provisioned) in the database form; it takes no fields, and the resolved path shows up on the database after create.
The manager resolves:
dataPath = s3://<bucket>/<tenant>_<dbname>-<id8>/
where id8 is the first 8 characters of the database's surrogate id. Prefixes are keyed by the incarnation, not by the name: delete acme_sales and create it again and the new database gets a fresh, empty prefix instead of inheriting its predecessor's files. The response carries the effective dataPath.
The database's objectStore map is filled from the config block (s3_endpoint, s3_region, s3_url_style, s3_access_key_id, s3_secret_access_key), so everything downstream behaves exactly as it does for a bring-your-own bucket: the path-scoped CREATE SECRET at node spawn, secret redaction on GET/list, the Kubernetes per-node Secret, and manifest export redaction.
Three refusals, all 400 with an actionable message:
- managed storage is not enabled on this deployment (the message names
QOD_MANAGED_STORE_ENABLED); managedStoragesent together with adataPathor anobjectStore(one intent per call);managedStorageon a kind other thanducklake.
database/update does not accept managedStorage. The field does not exist on the update request at all, so there is no bring-your-own-to-managed conversion and no managed-to-bring-your-own conversion. Moving a database between storage models means creating a new database and reloading the data.
Lifecycle: create, tombstone, retain, purge
- Create. The prefix is resolved and a row is written to the control-plane table
qodstate_managed_prefix(id, tenant, database name, prefix,created_at). - Delete.
database/deletestampsdeleted_atandpurge_eligible_aton that row and returns immediately.purge_eligible_atisdeleted_at + retainDays, ordeleted_atitself when the caller asked for an immediate purge. The objects are untouched at this point. Deleting the owning tenant cascades the same stamp with the normal retention window, never immediate. - Retain. Until
purge_eligible_at, the data is still in the bucket and still billed. That window doubles as the undrop window: the files are recoverable by hand while it lasts. - Purge. A background worker sweeps every
purgeSweepSec, picks up every row whosepurge_eligible_athas passed and whosepurged_atis null, lists and batch-deletes the objects under the prefix, and stampspurged_atonce a listing comes back empty. It logs onemanaged purge:line per outcome.
The worker is gated on HA leadership, so exactly one replica purges. It bounds the number of list-and-delete rounds per prefix per sweep, so a very large prefix drains over several sweeps instead of holding the sweep open; the tombstone row simply stays due until it is empty. A failure on one prefix logs a warning and moves on to the next row, and is retried on the following sweep.
Purging immediately
qod database delete --tenant acme --name acme_sales --purge-managed-data
curl -sS -X POST "$MGR/api/database/delete" \
-H "X-API-Key: $QOD_API_KEY" -H 'Content-Type: application/json' \
-d '{"tenant":"acme","name":"acme_sales","purgeManagedData":true}'
The admin UI asks a second time on a ducklake delete ("Also purge managed object storage now?"); confirming sets the same flag. "Immediately" means immediately eligible: the delete call still returns as fast as a plain delete, and the worker removes the objects on its next sweep. The flag is ignored, with a warning in the log, on a database that has no managed prefix.
Inventory
SELECT id, prefix, deleted_at, purge_eligible_at, purged_at
FROM qodstate_managed_prefix
ORDER BY created_at;
deleted_at IS NULL- a live database.deleted_atset,purged_atnull - retained. The objects still exist and still cost money;purge_eligible_attells you when the worker will take them.purged_atset - the objects are gone. The row is kept as an audit trail, one row per database incarnation.
Operational notes
Changing the bucket strands existing tombstones
QOD_MANAGED_STORE_BUCKET is not a value you can swap in place. Tombstone rows hold the full s3://bucket/... prefix they were written with; after a bucket change those rows no longer sit under the configured root, so the purge worker refuses to list them (it will not risk enumerating a whole bucket from a mismatched prefix), warns skipping <id>, prefix ... is not under ... on every sweep, and the old bucket's objects leak indefinitely. Before switching buckets, purge the outstanding prefixes or migrate them, and confirm the sweep log is quiet afterwards.
First-boot warning in HA
With several manager replicas starting together, they all probe the store and race to create the root bucket. The replicas that lose the race can log one managed object store unreachable warning at first boot. It is a false alarm, it self-heals on the next probe, and the store is fine. A warning that repeats on a settled cluster is real.
Isolation posture: shared credential, prefix by convention
All managed databases share the operator credential. Isolation between tenants' managed data is prefix-by-convention, enforced by QoD's node configuration (the per-database CREATE SECRET scoped to that database's own dataPath) plus per-pool lockdown, not by store-level ACLs. A principal who can run arbitrary configuration statements on a node with those credentials is not confined by the bucket itself. Keep node lockdown on for managed deployments, and treat the operator credential as a manager-level secret.
Per-database credential minting (per-database keys issued by the store's admin API or by IAM) is the designed follow-up; it would change only which credentials land in a database's objectStore, not the prefix layout or the lifecycle above.
Credential rotation
A managed create snapshots the operator credential (QOD_MANAGED_STORE_ACCESS_KEY_ID / QOD_MANAGED_STORE_SECRET_ACCESS_KEY) into that database's own objectStore at create time; it is a copy, not a live reference to the config block. Rotating QOD_MANAGED_STORE_SECRET_ACCESS_KEY on the manager therefore only affects future managed creates - every managed database that already exists keeps signing requests with the old key until something rewrites its objectStore, and it breaks at its next node respawn once the old key is revoked at the store.
Today's remediation is manual, per database: database/update with a fresh objectStore pointing at the new secret, then recycle that database's pools so nodes pick it up on their next spawn. Per-database credential minting (above) is the designed follow-up that would make rotation blast-radius-free by construction.
See also
- Tenants and databases - the bring-your-own-bucket alternative and the rest of the database fields.
- Configuration reference - the generated table for every
managedObjectStorekey. - Managed DuckLake maintenance - compaction and snapshot expiry, which bound what a managed prefix actually stores.