Skip to content

Commit ef8cd11

Browse files
aron-cfPi Agent
andauthored
computerd: Add an optional on-disk SQLite store (#123)
computerd keeps its workspace in memory, so a restart loses it and the durable object has to send every path again. On a large workspace that replay is most of the time it takes to get back to work. Set COMPUTERD_DB to an absolute path and the store goes on the container's disk instead. The sync positions live in the same database, so a restarted daemon still knows what it was sent and the durable object only sends the difference: about 25ms instead of a full resend, whatever the size of the tree. dofs gains a ./node export with NodeSQLiteStorage, which runs node:sqlite against a file or memory. It stays out of the main entry point because that has to load under workerd, which has no node:sqlite. SQLiteTestStorage is now the in-memory pinning of the same class. Also adds POST /__computerd/checkpoint for folding the write-ahead log back before a disk snapshot, reports the store on /__computerd/info, and adds store size and free pages to /__computerd/stats. Measured with script/store-compare.mjs and script/restore-time.mjs; numbers in packages/computerd/bench-results.md. Reads are not slower on disk. Writes cost 10 to 20 percent more through a real FUSE mount. The in-memory store stays the default. Co-authored-by: Pi Agent <pi-agent@cloudflare.local>
1 parent e6a92c5 commit ef8cd11

20 files changed

Lines changed: 1847 additions & 116 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/dofs": minor
3+
---
4+
5+
Let `computerd` keep its workspace on disk instead of in memory, so it survives a restart. Set `COMPUTERD_DB` to a file path — see [the `computerd` README](../packages/computerd/README.md#on-disk-store).

docs/02_sync_protocol.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -586,9 +586,9 @@ needed; pure DO-side optimisation.
586586
### Push backpressure
587587

588588
A long-running exec can dirty container state faster than the DO can
589-
pull. Today's process-lifetime container VFS caps this by OOMing, which
590-
is a bad answer. Once a disk-backed container mirror lands the bound
591-
shifts to path count, but the same problem persists. Likely shape: a
589+
pull. An in-memory container VFS caps this by running out of memory.
590+
Setting `COMPUTERD_DB` to a file path shifts the limit to path count,
591+
but the same problem remains. Likely shape: a
592592
soft cap on the dirty set (say, 256 MiB pending bytes or 100k paths)
593593
above which FUSE write replies are delayed (real backpressure into the
594594
writer), or the container opportunistically initiates a push to the DO

docs/11_lifecycle.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,14 @@ capnweb WebSocket session.
3030
│ └──────────┬──────────┘ │ │ └────────┬─────────┘ │
3131
│ │ │ │ │ │
3232
│ ┌──────────▼──────────┐ │ │ ┌────────▼─────────┐ │
33-
│ │ SQLite (ctx.storage)│ │ │ │ In-memory VFS │ │
34-
│ │ _vfs_watermark │ │ │ │ (process- │ │
35-
│ │ vfs_blobs / nodes │ │ │ │ lifetime DB) │ │
33+
│ │ SQLite (ctx.storage)│ │ │ │ SQLite VFS │ │
34+
│ │ _vfs_watermark │ │ │ │ (in memory, or │ │
35+
│ │ vfs_blobs / nodes │ │ │ │ COMPUTERD_DB) │ │
3636
│ └─────────────────────┘ │ │ └──────────────────┘ │
3737
└───────────────────────────┘ └────────────────────────┘
3838
| |
39-
| source of truth process-lifetime |
40-
| (durable across restarts) (lost on restart)|
39+
| source of truth mirror, in memory
40+
| (durable across restarts) or on the container disk
4141
```
4242

4343
The 1:1 mapping is load-bearing for several reasons:
@@ -91,8 +91,8 @@ an incarnation boundary. What survives is:
9191
On every new incarnation `Workspace.ready()` re-runs `#connect()`,
9292
which re-enters the backend's bootstrap sequence. If the container is
9393
still alive, the backend's `POST /connect` + `/api` handshake produces
94-
a fresh capnweb session against the same in-memory VFS on the
95-
container side. If the container died too (e.g. host OOM took both),
94+
a fresh capnweb session against the same container-side VFS. If the
95+
container died too,
9696
the next sync round is a rev-0 baseline rebuild from the DO's store.
9797

9898
### Wake triggers
@@ -127,11 +127,22 @@ lifetime policy. From the DO's perspective:
127127
exits, and the backend's `#monitoring` flag drops the cached handle at
128128
that point so the next call rebuilds from scratch (see the container host and backend implementations under `packages/computer/src/backends/container/`).
129129

130-
The critical asymmetry: the **container's VFS is process-lifetime
131-
in-memory**, while the **DO's VFS is durable SQLite**. A container
132-
restart loses container-side state. The durable object drives sync
133-
across the capnweb session it opens through `POST /connect`, and that
134-
is what brings state back on the next push/pull round.
130+
When `computerd` runs with its default in-memory store, the two sides
131+
differ: the **container's VFS lasts only as long as the process**,
132+
while the **DO's VFS is durable SQLite**. A container restart loses
133+
container-side state. The durable object drives sync across the
134+
capnweb session it opens through `POST /connect`, and that is what
135+
brings state back on the next push/pull round.
136+
137+
Setting `COMPUTERD_DB` to a path changes this for a process restart.
138+
The container-side store is written to disk, sync cursors included,
139+
and reopened on the next start. The durable object then finds a peer
140+
that still knows what it was sent, and only sends the difference.
141+
142+
The container's disk does not survive a container restart, so this
143+
helps a `computerd` crash inside a live container today. It will help
144+
a container restart once the platform offers disk snapshots. See the
145+
`computerd` README for the setting and its limits.
135146

136147
## Capnweb lifecycle
137148

docs/19_performance.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,69 @@ computerd is ~2x slower than the container's ext4 disk for the full
4848
`npm install`, and ~3.6x slower than tmpfs. The disk comparison is
4949
the more realistic baseline for general usage.
5050

51+
## In-memory store versus on-disk store
52+
53+
`computerd` keeps its SQLite store in memory by default. Set
54+
`COMPUTERD_DB` to a path and it goes on the container's disk instead.
55+
56+
These numbers come from `script/store-compare.mjs`, which uses the
57+
dofs filesystem directly with 2,000 files in one directory. There is
58+
no FUSE mount involved, so any difference is down to the store.
59+
60+
| Operation | memory | on disk (64 MiB cache) | ratio |
61+
|---|---:|---:|---:|
62+
| create 2000 files | 180.9 ms | 659.1 ms | 3.64x |
63+
| stat 2000 paths, cold | 1580.4 ms | 1558.7 ms | **0.99x** |
64+
| stat 2000 paths, warm | 12.1 ms | 15.1 ms | 1.25x |
65+
| readdir x50 | 155.4 ms | 143.1 ms | **0.92x** |
66+
67+
Reads are not slower on disk. That holds even when the cache is far
68+
too small for the tree: a 2 MiB cache against a 3.8 MiB database still
69+
reads at 0.99x. Two reasons. Most of the time goes on walking the path
70+
rather than fetching pages, and the operating system caches whatever
71+
SQLite drops.
72+
73+
Writes are slower, and the reason is the cost of flushing to disk.
74+
Creating 1,000 files takes 445 ms at `synchronous = full`, 292 ms at
75+
`normal` (what we ship), and 148 ms at `off` — which matches the
76+
in-memory store's 181 ms.
77+
78+
Through a real FUSE mount the write cost shrinks, because the mount
79+
itself is already the bigger expense:
80+
81+
| Scenario | memory store | file store | baseline |
82+
|---|---:|---:|---:|
83+
| stat 1000 files | 2777.3 ms (1.10x) | 3114.9 ms (1.22x) | ~2540 ms |
84+
| create 1000 files | 989.4 ms (0.98x) | 1178.7 ms (1.17x) | ~1010 ms |
85+
| write 64 MiB | 238.1 ms (11.14x) | 221.9 ms (12.69x) | ~19 ms |
86+
| overwrite 64 MiB | 294.3 ms (26.16x) | 304.6 ms (29.30x) | ~11 ms |
87+
88+
## Restore time
89+
90+
This is what the on-disk store buys. `script/restore-time.mjs` times
91+
what a host waits after a restart: connect, compare sync positions,
92+
and send whatever the other side is missing.
93+
94+
| Tree | memory store | file store |
95+
|---|---:|---:|
96+
| 500 files | 480 ms (502 entries sent) | 26 ms (0 sent) |
97+
| 3,000 files | 3749 ms (3002 entries sent) | 23 ms (0 sent) |
98+
99+
An in-memory store sends the whole workspace again after every
100+
restart, so its cost grows with the tree. A file store sends nothing,
101+
because the sync positions came back along with the files. Restoring
102+
takes about 25 ms whatever the size, so the saving grows too: 18x at
103+
500 files, 161x at 3,000.
104+
105+
The trade: small-file work costs 10 to 20 percent more, and a restart
106+
costs a flat 25 ms instead of resending everything.
107+
108+
Two things these numbers do not cover. They come from one Linux
109+
container, not from Cloudflare Containers hardware, and a full
110+
`cloudflare/sandbox-sdk` `npm install` has not been run. The restore
111+
figures also drive the sync protocol in process, so they show the work
112+
avoided but not the network round trips a real host would also skip.
113+
51114
## Where computerd is faster than the disk baseline
52115

53116
The in-memory inode store beats real disk on metadata-heavy work:

packages/computerd/README.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ Current endpoints:
2626

2727
- `GET /health` returns `200 OK` with `ok\n` once the HTTP server is up (it does not currently block on FUSE readiness).
2828
- `GET /__computerd/info` returns JSON with the selected FUSE backend, mount point, and bound port.
29-
- `GET /__computerd/stats` returns JSON with DOFS table row counts, total inline and blob byte sizes, the orphan-blob subset, and process resident memory. Useful for watching how the store grows under load.
29+
- `GET /__computerd/stats` returns JSON with DOFS table row counts, total inline and blob byte sizes, the orphan-blob subset, process resident memory, and the store's own size and free-page count. Useful for watching how the store grows under load.
30+
- `POST /__computerd/checkpoint` folds the store's write-ahead log back into the database file and returns `{ walFrames, sizeBytes, durationMs }`. For a host about to take a disk snapshot. Any other method returns `405`.
3031
- `GET /` returns `200 OK` with an empty JSON object: `{}`.
3132
- `GET /api` upgrades to a WebSocket carrying the capnweb RPC surface backed by `@cloudflare/computer-rpc`. This is the container's only RPC carrier. A request without an `Upgrade` header returns `400`; a handshake naming an unsupported `Sec-WebSocket-Version` returns `426` along with the versions the server speaks.
3233
- `GET /api/watermarks` returns JSON with `currentRev`, `pushRev`, and `fetchCursor`, read through the same `watermarks()` the wire serves. For samplers that want a few numbers without opening a session. It sits under `/api` because it reads the workspace surface; `/__computerd` is for daemon introspection.
@@ -45,7 +46,41 @@ Current filesystem support:
4546
- Unsupported FUSE operations return `ENOSYS` to the kernel; the binding logs a one-shot warning per operation.
4647
- capnweb RPC over `/api` exposes the workspace database and an `exec` runner to clients.
4748
- Synchronization is driven by whoever holds the other end of the session. The daemon serves `SyncRPC`; it does not run a sync loop of its own.
48-
- No on-disk persistence yet — the in-memory VFS is rebuilt on each start, and the host pushes state back after a restart.
49+
- Optional on-disk storage through `COMPUTERD_DB`. Unset, the in-memory store is rebuilt at each start and the host sends its state back. Set to a path, the store survives a restart and the host sends only what changed. See [On-disk store](#on-disk-store).
50+
51+
## On-disk store
52+
53+
`COMPUTERD_DB` picks where the workspace lives:
54+
55+
```sh
56+
COMPUTERD_DB=memory # default: in-memory, rebuilt on every start
57+
COMPUTERD_DB=/var/lib/computerd/state.db # on-disk, survives a restart
58+
```
59+
60+
The path must be absolute and must not sit inside `MOUNT_POINT`. A database file that the FUSE mount also shows would feed its own writes back to itself. `computerd` refuses to start on either mistake.
61+
62+
Keeping the store on disk saves more than the files. The sync positions live in the same database (`_vfs_watermark`, `_vfs_fetch_cursor`, `_vfs_push_cursor`). Without them a restarted daemon looks further behind than it is, so the durable object sends every path in the workspace again. With them it sends only what changed. On a large workspace that is the difference between resending everything and doing nothing.
63+
64+
The exec log does not persist. `computerd_exec_log` and `computerd_exec_meta` are cleared at every start, because the processes they describe are gone.
65+
66+
### Settings
67+
68+
A file store opens with write-ahead logging, `synchronous = normal`, a 64 MiB page cache, a 256 MiB memory map, temporary tables in memory, and a five-second busy timeout.
69+
70+
`synchronous = normal` flushes to disk when the log is folded back rather than on every commit. That is safe here: the durable object holds the real copy, so a host crash that loses the last few writes costs a resend, not data.
71+
72+
### Checkpointing
73+
74+
- `POST /__computerd/checkpoint` folds the write-ahead log back into the database file and returns `{ walFrames, sizeBytes, durationMs }`. Call it before taking a disk snapshot, so the snapshot holds one file rather than a file plus a log.
75+
- The same thing happens on `SIGTERM` and `SIGINT`, after the FUSE unmount. The order matters: the FUSE driver writes buffered bytes to the database when it releases a file, so unmounting first is what gets those bytes in.
76+
- `GET /__computerd/stats` reports `store_size_bytes` and `store_freelist_count` next to the table counts, so you can watch the file grow.
77+
78+
### Limits
79+
80+
- Take snapshots between commands, not during one. A checkpoint keeps the database itself valid, but a snapshot taken mid-command catches a half-written workspace. A half-finished `npm install` is still half-finished after a restore.
81+
- An older `computerd` exits with `EIO` rather than open a store written by a newer one. Restoring onto an older release fails loudly, which is intended.
82+
- Mount rows (`_vfs_mounts`) come back with the store and may be out of date until the durable object rebuilds them.
83+
- If the store is further ahead than the durable object, the daemon cannot fix it: it answers sync requests but never starts one. Begin from a fresh disk instead.
4984

5085
## FUSE write model
5186

@@ -116,6 +151,7 @@ EXEC_LOG_MAX_BYTES=1048576 # cap the in-memory exec log buffer (bytes)
116151
EXEC_SHELL=/usr/bin/bash # interpreter exec runs commands under (default /bin/sh)
117152
RPC_CLIENT_SECRET=<secret> # require Authorization: Bearer <secret> on every route but /health
118153
COMPUTER_VAR_NODE_ENV=production # forwarded into exec as NODE_ENV
154+
COMPUTERD_DB=/var/lib/computerd/state.db # on-disk store; "memory" or unset keeps it in memory
119155
```
120156

121157
`EXEC_SHELL` must be an absolute path. It exists because `/bin/sh` is `dash` on a Debian-family image, where bash-only syntax is a parse error that aborts the command rather than a missing feature: `${PIPESTATUS[@]}`, arrays, `[[ ... ]]`, and process substitution all fail that way. `PIPESTATUS` is the usual way to recover the real exit status of a pipeline whose output is filtered — a command redacting a credential through `sed`, for instance — so a caller that needs it can select an interpreter that has it without repointing `/bin/sh` for every other script in the image.

packages/computerd/bench-results.md

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,119 @@
1-
# FUSE mount option benchmarks
1+
# Store and FUSE mount option benchmarks
2+
3+
## In-memory store versus file-backed store
4+
5+
Numbers from `script/store-compare.mjs`, which
6+
drives the dofs filesystem directly against both storage backends.
7+
It deliberately skips FUSE so a difference here is the store and
8+
nothing else. 2,000 files in one directory, Node 24 on a Linux
9+
container.
10+
11+
| Store | create 2000 | stat cold | stat warm | readdir x50 |
12+
|---|---:|---:|---:|---:|
13+
| memory | 180.9 ms | 1580.4 ms | 12.1 ms | 155.4 ms |
14+
| file, 64 MiB cache | 659.1 ms (3.64x) | 1558.7 ms (0.99x) | 15.1 ms (1.25x) | 143.1 ms (0.92x) |
15+
| file, 256 MiB cache | 650.0 ms (3.59x) | 1509.8 ms (0.96x) | 13.8 ms (1.14x) | 146.9 ms (0.95x) |
16+
17+
Two findings, one of which contradicts what we assumed when writing
18+
the plan.
19+
20+
**Metadata reads do not regress.** Cold `stat` of 2,000 paths is a
21+
wash (0.96x to 0.99x), and `readdir` is if anything slightly faster
22+
on the file store. The plan predicted this was where a file-backed
23+
store would hurt. It does not, because the working set here is about
24+
1.4 MiB — small enough to sit entirely in SQLite's page cache, so the
25+
reads never reach the disk. Warm `stat` is 1.14x to 1.25x slower,
26+
which is the resolve cache doing its job in both cases and the
27+
remaining difference being page-cache lookup overhead rather than
28+
input or output.
29+
30+
**Writes are the real cost, and the cause is fsync.** Creating 2,000
31+
files is 3.6x slower on the file store. Varying `synchronous` isolates
32+
it:
33+
34+
| `synchronous` | create 1000 files |
35+
|---|---:|
36+
| `full` | 444.6 ms |
37+
| `normal` | 291.9 ms |
38+
| `off` | 148.1 ms |
39+
40+
`off` matches the in-memory store, so the gap is entirely the cost of
41+
flushing to disk. `normal` is the shipped default and already buys
42+
back a third of `full`. Anything faster trades durability for speed,
43+
which is defensible here because the durable object is the source of
44+
truth, but `off` risks a corrupt database on host loss rather than
45+
merely losing recent transactions, so it stays off the table.
46+
47+
Sweeping the cache budget changes almost nothing. At 6,000 files the
48+
database is 3.8 MiB; squeezing the cache to 2 MiB, so the working set
49+
genuinely cannot fit, still leaves cold `stat` at 0.99x:
50+
51+
| Store | create 6000 | stat cold | stat warm | readdir x50 |
52+
|---|---:|---:|---:|---:|
53+
| memory | 391.9 ms | 14389.1 ms | 34.4 ms | 437.1 ms |
54+
| file, 2 MiB cache | 1682.0 ms (4.29x) | 14177.7 ms (0.99x) | 55.4 ms (1.61x) | 474.4 ms (1.09x) |
55+
56+
That is the interesting result. The prediction was that a cache too
57+
small for the tree would turn every resolve into a `pread` and wreck
58+
the metadata numbers. It does not, because cold `stat` is dominated by
59+
the resolve walk itself rather than by fetching pages, and the
60+
operating system's own page cache absorbs what SQLite evicts. Warm
61+
`stat` is where the difference shows, and it is 20 microseconds per
62+
operation on a path that is already cheap.
63+
64+
## Through a real FUSE mount
65+
66+
The numbers above isolate the storage layer. These run the same
67+
comparison through `script/fs-bench.sh` against a real kernel FUSE
68+
mount, with `computerd` started on the host (`FUSE_MOUNT=fuse`), and
69+
`/tmp` as the baseline. REPS=2, WARMUP=1.
70+
71+
| Scenario | memory store | file store | baseline |
72+
|---|---:|---:|---:|
73+
| stat 1000 files | 2777.3 ms (1.10x) | 3114.9 ms (1.22x) | ~2540 ms |
74+
| create 1000 files | 989.4 ms (0.98x) | 1178.7 ms (1.17x) | ~1010 ms |
75+
| write 64 MiB | 238.1 ms (11.14x) | 221.9 ms (12.69x) | ~19 ms |
76+
| overwrite 64 MiB | 294.3 ms (26.16x) | 304.6 ms (29.30x) | ~11 ms |
77+
78+
Large-file input and output is unchanged between the two stores, which
79+
is what the storage-layer numbers predicted: those paths are dominated
80+
by chunking and the FUSE round trip, so the store barely registers.
81+
The small-file scenarios cost 10 to 20 percent more on disk. That is a
82+
real regression, and smaller than the 3.6x the storage-layer create
83+
number would suggest on its own, because FUSE overhead dilutes it.
84+
85+
## Restore time
86+
87+
What the on-disk store buys, measured by `script/restore-time.mjs`. It
88+
times the interval a host actually waits: from a healthy daemon to a
89+
workspace the peer agrees is current, meaning connect, reconcile
90+
watermarks, and push whatever the peer believes is missing.
91+
92+
| Tree | store | first boot | restart |
93+
|---|---|---:|---:|
94+
| 500 files | memory | 454 ms (502 pushed) | 480 ms (502 pushed) |
95+
| 500 files | file | 451 ms (502 pushed) | **26 ms (0 pushed)** |
96+
| 3,000 files | memory | 3725 ms (3002 pushed) | 3749 ms (3002 pushed) |
97+
| 3,000 files | file | 4063 ms (3002 pushed) | **23 ms (0 pushed)** |
98+
99+
An in-memory store re-ships the whole tree on every restart, so its
100+
restart cost tracks the tree size. A file store ships nothing, because
101+
the sync cursors came back with the files and the peer can see there
102+
is no difference to send. The saving is 18x at 500 files and 161x at
103+
3,000, and it keeps growing: the restore side stays flat at roughly
104+
25 ms while the memory side climbs with the workspace.
105+
106+
This is the trade in one line. Small-file work costs 10 to 20 percent
107+
more, and a restart costs a fixed 25 ms instead of a full replay.
108+
109+
Caveats. These run on one Linux container, not on Cloudflare
110+
Containers hardware. The restore measurement drives the sync protocol
111+
directly rather than through a real durable object over a real
112+
network, so it captures the work avoided but not the round-trip
113+
latency a real host would also save. The full `cloudflare/sandbox-sdk`
114+
`npm install` comparison has not been run.
115+
116+
## FUSE mount option benchmarks
2117

3118
Numbers from running `script/run-fs-bench.sh` against the linux-x64
4119
`computerd` binary in a privileged docker container, with the bench's pure

0 commit comments

Comments
 (0)