Skip to content

feat(sandboxd): add runtime catalog and live volume support - #379

Open
minatoaquaMK2 wants to merge 72 commits into
cocoonstack:mainfrom
minatoaquaMK2:fix/runtime-catalog-review-polish
Open

minatoaquaMK2 wants to merge 72 commits into
cocoonstack:mainfrom
minatoaquaMK2:fix/runtime-catalog-review-polish

Conversation

@minatoaquaMK2

@minatoaquaMK2 minatoaquaMK2 commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Why

An external controller needs to register existing disks and attach or detach them on a running sandbox. This implements the scope agreed in #370, replacing that PR's broader managed-disk proposal.

Plan and progress

This draft contains all three implementation stages in one PR, with separate commits:

  • 1. Runtime catalog. Root-only registration and removal of existing backing paths. Persist registration metadata before claim recovery, preserve idempotency, refuse conflicting bindings, and retain references through uncertain writes and VM cleanup. Unregister never deletes the backing.
  • 2. Live volumes and SDKs. Support mounted and attach-only RO/RW volumes through GET/PUT/PATCH, with Go and Python SDK support. Preserve tenant checks, reader/writer exclusion, partial progress, explicit retry, and writable dirty-marker rules.
  • 3. Integration coverage and controller documentation. Cover mixed modes, cancellation, restart, claim-write failures, stale cleanup and replica interactions. Include a tested Python controller example and document the external reservation, quiescence and access-gating requirements.

The PR remains draft. Final-revision Linux VM validation details will be recorded before it is marked ready; historical hardware and model-checking results do not qualify this implementation.

Review order

  1. sandboxd/pool/volume_catalog.go, registration routes, and claim recovery references.
  2. sandboxd/pool/volume_mutation.go, engine detach/dirty-marker handling, and both SDKs.
  3. Cleanup and replica regression tests, docs/volume-controller.md, and the Python controller example.

Compatibility and boundaries

  • Runtime catalog mutations require a configured root API token, an OS-root daemon and protected root-owned metadata directories. Empty runtime ACLs are operator-only; static empty ACLs remain public. Invalid static tenant names now fail startup.
  • New live RW additions require an effective pool with replicate_seconds: 0. Existing or pending replicas and this claim's recovery checkpoint references must retire first. Initial claim-time writers, retained writer repair/removal and RO live changes remain supported. Disabling archive failover with retained state has explicit transition requirements in docs/deploy.md.
  • Disk creation, growth, physical deletion, ownership, placement and hibernate orchestration remain external. The controller must quiesce and unmount before detach, retain its own reservation across sleep, gate API and preview access until wake/attach/mount complete, and fence the old node on ownership changes.
  • Backings require separate persistent storage outside daemon cleanup directories and their aliases. Registration does not protect files placed under data_dir/readers, data_dir/goldens or data_dir/fork-* from cleanup. The registration API and controller guide state this requirement.

@minatoaquaMK2 minatoaquaMK2 changed the title feat(sandboxd): add runtime catalog and live volume support feat(sandboxd): persist runtime volume registrations Oct 25, 2026
@minatoaquaMK2
minatoaquaMK2 marked this pull request as ready for review October 25, 2026 01:50
@minatoaquaMK2
minatoaquaMK2 marked this pull request as draft October 25, 2026 01:53
@minatoaquaMK2 minatoaquaMK2 changed the title feat(sandboxd): persist runtime volume registrations feat(sandboxd): add runtime catalog and live volume support Oct 25, 2026
Startup without a registry used to clear replica fields and queue their
checkpoints for deletion. A failed rewrite followed by a rollback to the
registry could then delete a checkpoint a peer had adopted, and the procedure
depended on operator preconditions nothing could observe. Without a registry
nothing can retire a replica, so live writable additions on such a claim now
return a permanent 409 instead, and its checkpoint and remote row stay intact.
The expected 503 while a replica retires no longer logs at error level.
Without a registry, expiry routed a claim with replica state through
dropReplica, which cannot settle a pending intent and deletes a published
replica checkpoint that another node may have adopted. An expired
ReplicaPending claim therefore never left the reaper. Expiry now drops rows
only when a registry exists, so such claims are destroyed and their
checkpoints stay on disk. Live writers also treat an unfinished archive intent
as stranded state, since nothing retires it without a registry.
A claim that archived at its deadline on a node without a registry deleted
its replica checkpoint, which a peer may still own through the registry row.
Without a registry, archive now leaves that checkpoint on disk, matching
release and expiry, and the docs say so instead of promising automatic
cleanup.
Without a registry, archiving a claim cleared its replica field, so after a
wake live writers were admitted while the replica's registry row still let a
peer adopt the pre-attach image. The claim now keeps that replica through a
local archive, so the 409 still applies after wake and the replica scan can
retire it once failover is re-enabled.
Keeping replica fields and checkpoints through a registry-less archive closed
one admission gap but broke fencing and row cleanup once archive_failover was
re-enabled, and left archive intents uncovered. Running without a registry
while claims still carry replica state is an operator precondition violation
that sandboxd cannot reason about, so archive keeps its upstream behaviour and
the docs state the precondition and what each exit does.
@minatoaquaMK2

minatoaquaMK2 commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @CMGS, while working on the writable half of live attach I ran into an interaction with replica failover that we didn't cover in #300 or #370. I'd like your view before I push the next milestone to this PR.

Before this PR, a writable volume could only be attached when a claim starts, and replicateOnce skips claims that hold volumes, so a claim never had a replica and a writer at the same time. Live RW attach changes that. If a claim on a replicating pool already has a replica and the controller then attaches a writable disk, a later failover can adopt the claim from that older replica while the original VM is still writing during a partition. The controller could then attach the same disk on the peer and end up with two writers. I reproduced the sequence in pool tests with a fake engine, not on two real nodes.

What I have locally right now is deliberately conservative. A live RW addition on a pool with replicate_seconds > 0 returns 409 before it reserves anything, while RW volumes attached at claim time and live RO changes still work. If a claim on a non-replicating pool still has an old replica, the request returns 503 with Retry-After until the replica scan retires it. Our use case doesn't need replication, so this costs us nothing.

I also looked at what happens if archive_failover is turned off while claims still have replica state. Without the registry, sandboxd can't tell whether a peer owns the row, and every local cleanup I tried could delete a checkpoint that a peer had adopted. So the branch leaves that state alone, returns a permanent 409 for live RW on those claims, and documents that replicas should retire before failover is disabled. While doing this I also fixed one expiry case: without a registry, a claim with a pending replica could stay in the reaper forever.

A few other options I considered:

  • Retire the replica inside the RW attach. This lets both features share a pool, but every RW attach then needs a registry call. I'd only do it later if someone needs it.
  • Record the RW intent and let the background scan retire the replica. I tried this, and it needed claim-write acknowledgement that turned into node-wide 503s under load, so I dropped it.
  • Have a node stop its writers when it loses its lease. That would remove the risk at the source, but it's a failover design question rather than something for this PR.

A few things I'd like to check with you:

  1. Is refusing live RW on replicating pools an acceptable contract for now?
  2. For disabling archive_failover, is a documented precondition enough, or would you rather sandboxd expose or enforce it, for example by showing which claims still have replica state?
  3. Should the expiry fix stay in this PR or go separately?
  4. Does 503 while a replica is retiring and 409 when it can't retire match how you'd want callers to retry?

@minatoaquaMK2
minatoaquaMK2 marked this pull request as ready for review October 25, 2026 10:21
@CMGS

CMGS commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

thanks, this lines up with what we agreed in #370. answers first:

  1. yes, 409 for live rw on a replicating pool is fine for now. it keeps the old rule: a claim never has a replica and a writer at the same time.
  2. a doc note is enough. the permanent 409 on those claims already enforces it.
  3. please move the expiry fix to its own pr, with its repro test. the other replica and checkpoint cleanup changes go with it (see inline). they change every pool, not only volume claims.
  4. yes, 503 + Retry-After while it retires, 409 when it can't. that matches feat(sandboxd): reconcile live read-only volume attachments #339.

two more calls from me:

  • bad tenant names in the static config: log a warning and skip them, don't fail startup. existing configs must keep booting. keep the strict check for runtime registration.
  • empty tenants: give it one meaning. a runtime entry with no tenants should mean the same as a static one. the controller sends a list when it wants to limit access.

on the shape:

  • please split this into stacked prs: catalog, then live volumes + sdks, then e2e + docs + example. 72 commits in one pr is hard to review.
  • before ready, run the final head on a real linux vm and post the results.

i'll leave a review with inline notes, mostly hot-path cost, guards we can drop, and style.

@CMGS CMGS left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline notes below. a few repo-wide ones:

  • asl ./... (https://github.com/CMGS/asl) shows new layout findings: split method sets in volume_catalog.go, volume.go, claims.go, engine/volume_live.go and config.go, a second var block in pool.go, a second const block in volume.go, and helpers above tests in six test files. it also wants cmp.Or at volume_catalog.go:91.
  • tests are about 4.9k of the 7k added lines, with nine new fake engine wrappers. fakeEngine already has err and hook fields. a few more hooks there, plus table tests per area, would cut a lot.
  • small cuts: resolveCatalogVolume and resolveVolumes each have one prod caller now, so inline them.

Comment thread sandboxd/pool/claims.go
return nil
}
rows, seq := slices.AppendSeq(make([]*claimRow, 0, len(s.rows)), maps.Values(s.rows)), s.seq
var refs map[string]claimRefs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this walks every row and builds a refs map on each commit. i benched 5000 claims with a checkpoint on each (a replicating pool): 19 → 5089 allocs, 43 KB → 1.9 MB, about 1.0 ms vs 0.5–0.7 ms per commit on main. the rows already carry their refs, so can the lookups read rows + the last acked set when asked, and leave commit as it was?

Comment thread sandboxd/pool/claims.go
written uint64
ackRefs map[string]claimRefs
attemptRefs map[string]claimRefs
replaceFile func([]byte) error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replaceFile here and catalog.replace / catalog.stat are test seams in prod structs. the existing claims tests make writes fail by pointing the path at a missing dir or a fifo. can these tests do the same, so the fields go?

Comment thread sandboxd/pool/volume.go
if err := m.catalog.admissionError(); err != nil {
return err
}
recovery := m.store.recoveryVolumes()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this merges every claim's refs under m.mu for each claim with volumes, so admission cost grows with the node's claim count. a lookup by volume name is enough here.

Comment thread sandboxd/pool/volume.go
m.mu.Unlock()
return nil, err
}
catalog := maps.Clone(m.catalog.entries)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the catalog map is copy-on-write: adding and removal clone it, and persist swaps it. so take the map under the lock, no clone needed. same in catalogSnapshot.

func (m *Manager) removeStaleVM(ctx context.Context, name string, rec types.VMRecord) bool {
logger := log.WithFunc("pool.removeStaleVM")
m.mu.Lock()
m.catalog.unknown++

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fences volume admission on the whole node for each stale vm until it is gone. a stuck rm on a vm with no disks then blocks every volume claim on the node. line 198 also keeps the fence for a running vm with zero disks. can the fence come from inspect per volume, and go node-wide only when inspect fails?

generation: c.generation + 1,
runtime: true,
disk: engine.VolumeSpec{Name: name, Path: spec.Path, DirectIO: spec.DirectIO},
acl: newVolumeACL(spec.Tenants, volumeOperatorOnly),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per my comment above: empty tenants mean the same as a static entry. same at 152.

Comment thread sandboxd/config/config.go
return fmt.Errorf("volume %q directio must be on, off, or auto, got %q", volume.Name, volume.DirectIO)
}
for _, tenant := range volume.Tenants {
if !types.NameRe.MatchString(tenant) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per my comment above: warn and skip a bad name in static config, don't fail startup. keep this strict for runtime registration.

Comment thread sandboxd/pool/failover.go

func (m *Manager) expiryDropsRow(sb *types.Sandbox) bool {
return m.archiveRowed(sb) || sb.HasReplica() && !m.archivesAtDeadline(sb)
return m.archiveRowed(sb) || m.archives != nil && sb.HasReplica() && !m.archivesAtDeadline(sb)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the expiry fix. please move it to its own pr with its repro test.

Comment thread sandboxd/pool/archive.go
if err := m.deleteCkLocked(ctx, ckID); err != nil {
return err
unlock := m.lockRec(ckID)
if _, referenced := m.store.checkpointRefs()[ckID]; referenced {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this changes checkpoint cleanup for every pool, not only volume claims. same for the clearReplicaIntent and dropReplica changes in replica.go. please ship them with the expiry fix, each with its repro.

// exclude remaining device mounts, and confirm host detach. Absence, an earlier
// external unmount, attach-only requests, read-only state, and errors give no
// proof. It cannot be reused across calls or certify externally managed writers.
type VolumeDetachResult struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

our comment budget is one line per doc. something like NormalUnmount reports a clean writable unmount by this call. is enough, the rest goes in the commit message. same for the two-line comments at replica.go:231 and server_http.go:25.

@CMGS

CMGS commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

some more perf numbers. i ran the same benchmarks on main and this head (5385209), interleaved main/pr/pr/main, 3 runs each, 5000 claims on the node. "with ck" means every claim holds a ReplicaCk, which is what a replicating pool looks like.

path main this pr
claims.json commit, no ck 0.42 ms, 19 allocs 0.41 ms, 19 allocs
claims.json commit, with ck 0.51 ms, 43 KB, 19 allocs 1.03 ms, 1.9 MB, 5088 allocs
volume claim admission (resolve + admit), no ck 3.6 µs 3.8 µs
volume claim admission, with ck 3.6 µs, 10 allocs 317 µs, 672 KB, 5031 allocs
hasVolumeHolds over all claims (replica scan) 43 µs 180 µs
pinnedArchiveCks 272 µs, 48 allocs 628 µs, 5047 allocs
writeJSON, every json response 770 ns 890 ns

the admission row hurts most. those 317 µs run under m.mu, so every claim and release on the node waits behind them. the scan and pinnedArchiveCks also hold m.mu.

the cause is one thing: ackRefs and attemptRefs are keyed by claim, and each commit and each query rebuilds them into full maps. so the cost grows with the claims on the node, not with the claims that changed.

how i'd fix it, without changing the design:

  • track only the delta: rows whose refs differ from the last acked write. that set is empty almost all the time, so queries scan the delta, not every claim.
  • or keep refcounts by volume name and by checkpoint id, update them in set/del/commit for the changed rows, and answer queries with a map lookup.
  • drop the response marshaler, per the inline note. that removes the writeJSON cost.

please add these benchmarks to the pr and post main vs head numbers when you fix it. claims with no checkpoints don't show the problem, so bench with every claim holding a checkpoint.

This branch has not been deployed

No deployments
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.

2 participants