Growing the data set WITHOUT a restart — the runtime path, as built
Written 2026-09-14 against gunnar branch data-set-grow and edda branch data-set-grow, the code half of private-gunnar-ops/DATA-SET-GROWTH-FLOW.md §3 under ruling 1 (the appliance never goes down). data-set-grow-drain.md beside this file is the design note it grew from; DATA-SET-GROWTH-DESIGN.md §5 stays the contract for the engine's steps G1–G6 and their journal. This file says what exists, where, and what the bench has to drive to prove it.
The verbs
| verb | credential | does |
Admin.GrowStart{target_sectors_per_member, go_ahead, drain_timeout_secs, wait_members_timeout_secs} |
gunnar-admin | checks the go-ahead's structure, the signer policy and the nonce ledger; refuses if a growth is active, /data is not served, or the set is not complete; then runs the growth on its own thread and answers the first status |
Admin.GrowStatus{} / Bootstrap.GrowStatus{} |
Read / none | phase, why, epoch, target, the members (device, partition, disk/set/offered bytes, larger?), members_verdict, the journal, engine_present, serving, identity_in_ram, in_flight, since, set_uuid, total_bytes |
Admin.GrowResume{give_up} |
gunnar-admin | while waiting-members: look again now; give_up: stop waiting, assemble what is there, serve |
Mirror.FlushReplication{timeout_secs, resync} |
gunnar-admin | the watermark: flush, wait, report {caught_up, in_flight, pending, bus_gaps, needs_full_resync, last_success, standbys, catalog_repos, verdict} |
CLI: gunnar control grow start|status|resume, gunnar control replication flush.
Bootstrap.GrowStatus is the third credential-free RPC (gunnar_grpc::UNAUTHENTICATED_METHODS), and T6 is why: the actor outside the box that owns the volumes must read waiting-members / failed with nothing to sign with, while the identity table is in RAM — or after a crash in that window, when there is no identity table to sign against at all.
The phases, and what each writes to /serialdat/grow-journal
The journal is the boot path's (korp_installer::grow::Journal, one line per fsynced record, epoch= target_sectors= member= step= at=); the runtime steps ride the same lines and korp-start reads past them to the open epoch.
| phase | on the box | journal |
draining |
the ONE admission gate is paused (Refusal::Paused, "gunnar.rs is growing its storage to make room for new members. Pushes are paused — please try again in a few minutes.", EXIT_BUSY over SSH, 503 Retry-After: 120 over HTTP); wait ≤ drain_timeout for in_flight == 0 |
on success DRAIN (epoch = Journal::epoch_for(target)); on timeout NOTHING — the gate reopens, failed{aborted…}, nothing changed (T1) |
unmounted |
ControlDb::suspend (documents, blobs, sealed secrets → RAM; control.redb closed); ServerStores::suspend_repos (handle cache emptied, engines' caches released, opens refused); sync; umount2("/data") (never lazy; EBUSY names the descriptors); a read-only tmpfs over the empty mountpoint; md array_state ← clear, the stale /dev/md/korp-data node removed |
UNMOUNT, STOP, WAIT; a refused unmount → everything back, ABORT |
waiting-members |
/sys/bus/pci/rescan + survey every 2 s (15 s after the timeout) until every member is present, larger, equal within 1 % (grow::detect, the engine's own G1) and the same set uuid; GrowResume wakes it; the timeout only changes the words (T6) |
RESCAN when eligible |
growing |
korp_installer::grow::runtime::grow_and_mount: G1–G3 → assemble → G4 → mount → G5 → G6; on any refusal the set is assembled and mounted at the size the members share |
the engine's G1 … G6; FAIL when it did not grow |
mounting |
ControlDb::resume (file reopened, every document pointer compared with the RAM copy — drift is named in why), resume_repos, gate unpaused, Ready announced |
REOPEN |
done / failed{why} |
failed still SERVES unless why starts with FATAL (cannot assemble or mount at all): then serving: false, identity stays in RAM, the gate stays paused, the control plane and GrowStatus keep answering |
— |
ABORT closes an epoch (nothing was touched); FAIL keeps it open for the next boot or the next grow start; a prelude-only epoch is joined by the engine whatever target G1 measures, so a runtime growth is one epoch.
How the torture rows are met
- T1 — bounded drain, then abort with nothing changed; no transfer is ever killed (
grow::tests::a_drain_that_times_out_aborts_with_nothing_changed). - T2 —
gunnar_core::ControlDb: both redb stores share one handle;suspendcopies the tables and closes the file (proven descriptor-free via/proc/self/fd), reads answer from the copy, writes areError::OffStripe→UNAVAILABLE(D7: refuse);resumeverifies RAM == disk. - T6 — the phase never fails on the wait;
Bootstrap.GrowStatusneeds no credential; a crash with volumes detached halts the next boot on a missing member, and the actor outside re-attaches — then reboot finishes the epoch from the disk, orGrowResumedoes. - T9 — every recoverable failure serves at the old size and names why; only cannot-assemble/mount is fatal, and even then the control plane is up.
- T11 —
Mirror.FlushReplicationbetween drain and unmount (step 0 of §3): it proves every push the replicator saw is on the twin as of the flush; it does NOT prove refs outside heads/tags, LFS, the control database, visibility, tombstones, or repositories nobody pushed since the twin existed unlessresyncran. The verdict says both, in words.
The go-ahead (D3)
GrowStartRequest.go_ahead is JSON: {v: 1, signer: "operator"|"monetize", target_sectors, nonce, issued_unix_ms, signature}. The structure and the nonce ledger (/serialdat/grow-go-aheads, one line per spent nonce) are checked for every signer; WHO may sign is gunnar_server::grow::GoAheadPolicy. The default OperatorPolicy accepts an unsigned operator go-ahead (the gunnar-admin RPC is the approval) and refuses monetize; a metered build plugs a policy verifying signature under monetize's Ed25519 key through GrowMachine::with_policy (the entitlement facts' monetize_embed::signing pair is the intended verifier). Not built here: monetize's minting side.
Draining{kind: growing}
Presence.Watch does not exist in this tree. GrowMachine::with_announcer is the hook: register the stream's fan-out and it receives Announcement::Draining{kind: "growing"} before the pause and Announcement::Ready after the reopen. Until then both are tracing rows on gunnar::grow (presence.draining, presence.ready).
The bench: a --runtime scenario for gunnar-growbench
What the bench should drive under KVM (software steps first, the hotplug half when the kernel has it); the bench crate is another agent's and is not edited here:
- boot the appliance image built with
gunnar-server --features grow-runtime(needs the sibling edda checkout — see the Cargo.toml note) on 4 member disks; bootstrap; push the manifest set;replication flushif a twin is configured; gunnar control grow start --wait-members-timeout-secs 120as gunnar-admin; assert the answer isdrainingand that agit pushstarted now is refused with the growing sentence and exit 75 (andgit fetchtoo);- poll
gunnar control grow status(NO credential) untilwaiting-members; assertserving: false,identity_in_ram: true,/proc/mountson the guest has no/data(a tmpfs is there instead),/sys/block/md_korp_datais gone; the journal readsDRAIN UNMOUNT STOP WAIT; a control-plane WRITE (user add) answers UNAVAILABLE naming the growth; a READ (user list,status) still answers — the identity is in RAM; - resize every member: today's kernel (no hotplug) — QMP
block_resizeon the four attached drives (virtio-blk raises a config change andsizeupdates in place); the hotplug kernel — QMPdevice_del×4, resize the backing files,device_add×4, and assert the guest enumerates them; - optionally
grow resume, else wait for the 2 s poll; poll status todone; asserttotal_bytesgrew,Admin.Capacityreads the new total withGROWTH_AT_RUNTIME, the journal ends… RESCAN G1 G2:0..3 G3:0..3 G4 G5 G6 REOPEN, the manifest set verifies (design §7's "intact"), a push lands,replication listshows the standby taking it; - the crash rows: halt the guest between
STOPand the resize (C1b) — assert the next boot HALTS on a missing member until the disks are re-attached, then boots and finishes the epoch from the disk; halt duringgrowing— the boot path resumes at the next G-step (C2–C5 as today); - the refusal rows: resize 3 of 4 → status stays
waiting-memberswithPARTIALin the survey;grow resume --give-up→failed(serving, old size), the journalFAIL REOPEN; an unmount with a descriptor held open →failed{aborted: … EBUSY … fd N → /data/…}, journalDRAIN ABORT, nothing changed.
Native tests cover every phase and every one of these transitions over a scripted world (gunnar-server/src/grow/tests.rs); what only the bench can prove is the real umount/md/xfs behaviour and, with the hotplug kernel, step 4's detach/re-attach.
What needs the hotplug kernel
Only step 4's second form. Everything else — drain, identity in RAM, close, umount, md stop, the guard tmpfs, rescan (a harmless no-op), G1–G6, mount, reopen — runs on today's kernel; a QMP block_resize of an attached virtio disk is enough to exercise the whole runtime path under KVM. On UpCloud the detach/resize/attach is the front-side watchdog's (D5), and the appliance's half is complete.
The registry — closed 2026-09-15
korp-installer 0.1.4 carries grow::runtime (released through dwarves crate-release from edda data-set-grow), gunnar-server's grow-runtime feature resolves it from the registry, and the feature is in all four server variants of .nornir/build-variants.toml. The developer patch config is gone.
Proven under KVM (bench run 6, DATA-SET-GROWTH-DESIGN.md §14)
gunnar-growbench R1–R5: the clean runtime growth (QMP block_resize under the running guest, today's kernel), the partial → give-up row, the power cut in waiting-members, the drain timeout (T1), and the hot-plug form (unplug → resize → re-plug ×4 on the kernel-pci-hotplug kernel, members behind PCIe root ports). 128 → 256 GiB with no reboot on both kernels. What the run found and fixed is in §14.