mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 00:01:49 +00:00
* feat(pipelines): fork-scoped ducklake namespaces with read-defer to parent Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pipelines): fork graph indicator + fork ducklake namespace cleanup endpoint Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pipelines): fork_views-keyed view transition, fork lineage clone, design doc Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): review hardening - fork DATA_PATH last-wins, registry cache TTL, defer tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pipelines): per-lake isolated/shared choice at fork creation Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): chain-aware defer discovery + per-location fork namespace registry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): lake-scoped fork schemas, catalog identity in registry, chain-aware graph chips Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): cleanup deletes fork data from the registered storage identity Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): collapse fork data-path segment to one component (slash-safe ids) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): per-catalog ancestor checks, ancestor extra_args passthrough, test compile fix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): invalidate fork ancestor-chain cache on lineage mutations Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): sweep descendant ancestor-chain caches on delete/reparent Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): run fork ducklake cleanup inline in delete_workspace Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): resolve fork cleanup credentials pre-commit, destroy post-commit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): shared dev-workspace authz gate for namespace drop, invalidatable registration cache, segment-boundary delete filter - extract require_prod_admin_for_dev_workspace, used by both delete_workspace and drop_forked_ducklake_namespaces so the gates cannot drift - key FORK_DUCKLAKE_REGISTERED per workspace and invalidate it in cleanup_fork_ducklake_namespaces so a same-id fork recreated within the TTL re-registers its namespaces - filter listed object locations to the segment boundary before deletion Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): keep orphaned wm-fork-* workspaces ducklake-isolated parent_workspace_id is ON DELETE SET NULL, so a fork can outlive its parent with an empty ancestor chain while its cloned config still points at the shared lake. Key the isolation gate on the wm-fork- prefix as well as the chain (mirroring workspace_is_fork): orphaned forks get the write redirect, registration and cleanup with zero ancestors (no defer), and keep their 'fork' graph chips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): attach orphaned wm-fork-* ancestors at their fork namespace Chain position alone classified the last ancestor as a root, but an orphaned wm-fork-* ancestor (its own parent deleted, SET NULL) ends the chain the same way while its data lives in its fork namespace — its descendants' defer views bound the dead root's lake instead. Key the root-vs-fork decision on the wm-fork- prefix too, matching the resolution gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): never inherit shared lake opt-out; durable cleanup ledger for failed fork deletions - fork creation strips cloned fork_behavior stamps before applying the request's shared_ducklakes list: sharing is a per-creation choice, a fork of a shared fork defaults back to isolated - fork_ducklake_namespace loses its ON DELETE CASCADE FK: rows are the durable cleanup ledger and outlive the workspace when physical cleanup fails post-commit; fork creation retries leftover rows for the reused id and refuses to create while a metadata schema still cannot be dropped (data-file leftovers alone are inert once the schema is gone and are swept by the next successful same-prefix cleanup) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): make orphaned-namespace cleanup retries independent of deleted fork resources - ledger rows gain a schema_dropped phase flag: set when the schema drop succeeded but data cleanup failed, so later retries skip the schema phase and need no catalog credentials at all; registration resets it on re-attach (ON CONFLICT DO UPDATE) since attaching recreates the schema - retry-path $res: resolution falls back to the workspace being forked (the deleted fork's resources were clones of a parent's); live paths (delete_workspace prepare, drop endpoint) pass no fallback Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pipelines): fork tables from failed-after-commit runs stay fork-owned in defer and graph A failed materialization must not disguise a physically existing fork table as deferred: CREATE VIEW IF NOT EXISTS silently yields to the table, so reads hit fork data while the graph claims parent defer. - record_mat upsert preserves the last committed snapshot_id on failure - defer discovery and graph chips treat fork rows with a committed snapshot as fork-owned even when status is failed - inspect_fork_catalog also lists live fork tables (same round trip) and the defer list is filtered against them — covers rows recorded before this fix and tables created by raw SQL - drop stale FK-cascade wording in the design doc and sidebar comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pipelines): fork-mode ducklake settings — per-lake isolated/shared chips + banner, fork_behavior round-trip The workspace-settings ducklake editor had no fork awareness: no reminder of each lake's isolated/shared choice and no warning about what edits mean in a fork. It also rebuilt each lake explicitly on save, silently dropping fork_behavior — any settings save in a shared fork flipped the lake back to isolated. - fork detection mirrors the backend gate (parent link or wm-fork- prefix) - info banner explaining isolated vs shared semantics in a fork - per-lake chip (emerald 'isolated' / amber 'shared with parent') with tooltips, matching the pipeline graph chip colors - fork_behavior added to DucklakeSettingsType and preserved through convertDucklakeSettingsToBackend Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
220 lines
15 KiB
Markdown
220 lines
15 KiB
Markdown
# Fork data environments for pipeline materialization ("dev data")
|
|
|
|
Status: **implemented** (branch `pipeline-env-isolation`). Companion to
|
|
[`ducklake-materialization.md`](./ducklake-materialization.md).
|
|
|
|
## The problem
|
|
|
|
Workspace forks fork the CODE, but `// materialize ducklake://<lake>/<table>` targets are
|
|
absolute pointers into shared physical storage. A fork clones `workspace_settings.ducklake`
|
|
verbatim, so `transform_attach_ducklake` resolved a fork's `ducklake://main` to the **parent's
|
|
catalog database and S3 data path** — deploying or running a materializing pipeline inside a
|
|
fork wrote the production tables. dbt answers this with target schemas + `--defer`; datatables
|
|
already have a fork answer (`forked_datatables` → `wm_fork_*` databases + a drop endpoint);
|
|
ducklake had none. `wmill pipeline run --local` only covers preview runs, and previews still
|
|
resolve targets against the real workspace.
|
|
|
|
## The design
|
|
|
|
Everything hangs off two DuckLake ATTACH capabilities (verified in a spike, DuckDB 1.5.4 +
|
|
current ducklake extension): `METADATA_SCHEMA` (the catalog's metadata tables live in a chosen
|
|
pg schema of the same catalog DB — auto-created on first attach; default is `public`) and
|
|
`READ_ONLY`, plus catalog-stored views that may reference other attached catalogs and rebind
|
|
per session.
|
|
|
|
### Fork-time choice: isolated (default) vs shared, per lake
|
|
|
|
The fork-creation dialog offers a per-lake data-environment choice (the section renders only
|
|
when the parent workspace has lakes configured, mirroring the datatable section):
|
|
|
|
- **Isolated** (default — also what unlisted lakes and API callers that omit the field get) —
|
|
the full behavior described below.
|
|
- **Shared** — explicit opt-out: the fork reads and writes the parent's lake directly through
|
|
its cloned config, like a non-fork workspace (e.g. a fork meant to run prod-equivalent
|
|
backfills). Stamped as `fork_behavior: "shared"` on the lake entry in the fork's own
|
|
`workspace_settings.ducklake` at creation (`shared_ducklakes` on the create-fork request);
|
|
the resolver, the graph indicator, and (trivially — nothing registers) cleanup all key off
|
|
it. Shared also lifts the mysql-catalog restriction, since nothing needs namespacing.
|
|
|
|
Absent-field-means-isolated keeps every pre-existing fork and API caller on the safe path.
|
|
Sharing is a per-fork-creation choice, never inherited: the settings clone copies the source's
|
|
config verbatim, so fork creation strips any cloned `fork_behavior` stamps before applying its
|
|
own `shared_ducklakes` list — a fork of a shared fork defaults back to isolated. A
|
|
shared fork ancestor in a fork chain resolves like a root (its data lives at its config's
|
|
default location). Flipping a fork's lake to `shared` later (settings edit) orphans any
|
|
namespace it already materialized until fork deletion cleans it — the registry row survives
|
|
the flip.
|
|
|
|
### Write redirect — one seam, no grammar changes
|
|
|
|
`get_ducklake_from_db_unchecked` (windmill-common/src/workspaces.rs) is fork-aware: when the
|
|
resolved workspace has a `parent_workspace_id` (throwaway `wm-fork-*` fork or standing dev
|
|
workspace), the returned `DucklakeWithConnData` is namespaced **in its existing fields**:
|
|
|
|
- `extra_args` += `METADATA_SCHEMA 'wm_fork_<mangled wid>_<mangled lake>_<hash8>'` —
|
|
injective, ≤63 chars, stable across releases (persisted state depends on it), and
|
|
**lake-scoped**: two lakes of one workspace may share a catalog database, and a
|
|
per-workspace schema would merge their namespaces in the fork;
|
|
- `storage.path` = `__wm_forks/<fork segment>/<original path>` — the segment is the
|
|
mangled+hashed workspace id collapsed to ONE path component (fork ids are only
|
|
git-branch-safe and may contain `/`; used raw, `wm-fork-a/b` would nest inside sibling
|
|
`wm-fork-a`'s cleanup prefix) — a **bucket-root prefix**, not a
|
|
sub-path of the parent's DATA_PATH, because lake maintenance
|
|
(`ducklake_delete_orphaned_files`, snapshot expiry) scans everything under the parent's path
|
|
and would delete live fork files nested there.
|
|
|
|
Folding the redirect into existing fields means the EE agent-worker endpoint (which just
|
|
serializes this struct) needs **no changes**, and an agent worker running an older binary still
|
|
writes the fork namespace — it only misses the defer views (reads fail loudly; never a prod
|
|
write). Every ducklake access funnels through the same ATTACH transform — the managed
|
|
materialize's synthetic `_wm_target` attach, `materialize manual`, raw user SQL, and all UI
|
|
preview / History / backfill surfaces (they are `WM_INTERNAL_DB_*` marker preview jobs) — so a
|
|
fork cannot reach the parent namespace through the managed path at all.
|
|
|
|
Hardening: in fork mode, user- and settings-supplied `METADATA_SCHEMA` / `DATA_PATH` /
|
|
`OVERRIDE_DATA_PATH` ATTACH options are stripped before injection (DuckDB silently keeps the
|
|
*last* occurrence of a duplicated option, so a duplicate would escape the namespace).
|
|
MySQL-catalog lakes error loudly in forks (no pg schemas to namespace with) rather than ever
|
|
writing the shared catalog.
|
|
|
|
### Read-defer — iterate on one node without rebuilding upstream
|
|
|
|
In a fork, `transform_attach_ducklake` additionally emits:
|
|
|
|
1. `ATTACH IF NOT EXISTS … AS __wm_dl_<lake>_<ancestor>_<hash> ([<ancestor's own args>, ]
|
|
DATA_PATH <ancestor's>, OVERRIDE_DATA_PATH TRUE, READ_ONLY[, METADATA_SCHEMA
|
|
<ancestor's>])` per fork-chain ancestor, resolved from the **ancestor's own settings**
|
|
(fork-side settings drift can't repoint what "parent" means). The ancestor config's
|
|
non-reserved `extra_args` (e.g. `ENCRYPTED true`) ride along — FIRST, so the fork-owned
|
|
options win under DuckDB's last-occurrence-wins. No
|
|
`AUTOMATIC_MIGRATION`/`CREATE_IF_NOT_EXISTS` — a fork job must never mutate an ancestor
|
|
lake. `READ_ONLY` makes prod writes *physically impossible* through ducklake, including in
|
|
`materialize manual` and raw SQL. Fork ancestors whose namespace was never bootstrapped are
|
|
skipped, with existence checked in each ancestor's OWN catalog database (a fork whose
|
|
catalog drifted away from an ancestor's must not misread that ancestor's namespace as
|
|
missing); an unreachable ancestor catalog only disables that ancestor's defer.
|
|
2. `CREATE VIEW IF NOT EXISTS <alias>.<table> AS SELECT * FROM <owning ancestor
|
|
alias>.<table>` for every **deferred table**: (`materialized_partition` rows with
|
|
`status = 'materialized'` anywhere in the ancestor chain) MINUS (the fork's own rows). Each
|
|
view targets the NEAREST ancestor that physically owns the table
|
|
(`ForkDeferTable.ancestor_idx`): in a `fork → parent → root` chain where only the root
|
|
materialized a table, the parent has no copy (it defers too), so a view over the parent
|
|
would not bind. SCD2 owners (detected by the `is_current` column in the owning ancestor's
|
|
latest captured `materialized_asset_schema`) also get the `<table>_current` companion view.
|
|
So a consumer reading `dl.orders` transparently reads the nearest owning ancestor's current
|
|
data until the fork materializes `orders` itself.
|
|
3. When the running job's managed materialize targets a table that currently exists **as a
|
|
view** in the fork namespace, the view (and its `_current` companion) is dropped after the
|
|
target attach — the first fork materialize replaces the defer view with a real fork table
|
|
(`CREATE [OR REPLACE] TABLE` refuses to replace a view — verified — so without the drop the
|
|
transition fails; with it, `sql_materialize.rs` codegen needs no fork awareness at all).
|
|
The is-it-a-view decision keys on the catalog's ACTUAL live views (`ducklake_view`, read at
|
|
resolution time into `fork_defer.fork_views`), **not** on recorded materialization status:
|
|
after a failed run the status cannot distinguish a defer view from a real table, and a
|
|
mismatched `DROP VIEW` (or skipped drop) would wedge the asset on every retry — found live
|
|
in e2e when a storage failure flipped a fork asset's status to `Failed`.
|
|
|
|
The graph shows the state per ducklake asset in a fork: an amber ↗ chip = *deferred* (some ancestor in the chain
|
|
owns the table),
|
|
an emerald fork chip = *fork-materialized* (`fork_materialization` on the `/assets/graph`
|
|
response, computed from fork + parent `materialized_partition`; the parent's rows are read on
|
|
the plain pool since fork membership doesn't imply parent membership). The details pane shows
|
|
the equivalent banner.
|
|
|
|
### Lifecycle & cleanup
|
|
|
|
A sidecar registry `fork_ducklake_namespace(workspace_id, ducklake_name,
|
|
metadata_schema, catalog, storage, storage_ref, data_path)` is upserted (behind a TTL'd in-process cache, keyed
|
|
per workspace and invalidated whenever a workspace's registry rows are cleaned up — a same-id fork recreated
|
|
within the TTL must re-register or its namespace would be orphaned at its own deletion) on fork
|
|
resolution, **one row per physical location ever attached** — its PK includes
|
|
`(catalog, storage, storage_ref, data_path)`, so if the fork's lake settings drift, later attaches add rows rather
|
|
than replace them and cleanup covers every location the fork wrote, not just the first — connecting to the
|
|
REGISTERED catalog identity and deleting files from the REGISTERED storage identity
|
|
(`storage_ref`, resolved from the logical storage name at attach time), never whatever the
|
|
fork's settings point at by deletion time. Explicit
|
|
`GRANT`s ship in the same migration; the registry is not cloned into sub-forks.
|
|
|
|
The registry has deliberately **no FK to `workspace`**: rows are the durable cleanup ledger and
|
|
outlive the workspace row when physical cleanup fails after the delete commits (unreachable
|
|
catalog, storage outage) — each row is deleted only after its schema + data cleanup succeed.
|
|
Fork **creation** retries any leftover rows for the reused id and refuses to proceed while a
|
|
metadata schema still cannot be dropped, so a recreated same-id fork can never silently
|
|
reattach stale tables. Data-file leftovers alone don't block: a deleted fork's `$res:` storage
|
|
resource is gone forever, but once the schema is dropped the files are inert — the surviving
|
|
row keeps them tracked and the next successful cleanup of the same prefix (typically the
|
|
recreated fork's own deletion, with live credentials) sweeps them.
|
|
|
|
Retries after the fork is gone get two aids: the row's `schema_dropped` phase flag (set when
|
|
the schema dropped but data cleanup failed; retries then skip the schema phase and need no
|
|
catalog credentials — registration resets it on re-attach, which recreates the schema), and
|
|
`$res:` fallback resolution against the workspace being forked (the deleted fork's resources
|
|
were clones of a parent's, so the new parent is the natural credential donor).
|
|
|
|
`POST /w/{fork}/workspaces/drop_forked_ducklake_namespaces` (same permissions as
|
|
`delete_workspace` via a shared gate: fork owner or superadmin, plus parent-prod-admin when the
|
|
fork is an attached dev workspace) drops each registered pg metadata schema
|
|
(`DROP SCHEMA … CASCADE` on the catalog connection, hard-guarded on the `wm_fork_` prefix) and
|
|
deletes the `__wm_forks/<fork segment>/…` objects in the workspace storage (hard-guarded on that
|
|
prefix; `parquet` feature). The fork-deletion UI calls it next to
|
|
`drop_forked_datatable_databases`, for the fork and for deleted child forks. Registry rows are
|
|
deleted per-lake only after both cleanups succeed, so a partial failure is retryable.
|
|
|
|
## Semantics & caveats (by design)
|
|
|
|
- **Orphaned forks stay isolated.** `parent_workspace_id` is `ON DELETE SET NULL`, so a
|
|
`wm-fork-*` workspace can outlive its parent. Its ancestor chain is then empty, but its cloned
|
|
config still points at the shared lake — so fork resolution keys on the `wm-fork-` prefix as
|
|
well as the chain (mirroring `workspace_is_fork`). An orphaned fork gets the write redirect,
|
|
registration and cleanup but no defer views; pre-orphaning defer views fail loudly at bind and
|
|
flip to real tables on first materialize. The same rule applies to a fork's *ancestors*: a
|
|
last-in-chain `wm-fork-*` ancestor is an orphaned fork (not a root) and its READ_ONLY attach
|
|
targets its fork namespace, not its cloned base config. Prefix-less dev workspaces cannot be
|
|
orphaned (deleting their prod is blocked while attached).
|
|
- **Defer is live, not snapshot-pinned.** A deferred read sees the parent's *current* table —
|
|
the same trade dbt `--defer` makes. Snapshot-pinned defer could later ride on the recorded
|
|
`snapshot_id`s.
|
|
- **Defer granularity is per-table.** Materializing one partition in the fork makes the fork's
|
|
table authoritative for *all* partitions (the rest are empty until backfilled in the fork).
|
|
- **Incremental strategies start empty in a fork.** `merge`/`append`/SCD2 create a fresh table
|
|
on first fork run — no parent rows are inherited, and fork SCD2 history diverges from the
|
|
parent's. Copy-on-fork seeding would be a follow-up opt-in.
|
|
- **Only tracked tables defer.** A parent table with no `materialized_partition` row (raw SQL
|
|
writes outside `// materialize`) or whose latest run `Failed` is not deferred — defer views
|
|
bind at CREATE against the parent catalog, and a view on a missing table would fail every
|
|
unrelated fork job. Such tables read as absent in the fork.
|
|
- **Transient race.** Between a fork materialize's commit and its state recording, a concurrent
|
|
fork job may still emit `CREATE VIEW IF NOT EXISTS` for that table — which silently yields to
|
|
an existing table (verified), so the race is a no-op.
|
|
- **Accident prevention, not a security boundary.** A fork holds the parent's cloned
|
|
credentials by design; a determined fork user can still `ATTACH 'postgres:…'` with the cloned
|
|
catalog resource or `COPY TO 's3://…'` directly. The isolation guarantees are scoped to the
|
|
managed ducklake path (where they are physical: READ_ONLY parent attach + separate metadata
|
|
schema + separate data prefix).
|
|
- **Fork chains** compose: each ancestor namespace is attached under a deterministic alias, and
|
|
a parent's own defer views (referencing *its* parent's alias) rebind correctly in the
|
|
grandchild's session because the whole chain is attached.
|
|
- **Datatables are out of scope** — the existing `forked_datatables` chooser at fork creation
|
|
covers them (`keep_original` remains their default, which shares the parent DB; picking
|
|
`schema_only` there is the datatable analog of this feature).
|
|
|
|
## Key files
|
|
|
|
- `backend/windmill-common/src/workspaces.rs` — fork resolution (`fork_scoped_ducklake`),
|
|
`fork_ancestor_chain`, naming (`fork_ducklake_metadata_schema`,
|
|
`fork_ducklake_ancestor_alias`, `fork_data_path`), option stripping, registry.
|
|
- `backend/windmill-common/src/materialization.rs` — `list_fork_defer_tables`.
|
|
- `backend/windmill-worker/src/duckdb_executor.rs` — `fork_defer_statements` (ancestor
|
|
attaches, defer views, target transition), user-arg stripping.
|
|
- `backend/windmill-api-assets/src/lib.rs` — `fork_materialization` on the graph.
|
|
- `backend/windmill-api-workspaces/src/workspaces_extra.rs` —
|
|
`drop_forked_ducklake_namespaces`.
|
|
- `backend/windmill-api-workspaces/src/workspaces.rs` — `clone_asset_usages_and_triggers`:
|
|
`asset` + `script_trigger` rows are deploy-derived and were never cloned into forks, so a
|
|
fork's pipeline graph had no asset nodes/edges and its dispatch cascade never fired (fixed
|
|
here because this feature is unusable without it).
|
|
- `backend/migrations/20260703170745_fork_ducklake_namespace.*` — registry + GRANTs.
|
|
- Frontend: `AssetGraph/{types.ts,AssetNode.svelte,AssetGraphCanvas.svelte,
|
|
AssetGraphDetailsPane.svelte}`, `pipeline/[folder]/+page.svelte`,
|
|
`sidebar/SidebarContent.svelte` (fork-delete wiring).
|