Files
windmill/docs/fork-object-storage.md
T
9375805fdf feat: isolate fork object storage under a per-fork prefix (#11829)
* feat: route the python SDK's S3 client settings through the S3 proxy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop the boto3 config key and expose the proxied bucket to polars

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: proxy S3 settings only for server-signed tokens and keep tls verify

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: isolate fork object storage under a per-fork prefix with read-through

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: guard fork storage isolation against traversal, shared lakes and orphaning

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: serialize fork purges with same-id creation and guard shared middle forks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop fork storage mappings on every replica and lock fork deletion against child creation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 455681e55eb7cdfcfdb7cd79cbae75b333e07943

This commit updates the EE repository reference after PR #893 was merged in windmill-ee-private.

Previous ee-repo-ref: 93f26e3cf816e4eb97b7d4dbbb5fc97fdf9821af

New ee-repo-ref: 455681e55eb7cdfcfdb7cd79cbae75b333e07943

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-16 22:21:38 +02:00

4.2 KiB

Fork-isolated workspace object storage

A fork clones its parent's large_file_storage settings, so without isolation it reads and writes the parent's objects: a script iterating in a fork overwrites production's files. Each storage the fork isolates (a fork_storage_namespace row, chosen at fork creation, default isolated) instead maps logical keys:

  • Writes (put, multipart, delete, copy destination) go to __wm_forks/<fork segment>/<key>, the same one-component segment DuckLake's fork namespaces use (fork_data_dir_segment).
  • Reads try the fork's own key, then each ancestor's mapping, nearest first, then the root's raw key. They stop at the first ancestor whose storage is a different one (lfs_entry_storage_ref): its keys are not in this bucket. A shared fork maps like its parent.
  • Listings show the workspace's own objects only, as logical keys.
  • Deletes remove the fork's copy; an ancestor's object stays readable through it.

The mapping (windmill_common::fork_storage::ForkStorageKeys) is applied wherever Windmill turns a workspace-storage key into a storage operation: the ForkStore object-store wrapper (windmill_object_store::fork_store::workspace_object_store) for every server-side helper, and key rewriting in the S3 proxy's raw S3 forwarding (URI key, list prefix/start-after/marker and response keys, x-amz-copy-source). A named resource (s3_resource_path) is not workspace storage and is never mapped. A workspace whose keys are mapped is refused its workspace storage's own credentials (s3_resource_info, the duckdb/polars settings endpoints): a client holding them would write past the mapping.

Not mapped, on purpose: AI sessions and volumes (their keys already carry the workspace id), SMTP attachments (job-id keys), the storage usage count (it counts the whole bucket). Known gaps: the worker's result-cache hash HEADs the logical key unmapped, and a read of an object the fork does not own costs one HEAD per chain level before the GET.

Invariants

  • __wm_forks/ is reachable only through the workspace's own chain. A key under it maps to itself when it is in the workspace's own area (writes) or an area of the chain (reads), and is refused otherwise; listings hide it. This is also what keeps DuckLake working: its fork data paths arrive already prefixed, and its defer attaches read ancestors' raw keys through the read chain. Every workspace gets the rule, isolating or not, so pre-existing forks' lakes keep working.
  • No . or .. key segments, in any workspace: the proxy forwards mapped keys in a URL, where they collapse and would climb out of the fork's prefix.
  • The prefix derives from the workspace id, so an isolating fork's id cannot be changed (change_workspace_id refuses): under a new id it would see none of its objects. Nor can an isolated storage be repointed, since the purge targets the storage recorded at creation.
  • A shared lake needs its storage shared: its files must land at the parent's keys, where the parent's catalog looks for them. Fork creation refuses the other combination.
  • Children before parents: deleting a fork that has forks of its own is refused when anything in its chain isolates a storage, shared forks included. A child reads through, or writes into, the isolating prefix via that link, and without it the child's chain would map like the root's.
  • Deletion and same-id creation are serialized on the fork:<id> advisory lock, and a purge skips an id that exists again: the rows are then the new fork's. The proxy caches only the raw client, never a mapping, so fork creation and deletion invalidate the one mapping cache.
  • Deletion always purges the fork's area of each isolated storage, post-commit, from the storage identity recorded at creation: nothing can reach it once the fork is gone. Rows have no FK and outlive the workspace when the purge fails; creating a fork with the same id retries them and refuses while they cannot be purged, since a same-id fork would read them.
  • Isolation is a per-fork-creation choice (shared_storages on create-fork), never inherited. Forks created before the registry existed have no rows and keep sharing.