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

57 lines
4.2 KiB
Markdown

# 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.