Draining for a data-set growth — the four steps, today and with hot-plug
Written 2026-09-14 beside private-gunnar-ops/DATA-SET-GROWTH-DESIGN.md (the contract) and the boot-stack grow in edda's korp-installer/src/grow.rs. Design only — no code implements the hot-plug half yet; §9 of the contract keeps no-restart growth out of scope until the tunnr kernel has CONFIG_HOTPLUG_PCI. This note fixes the shape of the drain, the journal and the Draining{kind: growing} announcement so that the same four steps work unchanged when hot-plug exists.
The four steps gunnar takes when it drains for a growth
- Stop admitting new git sessions. The SSH front door refuses a new transfer with the growing sentence from the contract's §8 (through the standby's
err_packet), andBootstrap.Status.serving_gitreads false withreadinessnaming the growth. The control plane stays up — it lives on the boot disk, not on/data. - Wait for in-flight transfers to finish: the admission counter (the one the shutdown path already waits on) reaching zero. Bounded by the existing drain timeout; a transfer that will not finish is cut the way a shutdown cuts it, never left holding
/dataopen. sync, then unmount/data. Everything the stores wrote is on the platter before the filesystem goes, and the unmount is the proof that no handle is open (anEBUSYis a finding, not something to force).- Later, remount — and only after gunnar has verified with its own eyes that the set is what it expects (below).
With today's kernel step 4's "later" is the next boot: korp-start (grow.rs, G1–G5) grows and remounts before gunnar is exec'd, so nothing in gunnar implements a remount. Steps 1–3 are the existing shutdown drain; the growth only gives it a kind.
The announcement
Draining{kind: growing} on the control plane (the Draining state the shop-presence contract already carries, with a kind field — growing is the first kind). It is what gunnar-ui turns into the customer sentence and what monetize waits on before it stops the server. It is raised BEFORE step 1 and cleared by Ready after the remount verified.
The remount trigger, when hot-plug exists
- Primary: an explicit
resumeverb on the control plane (Admin. GrowResume, or aresumearm on whatever verb raises the drain). The plane is on the boot disk and keeps answering while/datais unmounted. monetize calls it afterGET /1.3/storageshows all four volumesonlineat the new size. An explicit verb, because a remount is a decision an operator can audit, not a side effect of a device appearing. - Fallback: a kernel uevent / periodic rescan. A
change/adduevent on a member's block device, or a rescan every N seconds while draining, runs the same verification; it never remounts on its own before the verification passes, and it logs what it saw either way. This is what makes a metal box, where nobody calls the verb, remount itself. - Either way gunnar verifies before it grows or remounts: four members present, every one larger than the set it left, all equal within 1 % (the same
grow::detectrule the boot path uses — one implementation, reached through the same crate). Anything else is a refusal by name on the control plane (partial resize: k of 4), the drain stays up, and nothing is written.
The journal is already shaped for this
/serialdat/grow-journal (grow::Record: epoch, target_sectors, member, step, at) does not care whether a step ran in korp-start or in gunnar: a hot-plug grow appends the same G1…G6 records from the same engine, and the disk — never the journal — says which steps are already true. The G4 door (Grow::xfs_allowed) is what keeps a hot remount from growing the xfs before every superblock agrees, exactly as at boot.
What monetize sees
Admin.Capacity.growth: GROWTH_AT_RESTART on a box whose boot stack carries the engine (today), later a GROWTH_HOT value when hot-plug lands — added, never renumbered. grow_state carries the journal's last epoch and verdict either way, so the operator console reads one story.