Skip to content

Commit ab121aa

Browse files
committed
feat(health): per-core heartbeat with degraded surfacing (B4)
B4 (2026-08-26): the 12:23 wedge produced zero ERROR journal lines — executor/checkpoint stuck silently. New bridge/runtime_health.rs records a per-core tick every event-loop iteration and checkpoint results (3 consecutive failures → stall flag). HealthState::Degraded surfaces as HTTP 503 'degraded' and native STATUS 'Degraded' instead of Ok forever.
1 parent 389e68e commit ab121aa

4 files changed

Lines changed: 201 additions & 2 deletions

File tree

‎nodedb/src/bridge/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod admission_chokepoint;
44
pub mod dispatch;
55
pub mod envelope;
66
pub mod quiesce;
7+
pub mod runtime_health;
78
pub mod slab;
89

910
// Re-export shared query engine from nodedb-query crate.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! Runtime health registry — per-core heartbeat ticks + checkpoint stall flag.
4+
//!
5+
//! B4/B5 fix (2026-08-26): the 12:23 wedge showed zero ERROR logs while the
6+
//! data-plane core loop and the coordinated checkpoint silently stalled. The
7+
//! startup gate kept reporting `Ok` because it only tracks boot phases, so
8+
//! `/healthz` and `STATUS` had no idea the node was wedged.
9+
//!
10+
//! Every event-loop iteration records a monotonic tick per core; the health
11+
//! formatter compares ticks against a stall threshold. The checkpoint manager
12+
//! raises a stall flag after consecutive failed cycles. Both signals are
13+
//! surfaced through [`crate::control::startup::health::HealthState::Degraded`]
14+
//! so HTTP `/healthz` degrades to 503 and the native `STATUS` reports
15+
//! "Degraded" — a wedge becomes loud instead of silent.
16+
//!
17+
//! Lives in `bridge` so both the data plane (writer) and control plane
18+
//! (reader) can reach it without a control→data or data→control dependency.
19+
20+
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
21+
use std::sync::{LazyLock, Mutex};
22+
use std::time::{Duration, Instant};
23+
24+
/// A core whose last recorded tick is older than this is considered stalled.
25+
///
26+
/// The event loop polls at most every 100 ms (`IDLE_POLL_TIMEOUT_MS`), so a
27+
/// healthy core ticks at least 10×/s. 60 s of silence means the loop is stuck
28+
/// inside a request or maintenance work — the wedge shape we hit on 2026-08-26.
29+
pub const CORE_STALL_THRESHOLD: Duration = Duration::from_secs(60);
30+
31+
/// Consecutive checkpoint cycles that failed (deferred / partial / timed out)
32+
/// before the checkpoint stall flag is raised.
33+
pub const CHECKPOINT_STALL_THRESHOLD: u32 = 3;
34+
35+
/// Per-core `Instant` of the last event-loop tick. Index = core id; extended
36+
/// lazily as cores register on boot.
37+
static CORE_LAST_TICK: LazyLock<Mutex<Vec<Instant>>> = LazyLock::new(|| Mutex::new(Vec::new()));
38+
39+
/// Consecutive failed checkpoint cycles (reset on success).
40+
static CHECKPOINT_FAILURES: AtomicU32 = AtomicU32::new(0);
41+
42+
/// Raised once [`CHECKPOINT_STALL_THRESHOLD`] consecutive cycles failed;
43+
/// cleared on the next successful cycle.
44+
static CHECKPOINT_STALLED: AtomicBool = AtomicBool::new(false);
45+
46+
/// Record one event-loop iteration for `core_id`. Called at the top of every
47+
/// loop pass, before any work, so a stuck request cannot mask a live loop.
48+
pub fn record_core_tick(core_id: usize) {
49+
let now = Instant::now();
50+
let mut ticks = CORE_LAST_TICK.lock().unwrap_or_else(|p| p.into_inner());
51+
if ticks.len() <= core_id {
52+
ticks.resize(core_id + 1, now);
53+
}
54+
ticks[core_id] = now;
55+
}
56+
57+
/// Record the outcome of one checkpoint cycle.
58+
///
59+
/// `ok = true` (every core reported a fresh LSN) resets the consecutive
60+
/// counter and clears the stall flag. `ok = false` increments the counter and
61+
/// raises the stall flag once the threshold is crossed — with a single
62+
/// escalation log at the crossing (the caller logs every failure already).
63+
pub fn record_checkpoint_result(ok: bool) -> bool {
64+
if ok {
65+
CHECKPOINT_FAILURES.store(0, Ordering::Relaxed);
66+
CHECKPOINT_STALLED.store(false, Ordering::Relaxed);
67+
return false;
68+
}
69+
let failures = CHECKPOINT_FAILURES.fetch_add(1, Ordering::Relaxed) + 1;
70+
if failures >= CHECKPOINT_STALL_THRESHOLD && !CHECKPOINT_STALLED.swap(true, Ordering::Relaxed) {
71+
return true; // just crossed the threshold — caller escalates
72+
}
73+
false
74+
}
75+
76+
/// Core ids whose last tick is older than `threshold`. Only cores that have
77+
/// registered (booted) are considered; unregistered ids are never "stalled".
78+
pub fn stalled_core_ids(threshold: Duration) -> Vec<usize> {
79+
let now = Instant::now();
80+
let ticks = CORE_LAST_TICK.lock().unwrap_or_else(|p| p.into_inner());
81+
ticks
82+
.iter()
83+
.enumerate()
84+
.filter(|(_, last)| now.duration_since(**last) > threshold)
85+
.map(|(id, _)| id)
86+
.collect()
87+
}
88+
89+
/// True when the coordinated checkpoint has been stalling.
90+
pub fn checkpoint_stalled() -> bool {
91+
CHECKPOINT_STALLED.load(Ordering::Relaxed)
92+
}
93+
94+
/// Aggregate runtime health: no stalled cores and no checkpoint stall.
95+
pub fn runtime_healthy() -> bool {
96+
stalled_core_ids(CORE_STALL_THRESHOLD).is_empty() && !checkpoint_stalled()
97+
}
98+
99+
/// Human-readable reason for the first unhealthy signal found, for
100+
/// `HealthState::Degraded { reason }`.
101+
pub fn unhealthy_reason() -> &'static str {
102+
let stalled = stalled_core_ids(CORE_STALL_THRESHOLD);
103+
if !stalled.is_empty() {
104+
// Static prefix; the core ids are attached by the caller if needed.
105+
"data plane core stalled (no event-loop tick)"
106+
} else if checkpoint_stalled() {
107+
"checkpoint stalled (consecutive failed cycles)"
108+
} else {
109+
"unknown runtime health degradation"
110+
}
111+
}
112+
113+
#[cfg(test)]
114+
mod tests {
115+
use super::*;
116+
117+
#[test]
118+
fn fresh_cores_are_healthy() {
119+
record_core_tick(0);
120+
record_core_tick(1);
121+
assert!(runtime_healthy());
122+
assert!(stalled_core_ids(CORE_STALL_THRESHOLD).is_empty());
123+
}
124+
125+
#[test]
126+
fn checkpoint_failures_raise_stall_after_threshold() {
127+
assert!(!record_checkpoint_result(false));
128+
assert!(!record_checkpoint_result(false));
129+
// Third consecutive failure crosses the threshold exactly once.
130+
assert!(record_checkpoint_result(false));
131+
assert!(checkpoint_stalled());
132+
// A success clears the flag and the counter.
133+
assert!(!record_checkpoint_result(true));
134+
assert!(!checkpoint_stalled());
135+
assert!(!record_checkpoint_result(false));
136+
assert!(!checkpoint_stalled()); // counter restarted at 1
137+
}
138+
139+
#[test]
140+
fn unregistered_core_is_not_stalled() {
141+
// No tick ever recorded for core 7 — must not appear as stalled.
142+
assert!(stalled_core_ids(CORE_STALL_THRESHOLD).is_empty());
143+
}
144+
}

‎nodedb/src/control/startup/health.rs‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,37 @@ pub enum HealthState {
2525
Ok,
2626
/// Startup failed; includes the original error.
2727
Failed { error: Arc<StartupError> },
28+
/// Boot completed but the runtime is unhealthy: a data-plane core stopped
29+
/// ticking or the coordinated checkpoint is stalling (B4/B5, 2026-08-26).
30+
/// The node may still answer some requests, but writes/durability are at
31+
/// risk — surfaced as `503 degraded` on HTTP and "Degraded" on STATUS.
32+
Degraded { reason: &'static str },
2833
}
2934

30-
/// Read the current health from `gate`.
35+
/// Read the current health from `gate` and the runtime health registry.
3136
pub fn observe(gate: &StartupGate) -> HealthState {
3237
if let Some(err) = gate.is_failed() {
3338
return HealthState::Failed { error: err };
3439
}
3540
let phase = gate.current_phase();
3641
if phase >= StartupPhase::GatewayEnable {
42+
// Boot is done — fold in runtime signals so a silent wedge degrades
43+
// the health surfaces instead of reporting Ok forever.
44+
if !crate::bridge::runtime_health::runtime_healthy() {
45+
let reason = crate::bridge::runtime_health::unhealthy_reason();
46+
let stalled = crate::bridge::runtime_health::stalled_core_ids(
47+
crate::bridge::runtime_health::CORE_STALL_THRESHOLD,
48+
);
49+
if !stalled.is_empty() {
50+
tracing::error!(
51+
?stalled,
52+
"runtime health degraded: data plane core(s) stalled"
53+
);
54+
} else {
55+
tracing::error!("runtime health degraded: {}", reason);
56+
}
57+
return HealthState::Degraded { reason };
58+
}
3759
HealthState::Ok
3860
} else {
3961
HealthState::Starting { phase }
@@ -47,7 +69,7 @@ pub fn observe(gate: &StartupGate) -> HealthState {
4769
/// HTTP status code and JSON body for the given health state.
4870
///
4971
/// - `200 OK` when [`HealthState::Ok`]
50-
/// - `503 Service Unavailable` when starting or failed
72+
/// - `503 Service Unavailable` when starting, degraded, or failed
5173
pub fn to_http_response(state: &HealthState) -> (axum::http::StatusCode, serde_json::Value) {
5274
use axum::http::StatusCode;
5375
match state {
@@ -65,6 +87,14 @@ pub fn to_http_response(state: &HealthState) -> (axum::http::StatusCode, serde_j
6587
"phase": phase.name(),
6688
}),
6789
),
90+
HealthState::Degraded { reason } => (
91+
StatusCode::SERVICE_UNAVAILABLE,
92+
serde_json::json!({
93+
"status": "degraded",
94+
"reason": reason,
95+
"phase": StartupPhase::GatewayEnable.name(),
96+
}),
97+
),
6898
HealthState::Failed { error } => (
6999
StatusCode::SERVICE_UNAVAILABLE,
70100
serde_json::json!({
@@ -84,6 +114,7 @@ pub fn to_http_response(state: &HealthState) -> (axum::http::StatusCode, serde_j
84114
pub enum NativeStatus {
85115
Starting,
86116
Ok,
117+
Degraded,
87118
Failed,
88119
}
89120

@@ -92,6 +123,7 @@ pub fn to_native_status(state: &HealthState) -> NativeStatus {
92123
match state {
93124
HealthState::Ok => NativeStatus::Ok,
94125
HealthState::Starting { .. } => NativeStatus::Starting,
126+
HealthState::Degraded { .. } => NativeStatus::Degraded,
95127
HealthState::Failed { .. } => NativeStatus::Failed,
96128
}
97129
}
@@ -101,6 +133,7 @@ impl std::fmt::Display for NativeStatus {
101133
match self {
102134
Self::Ok => f.write_str("OK"),
103135
Self::Starting => f.write_str("Starting"),
136+
Self::Degraded => f.write_str("Degraded"),
104137
Self::Failed => f.write_str("Failed"),
105138
}
106139
}
@@ -159,6 +192,22 @@ mod tests {
159192
fn native_status_display() {
160193
assert_eq!(NativeStatus::Ok.to_string(), "OK");
161194
assert_eq!(NativeStatus::Starting.to_string(), "Starting");
195+
assert_eq!(NativeStatus::Degraded.to_string(), "Degraded");
162196
assert_eq!(NativeStatus::Failed.to_string(), "Failed");
163197
}
198+
199+
#[test]
200+
fn to_http_response_503_when_degraded() {
201+
let state = HealthState::Degraded {
202+
reason: "checkpoint stalled (consecutive failed cycles)",
203+
};
204+
let (code, body) = to_http_response(&state);
205+
assert_eq!(code, axum::http::StatusCode::SERVICE_UNAVAILABLE);
206+
assert_eq!(body["status"], "degraded");
207+
assert_eq!(
208+
body["reason"],
209+
"checkpoint stalled (consecutive failed cycles)"
210+
);
211+
assert_eq!(to_native_status(&state), NativeStatus::Degraded);
212+
}
164213
}

‎nodedb/src/data/runtime/event_loop.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ pub(super) fn run_event_loop(
4848
// Drain all accumulated signals.
4949
while efd.drain() > 0 {}
5050

51+
// B4: per-core heartbeat. Recorded every iteration (before any work)
52+
// so a loop stuck inside a request still trips the stall detector
53+
// instead of wedging silently — the 12:23 wedge shape (2026-08-26).
54+
crate::bridge::runtime_health::record_core_tick(core_id);
55+
5156
// If degraded, drain and reject all pending requests.
5257
if watchdog.is_degraded() {
5358
drain_and_reject(core, core_id);

0 commit comments

Comments
 (0)