pub mod go_ahead;
pub mod world;
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
pub use go_ahead::{GoAhead, GoAheadPolicy, OperatorPolicy};
#[cfg(feature = "metered")]
pub use go_ahead::MonetizePolicy;
pub use world::{Grown, MemberFacts, SurveyFacts, World};
pub const TARGET: &str = "gunnar::grow";
pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(600);
pub const DEFAULT_WAIT_MEMBERS_TIMEOUT: Duration = Duration::from_secs(3600);
pub const UNMOUNTED_TOO_LONG: &str = "grow.unmounted_too_long";
pub const DRAIN_POLL: Duration = Duration::from_millis(200);
pub fn target_reached(have: u64, asked: u64) -> bool {
have >= asked || have.saturating_mul(100) >= asked.saturating_mul(99)
}
pub const WAIT_POLL: Duration = Duration::from_secs(2);
pub const WAIT_POLL_AFTER_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Idle,
Draining,
Unmounted,
WaitingMembers,
Growing,
Mounting,
Done,
Failed,
}
impl Phase {
pub fn as_str(self) -> &'static str {
match self {
Phase::Idle => "idle",
Phase::Draining => "draining",
Phase::Unmounted => "unmounted",
Phase::WaitingMembers => "waiting-members",
Phase::Growing => "growing",
Phase::Mounting => "mounting",
Phase::Done => "done",
Phase::Failed => "failed",
}
}
pub fn is_active(self) -> bool {
matches!(
self,
Phase::Draining | Phase::Unmounted | Phase::WaitingMembers | Phase::Growing | Phase::Mounting
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
Drain,
Unmount,
Stop,
Wait,
Rescan,
Reopen,
Abort,
Fail,
}
impl Step {
pub fn as_str(self) -> &'static str {
match self {
Step::Drain => "DRAIN",
Step::Unmount => "UNMOUNT",
Step::Stop => "STOP",
Step::Wait => "WAIT",
Step::Rescan => "RESCAN",
Step::Reopen => "REOPEN",
Step::Abort => "ABORT",
Step::Fail => "FAIL",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Announcement {
Draining { kind: &'static str, reason: String },
Ready,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Plan {
pub target_sectors: u64,
pub drain_timeout: Duration,
pub wait_timeout: Duration,
pub signer: String,
pub alone: String,
}
pub const ALONE_MAX: usize = 600;
pub const ALONE_FLUSH_WAIT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Resume {
LookAgain,
GiveUp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Ended {
Eligible,
GaveUp,
Bound(String),
}
pub fn carries(survey: &SurveyFacts, target: u64) -> bool {
survey.verdict == "complete" && !survey.members.is_empty() && survey.members.iter().all(|m| target_reached(m.set_bytes / 512, target))
}
#[derive(Debug, Clone)]
struct State {
phase: Phase,
since_unix_ms: i64,
why: String,
detail: String,
epoch: u64,
target_sectors: u64,
signer: String,
members: SurveyFacts,
identity_in_ram: bool,
serving: bool,
in_flight: u64,
resume: Option<Resume>,
wait_timed_out: bool,
grown: Option<Grown>,
alarm: Option<(String, i64, String)>,
alone: String,
}
impl State {
fn idle(serving: bool) -> Self {
State {
phase: Phase::Idle,
since_unix_ms: gunnar_grpc::bus::now_unix_ms(),
why: String::new(),
detail: String::new(),
epoch: 0,
target_sectors: 0,
signer: String::new(),
members: SurveyFacts::default(),
identity_in_ram: false,
serving,
in_flight: 0,
resume: None,
wait_timed_out: false,
grown: None,
alarm: None,
alone: String::new(),
}
}
}
type Announcer = Box<dyn Fn(Announcement) + Send + Sync>;
pub struct GrowMachine {
world: Arc<dyn World>,
policy: Arc<dyn GoAheadPolicy>,
state: Mutex<State>,
wake: Condvar,
announcer: Mutex<Option<Announcer>>,
}
impl std::fmt::Debug for GrowMachine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GrowMachine").field("phase", &self.phase()).finish()
}
}
impl GrowMachine {
pub fn new(world: Arc<dyn World>) -> Arc<Self> {
Self::with_policy(world, Arc::new(OperatorPolicy::default()))
}
pub fn with_policy(world: Arc<dyn World>, policy: Arc<dyn GoAheadPolicy>) -> Arc<Self> {
let serving = world.serving();
tracing::info!(
target: TARGET,
event = "grow.up",
engine_present = world.engine_present(),
serving,
"the growth machine is up — idle; {}",
if world.engine_present() { "this build grows /data at runtime (Admin.GrowStart)" } else { "no runtime grow engine in this build: the set grows only at a restart" }
);
Arc::new(GrowMachine {
world,
policy,
state: Mutex::new(State::idle(serving)),
wake: Condvar::new(),
announcer: Mutex::new(None),
})
}
pub fn with_announcer(self: &Arc<Self>, f: impl Fn(Announcement) + Send + Sync + 'static) {
*self.announcer.lock().unwrap_or_else(|p| p.into_inner()) = Some(Box::new(f));
}
fn announce(&self, a: Announcement) {
match &a {
Announcement::Draining { kind, reason } => tracing::warn!(
target: TARGET,
event = "presence.draining",
kind,
reason = %reason,
"Draining{{kind: {kind}}}: {reason}"
),
Announcement::Ready => tracing::info!(target: TARGET, event = "presence.ready", "Ready: /data is back and the gate is open"),
}
if let Some(f) = self.announcer.lock().unwrap_or_else(|p| p.into_inner()).as_ref() {
f(a);
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(|p| p.into_inner())
}
pub fn alarm(&self) -> Option<gunnar_proto::HealthAlarm> {
self.lock().alarm.clone().map(|(event, since_unix_ms, detail)| gunnar_proto::HealthAlarm { event, count: 1, since_unix_ms, detail })
}
fn raise(&self, event: &str, detail: String, loud: bool) {
let mut s = self.lock();
if loud || s.alarm.as_ref().is_none_or(|(e, _, _)| e != event) {
tracing::error!(target: TARGET, event, "{detail}");
}
let since = match &s.alarm {
Some((e, since, _)) if e == event => *since,
_ => gunnar_grpc::bus::now_unix_ms(),
};
s.alarm = Some((event.to_owned(), since, detail));
}
pub fn phase(&self) -> Phase {
self.lock().phase
}
fn enter(&self, phase: Phase, detail: impl Into<String>) {
let mut s = self.lock();
let detail = detail.into();
tracing::info!(target: TARGET, event = "grow.phase", from = s.phase.as_str(), to = phase.as_str(), "{detail}");
s.phase = phase;
s.since_unix_ms = gunnar_grpc::bus::now_unix_ms();
s.detail = detail;
}
fn note(&self, step: Step) -> Result<(), String> {
let (epoch, target) = {
let s = self.lock();
(s.epoch, s.target_sectors)
};
self.world.note(epoch, target, step)
}
fn note_failing(&self, step: Step, why: &mut String) {
if let Err(e) = self.note(step) {
tracing::error!(target: TARGET, event = "grow.journal", step = step.as_str(), %e, "the grow journal did not record a step of a failing growth");
why.push_str(&format!("; the grow journal did not record {}: {e}", step.as_str()));
}
}
pub fn start(self: &Arc<Self>, request: &gunnar_proto::GrowStartRequest) -> Result<gunnar_proto::GrowStatusResponse, String> {
if !self.world.engine_present() {
return Err("this gunnar-server was built without the runtime grow engine (feature `grow-runtime`); the set grows only at a restart".into());
}
if !self.world.hotplug() {
return Err(format!(
"the running kernel has no PCI hot-plug ({}): a member detached, resized and re-attached while gunnar runs is invisible to it, so a runtime growth is refused — re-image onto a medium whose kernel carries CONFIG_HOTPLUG_PCI, or enlarge every member and restart (the set grows at the next boot)",
crate::dashboard::probe::pci_hotplug().evidence
));
}
if !self.world.journal_present() {
return Err(format!(
"this box's boot stack keeps no grow journal ({}): korp-start never opened one, so this medium's boot stack does not grow the data set and a runtime growth has nowhere to record the steps a crash would be finished from — serving more needs a fresh install onto a larger set, not another disk. This is the same fact `Admin.Capacity` reports as GROWTH_SEALED",
self.world.journal_path().display()
));
}
let go = GoAhead::parse(&request.go_ahead)?;
if go.target_sectors != request.target_sectors_per_member {
return Err(format!(
"the go-ahead names target_sectors {} and the request {}; they must agree",
go.target_sectors, request.target_sectors_per_member
));
}
let now = gunnar_grpc::bus::now_unix_ms();
self.policy.verify(&go, now)?;
if !self.world.remember_nonce(&go.nonce)? {
return Err(format!("the go-ahead's nonce {:?} was already spent on this box", go.nonce));
}
let plan = Plan {
target_sectors: request.target_sectors_per_member,
drain_timeout: if request.drain_timeout_secs == 0 { DEFAULT_DRAIN_TIMEOUT } else { Duration::from_secs(u64::from(request.drain_timeout_secs)) },
wait_timeout: if request.wait_members_timeout_secs == 0 { DEFAULT_WAIT_MEMBERS_TIMEOUT } else { Duration::from_secs(u64::from(request.wait_members_timeout_secs)) },
signer: go.signer.clone(),
alone: {
let one_line = go.alone.replace(['\n', '\r'], " ");
let mut cut = one_line.trim().to_owned();
if cut.len() > ALONE_MAX {
let mut at = ALONE_MAX;
while !cut.is_char_boundary(at) {
at -= 1;
}
cut.truncate(at);
}
cut
},
};
{
let mut s = self.lock();
if s.phase.is_active() {
return Err(format!("a growth is already {} (since {}); GrowResume looks again, and a new growth waits for this one", s.phase.as_str(), s.since_unix_ms));
}
if !s.serving {
return Err(format!("/data is not being served ({}); a growth cannot start from here — reboot, or re-attach the members and GrowResume", s.why));
}
let survey = self.world.survey();
if survey.verdict != "complete" {
return Err(format!("the data set is not a complete md set right now ({}); nothing to grow", survey.verdict));
}
if plan.target_sectors > 0 && carries(&survey, plan.target_sectors) {
let described = survey.describe();
*s = State::idle(true);
s.signer = plan.signer.clone();
s.target_sectors = plan.target_sectors;
s.members = survey;
s.phase = Phase::Done;
s.since_unix_ms = now;
s.detail = format!(
"done: every member already carries the {} sectors asked ({described}); nothing was drained and /data never left",
plan.target_sectors
);
drop(s);
tracing::warn!(
target: TARGET,
event = "grow.already_met",
signer = %plan.signer,
nonce = %go.nonce,
target_sectors = plan.target_sectors,
"GrowStart for a size the members already carry: done at once, nothing drained or unmounted"
);
return Ok(self.status());
}
*s = State::idle(true);
s.signer = plan.signer.clone();
s.target_sectors = plan.target_sectors;
s.members = survey;
s.phase = Phase::Draining;
s.since_unix_ms = now;
s.detail = format!("draining: the gate is paused, waiting up to {} s for {} transfer(s) in flight", plan.drain_timeout.as_secs(), self.world.in_flight());
}
tracing::warn!(
target: TARGET,
event = "grow.start",
signer = %plan.signer,
nonce = %go.nonce,
target_sectors = plan.target_sectors,
drain_timeout_s = plan.drain_timeout.as_secs(),
wait_timeout_s = plan.wait_timeout.as_secs(),
"a runtime data-set growth begins"
);
let started = self.status();
let me = Arc::clone(self);
std::thread::Builder::new()
.name("gunnar-grow".into())
.spawn(move || me.run(plan))
.map_err(|e| format!("spawning the growth thread: {e}"))?;
Ok(started)
}
pub fn resume(&self, give_up: bool) -> Result<gunnar_proto::GrowStatusResponse, String> {
if self.lock().phase == Phase::Done {
tracing::info!(target: TARGET, event = "grow.resume.done", give_up, "GrowResume on a growth that is already done: nothing to resume");
return Ok(self.status());
}
{
let s = self.lock();
if matches!(s.phase, Phase::Growing | Phase::Mounting) {
drop(s);
tracing::info!(target: TARGET, event = "grow.resume.progress", give_up, "GrowResume while the engine runs: the members were already seen; nothing to look at again");
return Ok(self.status());
}
}
{
let mut s = self.lock();
if s.phase != Phase::WaitingMembers && s.phase != Phase::Unmounted {
return Err(format!(
"GrowResume applies while waiting-members; the growth is {} ({})",
s.phase.as_str(),
if s.phase == Phase::Idle { "start one with GrowStart" } else { s.detail.as_str() }
));
}
s.resume = Some(if give_up { Resume::GiveUp } else { Resume::LookAgain });
}
tracing::info!(target: TARGET, event = "grow.resume", give_up, "GrowResume: looking at the members again");
self.wake.notify_all();
Ok(self.status())
}
pub fn status(&self) -> gunnar_proto::GrowStatusResponse {
let mut s = self.lock().clone();
if !s.phase.is_active() && self.world.engine_present() {
s.members = self.world.survey();
}
if s.phase == Phase::Draining {
s.in_flight = self.world.in_flight();
}
let journal = gunnar_grpc::grow_journal::Summary::read(&self.world.journal_path()).map(|j| j.to_wire());
let detail = if s.detail.is_empty() {
match s.phase {
Phase::Idle if !self.world.engine_present() => "no runtime grow engine in this build; the set grows only at a restart".to_owned(),
Phase::Idle => format!("idle; members: {}", s.members.describe()),
_ => String::new(),
}
} else {
s.detail.clone()
};
gunnar_proto::GrowStatusResponse {
phase: s.phase.as_str().to_owned(),
why: s.why.clone(),
epoch: s.epoch,
target_sectors_per_member: s.target_sectors,
members: s
.members
.members
.iter()
.map(|m| gunnar_proto::GrowMember {
index: m.index,
device: m.device.clone(),
partition: m.partition.clone(),
disk_bytes: m.disk_bytes,
set_bytes: m.set_bytes,
offered_bytes: m.offered_bytes,
larger: m.larger,
})
.collect(),
members_verdict: s.members.verdict.clone(),
journal,
engine_present: self.world.engine_present(),
hotplug: self.world.hotplug(),
serving: s.serving,
identity_in_ram: s.identity_in_ram,
in_flight: s.in_flight,
since_unix_ms: s.since_unix_ms,
server_unix_ms: gunnar_grpc::bus::now_unix_ms(),
detail,
go_ahead_signer: s.signer.clone(),
set_uuid: s.members.set_uuid.clone(),
total_bytes: if s.serving { self.world.mounted_bytes() } else { 0 },
}
}
fn run(self: Arc<Self>, plan: Plan) {
self.announce(Announcement::Draining { kind: "growing", reason: "the data set is being grown".into() });
self.world.pause_admission();
let started = Instant::now();
loop {
let in_flight = self.world.in_flight();
self.lock().in_flight = in_flight;
let starting = self.world.starting();
if in_flight == 0 && starting.is_none() {
break;
}
if started.elapsed() >= plan.drain_timeout {
self.world.unpause_admission();
let what = match &starting {
Some(why) => format!("{in_flight} transfer(s) still in flight and the box still {why}"),
None => format!("{in_flight} transfer(s) still in flight"),
};
self.fail_untouched(format!(
"aborted: {what} after the {} s drain; nothing was changed — retry when the box is quieter, or raise drain_timeout_secs",
plan.drain_timeout.as_secs()
));
return;
}
std::thread::sleep(DRAIN_POLL);
}
let flush_wait = if plan.alone.is_empty() { plan.drain_timeout } else { plan.drain_timeout.min(ALONE_FLUSH_WAIT) };
let verdict = match self.world.flush_replication(flush_wait) {
world::FlushVerdict::Behind { pending, needs_full_resync, why } if !plan.alone.is_empty() => {
let said = format!("a standby is behind ({pending} pending, needs_full_resync {needs_full_resync}): {why}");
tracing::warn!(target: TARGET, event = "grow.alone", pending, %why, alone = %plan.alone, "the growth goes on ALONE: {said}");
world::FlushVerdict::NoStandby(format!("the growth goes on ALONE ({}) — {said}; the twin catches up from the log after the remount", plan.alone))
}
world::FlushVerdict::CouldNotLook(why) if !plan.alone.is_empty() => {
tracing::warn!(target: TARGET, event = "grow.alone", %why, alone = %plan.alone, "the growth goes on ALONE: the standby could not be looked at");
world::FlushVerdict::NoStandby(format!("the growth goes on ALONE ({}) — the standby could not be looked at: {why}", plan.alone))
}
other => other,
};
if !plan.alone.is_empty() {
if let world::FlushVerdict::NoStandby(why) = &verdict {
self.lock().alone = why.clone();
}
}
match verdict {
world::FlushVerdict::CaughtUp { standbys, waited_ms } => {
tracing::info!(target: TARGET, event = "grow.flushed", standbys, waited_ms, "every standby has taken everything; the set may be unmounted");
self.lock().detail = format!("drained: nothing in flight and {standbys} standby(s) caught up after {waited_ms} ms");
}
world::FlushVerdict::NoStandby(why) => {
tracing::warn!(target: TARGET, event = "grow.no_standby", %why, "the set is being grown with no standby behind it");
self.lock().detail = format!("drained: nothing in flight, and {why}");
}
world::FlushVerdict::CouldNotLook(why) => {
self.world.unpause_admission();
self.fail_untouched(format!(
"aborted: the gate is shut and nothing is in flight, but whether the twin holds this set COULD NOT BE \
ESTABLISHED: {why} \u{2014} nothing was changed. A set unmounted now could only be pulled back from a twin, \
and nothing here says there is one that could."
));
return;
}
world::FlushVerdict::Behind { pending, needs_full_resync, why } => {
self.world.unpause_admission();
self.fail_untouched(format!(
"aborted: the gate is shut and nothing is in flight, but a standby is still behind ({pending} pending, needs_full_resync {needs_full_resync}): {why} \u{2014} nothing was changed. Nothing more is coming that would help it, and a set unmounted now could only be pulled back from a twin that does not have it."
));
return;
}
}
let epoch = self.world.epoch_for(plan.target_sectors);
self.lock().epoch = epoch;
if let Err(e) = self.note(Step::Drain) {
self.world.unpause_admission();
self.fail_untouched(format!("aborted: {e}; nothing was changed"));
return;
}
self.lock().detail = "draining → unmounting: the identity table is served from RAM, the stores close, /data unmounts, the array stops; the volumes are NOT free yet".to_owned();
tracing::info!(target: TARGET, event = "grow.unmounting", "unmounting: identity to RAM, stores close, /data unmounts, the array stops (the phase stays draining until the set is off)");
match self.world.suspend_identity("draining for the data-set growth") {
Ok(facts) => {
self.lock().identity_in_ram = true;
tracing::info!(target: TARGET, event = "grow.identity_in_ram", documents = facts.documents, bytes = facts.bytes, "the control store is served from its RAM copy");
}
Err(e) => {
self.world.unpause_admission();
let mut why = format!("aborted: the identity table could not be copied into RAM ({e}); nothing was changed");
self.note_failing(Step::Abort, &mut why);
self.fail_untouched(why);
return;
}
}
if let Err(e) = self.world.close_stores() {
self.abort_after_suspend(format!("aborted: the stores would not close ({e})"));
return;
}
if let Err(e) = self.world.sync_and_unmount() {
self.abort_after_suspend(format!("aborted: {e}"));
return;
}
self.lock().serving = false;
if let Err(e) = self.note(Step::Unmount) {
self.recover_serve(format!("{e} after the unmount"));
return;
}
if let Err(e) = self.world.stop_array() {
self.recover_serve(format!("the array would not stop ({e})"));
return;
}
if let Err(e) = self.note(Step::Stop) {
self.recover_serve(format!("{e} after the array stopped"));
return;
}
self.enter(Phase::Unmounted, "unmounted: /data is off, the stores are closed, the array is stopped; the volumes are free");
if let Err(e) = self.note(Step::Wait) {
self.recover_serve(format!("the grow journal did not record WAIT ({e}); the members were not let go of"));
return;
}
self.enter(Phase::WaitingMembers, format!(
"waiting for every member to come back larger{}; the outside actor detaches, resizes and re-attaches the volumes; GrowResume looks again",
if plan.target_sectors > 0 { format!(" (≥ {} sectors each)", plan.target_sectors) } else { String::new() }
));
let waited_from = Instant::now();
let ended = loop {
let timed_out = waited_from.elapsed() >= plan.wait_timeout;
if timed_out {
let mut s = self.lock();
if !s.wait_timed_out {
s.wait_timed_out = true;
tracing::warn!(target: TARGET, event = "grow.wait_timeout", waited_s = waited_from.elapsed().as_secs(), "the members did not come back larger within this growth's bound: with every member present /data is mounted back now at the size they carry; with one missing the wait goes on, alarmed — GrowResume looks again, GrowResume{{give_up}} serves what is there");
}
}
let _ = self.world.rescan();
let survey = self.world.survey();
let eligible = survey.eligible && survey.target_sectors.is_some_and(|t| plan.target_sectors == 0 || target_reached(t, plan.target_sectors));
let same_set = survey.set_uuid.is_empty() || {
let s = self.lock();
s.members.set_uuid.is_empty() || s.members.set_uuid == survey.set_uuid
};
{
let mut s = self.lock();
let described = survey.describe();
s.members = survey;
s.detail = format!(
"waiting-members: {described}{}",
if !same_set { " — NOT THE SAME SET (uuid differs); refusing to grow it" } else if s.wait_timed_out { " — the wait timeout passed; still waiting for GrowResume or the members" } else { "" }
);
}
if eligible && same_set {
break Ended::Eligible;
}
if timed_out {
let waited = waited_from.elapsed().as_secs();
let described = self.lock().members.describe();
let complete = self.lock().members.verdict == "complete";
if complete && same_set {
break Ended::Bound(format!(
"{UNMOUNTED_TOO_LONG}: /data stayed unmounted {waited} s, past this growth's {} s bound, waiting for members that never came back larger{} ({described}); mounted back at the size the members carry",
plan.wait_timeout.as_secs(),
if plan.target_sectors > 0 { format!(" (≥ {} sectors each)", plan.target_sectors) } else { String::new() }
));
}
self.raise(
UNMOUNTED_TOO_LONG,
format!(
"/data has been unmounted {waited} s, past this growth's {} s bound, and cannot be mounted back: {} ({described}) — re-attach the members (GrowResume looks again) or reboot",
plan.wait_timeout.as_secs(),
if same_set { "a member is missing" } else { "the members are NOT the set that was unmounted" }
),
false,
);
}
let resume = self.lock().resume.take();
match resume {
Some(Resume::GiveUp) => break Ended::GaveUp,
Some(Resume::LookAgain) => continue,
None => {}
}
let poll = if timed_out {
WAIT_POLL_AFTER_TIMEOUT
} else {
WAIT_POLL.min(plan.wait_timeout.saturating_sub(waited_from.elapsed()).max(Duration::from_millis(50)))
};
let guard = self.lock();
let (guard, _) = self.wake.wait_timeout(guard, poll).unwrap_or_else(|p| p.into_inner());
drop(guard);
};
if let Ended::Bound(why) = ended {
self.raise(UNMOUNTED_TOO_LONG, why.clone(), true);
self.recover_serve(why);
return;
}
let give_up = matches!(ended, Ended::GaveUp);
if let Err(e) = self.note(Step::Rescan) {
tracing::warn!(target: TARGET, event = "grow.journal", %e, "RESCAN not recorded; going on (the disk is the truth)");
}
self.enter(Phase::Growing, if give_up { "growing: the operator gave up waiting; assembling and mounting what is there" } else { "growing: every member is back and larger; the engine runs G1–G6" });
match self.world.grow_and_mount() {
Ok(grown) => {
self.lock().serving = true;
self.lock().grown = Some(grown.clone());
if grown.grew {
if let Err(why) = self.reopen() {
let why = format!("the data set grew ({}), but {why}", grown.describe());
self.lock().why = why.clone();
self.enter(Phase::Failed, format!("failed: {why}"));
return;
}
let alone = std::mem::take(&mut self.lock().alone);
self.enter(Phase::Done, if alone.is_empty() { format!("done: {}", grown.describe()) } else { format!("done: {}; grown {alone}", grown.describe()) });
tracing::warn!(target: TARGET, event = "grow.done", array_bytes = grown.array_bytes, "the data set grew and is being served");
} else {
let untouched = give_up && self.lock().members.members.iter().all(|m| !m.larger);
let why = if give_up {
format!("gave up waiting{}; serving at the size the members share: {}", if untouched { " with no member back larger (nothing was touched)" } else { "" }, grown.describe())
} else {
format!("the engine did not grow the set; serving at the size the members share: {}", grown.describe())
};
let mut why = why;
self.note_failing(if untouched { Step::Abort } else { Step::Fail }, &mut why);
self.lock().why = why.clone();
match self.reopen() {
Ok(()) => self.enter(Phase::Failed, format!("failed (serving): {why}")),
Err(more) => {
let why = format!("{why}; and {more}");
self.lock().why = why.clone();
self.enter(Phase::Failed, format!("failed: {why}"));
}
}
}
}
Err(e) => {
let mut why = format!("FATAL: /data could not be assembled or mounted ({e}); the control plane and GrowStatus keep answering, git is paused, the identity table is served from RAM; re-attach the members and GrowResume is not possible from here — reboot to let the boot path finish the epoch, or reload from the twin (a separate verb)");
self.note_failing(Step::Fail, &mut why);
self.lock().why = why.clone();
self.raise("grow.fatal", why.clone(), true);
self.enter(Phase::Failed, why);
tracing::error!(target: TARGET, event = "grow.fatal", "{e}");
}
}
}
fn fail_untouched(&self, why: String) {
tracing::warn!(target: TARGET, event = "grow.aborted", %why, "the growth aborted with nothing changed");
let mut s = self.lock();
s.why = why.clone();
s.serving = true;
s.identity_in_ram = false;
drop(s);
self.enter(Phase::Failed, why);
}
fn abort_after_suspend(&self, why: String) {
let reopened = self.world.reopen_stores();
match self.world.resume_identity() {
Ok(r) => self.report_resume(&r),
Err(e) => tracing::error!(target: TARGET, event = "grow.identity_resume_failed", %e, "the control store could not be put back on the stripe; it is still served from RAM"),
}
let mut why = why;
self.note_failing(Step::Abort, &mut why);
match reopened {
Ok(()) => {
self.world.unpause_admission();
self.fail_untouched(why);
}
Err(e) => self.stores_stay_closed(format!("{why}; and the stores would not reopen ({e})")),
}
}
fn stores_stay_closed(&self, why: String) {
let why = format!("{why}: git stays paused over closed stores — restart the box to recover");
{
let mut s = self.lock();
s.why = why.clone();
s.serving = false;
}
self.raise("grow.reopen_failed", why.clone(), true);
self.enter(Phase::Failed, why);
}
fn recover_serve(&self, why: String) {
tracing::warn!(target: TARGET, event = "grow.recover", %why, "recovering: mounting what is there and serving at the size it has");
match self.world.grow_and_mount() {
Ok(grown) => {
self.lock().serving = true;
self.lock().grown = Some(grown);
let mut why = why;
self.note_failing(Step::Fail, &mut why);
self.lock().why = why.clone();
match self.reopen() {
Ok(()) => self.enter(Phase::Failed, format!("failed (serving at the old size): {why}")),
Err(more) => {
let why = format!("{why}; and {more}");
self.lock().why = why.clone();
self.enter(Phase::Failed, format!("failed: {why}"));
}
}
}
Err(e) => {
let mut full = format!("FATAL: {why}; and /data could not be mounted back ({e}); the control plane and GrowStatus keep answering, git is paused, the identity table is served from RAM — reboot to let the boot path finish the epoch");
self.note_failing(Step::Fail, &mut full);
self.lock().why = full.clone();
self.raise("grow.fatal", full.clone(), true);
self.enter(Phase::Failed, full);
tracing::error!(target: TARGET, event = "grow.fatal", "{e}");
}
}
}
fn reopen(&self) -> Result<(), String> {
self.enter(Phase::Mounting, "mounting: /data is back; the identity table returns to the stripe and the stores reopen");
match self.world.resume_identity() {
Ok(r) => {
self.lock().identity_in_ram = false;
self.report_resume(&r);
}
Err(e) => tracing::error!(target: TARGET, event = "grow.identity_resume_failed", %e, "the control store could not be put back on the stripe; it is still served from RAM and every write is refused — restart to recover"),
}
if let Err(e) = self.world.reopen_stores() {
let why = format!("the stores did not reopen ({e}): git stays paused over closed stores — restart the box to recover");
self.lock().serving = false;
self.raise("grow.reopen_failed", why.clone(), true);
return Err(why);
}
let noted = self.note(Step::Reopen);
self.world.unpause_admission();
self.announce(Announcement::Ready);
noted.map_err(|e| {
tracing::error!(target: TARGET, event = "grow.journal", step = Step::Reopen.as_str(), %e, "the grow journal did not record REOPEN");
format!("the grow journal did not record REOPEN ({e}); the stores are open and serving")
})
}
fn report_resume(&self, r: &world::ResumeFacts) {
if r.drift.is_empty() {
tracing::info!(target: TARGET, event = "grow.identity_resumed", documents = r.documents_matched, off_stripe_ms = r.off_stripe_ms, "the control store is back on the stripe; the RAM copy and the disk agree");
} else {
tracing::error!(target: TARGET, event = "grow.identity_drift", drift = ?r.drift, "the control store on disk does NOT match the RAM copy that stood in for it — the disk is the truth and is what is served now; these documents changed underneath the growth");
let mut s = self.lock();
s.why = format!("{}{}identity drift on resume: {}", s.why, if s.why.is_empty() { "" } else { "; " }, r.drift.join(", "));
}
}
}
#[cfg(test)]
pub(crate) mod tests;