Volume fan-out: map, measurements and plan
Volume fan-out: map, measurements and plan
Status: in progress, October 2026. This file maps how a volume reaches many
sandboxes today, records what the prod E2E of the parent/children workload
measured, and lists the work that makes that workload fast. The E2E lives in
verify/good-volumes/e2e/fanout/parent-children.ts;
every item below is judged by re-running it.
The workload
One parent sandbox and N child sandboxes, at most X alive at once (N > X). Nothing is declared at create: each sandbox attaches its volumes after it is running, at paths chosen then, and changes them during its life.
results: each child writes intochildren/<name>/, the parent reads all of them live.tasks: the parent writestask.json, every child reads it from a read-only mount.- Results outlive every sandbox: a new parent, or the file API with no sandbox, reads them all.
Map
┌──────────────────────── control plane (apps/api) ─────────────────────────┐
POST attach ──▶ │ api/volumes.ts attach → vol/mounts.ts prepareMounts → host command │
│ vol/commits.ts submitCommit: base == head and whole volume → fast append │
│ otherwise → side commit + merge job │
│ vol/mergeQueue.ts + vol/gateway.ts: one merge batch in flight per volume, │
│ on one gateway host │
│ vol/driveSync.ts: head moved → volume.sync to every live mount behind head │
│ vol/driveLog.ts: closed-file changes → volume.logapply to whole-volume │
│ RW drive mounts (subdir mounts excluded) │
│ scheduler.ts: soft preference for the host that last mounted the volume │
└───────────────┬─────────────────────────────────────────▲──────────────────┘
host commands│ │commit / merge results
┌───────────────────────────────▼──── host (hosts/host-agent) ────────────┴──────────────────┐
│ vold: one ublk device per mount = private CoW overlay (epoch-N.ovl) over a base image │
│ base image = 1 MiB content-addressed blocks: L1 host cache → regional L2 → S3 │
│ vol_attach.go: ch-remote add-disk (virtio-blk hotplug) → guest mounts ext4 by serial │
│ commit: freeze guest fs → hash dirty blocks → upload → CP head swap │
│ vol_sync.go: commit own changes, wait, diff against head, apply file by file in guest │
│ vol_logapply.go: apply other writers' logged file changes guarded by base version │
│ volgw: merges side commits into head with userspace ext4 (pt-volfs) │
└──────────────────────────────────────────────────────────────────────────────────────────┘
S3: P/vol/<org>/{blocks,maps,idx}/…, commits/<id>/{image.json,index.bin}
Postgres: volumes.head_commit_id, volume_mounts, merge jobs, drive logLimits that matter here: 8 mounts per sandbox (policy, vol/mounts.ts:24),
1024 ublk devices per host (vold/ublk/limit.go), no limit on sandboxes per
volume. No live migration exists; moves are stop → copy → start.
What prod measured (2026-10-17, api.platinum.dev)
| Run | Lag p50 | p90 | max | Notes |
|---|---|---|---|---|
| 10 concurrent children, subdir mounts | 4.4 s | 6.8 s | 7.8 s | 40 children total |
| 40 concurrent children, subdir mounts | 23 s | 40 s | 71 s | 96 + 20 children, 42 live mounts on one volume |
| 40 concurrent children, whole-volume RW mounts | 67 s | 115 s | 151 s | parent's RO view: p50 26-49 s |
Lag is child write + sync returned → first parent ls/cat that shows it.
All correctness checks passed in every run: 0 missed writes of 1 064, 116/116
payload checksums, results intact after parent stop/start, after parent
deletion and through the file API.
Failures seen under load:
- About 11% of attaches at ~40 concurrent returned
503 database connection reset, retry. - A retried attach whose first attempt had succeeded got
409 path_exists: attach is not idempotent. - Attach p50 4.8 s, p90 11 s, max 17 s; moving the parent's mount took 24 s (subdir) to 62 s (whole).
Why it degrades: the causal chains
Traced through the code after the E2E (file:line as of origin/main 8e4897180).
Subdir children (the parent/children pattern).
- A child's commit is always a side commit: fast append needs
subPath === ''(apps/api/src/vol/commits.ts:281). - Merges run one batch at a time per volume on its gateway host (
commits.ts:417-467,vol/mergeQueue.ts). - Each head move sends
volume.syncto every live drive mount behind head, including children whose own folder did not change (vol/driveSync.ts:24-50). - Each such sync commits, waits for its merges (
hostApi.ts:260-329), then derives a sub_path image of the new head (hostApi.ts:343→mounts.ts:135-190). - Sub_path derivations take the same per-volume gateway mutex as merges (
hosts/host-agent/internal/volgw/subimage.go:55,merge.go:135): ~40 builds queue ahead of the next merge, per head move, so work grows with writers². - The drive change log, which shows a closed file to other mounts in ~1.5 s, refuses subdir writers (
vol/driveLog.ts:325,mounts.ts:466), so the parent only sees a child after all of the above. Without the log policy a child's own commits also stall at 3 merges in flight instead of 64 (internal/vold/commit.go:389-398).
Whole-volume children. Same chain, and every writer is also a receiver of the other 39 writers' log entries and syncs. Each apply holds that mount's own commits (vol_logapply.go:812, vol_sync_chunk.go:1035), and after three unsettled syncs the hold escalates to 20 s (vol_sync.go:196-210). Writers stall each other: writers × mounts.
Attach latency. waitForDepartingWriters waited until no writer was leaving the volume (mounts.ts:302-317). Children leaving all the time meant the wait never emptied and rode its 15 s bound.
Attach 503 then 409. The attach commits its row, then re-reads while it waits; a dropped connection on a re-read became a 503 for a mount that existed, and the retry hit the overlap check (api/volumes.ts:817).
Detach under load. A running sync holds the mount gate through its merge wait and settle tries (vol_sync.go:384-473), and detach waits for the gate (vol_attach.go:697).
Plan
Each workstream names the E2E number it moves. Status as of this branch set.
| # | Change | Fixes | Where | Ships via | Status |
|---|---|---|---|---|---|
| A1 | Idempotent attach: an identical live mount is replayed (200/202) before prepareMounts and under the sandbox row lock; transaction retried on a dropped connection | 409 after 503 | api/volumes.ts | deploy-cp | done, perf/vol-fanout |
| A2 | Wait loops (waitForCommand, waitUntil) retry a re-read that lost its connection | 11% attach 503s | api/_helpers.ts, vol/events.ts | deploy-cp | done, perf/vol-fanout |
| A3 | Departing-writer wait follows only the departures in flight at the start, narrowed to writers that can reach the mount's folder | attach p90 11 s, max 17 s | vol/mounts.ts | deploy-cp | done, perf/vol-fanout |
| B1 | Subdir writers append to the drive change log, paths made volume-absolute on the CP; commit_sub_path column; non-self-contained subdir rows hidden from vol_v < 7 readers | subdir lag p50 23 s | vol/driveLog.ts, vol/meta.ts, mounts.ts:466, schema + migration | deploy-cp | in progress, perf/vol-subdir-drivelog |
| B2 | Hosts drop the SubPath == "" gate, advertise vol_v 7, strip the writer's prefix before an index lookup | same | internal/vold/commit.go, vold/types.go, vol_drivelog*.go, vol_logapply.go | host rollout | in progress, perf/vol-subdir-drivelog |
| C1 | No sync for a subdir mount whose folder no merged job touched (volume_mounts.sub_current_commit_id) | writers² syncs and derivations | vol/driveSync.ts, vol/commits.ts, vol/handoff.ts | deploy-cp | in progress, perf/vol-sync-fanout |
| C2 | Sub_path derivation locked per (volume, commit, subPath), off the merge mutex | writers² behind one lock | internal/volgw/subimage.go | host rollout (gateway hosts) | in progress, perf/vol-sync-fanout |
| C3 | Held-wait escalation capped near 2 s for drive mounts | whole-volume p50 67 s | cmd/host-agent/vol_sync.go | host rollout | in progress, perf/vol-sync-fanout |
| C4 | Detach cuts a sync that is only waiting | detach 24-62 s | vol_sync.go, vol_attach.go | host rollout | in progress, perf/vol-sync-fanout |
| C5 | A young fast-acked job no longer holds back the older jobs in a merge batch | batches delayed up to 5 s | vol/commits.ts, vol/fastack.ts | deploy-cp | in progress, perf/vol-sync-fanout |
| D1 | SDKs retry attach on 503 unavailable and connection drops (budget ~90 s) | client-visible 503s | packages/sdk-ts/src/volumes.ts, packages/sdk-py/platinum/_volumes.py | SDK release, after A1 is live on prod | next |
| D2 | Write-only ("push") drive mount: excluded from log fan-out and syncs, so children do not receive each other's changes | writers × mounts receiver holds | API field, driveLog.ts:419, driveSync.ts | deploy-cp + docs | next |
| D3 | Sync applies without waiting for its own merge (diff from the last applied head, excluding own pending paths) | sync cost under hot writers | vol_sync.go:399 | host rollout | later: medium-high risk around removals and moves |
| D4 | Parallel merge lanes for disjoint subdirs, rebased on completion | merge ceiling per volume | commits.ts:614, volgw | both | later: high effort |
| D5 | Attach host path: guest-mount retry delays, volReport off the critical path | residual attach time | vol_attach.go | host rollout | after re-measure: pull [perf] volume.mount step times first |
| E1 | Prewarm a new host's L1 with the volume's hot blocks when a mount is placed there; peer fetch between hosts | cold start at scale-out, S3 stampede | vold, scheduler | both | later |
| E2 | Placement prefers hosts with live mounts of the volume (soft), on create and as a hint on attach | cache locality | scheduler.ts:708-715, mounts.ts:334 | deploy-cp | later |
| E3 | Per-sandbox mount cap above 8 (policy only; device limit is 1024 per host) | product ask | vol/mounts.ts:24, config.ts:1711, apps/web/lib/volumes.ts:11 | deploy-cp + web | decide |
Not planned: live migration to colocate sharers. Colocation caps one volume's fan-out at one host and makes it one failure domain, and each host only pays the cold fetch once (256 MiB in ~0.8 s measured). E1 and E2 give the locality without moving running VMs.
Rollout order
- CP: A1-A3, B1 (gated by
vol_v7, inert until hosts advertise it), C1, C5.main→ Development; run the E2E on dev. - Hosts: B2, C2-C4 to Development hosts; run the E2E again (both
MODEs). - Promote CP and host builds to staging, E2E, then prod, E2E.
- D1 SDK release once A1 is live on prod.
Targets
At 40 concurrent subdir children: child write → parent sees it p50 ≤ 3 s, p99 ≤ 8 s; attach p90 ≤ 3 s; 0 attach 503s reaching the client; every E2E check true.
Results on Development (after #1653)
The E2E runs from a driver sandbox on dev (a laptop that sleeps voids runs:
the harness now reports client_paused_ms and voids them). Dev's two hosts
hold about 30 children next to their other tenants, so dev runs use X=28 and
MIN_SHARED=28; the 32+ bar needs Staging.
| Date | Children | Mode | Checks | write → parent p50 / p99 | exec p99 | volume-API 503s |
|---|---|---|---|---|---|---|
| 2026-10-17 dev, old code | ~35 | subdir | capacity failures | 6.7 s / 26 s (max) | 4.8 s | 122 retried |
| 2026-10-17 dev, #1653 | 28 (peak 30-31 mounts) | subdir ×3 | all true | 2.9-3.7 s / 10.6-15.2 s | 0.9-4.5 s | 0 |
| 2026-10-17 dev, #1653 | 28 (peak 29-30 mounts) | whole ×3 | all true | 4.0-5.6 s / 8.6-12.1 s | 2.2-2.5 s | 0 |
| 2026-10-17 dev, #1658, gap 150/750 ms | 28 | subdir ×3 | all true | 2.3-2.7 s / 7.2-10.1 s | 2.6-3.4 s | 0 |
| 2026-10-17 dev, #1658, gap 150/750 ms | 28 | whole ×3 | all true | 4.4-6.2 s / 11.5-13.4 s | 1.8-4.9 s | 0 |
Where the remaining time goes, from the parent's host commands
(/internal/admin/commands.json): fan-out → pickup p50 40-70 ms, pickup →
applied p50 40-140 ms. The rest is on the writer: a drive-log mount spaces its
flush commits by driveLogFlushGap (500 ms per merge still waiting, at most
2 s), so with 1-4 merges pending a child's sync waits 0.5-2 s before its
commit starts. That spacing is now a control-plane setting
(vol.drive_log_flush_gap_min_ms / _max_ms), to be tuned against merge load
on dev.
The tighter gap helps sub_path writers and hurts whole-volume ones: a sub_path commit's merge syncs nobody else (markSubCurrent), a whole-volume one syncs every other whole-volume mount, so more of them cost more than they save. The setting now applies to sub_path drive-log mounts only.
Session-workspace bars on Volumes (2026-10-17, Development)
These are the "Depots" proposal's bars, measured on today's volumes with
verify/good-volumes/e2e/fanout/depot-bars.ts, depot-indep.ts and
depot-chunks.ts. They feed P4 (prewarm) and P5 (chunking) of
volume-node-share-plan.md. The workspace is 72,000 files, 2.0 GB, 121,743
entries, in a 2 CPU / 4 GiB box.
| Bar | Result |
|---|---|
| Box boot with the workspace mounted | p50 1.06 s (1.08 s p90); without a volume 0.84 s |
| Hot attach into a running box | p50 101 ms, p90 210 ms |
Save after editing 3 files (pt-vol commit) | p50 1.40 s, p90 1.88 s |
| Save after adding 500 files | p50 1.64 s |
| Save with nothing changed | p50 12 ms |
| First metadata walk (cold) | 2.2 s |
| First 2,000 random reads (cold) | 0.76 s |
| First full read (cold) | 12.8 s, which is the P4 prewarm case |
| Space, 10 sessions forked from one base | 54× logical/stored |
Cross-session dedup was measured on 5 independent sessions that wrote the
same 20,000 files (3.16 GB logical) each. depot-chunks.ts hashed each raw
image in fixed windows and skipped all-zero windows. 5× is the ceiling.
| Window | Same write order | Shuffled order |
|---|---|---|
| 4 KiB (an ext4 block, so effectively file-aligned) | 4.55× | 3.65× |
| 64 KiB | 2.93× | 1.21× |
| 1 MiB (today's volume block) | 2.81× | 1.15× |
Takeaways:
- Today's 1 MiB blocks share almost nothing between independent sessions: 1.15× when the write order differs.
- A fixed 64 KiB window barely helps. Most of the gain comes from aligning chunks to files.
- That confirms the P5 scope: file-aligned or content-defined (FastCDC) chunks, rather than smaller fixed blocks.
- The gap between 4 KiB and the 5× ceiling is filesystem metadata (inode tables, directories, bitmaps), which file-level dedup would not store at all.