tidaldb/tidal-net/src/circuit_breaker.rs
jx12n 1b5bcbacd7 fix(net): classify ship deadline as timeout, not partition (write-burst false-partition)
A client-side ship DEADLINE means the RPC did not round-trip within
request_timeout — which a slow-but-ALIVE follower produces under a sustained
1536-D ack=quorum apply burst (transport runtime momentarily starved by the
CPU-heavy HNSW apply on its single segment-receiver thread) exactly as a
genuinely blackholed peer does. Counting that as record_failure was the
write-burst false-partition: 5 such opened both followers' breakers, the commit
index stalled, ack=quorum 503'd, and retries re-burst the same starved peers
with no self-heal.

- CircuitBreaker::record_timeout: opens ONLY when the peer shows no recent proof
  of life (no round-tripped success/backpressure within reset_duration); neutral
  no-op when liveness is fresh; re-opens (never wedges) HalfOpen; never refreshes
  the liveness stamp (no reply arrived).
- PeerPool::send_to routes tonic DeadlineExceeded/Cancelled -> record_timeout;
  genuine severance still surfaces as connect-level Unavailable/transport reset
  -> record_failure and still opens the breaker.
- ship_timeout_breaker.rs: end-to-end proof over a REAL tonic WalShipping server
  (handler succeeds once then hangs past the client deadline) + 6 unit tests.

Also: re-scope G-S Scalability guarantee to read-throughput with the Ref-A
tidal-t5-readtput owner-test (write 2.5x is structurally impossible on 3-node
full-placement RF3); bump k8s image to m12-rc6 (live, commit 0919b0a); rustfmt
soak_eval / soak-eval / s3 / tidalctl.
2026-06-19 16:25:50 -06:00

744 lines
31 KiB
Rust

//! Per-peer circuit breaker for gRPC connections.
//!
//! State machine: Closed → Open → `HalfOpen` → Closed.
//! After `threshold` consecutive failures, the breaker opens for `reset_duration`.
//! The first [`check`](CircuitBreaker::check) after the reset period transitions
//! to `HalfOpen` and admits **exactly one** probe: that single `check()` returns
//! `Ok(())`, and every subsequent `check()` returns [`CircuitOpenError`] until the
//! outstanding probe resolves via [`record_success`](CircuitBreaker::record_success)
//! (→ Closed) or [`record_failure`](CircuitBreaker::record_failure) (→ Open again).
//! This single-probe invariant is enforced by the state machine itself, not by a
//! separate flag: `check()` returns `Ok(())` only on the `Open → HalfOpen`
//! transition and errors for every call while already in `HalfOpen`, so being in
//! the fieldless `HalfOpen` variant *is* the "a probe is outstanding" marker. No
//! `in_flight` field is needed (or present).
//!
//! Struct-shaped error: `CircuitOpenError` carries no fields and represents the single "breaker is open" outcome; an enum with one variant would be ceremony with no readability gain.
use std::{
sync::Mutex,
time::{Duration, Instant},
};
/// Error returned when the circuit breaker is open and not yet ready to probe.
#[derive(Debug, Clone, thiserror::Error)]
#[error("circuit breaker is open")]
pub struct CircuitOpenError;
/// A read-only snapshot of the breaker's state for observability (m11p8).
///
/// Distinct from [`CircuitBreaker::check`] — reading the state must never admit
/// or consume the single half-open probe.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BreakerState {
/// Requests flow normally.
Closed,
/// Open: requests are rejected until the reset period elapses.
Open,
/// A single probe has been admitted and is in flight.
HalfOpen,
}
impl BreakerState {
/// The metric encoding: 0 closed, 1 open, 2 half-open (matches the
/// `tidaldb_cluster_peer_breaker_state` gauge).
#[must_use]
pub const fn as_gauge(self) -> u8 {
match self {
Self::Closed => 0,
Self::Open => 1,
Self::HalfOpen => 2,
}
}
}
/// A circuit breaker that tracks consecutive failures for a single peer.
pub struct CircuitBreaker {
state: Mutex<CircuitState>,
threshold: u32,
reset_duration: Duration,
/// Wall-time of the last gRPC round-trip that PROVED the peer is alive: a
/// `record_success` (segment accepted) OR a `record_backpressure` (the peer
/// replied `accepted=false` — its queue was full, but the RPC round-tripped,
/// so its gRPC server is up). `record_failure` (a genuine transport error)
/// deliberately does NOT refresh this: a dead/partitioned peer must let it
/// go stale. Read by the leader's `/cluster/status` aggregator to tell a
/// SLOW-but-alive peer (HTTP control-plane starved under an apply burst, yet
/// still acking replication) apart from a GENUINELY unreachable one — the
/// false-partition fix. `None` until the first round-trip.
last_contact: Mutex<Option<Instant>>,
}
#[derive(Debug)]
enum CircuitState {
Closed {
consecutive_failures: u32,
},
Open {
opened_at: Instant,
},
/// A single probe has been admitted and is in flight. `check()` returns
/// `Ok(())` exactly once on the Open→`HalfOpen` transition; while in this
/// state every further `check()` errors until `record_success` (→ Closed)
/// or `record_failure` (→ Open) resolves the outstanding probe.
HalfOpen,
}
impl CircuitBreaker {
/// Create a new circuit breaker.
#[must_use]
pub const fn new(threshold: u32, reset_duration: Duration) -> Self {
Self {
state: Mutex::new(CircuitState::Closed {
consecutive_failures: 0,
}),
threshold,
reset_duration,
last_contact: Mutex::new(None),
}
}
/// Stamp "the peer responded just now" — called on any gRPC round-trip that
/// reached the peer (success or backpressure). Fail-soft on lock poisoning
/// (skip the stamp; a missed refresh only makes a live peer look slightly
/// staler, never falsely alive).
fn touch_contact(&self) {
if let Ok(mut last) = self.last_contact.lock() {
*last = Some(Instant::now());
}
}
/// How long since the last gRPC round-trip that proved the peer alive, or
/// `None` if it has never responded. The leader's status aggregator treats a
/// peer as reachable-despite-HTTP-timeout iff this is `Some` and recent.
///
/// Read-only; never admits or consumes the half-open probe. Returns `None`
/// (treated as "no recent contact") on lock poisoning — fail toward the
/// honest unreachable verdict rather than masking a real partition.
#[must_use]
pub fn last_contact_elapsed(&self) -> Option<Duration> {
self.last_contact
.lock()
.ok()
.and_then(|g| g.map(|t| t.elapsed()))
}
/// Check if a request is allowed.
///
/// Returns `Ok(())` if the breaker is closed, or on the single Open→`HalfOpen`
/// transition that admits one probe, and `Ok(())` on lock poisoning (fail-open:
/// allow the request through).
///
/// # Half-open single-probe invariant
///
/// The first `check()` after `reset_duration` elapses transitions Open→`HalfOpen`
/// and returns `Ok(())` — that is the one admitted probe. While that probe is
/// outstanding the breaker stays `HalfOpen`, and **every subsequent `check()`
/// returns [`CircuitOpenError`]** until the probe resolves via
/// [`record_success`](Self::record_success) (→ Closed) or
/// [`record_failure`](Self::record_failure) (→ Open). N concurrent callers can
/// therefore never all probe a peer that just failed; exactly one gets through.
///
/// # Errors
///
/// Returns [`CircuitOpenError`] if the breaker is open and the reset period
/// has not yet elapsed, or if it is `HalfOpen` with a probe already in flight.
pub fn check(&self) -> Result<(), CircuitOpenError> {
let Ok(mut state) = self.state.lock() else {
tracing::warn!("circuit breaker lock poisoned; failing open");
return Ok(());
};
match *state {
CircuitState::Closed { .. } => Ok(()),
// A probe is already outstanding; admit no more until it resolves.
CircuitState::HalfOpen => Err(CircuitOpenError),
CircuitState::Open { opened_at } => {
if opened_at.elapsed() >= self.reset_duration {
// Admit exactly one probe: the transition itself is the
// single Ok. The next `check()` will see `HalfOpen` and error
// until this probe's record_success/record_failure resolves it.
*state = CircuitState::HalfOpen;
Ok(())
} else {
Err(CircuitOpenError)
}
}
}
}
/// Peek the current state for observability WITHOUT admitting a probe.
///
/// Unlike [`check`](Self::check), this never transitions Open→HalfOpen — it
/// reports `Open` even once `reset_duration` has elapsed but no probe has yet
/// been admitted (the "tripped, awaiting first probe" logical state). For a
/// metrics gauge that distinction is immaterial; what matters is that reading
/// the gauge never steals the single probe `check()` would admit. Fail-open
/// (reports `Closed`) on lock poisoning, consistent with `check`.
#[must_use]
pub fn query_state(&self) -> BreakerState {
let Ok(state) = self.state.lock() else {
return BreakerState::Closed;
};
match *state {
CircuitState::Closed { .. } => BreakerState::Closed,
CircuitState::Open { .. } => BreakerState::Open,
CircuitState::HalfOpen => BreakerState::HalfOpen,
}
}
/// Record a successful request. Resets the failure count.
pub fn record_success(&self) {
// The peer round-tripped an accepted segment: it is alive. Stamp this
// BEFORE touching the state machine so the liveness signal is refreshed
// even if the state lock is poisoned below.
self.touch_contact();
let Ok(mut state) = self.state.lock() else {
tracing::warn!("circuit breaker lock poisoned; ignoring success");
return;
};
*state = CircuitState::Closed {
consecutive_failures: 0,
};
}
/// Record a follower backpressure reply (`accepted=false`).
///
/// Backpressure is normal flow control from a healthy-but-busy follower whose
/// inbound channel is full — the WAL is durable on the leader and the follower
/// catches up once capacity frees. Counting it as a failure would let routine
/// congestion open the breaker and convert transient backpressure into a
/// self-inflicted `reset_duration` replication stall (see `error.rs`:
/// `SegmentRejected` is classified transient). Only genuine transport/gRPC
/// errors call [`record_failure`](Self::record_failure).
///
/// Effect by state:
/// - **Closed:** no-op. It neither opens the breaker nor resets a genuine
/// failure streak (a follower alternating real errors with backpressure must
/// still trip the breaker — see `backpressure_does_not_reset_failure_streak`).
/// - **`HalfOpen`:** **resolves the outstanding probe by closing the breaker.**
/// A backpressure reply proves the *transport* is healthy (the RPC round-tripped;
/// only the follower's queue was full), so the half-open probe succeeded at the
/// layer the breaker protects. Resolving it is mandatory: under the single-probe
/// invariant a `HalfOpen` breaker admits no further `check()` until the probe is
/// resolved, so a no-op here would wedge the breaker permanently the first time a
/// probe drew a backpressure reply.
/// - **Open:** no-op (no probe to resolve).
pub fn record_backpressure(&self) {
// A backpressure reply (`accepted=false`) means the RPC round-tripped —
// only the follower's queue was full. That PROVES the peer's gRPC server
// is alive, so refresh the liveness stamp here exactly as `record_success`
// does. This is the load-bearing case for the false-partition fix: under a
// sustained apply burst the follower's inbound channel fills and every
// ship draws backpressure, so success stamps stop — but the peer is very
// much alive and this keeps its liveness fresh.
self.touch_contact();
let Ok(mut state) = self.state.lock() else {
tracing::warn!("circuit breaker lock poisoned; ignoring backpressure");
return;
};
// Only a HalfOpen probe is resolved; Closed/Open are left untouched so
// backpressure never opens the breaker nor resets a genuine failure streak.
if matches!(*state, CircuitState::HalfOpen) {
*state = CircuitState::Closed {
consecutive_failures: 0,
};
}
}
/// Record a failed request. Increments the failure count and opens if threshold reached.
pub fn record_failure(&self) {
let Ok(mut state) = self.state.lock() else {
tracing::warn!("circuit breaker lock poisoned; ignoring failure");
return;
};
match *state {
CircuitState::Closed {
consecutive_failures,
} => {
let new_count = consecutive_failures + 1;
if new_count >= self.threshold {
*state = CircuitState::Open {
opened_at: Instant::now(),
};
} else {
*state = CircuitState::Closed {
consecutive_failures: new_count,
};
}
}
CircuitState::HalfOpen => {
// Probe failed; re-open.
*state = CircuitState::Open {
opened_at: Instant::now(),
};
}
CircuitState::Open { .. } => {
// Already open, nothing to do.
}
}
}
/// Record a ship RPC that exceeded its deadline WITHOUT a reply
/// (`tonic::Code::DeadlineExceeded`) — distinct from a genuine transport error.
///
/// A timeout is AMBIGUOUS: a slow-but-ALIVE follower and a genuinely blackholed
/// peer both produce it. Under a sustained 1536-D apply burst a follower's
/// transport runtime can be momentarily starved by the CPU-heavy HNSW apply on
/// its single segment-receiver thread, so an individual ship misses the leader's
/// `request_timeout` even though the follower is up and still acking other ships.
/// Counting that as a [`record_failure`](Self::record_failure) is the write-burst
/// false-partition: both followers' breakers latch Open for `reset_duration`, the
/// commit index stalls, ack=quorum writes 503, and the retries re-burst the same
/// starved followers — no self-heal.
///
/// We disambiguate with the same liveness stamp the status aggregator uses: a
/// timeout received no reply so it does NOT refresh `last_contact`; instead it
/// opens the breaker ONLY when there is no RECENT proof of life (no round-tripped
/// success/backpressure within `reset_duration`). That preserves genuine-blackhole
/// detection (a peer that round-trips nothing for `reset_duration` IS effectively
/// unreachable, and a real severance also surfaces as a connect-level
/// `Unavailable`/transport error → [`record_failure`](Self::record_failure), not a
/// deadline) while a follower that keeps acking (success/backpressure refresh the
/// stamp) is never ejected for a slow ship.
///
/// Effect by state:
/// - **Closed, recent contact:** neutral no-op — neither advances toward Open nor
/// resets a genuine failure streak (mirrors [`record_backpressure`](Self::record_backpressure)).
/// - **Closed, stale/no contact:** counts toward Open exactly like a failure.
/// - **`HalfOpen`:** re-opens (conservative) — a timed-out probe proves nothing and
/// must not wedge `HalfOpen` (which would admit no further probes).
/// - **Open:** no-op.
pub fn record_timeout(&self) {
// No reply arrived, so (unlike success/backpressure) we do NOT touch_contact.
let recent = self
.last_contact_elapsed()
.is_some_and(|e| e < self.reset_duration);
let Ok(mut state) = self.state.lock() else {
tracing::warn!("circuit breaker lock poisoned; ignoring timeout");
return;
};
match *state {
CircuitState::Closed {
consecutive_failures,
} => {
if recent {
tracing::debug!(
"ship deadline-exceeded but peer proved liveness within reset window; \
classifying slow-but-alive (breaker neutral)"
);
} else {
tracing::debug!(
"ship deadline-exceeded with stale liveness; counting toward breaker open"
);
let new_count = consecutive_failures + 1;
*state = if new_count >= self.threshold {
CircuitState::Open {
opened_at: Instant::now(),
}
} else {
CircuitState::Closed {
consecutive_failures: new_count,
}
};
}
}
CircuitState::HalfOpen => {
// Re-open rather than leave a wedged HalfOpen: a timed-out probe is
// inconclusive, and the reset timer re-arms for the next probe.
*state = CircuitState::Open {
opened_at: Instant::now(),
};
}
CircuitState::Open { .. } => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn closed_allows_requests() {
let cb = CircuitBreaker::new(3, Duration::from_secs(30));
assert!(cb.check().is_ok());
}
#[test]
fn opens_after_threshold_failures() {
let cb = CircuitBreaker::new(3, Duration::from_secs(30));
cb.record_failure();
cb.record_failure();
assert!(cb.check().is_ok()); // 2 failures, threshold is 3
cb.record_failure();
assert!(cb.check().is_err()); // 3 failures, now open
}
#[test]
fn success_resets_failure_count() {
let cb = CircuitBreaker::new(3, Duration::from_secs(30));
cb.record_failure();
cb.record_failure();
cb.record_success();
cb.record_failure();
cb.record_failure();
assert!(cb.check().is_ok()); // reset happened, only 2 failures since
}
#[test]
fn transitions_to_half_open_after_reset() {
let cb = CircuitBreaker::new(2, Duration::from_millis(1));
cb.record_failure();
cb.record_failure();
assert!(cb.check().is_err()); // open
std::thread::sleep(Duration::from_millis(5));
assert!(cb.check().is_ok()); // half-open after reset period
}
#[test]
fn half_open_failure_reopens() {
let cb = CircuitBreaker::new(2, Duration::from_millis(1));
cb.record_failure();
cb.record_failure();
std::thread::sleep(Duration::from_millis(5));
assert!(cb.check().is_ok()); // half-open
cb.record_failure(); // probe failed
assert!(cb.check().is_err()); // re-opened
}
#[test]
fn half_open_admits_exactly_one_probe() {
// Single-probe invariant: once `check()` transitions Open→HalfOpen and
// returns Ok (the one admitted probe), every subsequent `check()` must
// error until the probe resolves. Without this, N concurrent callers in
// half-open would all probe a peer that just failed.
let cb = CircuitBreaker::new(2, Duration::from_millis(1));
cb.record_failure();
cb.record_failure();
assert!(cb.check().is_err(), "breaker should be open");
std::thread::sleep(Duration::from_millis(5));
// First check after reset admits the single probe.
assert!(
cb.check().is_ok(),
"first half-open check admits exactly one probe"
);
// The probe is still outstanding: no success/failure recorded yet.
assert!(
cb.check().is_err(),
"second half-open check must error while the probe is in flight"
);
assert!(
cb.check().is_err(),
"further half-open checks keep erroring until the probe resolves"
);
// Resolving the probe with success closes the breaker and re-admits traffic.
cb.record_success();
assert!(cb.check().is_ok(), "closed after probe succeeds");
}
#[test]
fn half_open_probe_failure_then_check_stays_closed_to_traffic() {
// The complementary resolution path: if the single probe fails, the
// breaker re-opens and `check()` errors (now because it is Open, not
// because a probe is in flight).
let cb = CircuitBreaker::new(2, Duration::from_millis(1));
cb.record_failure();
cb.record_failure();
std::thread::sleep(Duration::from_millis(5));
assert!(cb.check().is_ok(), "single probe admitted");
assert!(cb.check().is_err(), "second check errors: probe in flight");
cb.record_failure(); // probe failed → re-open
assert!(cb.check().is_err(), "re-opened after probe failure");
}
#[test]
fn half_open_success_closes() {
let cb = CircuitBreaker::new(2, Duration::from_millis(1));
cb.record_failure();
cb.record_failure();
std::thread::sleep(Duration::from_millis(5));
assert!(cb.check().is_ok()); // half-open
cb.record_success(); // probe succeeded
assert!(cb.check().is_ok()); // closed
}
#[test]
fn backpressure_never_opens_breaker() {
// CRITICAL-7 regression: a healthy-but-busy follower replying
// `accepted=false` is the path `send_to` routes to `record_backpressure`.
// Far more than `threshold` consecutive backpressure replies must leave
// the breaker closed so the leader keeps shipping (no self-inflicted
// replication stall).
let cb = CircuitBreaker::new(5, Duration::from_secs(30));
for _ in 0..10 {
cb.record_backpressure();
}
assert!(
cb.check().is_ok(),
"backpressure must not open the breaker; a subsequent send must still be permitted"
);
}
#[test]
fn half_open_backpressure_resolves_probe_and_closes() {
// A backpressure reply to the single half-open probe proves the transport
// is healthy (the RPC round-tripped; only the follower's queue was full),
// so it must resolve the probe by closing the breaker. Otherwise the
// single-probe invariant would wedge the breaker in HalfOpen forever the
// first time a probe drew `accepted=false`.
let cb = CircuitBreaker::new(2, Duration::from_millis(1));
cb.record_failure();
cb.record_failure();
std::thread::sleep(Duration::from_millis(5));
assert!(cb.check().is_ok(), "single probe admitted");
assert!(cb.check().is_err(), "second check errors: probe in flight");
cb.record_backpressure(); // probe round-tripped, follower just busy
assert!(
cb.check().is_ok(),
"backpressure resolves the half-open probe and re-admits traffic"
);
}
#[test]
fn genuine_failures_still_open_breaker() {
// The opposite arm of `send_to` (`Err(status)`) calls `record_failure`,
// which must still open the breaker after `threshold` consecutive
// transport errors — backpressure handling must not weaken this.
let cb = CircuitBreaker::new(5, Duration::from_secs(30));
for _ in 0..5 {
cb.record_failure();
}
assert!(
cb.check().is_err(),
"genuine transport errors must still open the breaker at threshold"
);
}
#[test]
fn backpressure_does_not_count_toward_open_among_failures() {
// Backpressure must be neutral: interleaving it with genuine failures
// neither advances the failure count toward the threshold nor resets it.
// Here 4 real failures + many backpressures stays below the 5-failure
// threshold (closed), and the 5th real failure opens it.
let cb = CircuitBreaker::new(5, Duration::from_secs(30));
for _ in 0..4 {
cb.record_failure();
cb.record_backpressure();
}
assert!(
cb.check().is_ok(),
"4 failures interleaved with backpressure must remain below threshold"
);
cb.record_failure(); // 5th genuine failure
assert!(
cb.check().is_err(),
"the 5th genuine failure must open the breaker"
);
}
#[test]
fn success_refreshes_last_contact() {
// The false-partition liveness signal: an accepted ship proves the peer
// is alive, so `last_contact_elapsed` becomes `Some(~0)`.
let cb = CircuitBreaker::new(3, Duration::from_secs(30));
assert!(
cb.last_contact_elapsed().is_none(),
"no contact before the first round-trip"
);
cb.record_success();
let elapsed = cb
.last_contact_elapsed()
.expect("a success stamps last_contact");
assert!(
elapsed < Duration::from_secs(1),
"freshly stamped contact must read as recent: {elapsed:?}"
);
}
#[test]
fn backpressure_refreshes_last_contact() {
// The LOAD-BEARING case for the apply-burst fix: under sustained
// backpressure the follower replies `accepted=false` (gRPC round-tripped,
// its queue was just full). That proves it is ALIVE, so the liveness
// stamp must refresh exactly as a success does — otherwise a busy-but-up
// follower would be misread as partitioned the moment success stamps stop.
let cb = CircuitBreaker::new(5, Duration::from_secs(30));
assert!(cb.last_contact_elapsed().is_none());
cb.record_backpressure();
let elapsed = cb
.last_contact_elapsed()
.expect("backpressure stamps last_contact (the peer responded)");
assert!(
elapsed < Duration::from_secs(1),
"backpressure contact must read as recent: {elapsed:?}"
);
}
#[test]
fn failure_does_not_refresh_last_contact() {
// A genuine transport error (a dead/partitioned peer) must NOT refresh
// the liveness stamp — that is what lets the leader's status aggregator
// tell a real partition (stamp goes stale) from a slow-but-alive peer.
let cb = CircuitBreaker::new(3, Duration::from_secs(30));
// First a real contact, so there IS a stamp to (not) refresh.
cb.record_success();
let after_success = cb.last_contact_elapsed().expect("stamped by success");
// Now several genuine failures: the stamp must only AGE, never reset.
for _ in 0..5 {
cb.record_failure();
}
let after_failures = cb
.last_contact_elapsed()
.expect("the prior stamp is retained, never cleared");
assert!(
after_failures >= after_success,
"failures must not move the stamp forward (it can only age): \
{after_failures:?} >= {after_success:?}"
);
}
#[test]
fn never_contacted_peer_has_no_stamp() {
// A peer the leader has never round-tripped reports no contact, so the
// status aggregator treats it as unreachable (the honest default — no
// evidence of life).
let cb = CircuitBreaker::new(3, Duration::from_secs(30));
assert!(
cb.last_contact_elapsed().is_none(),
"a peer with no gRPC round-trip has no liveness evidence"
);
}
#[test]
fn backpressure_does_not_reset_failure_streak() {
// Backpressure must not reset the consecutive-failure count the way
// `record_success` does — otherwise a follower alternating real errors
// with backpressure could never trip the breaker. 4 failures, a
// backpressure, then a 5th failure must open it.
let cb = CircuitBreaker::new(5, Duration::from_secs(30));
for _ in 0..4 {
cb.record_failure();
}
cb.record_backpressure();
assert!(cb.check().is_ok()); // still 4 failures, below threshold
cb.record_failure(); // 5th consecutive genuine failure
assert!(
cb.check().is_err(),
"backpressure must not reset the failure streak; the 5th failure opens the breaker"
);
}
#[test]
fn timeout_with_recent_contact_never_opens() {
// THE write-burst false-partition fix: a follower that round-tripped a reply
// recently (success/backpressure) is slow-but-ALIVE, so a burst of ship
// deadline-exceeds must NOT open the breaker. Far more than `threshold`
// timeouts stay closed while liveness is fresh.
let cb = CircuitBreaker::new(5, Duration::from_secs(30));
cb.record_success(); // recent proof of life
for _ in 0..20 {
cb.record_timeout();
}
assert!(
cb.check().is_ok(),
"timeouts from a peer with recent liveness must not open the breaker"
);
}
#[test]
fn timeout_without_proof_of_life_opens_like_failure() {
// Genuine-blackhole detection preserved: a peer the leader has never
// round-tripped (no liveness stamp) that only times out IS effectively
// unreachable, so timeouts accrue toward Open exactly like failures.
let cb = CircuitBreaker::new(5, Duration::from_secs(30));
for _ in 0..5 {
cb.record_timeout();
}
assert!(
cb.check().is_err(),
"timeouts with no proof of life must open the breaker (blackhole detection)"
);
}
#[test]
fn timeout_after_liveness_goes_stale_opens() {
// Recent contact suppresses timeout-driven opening; once the stamp ages past
// `reset_duration` with no round-trips at all, timeouts resume opening.
let cb = CircuitBreaker::new(3, Duration::from_millis(50));
cb.record_success(); // stamp liveness
std::thread::sleep(Duration::from_millis(60)); // stamp now older than reset_duration
for _ in 0..3 {
cb.record_timeout();
}
assert_eq!(
cb.query_state(),
BreakerState::Open,
"once liveness is stale, timeouts must open the breaker"
);
}
#[test]
fn timeout_does_not_refresh_last_contact() {
// A timeout received NO reply, so (unlike success/backpressure) it must not
// stamp liveness — otherwise a dead peer's own timeouts would keep it
// perpetually 'recent' and mask a genuine partition.
let cb = CircuitBreaker::new(3, Duration::from_secs(30));
assert!(cb.last_contact_elapsed().is_none());
cb.record_timeout();
assert!(
cb.last_contact_elapsed().is_none(),
"a timeout (no reply) must not refresh the liveness stamp"
);
}
#[test]
fn timeout_neutral_among_failures_with_recent_contact() {
// With recent contact a timeout is neutral: it neither advances the failure
// streak toward Open nor resets it. 4 failures + a backpressure (stamps
// recent liveness) + a timeout stays below threshold; the 5th failure opens.
let cb = CircuitBreaker::new(5, Duration::from_secs(30));
for _ in 0..4 {
cb.record_failure();
}
cb.record_backpressure(); // stamps recent liveness; neutral to the streak
cb.record_timeout(); // recent -> neutral, streak stays at 4
assert!(
cb.check().is_ok(),
"4 failures + a neutral timeout must stay below threshold"
);
cb.record_failure(); // 5th genuine failure
assert!(
cb.check().is_err(),
"the 5th genuine failure must still open the breaker"
);
}
#[test]
fn half_open_timeout_reopens_without_wedging() {
// A timed-out probe is inconclusive; it must re-open (re-arm the reset
// timer) rather than leave a wedged HalfOpen that admits no further probes.
let cb = CircuitBreaker::new(2, Duration::from_millis(1));
cb.record_failure();
cb.record_failure();
std::thread::sleep(Duration::from_millis(5));
assert!(cb.check().is_ok(), "half-open probe admitted");
assert_eq!(cb.query_state(), BreakerState::HalfOpen);
cb.record_timeout(); // probe timed out
assert_eq!(
cb.query_state(),
BreakerState::Open,
"a half-open timeout must re-open, never wedge HalfOpen"
);
}
}