Skip to content

Commit b4cc83f

Browse files
committed
feat(nodedb): add startup sequencer with deterministic boot phases
Add `control::startup` with a `Sequencer` that enforces a fixed `StartupPhase` progression: WalRecovery → ClusterCatalogOpen → RaftMetadataReplay → SchemaCacheWarmup → DataGroupsReplay → TransportBind → WarmPeers → HealthLoopStart → GatewayEnable. Phases advance monotonically and reject regressions or skips at startup rather than silently leaving the node half-initialised. A `GatewayGuard` lets listeners block until `GatewayEnable` is reached, ensuring no client traffic is accepted before all subsystems are ready. Wire `Sequencer` into `SharedState` and drive phase transitions through `main.rs`, including peer warm-up and raft readiness failure paths that call `Sequencer::fail()`.
1 parent 12944e7 commit b4cc83f

10 files changed

Lines changed: 1073 additions & 0 deletions

File tree

nodedb/src/control/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub mod security;
2626
pub mod sequence;
2727
pub mod server;
2828
pub mod shutdown;
29+
pub mod startup;
2930
pub mod state;
3031
pub mod trace_context;
3132
pub mod trigger;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//! Sequencer error types. A `SequencerError` is always a
2+
//! programming bug — the sequencer never returns an error
3+
//! for legitimate runtime reasons, so callers `?` and the
4+
//! error propagates to startup abort.
5+
6+
use super::phase::StartupPhase;
7+
8+
/// Reasons the sequencer can reject an `advance_to` call.
9+
#[derive(Debug, thiserror::Error)]
10+
pub enum SequencerError {
11+
/// The new phase is strictly less than `current`. Always a
12+
/// programming bug — phases move forward, never back.
13+
#[error("startup phase regression: current is {current}, attempted to advance to {attempted}")]
14+
Regression {
15+
current: StartupPhase,
16+
attempted: StartupPhase,
17+
},
18+
19+
/// The new phase is further than one step from `current`.
20+
/// The sequencer enforces strict sequential advance to
21+
/// surface "forgot to advance intermediate phase" bugs
22+
/// at the moment they happen rather than during a later
23+
/// snapshot.
24+
#[error(
25+
"startup phase skip: current is {current}, attempted to jump to {attempted} — \
26+
phases must advance sequentially"
27+
)]
28+
Skip {
29+
current: StartupPhase,
30+
attempted: StartupPhase,
31+
},
32+
33+
/// Advanced past `GatewayEnable`. Terminal states cannot
34+
/// be left.
35+
#[error("startup phase already at terminal state {current}")]
36+
AlreadyTerminal { current: StartupPhase },
37+
}
38+
39+
impl From<SequencerError> for crate::Error {
40+
fn from(e: SequencerError) -> Self {
41+
crate::Error::Config {
42+
detail: e.to_string(),
43+
}
44+
}
45+
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
//! Gateway guard — the gate every client-facing listener
2+
//! waits on before processing requests.
3+
//!
4+
//! Wired into each listener so that a node in the middle of
5+
//! startup accepts TCP connections but does not proceed to
6+
//! wire-protocol handshake until
7+
//! [`GatewayGuard::await_ready`] returns. If shutdown fires
8+
//! during startup, the guard short-circuits with
9+
//! [`GatewayRefusal::ShuttingDown`] and the listener closes
10+
//! the stream cleanly instead of hanging.
11+
12+
use std::sync::Arc;
13+
14+
use super::phase::StartupPhase;
15+
use super::sequencer::Sequencer;
16+
use crate::control::shutdown::ShutdownWatch;
17+
18+
/// Reasons the gateway guard can refuse a pending connection.
19+
#[derive(Debug, thiserror::Error)]
20+
pub enum GatewayRefusal {
21+
/// Shutdown was signaled while the listener was waiting
22+
/// for `GatewayEnable`. Treat as a clean close.
23+
#[error("gateway refusing new connections: shutdown in progress")]
24+
ShuttingDown,
25+
/// The startup sequencer transitioned to `Failed` before
26+
/// `GatewayEnable`. The operator must inspect the startup
27+
/// log; new connections are rejected to avoid serving
28+
/// against a half-bootstrapped node.
29+
#[error("gateway refusing new connections: startup failed ({detail})")]
30+
StartupFailed { detail: String },
31+
}
32+
33+
/// Gateway guard. Cheap to clone — all state lives in two
34+
/// `Arc`s shared with `SharedState`.
35+
#[derive(Debug, Clone)]
36+
pub struct GatewayGuard {
37+
sequencer: Arc<Sequencer>,
38+
shutdown: Arc<ShutdownWatch>,
39+
}
40+
41+
impl GatewayGuard {
42+
/// Construct a guard from the canonical sequencer + watch.
43+
/// Usually created on-demand via
44+
/// `GatewayGuard::from_state(&shared)` so listeners don't
45+
/// need to pass both Arcs individually.
46+
pub fn new(sequencer: Arc<Sequencer>, shutdown: Arc<ShutdownWatch>) -> Self {
47+
Self {
48+
sequencer,
49+
shutdown,
50+
}
51+
}
52+
53+
/// Block until the sequencer reaches `GatewayEnable`,
54+
/// shutdown fires, or the sequencer fails. Returns
55+
/// `Ok(())` on successful start, `Err(ShuttingDown)` if
56+
/// shutdown wins, or `Err(StartupFailed)` if the
57+
/// sequencer transitioned to `Failed`.
58+
///
59+
/// Fast path: if the sequencer is already at
60+
/// `GatewayEnable`, returns immediately without a
61+
/// `select!`.
62+
pub async fn await_ready(&self) -> Result<(), GatewayRefusal> {
63+
// Fast path.
64+
let current = self.sequencer.current();
65+
if current == StartupPhase::Failed {
66+
return Err(GatewayRefusal::StartupFailed {
67+
detail: "sequencer already in Failed state".into(),
68+
});
69+
}
70+
if current >= StartupPhase::GatewayEnable {
71+
return Ok(());
72+
}
73+
if self.shutdown.is_shutdown() {
74+
return Err(GatewayRefusal::ShuttingDown);
75+
}
76+
77+
// Slow path: race phase advance against shutdown.
78+
let mut rx = self.shutdown.subscribe();
79+
tokio::select! {
80+
() = self.sequencer.await_phase(StartupPhase::GatewayEnable) => {
81+
// Could be GatewayEnable *or* Failed (both
82+
// satisfy `>= GatewayEnable` for the inner
83+
// watch compare). Re-read current to decide.
84+
match self.sequencer.current() {
85+
StartupPhase::Failed => Err(GatewayRefusal::StartupFailed {
86+
detail: "sequencer transitioned to Failed during startup".into(),
87+
}),
88+
_ => Ok(()),
89+
}
90+
}
91+
_ = rx.wait_cancelled() => Err(GatewayRefusal::ShuttingDown),
92+
}
93+
}
94+
95+
/// Non-blocking readiness probe. Used by `/health/ready`
96+
/// to return 503 until startup completes.
97+
pub fn is_ready(&self) -> bool {
98+
self.sequencer.current() >= StartupPhase::GatewayEnable
99+
&& self.sequencer.current() != StartupPhase::Failed
100+
}
101+
}
102+
103+
#[cfg(test)]
104+
mod tests {
105+
use super::*;
106+
use std::time::Duration;
107+
108+
fn advance_to_gateway(s: &Sequencer) {
109+
let mut cur = s.current();
110+
while let Some(next) = cur.next() {
111+
s.advance_to(next).unwrap();
112+
cur = next;
113+
if cur == StartupPhase::GatewayEnable {
114+
break;
115+
}
116+
}
117+
}
118+
119+
#[tokio::test]
120+
async fn await_ready_unblocks_on_gateway_enable() {
121+
let seq = Arc::new(Sequencer::new());
122+
let watch = Arc::new(ShutdownWatch::new());
123+
let guard = GatewayGuard::new(Arc::clone(&seq), Arc::clone(&watch));
124+
125+
let g2 = guard.clone();
126+
let handle = tokio::spawn(async move { g2.await_ready().await });
127+
tokio::time::sleep(Duration::from_millis(5)).await;
128+
assert!(!handle.is_finished());
129+
130+
advance_to_gateway(&seq);
131+
tokio::time::timeout(Duration::from_millis(100), handle)
132+
.await
133+
.expect("guard did not unblock on GatewayEnable")
134+
.expect("task panicked")
135+
.expect("await_ready returned error");
136+
assert!(guard.is_ready());
137+
}
138+
139+
#[tokio::test]
140+
async fn await_ready_returns_shutting_down_on_signal() {
141+
let seq = Arc::new(Sequencer::new());
142+
let watch = Arc::new(ShutdownWatch::new());
143+
let guard = GatewayGuard::new(seq, Arc::clone(&watch));
144+
145+
let g2 = guard.clone();
146+
let handle = tokio::spawn(async move { g2.await_ready().await });
147+
tokio::time::sleep(Duration::from_millis(5)).await;
148+
149+
watch.signal();
150+
let result = tokio::time::timeout(Duration::from_millis(50), handle)
151+
.await
152+
.expect("guard did not react to shutdown")
153+
.expect("task panicked");
154+
assert!(matches!(result, Err(GatewayRefusal::ShuttingDown)));
155+
}
156+
157+
#[tokio::test]
158+
async fn await_ready_fast_path_when_already_ready() {
159+
let seq = Arc::new(Sequencer::new());
160+
advance_to_gateway(&seq);
161+
let watch = Arc::new(ShutdownWatch::new());
162+
let guard = GatewayGuard::new(seq, watch);
163+
tokio::time::timeout(Duration::from_millis(5), guard.await_ready())
164+
.await
165+
.expect("fast path blocked")
166+
.expect("await_ready returned error on ready guard");
167+
}
168+
169+
#[tokio::test]
170+
async fn await_ready_fails_when_sequencer_failed() {
171+
let seq = Arc::new(Sequencer::new());
172+
let watch = Arc::new(ShutdownWatch::new());
173+
let guard = GatewayGuard::new(Arc::clone(&seq), watch);
174+
175+
let g2 = guard.clone();
176+
let handle = tokio::spawn(async move { g2.await_ready().await });
177+
tokio::time::sleep(Duration::from_millis(5)).await;
178+
seq.fail();
179+
180+
let result = tokio::time::timeout(Duration::from_millis(50), handle)
181+
.await
182+
.expect("guard did not react to fail()")
183+
.expect("task panicked");
184+
assert!(matches!(result, Err(GatewayRefusal::StartupFailed { .. })));
185+
assert!(!guard.is_ready());
186+
}
187+
188+
#[tokio::test]
189+
async fn await_ready_fast_path_when_already_failed() {
190+
let seq = Arc::new(Sequencer::new());
191+
seq.fail();
192+
let watch = Arc::new(ShutdownWatch::new());
193+
let guard = GatewayGuard::new(seq, watch);
194+
let result = guard.await_ready().await;
195+
assert!(matches!(result, Err(GatewayRefusal::StartupFailed { .. })));
196+
}
197+
198+
#[tokio::test]
199+
async fn await_ready_fast_path_when_already_shutting_down() {
200+
let seq = Arc::new(Sequencer::new());
201+
let watch = Arc::new(ShutdownWatch::new());
202+
watch.signal();
203+
let guard = GatewayGuard::new(seq, watch);
204+
let result = guard.await_ready().await;
205+
assert!(matches!(result, Err(GatewayRefusal::ShuttingDown)));
206+
}
207+
}

nodedb/src/control/startup/mod.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//! Deterministic startup phase sequencer.
2+
//!
3+
//! Every node advances through a fixed sequence of
4+
//! [`StartupPhase`] values from `Boot` to `GatewayEnable`. The
5+
//! `main.rs` startup code calls [`Sequencer::advance_to`] at
6+
//! each phase boundary, and client-facing listeners wait on
7+
//! [`GatewayGuard::await_ready`] before processing the first
8+
//! request. A phase regression or skip is a programming bug
9+
//! and is rejected at the sequencer.
10+
//!
11+
//! See [`phase::StartupPhase`] for the canonical ordering.
12+
13+
pub mod error;
14+
pub mod guard;
15+
pub mod phase;
16+
pub mod sequencer;
17+
pub mod snapshot;
18+
19+
pub use error::SequencerError;
20+
pub use guard::{GatewayGuard, GatewayRefusal};
21+
pub use phase::{PHASE_COUNT, StartupPhase};
22+
pub use sequencer::Sequencer;
23+
pub use snapshot::{PhaseEntry, StartupStatus};

0 commit comments

Comments
 (0)