Skip to content

Prototype: Expose filesystem to a durable object - #1

Draft
aron-cf wants to merge 17 commits into
mainfrom
filesystem
Draft

Prototype: Expose filesystem to a durable object#1
aron-cf wants to merge 17 commits into
mainfrom
filesystem

Conversation

@aron-cf

@aron-cf aron-cf commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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 ctx or 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's ctx.storage and ctx.storage.sql database.

graph TD
  A["node:fs / node:fs/promises"] --> C["/data (virtual filesystem)"]
  B["ctx.filesystem"] --> C
  C --> D["filesystem SQLite database"]
  E["application storage (key/value + SQL)"] --> F["storage SQLite database"]
  D --> G["output gate"]
  F --> G
  H["container /data"] <--> I["9p live mount"]
  I <--> C
Loading

Both Worker-facing surfaces read and write the same bytes (ultimately there should only be one interface). A file written with node:fs is visible through ctx.filesystem, and a file written with ctx.filesystem is visible through node:fs:

import { writeFile, readFile } from "node:fs/promises";
import { DurableObject } from "cloudflare:workers";

export class Workspace extends DurableObject {
  async run() {
    await writeFile("/data/notes.txt", "hello");
    await this.ctx.filesystem.readFile("/data/notes.txt", "utf8"); // "hello"

    await this.ctx.filesystem.writeFile("/data/other.txt", "world");
    await readFile("/data/other.txt", "utf8"); // "world"
  }
}

The ctx.filesystem API is intentionally small. It gives code a promise-based surface over the same tree with readFile, writeFile, readdir, stat, mkdir, rm, and fsync. 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.

await this.ctx.filesystem.mkdir("/data/cache");
await this.ctx.filesystem.writeFile("/data/cache/key", bytes);
await this.ctx.filesystem.fsync();

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.

ctx.container.start({
  entrypoint: ["sleep", "3600"],
  filesystemMounts: [{ path: "/data" }],
});

await ctx.container.exec(["sh", "-c", "echo hi > /data/hello.txt"]);
await ctx.filesystem.readFile("/data/hello.txt", "utf8"); // "hi\n"
await ctx.filesystem.fsync();

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 /data mounted 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 both node:fs and ctx.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.capnp

Then 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 dev against a custom build:

cd examples/filesystem
MINIFLARE_WORKERD_PATH=$PWD/../../bazel-bin/src/workerd/server/workerd npx wrangler dev
curl localhost:8787/

That response shows native node:fs, ctx.filesystem, cross-surface reads, and fsync() all working:

{"nativeWrite":"hi","ctxWrite":"yo","crossRead":["x.txt","y.txt"],"fsync":"confirmed"}

examples/filesystem/README.md covers 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.

aron-cf added 17 commits August 5, 2026 11:12
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant