Skip to main content

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