Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0723f57
Drive circuit-breaker probes for validator notification streams with …
ndr-ds Aug 12, 2026
43b45be
Box the listener update-streams future to keep trait-solver Send chec…
ndr-ds Aug 12, 2026
a264550
Merge remote-tracking branch 'upstream/main' into ndr-ds/notification…
ndr-ds Sep 7, 2026
245e3e5
Require an elapsed probe deadline before declaring a validator recove…
ndr-ds Sep 7, 2026
6a6291e
Key circuit-breaker recovery on subscribe having resolved, and arm th…
ndr-ds Sep 11, 2026
0bddf6b
Keep the probe timer armed when an update fails, and pace the retry o…
ndr-ds Sep 14, 2026
042d829
Narrow retry_update_deadline back to private now that no test calls it
ndr-ds Sep 14, 2026
bb2ee4c
Count a healthy validator's subscribes, and correct three mutation cl…
ndr-ds Sep 14, 2026
cefd48d
Cover the in-loop retry arming, and drop three dead constructs
ndr-ds Sep 14, 2026
e390115
Say what the subscribed flag actually separates
ndr-ds Sep 14, 2026
1f5be8c
Key stream health on serving rather than subscribing, and arm every l…
ndr-ds Sep 14, 2026
97e1eee
Report a recovery only for a breaker that actually tripped
ndr-ds Sep 15, 2026
d190457
Charge a stream's first failure at the configured interval, not double
ndr-ds Sep 15, 2026
a8ec5ec
Pin the backoff factor exactly instead of bounding it on one side
ndr-ds Sep 15, 2026
25661e5
Bound probe deadlines on both sides, and cover the per-validator spread
ndr-ds Sep 15, 2026
b236b0d
Cover the retry-deadline clear and the escalation cap
ndr-ds Sep 15, 2026
30f9a7e
Assert the recovery barrier instead of discarding it
ndr-ds Sep 15, 2026
bcb3a88
Sample the clock after the committee read, and make recovery symmetri…
ndr-ds Sep 16, 2026
7c82cdb
Re-arm an elapsed deadline for a stream that is serving but not yet h…
ndr-ds Sep 16, 2026
72994cd
Pin the re-arm instant, and assert the empty-map precondition
ndr-ds Sep 16, 2026
6e71c22
Elapse the briefly-serving fixture by a real margin
ndr-ds Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 136 additions & 37 deletions linera-core/src/client/chain_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,18 @@ pub struct Options {
pub max_event_stream_queries: usize,
}

struct CircuitBreakerState {
next_probe_at: Timestamp,
probe_interval: Duration,
pub(super) struct CircuitBreakerState {
pub(super) next_probe_at: Timestamp,
pub(super) probe_interval: Duration,
}

/// A boxed per-validator notification stream task. `Send` on native targets; on web
/// the validator node types are not `Send` (there is only one thread).
#[cfg(not(web))]
type NotificationStreamTask = future::BoxFuture<'static, ()>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the third copy of the cfg(web) / cfg(not(web)) boxed-future alias pair in this crate: BatchFuture (worker.rs:593-595) is byte-for-byte identical, and CertificateBatchFuture (client/mod.rs:685-690) is the same shape with a different output. A single crate-level MaybeSendBoxFuture<T> would replace all three and keep the Send policy in one place.

#[cfg(web)]
type NotificationStreamTask = future::LocalBoxFuture<'static, ()>;

/// A fingerprint of the chain manager's observable consensus state, used to detect
/// whether a per-validator updater absorbed new info during a proposal attempt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -3266,49 +3273,123 @@ impl<Env: Environment> ChainClient<Env> {
.update_notification_streams(&mut senders, &mut circuit_breakers)
.await
{
Ok(handler) => process_notifications.push(handler),
Ok(tasks) => process_notifications.extend(tasks),
Err(error) => error!("Failed to update committee: {error}"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startup update still only logs its error, so the listener can begin life permanently deaf.

If this call fails — a transient storage error in local_committee() / admin_committee() — senders and circuit_breakers stay empty, process_notifications is empty, and retry_update_at is initialised to None just below (3291). So next_probe_at is None, probe_due is future::pending(), and the only live select! branch is abortable_notifications — which can never yield a NewBlock, because there are no validator streams to carry one.

ChainListener calls listen() once per chain and never retries (linera-client/src/chain_listener.rs:541), so that chain stays deaf until the process restarts: the same absorbing state as the incident this PR fixes, reached from a different direction.

Arming retry_update_at here, the way the in-loop Err branch now does, closes it.

};

let this = self.clone();
let update_streams = async move {
let mut abortable_notifications = abortable_notifications.fuse();

while let Some(notification) =
await_while_polling(abortable_notifications.next(), &mut process_notifications)
.await
{
if let Reason::NewBlock { .. } = notification.reason {
match Box::pin(await_while_polling(
this.update_notification_streams(&mut senders, &mut circuit_breakers)
.fuse(),
&mut process_notifications,
))
.await
{
Ok(handler) => process_notifications.push(handler),
Err(error) => error!("Failed to update committee: {error}"),
// Boxed (type-erased): the loop below nests enough futures that leaving the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments record history rather than the final state of the code: the (E0275, seen in the bridge e2e workspace) here, the 2026-08-12 PM control-chain incident at client_tests.rs:163-166, and the mutation-testing narratives at client_tests.rs:269-273 and 5842-5850.

The mechanism halves are worth keeping ("boxed because the nested futures overflow the trait solver when proving Send"). The archaeology dates the code — once the trait-solver limit, the incident, or the mutation set moves, these read as current and nothing signals that they need updating. The PR description is the right home for it, and it is already there.

// concrete type exposed makes downstream `tokio::spawn` calls overflow the
// trait solver when proving `Send` (E0275, seen in the bridge e2e workspace).
let update_streams: NotificationStreamTask = Box::pin(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NotificationStreamTask is documented just above as "a boxed per-validator notification stream task", but here it is the type of the whole listener loop — which is neither per-validator nor a stream task.

Worth a second alias, or a neutral name, so that a later refactor narrowing this one (say, to carry the validator key alongside the future) does not have to untangle the two uses first.

async move {
let mut abortable_notifications = abortable_notifications.fuse();
// Set when an update attempt fails, to the time at which to retry it.
// Updates fail before touching the breaker state, so without this a
// failure would either spin on a past probe deadline or leave ended
// streams unregistered with no timer to ever pick them up.
let mut retry_update_at: Option<Timestamp> = None;

'listen: loop {
// Deadline of the earliest scheduled circuit-breaker probe, if any.
// Waking on it is what allows a chain with no live notification
// streams to repair them: `update_notification_streams` would
// otherwise only run again on this chain's next `NewBlock`, which may
// never arrive precisely because every stream is dead and the client
// cannot hear the traffic that would produce one.
// While updates are failing, probes cannot run anyway (they are
// launched by the update), so the retry deadline governs alone.
let next_probe_at = retry_update_at

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or_else masks the breaker deadlines rather than taking the minimum of both, so one failed committee read postpones every already-armed probe.

If a breaker has next_probe_at = now + 10s and an unrelated transient storage failure sets retry_update_at = now + 300s, Option::or_else short-circuits: the loop sleeps 300s and the probe that was due in 10s is 290s late. The comment argues probes cannot run while updates are failing, but the two are not causally linked — the retry deadline is set by one failed attempt, and the next attempt may well succeed.

min(retry_update_at, breaker deadlines) is strictly correct and lets the comment go away. Separately: retry_update_at re-arms at a flat initial_probe_interval on every failure, so unlike the probes it is masking, it never backs off.

.or_else(|| circuit_breakers.values().map(|s| s.next_probe_at).min());
let probe_due = async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timer future is rebuilt on every iteration, including the very common path where a non-NewBlock notification arrives, update_now is false and we continue.

Under TestClock each abandoned sleep_until leaves an orphaned oneshot::Sender in TestClockInner::sleeps, reaped only when the clock advances past that timestamp — so a test with an armed breaker and heavy notification traffic accumulates entries in the shared map. Hoisting a single timer outside the loop and resetting it only when next_probe_at actually changes avoids both the churn and the orphans.

match next_probe_at {
Some(deadline) => {
this.storage_client().clock().sleep_until(deadline).await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With --notification-circuit-breaker-initial-probe-interval-ms=0 this becomes a CPU-bound busy loop, which is a regression over main.

I read the note about leaving the flag unclamped for consistency with --retry-delay-ms / --max-backoff-ms, and I agree the flag itself should not change here — but the meaning of 0 does change, and not in the "disable the throttle" direction the siblings have.

The breaker is created with probe_interval = 0, next_probe_at = now; sleep_until(now) returns immediately; now < next_probe_at is false so a probe launches; the re-arm sets next_probe_at = now + 0 = now; and escalation computes (ZERO * 2).min(max) == ZERO forever. The outer loop then spins at CPU speed, and the inner loop never satisfies !senders.values().any(is_aborted) for a down validator, so it hammers subscribe + synchronize_chain_state_from continuously.

On main, 0 merely meant "probe on every NewBlock" — bounded by chain activity. No caller sets it today, so this is not urgent, but a floor applied where the timer consumes the interval would keep 0 meaning "no backoff" instead of "spin", without touching the flag or its parser.

}
None => future::pending().await,
}
}
.fuse();
tokio::pin!(probe_due);

let update_now = futures::select! {
maybe_notification = abortable_notifications.next() => {
match maybe_notification {
// Re-subscribe to validators on new blocks to handle
// committee changes.
Some(notification) => {
matches!(notification.reason, Reason::NewBlock { .. })
}
None => break 'listen,
}
}
// A validator notification stream task finished: update now, so
// that the ended stream is registered in the circuit breaker and
// a probe gets scheduled even if this chain never produces
// another block.
_ = process_notifications.select_next_some() => true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the tasks are individual, a simultaneous loss of every stream on a chain lands N completions here, and each one drives its own update_notification_streams. Coalescing happens only if await_while_polling drains the others while the update is suspended; when local_committee() resolves without yielding, the remaining completions each run a full update of their own.

That is an Nx amplification per chain at precisely the moment a fleet-wide event has hit every chain on the worker — and each of those updates can, through the BlobsNotFound fallback, become a quorum synchronize_chain_state(). Draining the completions that are already ready before deciding to update would bound it to one pass.

// A circuit-breaker probe is due.
_ = probe_due => true,
};
if !update_now {
continue;
}
// A stream can also end while an update is in flight; its wake-up is
// then consumed by `await_while_polling` below. Update again until no
// ended stream remains unregistered, so that the probe deadline above
// is armed whenever there is something left to repair.
loop {
match Box::pin(await_while_polling(
this.update_notification_streams(&mut senders, &mut circuit_breakers)
.fuse(),
&mut process_notifications,
))
.await
{
Ok(tasks) => {
retry_update_at = None;
process_notifications.extend(tasks);
}
Err(error) => {
error!("Failed to update committee: {error}");
let now = this.storage_client().clock().current_time();
retry_update_at = Some(
now.saturating_add(TimeDelta::from_duration(
this.options
.notification_circuit_breaker_initial_probe_interval,
)),
);
break;
}
}
if !senders.values().any(|abort| abort.is_aborted()) {
break;
}
}
}
}

for abort in senders.into_values() {
abort.abort();
}
for abort in senders.into_values() {
abort.abort();
}

let () = process_notifications.collect().await;
}
.in_current_span();
let () = process_notifications.collect().await;
}
.in_current_span(),
);

Ok((update_streams, AbortOnDrop(abort), notifications))
}

/// Creates or repairs the per-validator notification stream tasks for this chain,
/// and updates the circuit-breaker state for validators whose streams have ended.
/// Returns the newly created stream tasks; the caller is responsible for polling
/// them.
#[instrument(level = "trace", skip(senders, circuit_breakers))]
async fn update_notification_streams(
pub(super) async fn update_notification_streams(
&self,
senders: &mut HashMap<ValidatorPublicKey, AbortHandle>,
circuit_breakers: &mut HashMap<ValidatorPublicKey, CircuitBreakerState>,
) -> Result<impl Future<Output = ()>, Error> {
) -> Result<Vec<NotificationStreamTask>, Error> {
let initial_probe_interval = self
.options
.notification_circuit_breaker_initial_probe_interval;
Expand Down Expand Up @@ -3366,8 +3447,18 @@ impl<Env: Environment> ChainClient<Env> {
"Validator notification stream ended; entering circuit breaker"
);
}
} else if !abort.is_aborted() && circuit_breakers.contains_key(validator) {
// Stream alive while in circuit breaker -> probe succeeded -> recovered.
} else if !abort.is_aborted()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because recovery now requires a full elapsed interval, any stream that dies sooner than the current interval escalates the backoff — so a healthy-but-churny validator ratchets to the cap and never escapes.

Consider a proxy or load balancer with a ~30 min idle-stream timeout, i.e. the same routine churn that produced the Validator notification stream ended lines in the incident. Probe at T succeeds, next_probe_at = T + I, stream dies at T + 30min. Once I has grown past 30 min, recovery is never declared, so the death lands in the escalation branch and doubles I instead of resetting it. Repeat, and probe_interval pins at max_probe_interval (1h) permanently: a fundamentally healthy validator is re-subscribed at most once an hour, and the client is deaf to it for up to 59 minutes after every routine reset.

The coupling is that recovery means "survived an interval" rather than "delivered something"; resetting probe_interval once a subscription actually produces a notification would break the ratchet.

&& circuit_breakers
.get(validator)
.is_some_and(|state| now >= state.next_probe_at)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This infers health from "the probe task has not died within one interval", which is not the same as "a stream was established".

The probe body is stream::once(async { node.subscribe(..).await?; .. synchronize_chain_state_from(..) }). synchronize_chain_state_from has no overall deadline and calls download_certificates_from; for a chain that is far behind, exceeding a 300s probe interval is routine. When that happens the probe timer fires, the sender is still non-aborted (the task simply has not finished), so the breaker is dropped and the accumulated backoff reset — logging Validator recovered from circuit breaker for a validator that has never delivered a notification.

The worse tail: if subscribe/sync hangs indefinitely, the task never ends, the senders entry stays Occupied forever, the Entry::Vacant guard below skips it on every future update, and with the breaker now deleted there is no probe deadline left either. That validator is silently and permanently unreachable — the exact failure class this PR is closing.

Deriving recovery from the stream actually yielding (or at least from subscribe having resolved) rather than from the task still being alive would avoid both.

{
// Stream still alive a full probe interval after the probe was launched
// -> recovered. The deadline check is load-bearing: the abort handle is
// non-aborted from the instant the probe task is created, long before
// `subscribe` resolves, so without it any update landing inside that
// connect window reads a still-connecting probe as a healthy stream and
// drops the breaker — resetting the accumulated backoff, so a
// permanently-down validator is probed forever at the shortest cadence.
info!(
%validator,
chain_id = %self.chain_id,
Expand All @@ -3385,17 +3476,25 @@ impl<Env: Environment> ChainClient<Env> {
});
circuit_breakers.retain(|validator, _| nodes.contains_key(validator));

let validator_tasks = FuturesUnordered::new();
let mut validator_tasks: Vec<NotificationStreamTask> = Vec::new();
for (public_key, node) in nodes {
let hash_map::Entry::Vacant(entry) = senders.entry(public_key) else {
continue;
};

// Circuit breaker: skip if not time to probe yet.
if let Some(state) = circuit_breakers.get(&public_key) {
if let Some(state) = circuit_breakers.get_mut(&public_key) {
if now < state.next_probe_at {
continue;
}
// Re-arm the deadline before launching the probe: its outcome is only
// known at a later update (task death = failure, healthy sender =
// recovery). Without this the deadline stays in the past, the probe
// timer immediately re-runs the update, and the still-connecting
// probe is mistaken for a recovered stream — wiping the breaker
// state and its backoff.
state.next_probe_at =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description flags the missing jitter as a known property equivalent to restarting the workers, which is fair for a one-shot event. Two things make it recur rather than fire once, though: every later doubling re-synchronises the whole fleet on the same instant, and with (true, true) resetting to the initial interval, a fleet-wide churn event re-converges on a 300s cadence instead of spreading out over time.

For a worker with thousands of chains, each probe being a subscribe plus a full synchronize_chain_state_from, that spike has the same shape as the sync storms that have saturated Scylla before. A few percent of jitter on next_probe_at is a small change and removes the recurrence rather than just the first spike.

now.saturating_add(TimeDelta::from_duration(state.probe_interval));
debug!(
validator = %public_key,
chain_id = %self.chain_id,
Expand Down Expand Up @@ -3461,7 +3560,7 @@ impl<Env: Environment> ChainClient<Env> {
let this = self.clone();
let local_node = local_node.clone();
let remote_node = RemoteNode { public_key, node };
validator_tasks.push(async move {
validator_tasks.push(Box::pin(async move {
while let Some(notification) = stream.next().await {
if let Err(error) = this
.process_notification(
Expand All @@ -3486,10 +3585,10 @@ impl<Env: Environment> ChainClient<Env> {
"Validator notification stream ended"
);
abort_on_exit.abort();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed synchronize_chain_state_from is indistinguishable from a failed subscribe: both end the task, and the next update reads either as a failed probe.

If node.subscribe(..) succeeds and then synchronize_chain_state_from (or sync_events_from_node) returns Err — a certificate download timeout, a blob fetch failure, a momentarily overloaded validator — the ? propagates, the filter_map logs could not connect to validator and swallows it, the stream is empty, the task ends, and this abort() runs. The next update scores that as a failed probe and doubles the interval.

Before the timer, that cost one re-subscribe attempt. Now the same transient hiccup converts into a doubling backoff, up to an hour of deafness against a validator that was reachable the whole time — under a log line claiming the connection failed.

});
}));
entry.insert(abort);
}
Ok(validator_tasks.collect())
Ok(validator_tasks)
}

/// Attempts to update a validator with the local information: sends any confirmed
Expand Down
Loading
Loading