Prototype: Expose filesystem to a durable object - #1
Draft
aron-cf wants to merge 17 commits into
Draft
Conversation
Mounts a writable filesystem at /data backed by the actor's own SQLite database, reachable both through node:fs and a new ctx.filesystem surface that resolves through the same VFS. Contents are stored as content-addressed 4096-byte chunks. Writes go out on the relaxed tier (allowUnconfirmed), so they commit locally and are visible immediately without holding the output gate; ctx.filesystem.fsync() promotes the current state to a gated, confirmed commit.
fill() reassembled only the filled range, truncating everything after it.
The filesystem's nodes/chunks/file_chunks tables previously lived in the actor's primary database, sharing table namespace and transaction scope with the app's KV and SQL data. Open a second database per actor instead, wrapped in its own ActorSqlite sharing the actor's output gate, and resolve filesystem operations against it.
A container must not touch the actor's SQLite database, so give the store the three pieces needed to hand the tree to one and take writes back: a tar export of the subtree, a lease with epoch fencing that makes the container the only writer, and an intent log whose frames are applied only up to the last commit marker. Container.importDirectorySnapshot() turns exported bytes into a snapshot volume, which start() can restore.
Covers the round trip against local Docker: the DO's tree is exported and restored into the container, the container takes the write lease, a write-back agent ships commit-marked intent frames, and the DO applies them and promotes them with fsync. Crash safety is tested both ways -- a batch with no commit marker is discarded whole, and frames from a superseded epoch are rejected. Docker-dependent targets carry requires-container-engine so CI without a daemon skips them.
Covers both surfaces (node:fs and ctx.filesystem) and the container round trip, with a README describing the implementation and how to run it against a custom workerd build -- either directly via `workerd serve` or under `wrangler dev` with MINIFLARE_WORKERD_PATH.
jsg::BufferSource and jsg::BackingStore no longer exist, so the filesystem and container snapshot APIs use jsg::JsBufferSource instead. Struct fields hold jsg::JsRef, since a JSG_STRUCT outlives the handle scope it was read in. exportTar() returns jsg::JsRef<jsg::JsUint8Array> for the same reason: it previously returned an unrooted local, which aborted the process with a V8 fatal error when it was the first filesystem call in an object. The ctx.filesystem methods now reject their promises rather than throwing synchronously, so callers can use .catch() as they would with node:fs/promises.
Each actor now has a .sqlite.fs pair alongside its storage database, so the on-disk Durable Object test's file counts were one pair per object short.
A container round trip was a sequence the application had to orchestrate: export a tar, import a snapshot, take a lease, run a write-back agent in the container, turn its output into ordered intent frames, apply them under the right epoch, release the lease and fsync. The intent log and the lease epoch were part of the public API even though they are reconciliation mechanics. ctx.filesystem.withContainer(container, callback) now owns all of that. It seeds the container from the current tree, runs the callback, then reads the container's /data back out as a tar and applies it as one checkpoint, so a caller only writes files and runs commands. The archive travels both ways: a new exportDirectory() RPC reads a directory out of a running container, and SqliteFsStore::importTar() applies an archive as a whole-tree checkpoint, which also makes deletions in the container come back as deletions. The lease still exists, so the object's own writes are refused while the container holds the tree, and the tree is handed back whether the callback succeeds or throws.
A container that mounts the object's filesystem sees the same tree the object's own node:fs calls do, with no archive exchange in between: no snapshot to seed, no whole-tree checkpoint to apply, and no window in which the two sides disagree. This is the protocol half. NinepServer speaks the 9P2000.L subset the Linux kernel's in-tree v9fs client uses, answering every message out of SqliteFsStore, so nothing has to be installed in the container image. It is deliberately free of JavaScript and of the isolate: the filesystem lives in its own database, separate from the one behind ctx.storage, so reads and writes can be answered while the object itself is parked awaiting the container. Renaming needed a store primitive, since rename(2) must not copy and must not detach a subtree from the root.
Tools running in a container against the object's tree depend on metadata the store did not keep. An executable bit that does not survive a write leaves a checked-out script unrunnable, and a checkout containing a symlink fails outright. Nodes now carry a mode, a uid and a gid, and a symlink's target is stored as its contents, so the 9p server can answer Tgetattr, Tsetattr, Tsymlink and Treadlink from the store instead of synthesizing a fixed mode and refusing links. Filesystems written before this have no such columns; they are added on open, and a node whose mode is 0 is read as the default for its type, so nothing needs backfilling.
A read or a write rebuilt the whole file: reading 32MB took 8192 chunk rows for every request the client made, and writing rehashed and rewrote every chunk, then scanned the entire chunk table looking for content nothing referenced. Cost grew with the size of the file rather than with the size of the operation, which a mounted filesystem makes immediately visible -- a kernel client fills a file with thousands of ordinary writes. Both paths now address the chunks the offset range covers, a partial write merges into the chunk it lands in, and replacing a chunk collects just the content it displaced. Against a container mounting the tree over 9p, reading 32MB goes from 27s to 0.3s and writing it from 85s to 11.5s. ninep-serve serves one filesystem database over TCP so the kernel's own 9p client can be pointed at it; that is where those numbers come from. It is a manual target, not part of the runtime.
A container started with `filesystemMounts` gets the object's own tree at the path it names, instead of a copy of it. The container's kernel is the 9p client, so the image needs no daemon; workerd answers the protocol directly against the actor's filesystem database, without entering JavaScript, so it keeps serving while the object awaits a container process. The engine binds a listener per mount before creating the container, and mounts it from outside the container's namespace once the container is running. Mounting requires CAP_SYS_ADMIN and, under AppArmor's default profile, an unconfined profile; both are added only for a container that is served a filesystem. Symlinks now resolve through the object's own surfaces too, so a link a container writes is a link when `node:fs` reads it.
A manual benchmark running the same workloads in the same image against each of the two ways a container is given the object's filesystem, so the choice between them rests on measurements rather than on estimates.
Use live 9p mounts as the only Durable Object filesystem path for containers. Remove ctx.filesystem.withContainer(), the tar import and export RPCs, the SQLite lease and intent log code, and the checkpoint tests. Update the example and benchmark to use the live mount path.
Perfetto relies on transitive mach headers that newer macOS SDKs no longer provide. Add macOS-only per-file compiler options so the missing mach declarations are visible for both target and host builds.
Capture output from helper Docker exec commands so mount failures include the underlying stderr. This makes container startup failures report the actual mount error instead of only an exit code.\n\nUse the Docker AppArmor security option spelling accepted by Docker, and advertise a host-reachable numeric address for macOS local container mounts so the guest 9p client does not connect to its own loopback.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A core problem with Durable Objects as a runtime for agentic workflows is a lack of filesystem. @cloudflare/computer aims to solve this in user space by using the SQLite store built into each DO as the basis of a virtual filesystem. It goes further by syncing this filesystem to a container using a daemon running on the container so that code execution (such as
npm install) runs on the same files.This PR looks at what it might look like for a Durable Object to have a first class filesystem API that is made available to both the Durable Object itself via either an interface on
ctxor by importing "node:fs" directly. Then making this same filesystem available directly to the container.This pull request gives every SQLite-backed Durable Object a writable filesystem at
/data. The filesystem is backed by a second SQLite database that belongs to the object but is separate from the application'sctx.storageandctx.storage.sqldatabase.Both Worker-facing surfaces read and write the same bytes (ultimately there should only be one interface). A file written with
node:fsis visible throughctx.filesystem, and a file written withctx.filesystemis visible throughnode:fs:The
ctx.filesystemAPI is intentionally small. It gives code a promise-based surface over the same tree withreadFile,writeFile,readdir,stat,mkdir,rm, andfsync. Writes use the relaxed durability tier: they are committed locally and are visible immediately, but they do not hold the output gate.ctx.filesystem.fsync()performs an explicit strict write, which gives applications a clear point to promote prior filesystem changes to the confirmed durability path.Containers see the same files without touching SQLite. When
ctx.container.start()receives a filesystem mount, workerd serves the Durable Object filesystem over 9p and the container mounts it at the requested path. The container and the object then see one live tree as it changes.The container API owns the container lifecycle and the mount.
A runnable example lives in
examples/filesystem. Its full-cycle worker writes a file from the Durable Object, starts a container with/datamounted live, reads the object's file from the container, writes a second file from the container, and reads that file back from the Durable Object through bothnode:fsandctx.filesystem. A second route throws the object away and reads again, which shows the container's write survived in the Durable Object filesystem database.Build workerd from this branch and run the container example directly with
workerd serve:bazel build //src/workerd/server:workerd bazel run //images:load_all cd examples/filesystem mkdir -p state ../../bazel-bin/src/workerd/server/workerd serve --experimental --directory-path CYCLE_TMPDIR=./state workerd.capnpThen call the example:
curl localhost:8790/ curl localhost:8790/evict curl "localhost:8790/read?path=/data/from-container.txt"The same directory also has a filesystem-only worker that needs no container and can run under
wrangler devagainst a custom build:That response shows native
node:fs,ctx.filesystem, cross-surface reads, andfsync()all working:{"nativeWrite":"hi","ctxWrite":"yo","crossRead":["x.txt","y.txt"],"fsync":"confirmed"}examples/filesystem/README.mdcovers both flows and calls out the requirements that are easy to miss: the worker needs the experimental flag, the Durable Object class must be SQLite-backed, and the full container cycle needs a local container engine and the test container image.Follow-up work
This is intended as a UX prototype only, not a suggestion for a real implementation. The use of a second SQLite database is convenience rather than recommendation.