gunnar_server/grow/world.rs
1//! **The box, as the growth sees it** — one trait, two implementations.
2//!
3//! [`World`] is everything [`super::GrowMachine`] does to the machine:
4//! pause the gate, count transfers, copy the identity to RAM, close and
5//! reopen stores, unmount, stop the array, survey the members, run the
6//! engine. Its shape is what makes the phase machine, the journal discipline
7//! and the failure policy testable natively ([`super::tests`] scripts a
8//! world) and what makes the bench's `--runtime` scenario a script over the
9//! same verbs.
10//!
11//! [`BoxWorld`] is the appliance, compiled only with `grow-runtime`: the
12//! `Arc<Admission>` all three fronts share, `ServerStores`, the `ControlDb`
13//! handle, and edda's `korp-installer::grow::runtime`. [`NoEngine`] is every
14//! other build: `engine_present() == false`, `Start` is refused by name and
15//! `Status` still answers.
16
17use std::path::PathBuf;
18
19use super::Step;
20
21/// One member, as the last survey saw it (`GrowMember` on the wire).
22#[derive(Debug, Clone, PartialEq, Eq, Default)]
23pub struct MemberFacts {
24 pub index: u32,
25 pub device: String,
26 pub partition: String,
27 pub disk_bytes: u64,
28 pub set_bytes: u64,
29 pub offered_bytes: u64,
30 pub larger: bool,
31}
32
33/// The members, surveyed.
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
35pub struct SurveyFacts {
36 /// `complete`, `incomplete: <why>`, `none`.
37 pub verdict: String,
38 pub raid_disks: u32,
39 pub members: Vec<MemberFacts>,
40 /// Every member present, larger, equal within 1 % — the engine would grow.
41 pub eligible: bool,
42 /// The per-member target G1 would grow to, when eligible.
43 pub target_sectors: Option<u64>,
44 /// The set's md uuid, hex.
45 pub set_uuid: String,
46 /// One line.
47 pub description: String,
48}
49
50impl SurveyFacts {
51 pub fn describe(&self) -> String {
52 if self.description.is_empty() {
53 format!(
54 "{} ({} of {})",
55 self.verdict,
56 self.members.len(),
57 self.raid_disks
58 )
59 } else {
60 self.description.clone()
61 }
62 }
63}
64
65/// What the RAM copy holds, for the log.
66#[derive(Debug, Clone, PartialEq, Eq, Default)]
67pub struct IdentityFacts {
68 pub documents: usize,
69 pub bytes: u64,
70}
71
72/// What the resume found.
73#[derive(Debug, Clone, PartialEq, Eq, Default)]
74pub struct ResumeFacts {
75 pub documents_matched: usize,
76 pub drift: Vec<String>,
77 pub off_stripe_ms: i64,
78}
79
80/// What the engine left behind after `grow_and_mount`.
81#[derive(Debug, Clone, PartialEq, Eq, Default)]
82pub struct Grown {
83 /// The epoch closed at its target (G6 on record).
84 pub grew: bool,
85 pub array_bytes: u64,
86 /// The engine's refusal, by name, when it refused.
87 pub refusal: Option<String>,
88 /// The xfs step's outcome, in words.
89 pub xfs: String,
90}
91
92impl Grown {
93 pub fn describe(&self) -> String {
94 let mut parts = vec![format!("array {} B", self.array_bytes)];
95 if self.grew {
96 parts.push("grown (G6)".into());
97 }
98 if let Some(r) = &self.refusal {
99 parts.push(format!("engine refused: {r}"));
100 }
101 if !self.xfs.is_empty() {
102 parts.push(format!("xfs: {}", self.xfs));
103 }
104 parts.join("; ")
105 }
106}
107
108/// **Everything the growth does to the box.** Every method is a verb the
109/// bench's runtime scenario can name, and every one is a step a scripted
110/// world can make fail.
111pub trait World: Send + Sync + 'static {
112 /// Does this build link the engine?
113 fn engine_present(&self) -> bool;
114 /// **Does the RUNNING kernel have PCI hot-plug?** A member detached and
115 /// re-attached while gunnar runs is invisible without it, so a runtime
116 /// growth is refused by name ([`crate::dashboard::probe::pci_hotplug`]).
117 fn hotplug(&self) -> bool {
118 crate::dashboard::probe::pci_hotplug().present
119 }
120 /// `/serialdat/grow-journal`.
121 fn journal_path(&self) -> PathBuf;
122 /// **Is that journal FILE on this box?** — the one measurement that says
123 /// whether this MEDIUM's boot stack grows the set, as opposed to whether
124 /// this BINARY links the engine (`engine_present`). The two are different
125 /// facts and the box carries both: `korp_installer::grow::linux::open_journal`
126 /// (0.1.12, `src/grow.rs:1072`) is called from `datahome`'s assemble pass
127 /// on every boot that carries the engine, and `Journal::open`
128 /// (`src/grow.rs:277`) opens with `.create(true)` — so the file exists from
129 /// the FIRST such boot, before any growth has run, and is absent exactly
130 /// when korp-start never had a grow pass (or found no off-stripe home to
131 /// keep it on, in which case the engine went `Journal::volatile` and this
132 /// box keeps no crash record either way).
133 ///
134 /// MEASURED on the live appliance 2026-09-18: `data grow no journal: this
135 /// boot stack does not grow the data set` with `/serialdat` a real mounted
136 /// P3 (`boot.p3_dev /dev/vda3`, 47.9 GiB) — the medium's korp-start
137 /// predates the engine. `Admin.Capacity` reads the same file through
138 /// `gunnar_grpc::grow_journal::Summary::measure` and says `GROWTH_SEALED`
139 /// on it; [`super::GrowMachine::start`] refuses on it, so the two doors
140 /// cannot disagree.
141 fn journal_present(&self) -> bool {
142 self.journal_path().exists()
143 }
144 /// Is `/data` mounted and are the stores open?
145 fn serving(&self) -> bool;
146 /// `/data`'s size as mounted, 0 when not.
147 fn mounted_bytes(&self) -> u64;
148
149 // ── the drain ──
150 fn pause_admission(&self);
151 fn unpause_admission(&self);
152 /// **Push everything to every standby and say whether they took it**,
153 /// bounded by `ceiling`. Called once, by the drain, after `in_flight`
154 /// reaches 0 — see [`FlushVerdict`].
155 ///
156 /// The default is `NoStandby`: a build with no replicator linked has
157 /// nothing to flush, and saying so by name is the honest answer. A box
158 /// that HAS a standby overrides this, and a `Behind` from it aborts the
159 /// growth with nothing changed.
160 fn flush_replication(&self, ceiling: std::time::Duration) -> FlushVerdict {
161 let _ = ceiling;
162 FlushVerdict::NoStandby(
163 "this build links no replicator, so there is no standby to flush to".to_owned(),
164 )
165 }
166 fn in_flight(&self) -> u64;
167 /// **Is the box still STARTING** — the start-up disk test running on its
168 /// own thread with its probe file open under `/data`? `Some(why)` while
169 /// it is. The drain waits for it like a transfer: MEASURED 2026-09-15
170 /// under KVM (bench R1), a growth started 13 s after the banner unmounted
171 /// straight into `EBUSY — fd 18 → /data/.korp-disktest/probe` and
172 /// aborted, because the test is gunnar's own I/O and no gate pauses it.
173 fn starting(&self) -> Option<String> {
174 None
175 }
176
177 // ── the identity (T2) ──
178 fn suspend_identity(&self, why: &str) -> Result<IdentityFacts, String>;
179 fn resume_identity(&self) -> Result<ResumeFacts, String>;
180
181 // ── the stores and the mount ──
182 fn close_stores(&self) -> Result<(), String>;
183 fn reopen_stores(&self) -> Result<(), String>;
184 fn sync_and_unmount(&self) -> Result<(), String>;
185 fn stop_array(&self) -> Result<(), String>;
186
187 // ── the members ──
188 fn rescan(&self) -> Result<(), String>;
189 fn survey(&self) -> SurveyFacts;
190
191 // ── the engine ──
192 /// G1–G6 and the mount, with the serve-at-old-size fallback inside;
193 /// `Err` only when nothing could be assembled or mounted.
194 fn grow_and_mount(&self) -> Result<Grown, String>;
195
196 // ── the journal ──
197 fn epoch_for(&self, target_sectors: u64) -> u64;
198 fn note(&self, epoch: u64, target_sectors: u64, step: Step) -> Result<(), String>;
199
200 // ── the go-ahead ledger ──
201 /// `Ok(true)` when `nonce` was never seen on this box and is now
202 /// recorded; `Ok(false)` when it was spent before.
203 fn remember_nonce(&self, nonce: &str) -> Result<bool, String>;
204}
205
206/// **What a flush to the standbys found**, at the one moment it matters: the
207/// gate is paused and `in_flight == 0`, so what the twin does not have now it
208/// will not be given before the set is unmounted.
209///
210/// `DATA-SET-GROWTH-FLOW.md` §5, "Drain", must prove: *"in_flight == 0; step
211/// 0's conjunction holds again"*. Step 0's proof is taken by the OUTSIDE actor
212/// before the growth starts, and between it and this moment the twin may have
213/// been stopped, resized and started again — monetize's `twin-sizing` does
214/// exactly that. A proof taken then is a proof about a different box.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub enum FlushVerdict {
217 /// **No standby is configured on this box.** Not a pass and not a
218 /// failure: there is nothing to prove, and the growth says so rather than
219 /// implying a twin was checked. The same distinction
220 /// `replication_flush` makes when it answers FailedPrecondition.
221 NoStandby(String),
222 /// Every standby has taken everything.
223 CaughtUp { standbys: u64, waited_ms: u64 },
224 /// At least one standby is behind, and will stay behind: the gate is
225 /// already shut, so nothing more is coming that would help it.
226 Behind {
227 pending: u64,
228 needs_full_resync: bool,
229 why: String,
230 },
231 /// **The question could not be asked**, so nothing is known — and a drain
232 /// that knows nothing must not report a flush.
233 ///
234 /// Its own arm, and NOT folded into [`FlushVerdict::NoStandby`], because
235 /// the two are opposites at the only moment they are read: `NoStandby`
236 /// means "there is provably nothing to prove" and the growth continues;
237 /// this means "there is something to prove and it could not be looked at",
238 /// and the growth must abort with nothing changed. Silently treating the
239 /// second as the first is how `/data` gets unmounted over a twin nobody
240 /// asked about — the exact shape of the failure this whole drain exists
241 /// to prevent. The same distinction `Mirror.LogRetain` already makes when
242 /// a twin has reported no position (`retention-twin-position-unknown`).
243 CouldNotLook(String),
244}
245
246/// **The drain's verdict on the twins, as a function of what they reported.**
247///
248/// Pure and outside the engine's feature gate, so the rule in front of the
249/// unmount can be tested without a box, a store or a mounted set. `BoxWorld`
250/// reads `head` and the rows and calls THIS; there is no second copy of the
251/// rule.
252///
253/// `Ok(n)` = `n` enrolled twins have each applied up to `head`. Every other
254/// outcome is a refusal that names the twin.
255pub fn twin_verdict(
256 members: &[String],
257 rows: &[gunnar_proto::TwinHeartbeat],
258 head: u64,
259) -> Result<(u64, u64), FlushVerdict> {
260 if members.is_empty() {
261 return Ok((0, 0));
262 }
263 for m in members {
264 let Some(r) = rows.iter().find(|r| &r.principal == m) else {
265 return Err(FlushVerdict::CouldNotLook(format!(
266 "twin '{m}' is enrolled in group `twin` and has no heartbeat row at all"
267 )));
268 };
269 // Never polled since this server started. The registry is in memory,
270 // so a restart makes every position unknown — and unknown is the one
271 // window this refusal exists for. `Mirror.LogRetain`'s rule, reused.
272 if r.last_seen_unix_ms == 0 {
273 return Err(FlushVerdict::CouldNotLook(format!(
274 "twin '{m}' has reported no position since this server started, so what it has applied is \
275 unknown; nothing says it holds this set. Wait for its next poll (every few seconds while it \
276 follows)"
277 )));
278 }
279 if r.seen_ago_ms > r.silent_after_ms {
280 return Err(FlushVerdict::CouldNotLook(format!(
281 "twin '{m}' last reported {} s ago (bound {} s), so its position is stale and says nothing about \
282 the set as it stands now",
283 r.seen_ago_ms / 1000,
284 r.silent_after_ms / 1000
285 )));
286 }
287 // **BOTH clauses, and neither is the lag.** `head > 0` because
288 // `applied 0 of 0` is lag-zero containing nothing, and `applied >=
289 // head` because behind is not caught up. `head` is the drain's BARRIER
290 // (the head when the gate shut), so a twin already past it — the live
291 // head moved on by control-plane entries — holds everything it must.
292 if head == 0 || r.applied_seq < head {
293 return Err(FlushVerdict::Behind {
294 pending: head.saturating_sub(r.applied_seq),
295 needs_full_resync: false,
296 why: format!(
297 "twin '{m}' has applied {} of this box's head {head}; a twin that has not taken the log cannot \
298 restore what is about to be unmounted",
299 r.applied_seq
300 ),
301 });
302 }
303 }
304 Ok((members.len() as u64, head))
305}
306
307/// **Wait for the twins to reach the head**, within `ceiling`: `look` is asked
308/// again every `every` while it answers [`FlushVerdict::Behind`] (a twin that
309/// follows catches up at its next poll; the gate is shut, so the head stands
310/// still). Any other refusal — a twin that cannot be looked at — is answered at
311/// once; `Behind` at the ceiling is answered as it stands.
312pub fn await_twins(
313 ceiling: std::time::Duration,
314 every: std::time::Duration,
315 mut look: impl FnMut() -> Result<(u64, u64), FlushVerdict>,
316) -> Result<(u64, u64), FlushVerdict> {
317 let started = std::time::Instant::now();
318 loop {
319 match look() {
320 Err(FlushVerdict::Behind { .. }) if started.elapsed() < ceiling => {
321 std::thread::sleep(every)
322 }
323 other => return other,
324 }
325 }
326}
327
328/// The build without the engine: honest `idle`, refusals by name.
329#[derive(Debug, Default)]
330pub struct NoEngine;
331
332impl World for NoEngine {
333 fn engine_present(&self) -> bool {
334 false
335 }
336 fn journal_path(&self) -> PathBuf {
337 PathBuf::from(gunnar_grpc::grow_journal::JOURNAL_PATH)
338 }
339 fn serving(&self) -> bool {
340 true
341 }
342 fn mounted_bytes(&self) -> u64 {
343 0
344 }
345 fn pause_admission(&self) {}
346 fn unpause_admission(&self) {}
347 fn in_flight(&self) -> u64 {
348 0
349 }
350 fn suspend_identity(&self, _why: &str) -> Result<IdentityFacts, String> {
351 Err("no engine".into())
352 }
353 fn resume_identity(&self) -> Result<ResumeFacts, String> {
354 Err("no engine".into())
355 }
356 fn close_stores(&self) -> Result<(), String> {
357 Err("no engine".into())
358 }
359 fn reopen_stores(&self) -> Result<(), String> {
360 Ok(())
361 }
362 fn sync_and_unmount(&self) -> Result<(), String> {
363 Err("no engine".into())
364 }
365 fn stop_array(&self) -> Result<(), String> {
366 Err("no engine".into())
367 }
368 fn rescan(&self) -> Result<(), String> {
369 Ok(())
370 }
371 fn survey(&self) -> SurveyFacts {
372 SurveyFacts {
373 verdict: "none".into(),
374 description: "no runtime grow engine in this build".into(),
375 ..Default::default()
376 }
377 }
378 fn grow_and_mount(&self) -> Result<Grown, String> {
379 Err("no engine".into())
380 }
381 fn epoch_for(&self, _target_sectors: u64) -> u64 {
382 0
383 }
384 fn note(&self, _epoch: u64, _target_sectors: u64, _step: Step) -> Result<(), String> {
385 Err("no engine".into())
386 }
387 fn remember_nonce(&self, _nonce: &str) -> Result<bool, String> {
388 Err("no engine".into())
389 }
390}
391
392/// Where the spent go-ahead nonces are kept, beside the journal.
393pub const NONCE_LEDGER_FILE: &str = "grow-go-aheads";
394
395/// **The nonce ledger on disk**: one line per accepted go-ahead, appended
396/// and fsynced, read back on every check. Beside the journal on
397/// `/serialdat`, off the stripe, so it is readable and writable while
398/// `/data` is unmounted and survives the growth.
399pub fn remember_nonce_in(ledger: &std::path::Path, nonce: &str) -> Result<bool, String> {
400 use std::io::Write as _;
401 let text = std::fs::read_to_string(ledger).unwrap_or_default();
402 if text.lines().any(|l| l.trim() == nonce) {
403 return Ok(false);
404 }
405 let mut f = std::fs::OpenOptions::new()
406 .create(true)
407 .append(true)
408 .open(ledger)
409 .map_err(|e| format!("open {}: {e}", ledger.display()))?;
410 f.write_all(format!("{nonce}\n").as_bytes())
411 .and_then(|_| f.sync_all())
412 .map_err(|e| format!("append {}: {e}", ledger.display()))?;
413 Ok(true)
414}
415
416/// **The appliance.** Compiled only with `grow-runtime`.
417#[cfg(feature = "grow-runtime")]
418pub use real::BoxWorld;
419
420#[cfg(feature = "grow-runtime")]
421mod real {
422 use super::*;
423 use std::sync::{Arc, Mutex};
424
425 use korp_installer::grow::{self as engine, runtime};
426
427 /// The real world: the gate, the stores, the control database and the
428 /// engine.
429 pub struct BoxWorld {
430 admission: Arc<gunnar_ssh::Admission>,
431 stores: Arc<crate::stores::ServerStores>,
432 db: Arc<gunnar_core::ControlDb>,
433 /// **The standbys, for the flush at the end of the drain.** `None`
434 /// leaves [`World::flush_replication`]'s default in place, which
435 /// answers `NoStandby` — honest for a build with no slot, and the
436 /// reason this is an option rather than a required argument.
437 replication: Option<Arc<crate::mirror::ReplicationSlot>>,
438 /// **The twin heartbeat registry the gRPC plane records into**, shared
439 /// with it rather than a second copy: what a twin has applied is
440 /// reported by the twin's own poll, and this is the one place on this
441 /// box that holds it. `None` means the drain cannot look, which is
442 /// [`FlushVerdict::CouldNotLook`] and not a pass.
443 heartbeats: Option<Arc<gunnar_grpc::heartbeat::Registry>>,
444 /// The journal, opened at the first record and held for the growth's
445 /// whole life (one writer per box, like `korp-start`'s).
446 journal: Mutex<Option<engine::Journal>>,
447 mountpoint: PathBuf,
448 }
449
450 impl BoxWorld {
451 pub fn new(
452 admission: Arc<gunnar_ssh::Admission>,
453 stores: Arc<crate::stores::ServerStores>,
454 ) -> Self {
455 let db = stores.control_db();
456 BoxWorld {
457 admission,
458 stores,
459 db,
460 replication: None,
461 heartbeats: None,
462 journal: Mutex::new(None),
463 mountpoint: runtime::data_mountpoint().to_path_buf(),
464 }
465 }
466
467 /// **Hand this world the live replication slot**, so the drain can
468 /// prove the standbys are caught up at the one moment that proves
469 /// anything. Called by `Server::start` before the world is wrapped in
470 /// an `Arc`, the same shape as `CoreControlPlane::with_webhooks`.
471 pub fn with_replication(mut self, slot: Arc<crate::mirror::ReplicationSlot>) -> Self {
472 self.replication = Some(slot);
473 self
474 }
475
476 /// **Hand this world the SAME heartbeat registry the gRPC plane
477 /// records twin polls into.** The drain's proof that a twin holds the
478 /// set is the twin's own reported position, and nothing else on this
479 /// box knows it.
480 pub fn with_heartbeats(mut self, registry: Arc<gunnar_grpc::heartbeat::Registry>) -> Self {
481 self.heartbeats = Some(registry);
482 self
483 }
484
485 /// **Has every enrolled twin applied up to this box's head?**
486 ///
487 /// THE DIRECTION MATTERS. The replication roster names
488 /// `ssh://git@<twin>:2222` and the drain used to prove itself by
489 /// pushing there — but a twin refuses all git by design
490 /// (`gunnar_ssh` `Dispatch::authorise` answers `DispatchError::Twin`
491 /// before the ACL, before the catalogue, before a byte), so that send
492 /// can never succeed and `pending` can never fall because anything
493 /// arrived. It falls because nothing was ever enqueued. MEASURED
494 /// 2026-09-23: a roster entry reading `pending 0 · failures 0 · last
495 /// success NEVER` — which is `pending == 0`, and was read as caught
496 /// up, over a twin holding nothing at all.
497 ///
498 /// The twin does hold everything, and it got it the other way round:
499 /// it PULLED the log over the mutual replication listener. So that is
500 /// what is asked about here. `head > 0` and `applied == head` are both
501 /// required: `lag 0 entries (applied 0 of 0)` is lag-zero containing
502 /// nothing, and it is exactly the reading that made this green before.
503 ///
504 /// The membership read and the "has not reported" refusal are
505 /// `Mirror.LogRetain`'s, deliberately — one rule about when a twin's
506 /// position is unknown, not two.
507 fn twins_caught_up(&self, barrier: u64) -> Result<(u64, u64), FlushVerdict> {
508 let Some(hb) = self.heartbeats.as_ref() else {
509 return Err(FlushVerdict::CouldNotLook(
510 "no twin heartbeat registry was handed to the grow world, so what any twin has applied cannot be read on this box"
511 .to_owned(),
512 ));
513 };
514 let members = self.stores.auth().members_of(crate::twin::TWIN_GROUP).map_err(|e| {
515 FlushVerdict::CouldNotLook(format!(
516 "the identity table could not be read for group `{}`, so which twins exist is unknown: {e}",
517 crate::twin::TWIN_GROUP
518 ))
519 })?;
520 if members.is_empty() {
521 return Ok((0, 0));
522 }
523 // THE BARRIER, not the live head (see flush_replication).
524 let head = barrier;
525 let now = estate_clock::now_unix_ms();
526 let rows = hb.view(&members, now);
527 twin_verdict(&members, &rows, head)
528 }
529
530 fn with_journal<T>(
531 &self,
532 f: impl FnOnce(&mut engine::Journal) -> Result<T, String>,
533 ) -> Result<T, String> {
534 let mut guard = self.journal.lock().unwrap_or_else(|p| p.into_inner());
535 if guard.is_none() {
536 let path = engine::journal_path();
537 let j = engine::Journal::open(&path).map_err(|e| {
538 format!(
539 "open the grow journal {}: {e} — is /serialdat mounted?",
540 path.display()
541 )
542 })?;
543 *guard = Some(j);
544 }
545 f(guard.as_mut().expect("opened above"))
546 }
547
548 fn log(line: &str) {
549 tracing::info!(target: super::super::TARGET, event = "grow.engine", "{line}");
550 }
551 }
552
553 impl World for BoxWorld {
554 fn engine_present(&self) -> bool {
555 true
556 }
557 fn journal_path(&self) -> PathBuf {
558 engine::journal_path()
559 }
560 fn serving(&self) -> bool {
561 runtime::is_mounted(&self.mountpoint) && !self.stores.suspended()
562 }
563 /// The same loop `CoreControlPlane::replication_flush` runs, at the
564 /// one moment the gate is shut: wait for every standby to reach
565 /// `pending == 0`, then report what is actually true. No `resync` is
566 /// attempted here — a full resync under a paused gate is a different,
567 /// much longer operation, and the growth's answer to "the twin cannot
568 /// catch up" is to abort with nothing changed, not to start one.
569 fn flush_replication(&self, ceiling: std::time::Duration) -> FlushVerdict {
570 // **A TWIN HAS NO STANDBY OF ITS OWN.** Its set is a COPY of its
571 // primary's — the follower it just drained (`pause_admission`) is its
572 // only writer — so what it holds is pulled back from the primary, not
573 // from a twin of the twin. The twin roster it reads here is the one it
574 // REPLICATED from its primary, and it names THIS box: asked, it waited
575 // for its own position and refused every growth. MEASURED on the mock
576 // 2026-09-27 (monetize's twin lane, `twin 0.6 %, 20 GB → 88 GB`):
577 // "whether the twin holds this set COULD NOT BE ESTABLISHED: twin
578 // 'gunnar-twin' has reported no position" — so the twin never grew,
579 // and main's growth waited for it at `twin-sizing` forever.
580 if self.stores.is_twin() {
581 return FlushVerdict::NoStandby("this box is a twin: its set is a copy of its primary's, which is where it is pulled back from".to_owned());
582 }
583 // **THE TWIN'S OWN POSITION, FIRST AND UNCONDITIONALLY.**
584 //
585 // Before anything is asked about the push roster, because the
586 // roster cannot answer this question: its entry points at
587 // `ssh://git@<twin>:2222`, and a twin serves no git to anyone.
588 // Whatever `pending` says there, it is not evidence that the twin
589 // holds the set — and this is the last moment before /data is
590 // unmounted, so "no evidence" must not read as "flushed".
591 //
592 // Asked even when there is no replication slot at all: a twin
593 // follows over its own channel, so a box with an enrolled twin and
594 // no roster still has a twin whose position decides whether this
595 // set can be pulled back.
596 // WAITED FOR, within this flush's ceiling: the gate is shut, so a twin
597 // that is BEHIND can only catch up — its next poll is seconds away.
598 // MEASURED loki 2026-09-26 (the pro kit's paid growth on the mock): one
599 // look aborted the growth at "applied 718 of this box's head 719", the
600 // one entry the growth itself had just written. Silence or a stale
601 // position is still refused at once ([`await_twins`]).
602 // **THE BARRIER: the head as it stands NOW**, with the gate shut and
603 // nothing in flight. The twin must hold everything up to it; the head
604 // keeps moving by a few control-plane entries a minute (MEASURED on the
605 // mock 2026-09-27: one entry every ~4 s, the twin always 1-2 behind the
606 // LIVE head, so a comparison against it never settled and main never
607 // grew: "twin applied 1153 of this box's head 1154"). Those later
608 // entries are not the set being unmounted; the twin takes them after the
609 // remount, as it takes every entry.
610 let barrier = self.stores.txlog().head_seq();
611 let twins = match await_twins(ceiling, std::time::Duration::from_millis(250), || {
612 self.twins_caught_up(barrier)
613 }) {
614 Ok(t) => t,
615 Err(verdict) => return verdict,
616 };
617 let Some(slot) = self.replication.as_ref() else {
618 if twins.0 > 0 {
619 return FlushVerdict::CaughtUp {
620 standbys: twins.0,
621 waited_ms: 0,
622 };
623 }
624 return FlushVerdict::NoStandby(
625 "this box has no replication slot, so there is no standby to flush to"
626 .to_owned(),
627 );
628 };
629 let Some(replicator) = slot.replicator() else {
630 if twins.0 > 0 {
631 return FlushVerdict::CaughtUp {
632 standbys: twins.0,
633 waited_ms: 0,
634 };
635 }
636 return FlushVerdict::NoStandby(
637 "no replicator is running on this box (no standby configured, or this box is not a primary); there is nothing to flush and nothing to prove".to_owned(),
638 );
639 };
640 let started = std::time::Instant::now();
641 let deadline = started + ceiling;
642 loop {
643 if replicator.status().iter().all(|s| s.pending == 0)
644 || std::time::Instant::now() >= deadline
645 {
646 break;
647 }
648 std::thread::sleep(std::time::Duration::from_millis(100));
649 }
650 let status = replicator.status();
651 let waited_ms = started.elapsed().as_millis() as u64;
652 let pending: u64 = status.iter().map(|s| s.pending as u64).sum();
653 let needs_full_resync = status.iter().any(|s| s.needs_full_resync);
654 if pending == 0 && !needs_full_resync {
655 // `twins_caught_up` has already proved the twin's side above;
656 // this only adds what the push roster is entitled to say.
657 return FlushVerdict::CaughtUp {
658 standbys: status.len() as u64 + twins.0,
659 waited_ms,
660 };
661 }
662 FlushVerdict::Behind {
663 pending,
664 needs_full_resync,
665 why: format!(
666 "waited {waited_ms} ms of the {} ms the drain allows, over {} standby(s)",
667 ceiling.as_millis(),
668 status.len()
669 ),
670 }
671 }
672 fn mounted_bytes(&self) -> u64 {
673 if !runtime::is_mounted(&self.mountpoint) {
674 return 0;
675 }
676 rustix::fs::statvfs(&self.mountpoint)
677 .map(|s| s.f_blocks * s.f_frsize)
678 .unwrap_or(0)
679 }
680 fn pause_admission(&self) {
681 self.admission.pause(gunnar_ssh::Pause::Growing);
682 // **A twin drains its FOLLOWER** (T21, owner ruling 2026-09-22: the twin grows exactly
683 // like main, never restarted). The follower is the twin's only writer under /data; this
684 // returns once no fetch/append/apply is in flight, and the twin says `maintenance`.
685 if self.stores.is_twin() {
686 crate::twin::maintenance::hold(self.stores.root());
687 }
688 }
689 fn unpause_admission(&self) {
690 self.admission.unpause();
691 if self.stores.is_twin() {
692 crate::twin::maintenance::release(self.stores.root());
693 }
694 }
695 fn in_flight(&self) -> u64 {
696 self.admission.in_flight()
697 }
698 fn starting(&self) -> Option<String> {
699 (!crate::readiness::is_ready()).then(crate::readiness::detail)
700 }
701 fn suspend_identity(&self, why: &str) -> Result<IdentityFacts, String> {
702 let copy = self.db.suspend(why).map_err(|e| e.to_string())?;
703 Ok(IdentityFacts {
704 documents: copy.documents(),
705 bytes: copy.bytes(),
706 })
707 }
708 fn resume_identity(&self) -> Result<ResumeFacts, String> {
709 let r = self.db.resume().map_err(|e| e.to_string())?;
710 Ok(ResumeFacts {
711 documents_matched: r.documents_matched,
712 drift: r.drift,
713 off_stripe_ms: r.off_stripe_ms,
714 })
715 }
716 fn close_stores(&self) -> Result<(), String> {
717 self.stores.suspend_repos("the data set is being grown");
718 // The transaction log keeps its open segment's `.log` + `.idx` under /data for the
719 // server's whole life. MEASURED 2026-09-15 (growbench R5 on the metered medium):
720 // they were the only descriptors left after the stores closed, and the unmount
721 // answered EBUSY on every runtime growth. Given up here, taken back in
722 // `reopen_stores` — which every abort after the drain also reaches.
723 self.stores.txlog().release().map_err(|e| {
724 format!(
725 "release the transaction log's files under {}: {e}",
726 self.mountpoint.display()
727 )
728 })
729 }
730 fn reopen_stores(&self) -> Result<(), String> {
731 let taken = self.stores.txlog().reacquire().map_err(|e| {
732 format!(
733 "take the transaction log's files back under {}: {e}",
734 self.mountpoint.display()
735 )
736 });
737 self.stores.resume_repos();
738 taken
739 }
740 fn sync_and_unmount(&self) -> Result<(), String> {
741 runtime::unmount(&self.mountpoint)?;
742 // A read-only tmpfs over the empty mountpoint: anything that still
743 // writes by path under /data during the window meets EROFS —
744 // an error it can report — instead of a file in the root
745 // filesystem's /data directory that the remount would hide.
746 nix::mount::mount(
747 Some("none"),
748 &self.mountpoint,
749 Some("tmpfs"),
750 nix::mount::MsFlags::MS_RDONLY,
751 Some("size=64k"),
752 )
753 .map_err(|e| format!("guard tmpfs over {}: {e}", self.mountpoint.display()))
754 }
755 fn stop_array(&self) -> Result<(), String> {
756 runtime::stop_array(std::time::Duration::from_secs(10))
757 }
758 fn rescan(&self) -> Result<(), String> {
759 runtime::pci_rescan()
760 }
761 fn survey(&self) -> SurveyFacts {
762 let s = runtime::survey();
763 SurveyFacts {
764 verdict: s.verdict.clone(),
765 raid_disks: s.raid_disks,
766 eligible: s.eligible(),
767 target_sectors: s.target_sectors(),
768 set_uuid: s.set_uuid.clone(),
769 description: s.describe(),
770 members: s
771 .members
772 .iter()
773 .map(|m| MemberFacts {
774 index: m.index,
775 device: m.disk.clone(),
776 partition: m.partition.clone(),
777 disk_bytes: m.disk_sectors * 512,
778 set_bytes: m.set_sectors * 512,
779 offered_bytes: m.device_offers * 512,
780 larger: m.larger,
781 })
782 .collect(),
783 }
784 }
785 fn grow_and_mount(&self) -> Result<Grown, String> {
786 // The guard tmpfs goes first; a mount over a mount would hide it
787 // and leave it to pin nothing forever.
788 if runtime::is_mounted(&self.mountpoint) {
789 nix::mount::umount2(&self.mountpoint, nix::mount::MntFlags::empty())
790 .map_err(|e| format!("unguard {}: {e}", self.mountpoint.display()))?;
791 }
792 let mountpoint = self.mountpoint.clone();
793 let mounted =
794 self.with_journal(|j| runtime::grow_and_mount(j, &mountpoint, &mut Self::log))?;
795 Ok(Grown {
796 grew: mounted.grew,
797 array_bytes: mounted.array_bytes,
798 refusal: mounted.refusal,
799 xfs: match &mounted.xfs {
800 None => String::new(),
801 Some(Ok((plan, after))) => format!("{plan:?} → {after} blocks"),
802 Some(Err(e)) => format!("FAILED: {e}"),
803 },
804 })
805 }
806 fn epoch_for(&self, target_sectors: u64) -> u64 {
807 self.with_journal(|j| Ok(j.epoch_for(target_sectors)))
808 .unwrap_or(0)
809 }
810 fn note(&self, epoch: u64, target_sectors: u64, step: Step) -> Result<(), String> {
811 let step = match step {
812 Step::Drain => engine::Step::Drain,
813 Step::Unmount => engine::Step::Unmount,
814 Step::Stop => engine::Step::Stop,
815 Step::Wait => engine::Step::Wait,
816 Step::Rescan => engine::Step::Rescan,
817 Step::Reopen => engine::Step::Reopen,
818 Step::Abort => engine::Step::Abort,
819 Step::Fail => engine::Step::Fail,
820 };
821 self.with_journal(|j| runtime::note(j, epoch, target_sectors, step).map(|_| ()))
822 }
823 fn remember_nonce(&self, nonce: &str) -> Result<bool, String> {
824 let ledger = engine::journal_path().with_file_name(NONCE_LEDGER_FILE);
825 remember_nonce_in(&ledger, nonce)
826 }
827 }
828}
829
830#[cfg(test)]
831mod twin_verdict_tests {
832 use super::*;
833
834 fn row(
835 principal: &str,
836 applied: u64,
837 head: u64,
838 seen_ago_ms: u64,
839 last_seen: i64,
840 ) -> gunnar_proto::TwinHeartbeat {
841 gunnar_proto::TwinHeartbeat {
842 principal: principal.to_owned(),
843 applied_seq: applied,
844 head_seq: head,
845 lag_entries: head.saturating_sub(applied),
846 last_seen_unix_ms: last_seen,
847 seen_ago_ms,
848 silent_after_ms: 60_000,
849 ..Default::default()
850 }
851 }
852
853 fn twin() -> Vec<String> {
854 vec!["gunnar-twin".to_owned()]
855 }
856
857 /// ★ **THE READING THAT UNMOUNTED /data OVER A DEAD TWIN.**
858 ///
859 /// MEASURED 2026-09-23 on the t23r mock: the primary at head 318, the twin
860 /// reporting `lag 0 entries (applied 0 of 0)`, and the roster entry beside
861 /// it reading `pending 0 · failures 0 · last success NEVER`. Both numbers
862 /// are zero and both mean nothing ever happened.
863 ///
864 /// To see this fail with the defect back, drop the `head == 0` clause from
865 /// `twin_verdict`, or decide on `r.lag_entries == 0` instead.
866 #[test]
867 fn a_twin_reporting_zero_of_zero_is_never_caught_up() {
868 let rows = [row("gunnar-twin", 0, 0, 500, 1_790_000_000_000)];
869 // The twin's own lag is zero. It holds nothing.
870 assert_eq!(rows[0].lag_entries, 0, "the trap: lag zero");
871 match twin_verdict(&twin(), &rows, 318) {
872 Err(FlushVerdict::Behind { pending, why, .. }) => {
873 assert_eq!(pending, 318);
874 assert!(why.contains("applied 0 of this box's head 318"), "{why}");
875 }
876 other => panic!("a twin holding nothing must never be caught up: {other:?}"),
877 }
878 }
879
880 /// A twin PAST the barrier (the live head moved on while it followed) holds
881 /// everything up to it and passes. MEASURED on the mock 2026-09-27: main's
882 /// head moved an entry every ~4 s and a twin always 1-2 behind the LIVE head
883 /// kept main from ever growing.
884 #[test]
885 fn a_twin_past_the_barrier_is_caught_up() {
886 let rows = [row("gunnar-twin", 320, 321, 500, 1_790_000_000_000)];
887 assert_eq!(twin_verdict(&twin(), &rows, 318), Ok((1, 318)));
888 let behind = [row("gunnar-twin", 317, 321, 500, 1_790_000_000_000)];
889 assert!(matches!(
890 twin_verdict(&twin(), &behind, 318),
891 Err(FlushVerdict::Behind { pending: 1, .. })
892 ));
893 }
894
895 /// ★ A twin that has genuinely taken the log passes, and only then.
896 #[test]
897 fn a_twin_applied_up_to_the_head_is_caught_up() {
898 let rows = [row("gunnar-twin", 318, 318, 500, 1_790_000_000_000)];
899 assert_eq!(twin_verdict(&twin(), &rows, 318), Ok((1, 318)));
900 }
901
902 /// ★ Behind by one entry is behind.
903 #[test]
904 fn a_twin_one_entry_behind_is_behind() {
905 let rows = [row("gunnar-twin", 317, 318, 500, 1_790_000_000_000)];
906 assert!(matches!(
907 twin_verdict(&twin(), &rows, 318),
908 Err(FlushVerdict::Behind { pending: 1, .. })
909 ));
910 }
911
912 /// ★ **NEVER POLLED IS NOT CAUGHT UP, AND NOT `NoStandby` EITHER.**
913 /// The registry is in memory, so this is the state right after a restart.
914 #[test]
915 fn a_twin_that_never_reported_could_not_be_looked_at() {
916 let rows = [row("gunnar-twin", 0, 0, 0, 0)];
917 match twin_verdict(&twin(), &rows, 318) {
918 Err(FlushVerdict::CouldNotLook(why)) => {
919 assert!(
920 why.contains("reported no position since this server started"),
921 "{why}"
922 );
923 assert!(why.contains("gunnar-twin"), "{why}");
924 }
925 other => panic!("expected CouldNotLook: {other:?}"),
926 }
927 }
928
929 /// ★ A position older than the silence bound says nothing about now.
930 #[test]
931 fn a_stale_position_could_not_be_looked_at() {
932 let rows = [row("gunnar-twin", 318, 318, 120_000, 1_790_000_000_000)];
933 assert!(matches!(
934 twin_verdict(&twin(), &rows, 318),
935 Err(FlushVerdict::CouldNotLook(_))
936 ));
937 }
938
939 /// ★ An enrolled twin with no row at all is a refusal, never a pass.
940 #[test]
941 fn an_enrolled_twin_with_no_row_could_not_be_looked_at() {
942 assert!(matches!(
943 twin_verdict(&twin(), &[], 318),
944 Err(FlushVerdict::CouldNotLook(_))
945 ));
946 }
947
948 /// ★ **A HEAD OF ZERO PROVES NOTHING**, even against a twin reporting zero.
949 /// Equality alone would call this caught up.
950 #[test]
951 fn a_head_of_zero_is_not_a_flush() {
952 let rows = [row("gunnar-twin", 0, 0, 500, 1_790_000_000_000)];
953 assert!(
954 twin_verdict(&twin(), &rows, 0).is_err(),
955 "applied == head == 0 must not pass"
956 );
957 }
958
959 /// ★ No twin enrolled: nothing to prove, and it does not pretend one was checked.
960 #[test]
961 fn no_enrolled_twin_is_nothing_to_prove() {
962 assert_eq!(twin_verdict(&[], &[], 318), Ok((0, 0)));
963 }
964
965 /// ★ Every enrolled twin must be caught up, not merely one of them.
966 #[test]
967 fn one_lagging_twin_among_two_refuses() {
968 let members = vec!["twin-a".to_owned(), "twin-b".to_owned()];
969 let rows = [
970 row("twin-a", 318, 318, 500, 1_790_000_000_000),
971 row("twin-b", 12, 318, 500, 1_790_000_000_000),
972 ];
973 match twin_verdict(&members, &rows, 318) {
974 Err(FlushVerdict::Behind { why, .. }) => assert!(why.contains("twin-b"), "{why}"),
975 other => panic!("expected twin-b to refuse: {other:?}"),
976 }
977 }
978}
979
980#[cfg(all(test, feature = "grow-runtime"))]
981mod twin_flush_tests {
982 use super::*;
983 use std::sync::Arc;
984
985 /// **A twin's drain proves nothing about a standby, because it has none.**
986 /// MEASURED on the mock 2026-09-27: the twin read the roster it replicated
987 /// from main (which names the twin), waited for its OWN position and
988 /// refused every growth ("COULD NOT BE ESTABLISHED"). A twin answers
989 /// `NoStandby` — its primary is where its set is pulled back from.
990 #[test]
991 fn a_twin_has_no_standby_to_flush_to() {
992 let d = tempfile::tempdir().unwrap();
993 let stores = Arc::new(crate::stores::ServerStores::open(d.path()).unwrap());
994 stores.mark_twin();
995 let w = BoxWorld::new(Arc::new(gunnar_ssh::Admission::new(None, None)), stores);
996 match w.flush_replication(std::time::Duration::from_millis(50)) {
997 FlushVerdict::NoStandby(why) => assert!(why.contains("this box is a twin"), "{why}"),
998 other => panic!("a twin must answer NoStandby: {other:?}"),
999 }
1000 }
1001}