back off between failed broker recvs in the serve loop
This commit is contained in:
parent
ccc6993703
commit
0599c132e5
2 changed files with 180 additions and 16 deletions
|
|
@ -31,7 +31,10 @@ agents) runs:
|
||||||
1. Long-poll `Recv` on its socket. The host-side broker
|
1. Long-poll `Recv` on its socket. The host-side broker
|
||||||
(`broker.rs::recv_blocking_batch`) returns immediately if there's
|
(`broker.rs::recv_blocking_batch`) returns immediately if there's
|
||||||
a pending message, otherwise waits up to 30 s for a broker `Sent`
|
a pending message, otherwise waits up to 30 s for a broker `Sent`
|
||||||
event for this recipient.
|
event for this recipient. After a failed `Recv` (a socket error, or
|
||||||
|
a broker error, which comes back at once) the loop waits before the
|
||||||
|
next one: 250 ms, doubling up to 30 s, and no wait again once a
|
||||||
|
`Recv` succeeds. Todo wakes and local messages don't wait for it.
|
||||||
2. Pop one message. Peek the remaining inbox depth with `Status`.
|
2. Pop one message. Peek the remaining inbox depth with `Status`.
|
||||||
3. Emit `LiveEvent::TurnStart { from, body, unread }` onto the SSE
|
3. Emit `LiveEvent::TurnStart { from, body, unread }` onto the SSE
|
||||||
bus.
|
bus.
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,14 @@ const RECV_WAIT: Duration = Duration::from_mins(3);
|
||||||
/// hive-c0re.
|
/// hive-c0re.
|
||||||
const RECV_RESPONSE_TIMEOUT: Duration = RECV_WAIT.saturating_add(Duration::from_secs(30));
|
const RECV_RESPONSE_TIMEOUT: Duration = RECV_WAIT.saturating_add(Duration::from_secs(30));
|
||||||
|
|
||||||
|
/// Wait before the `Recv` after a failed one. A broker error comes back at
|
||||||
|
/// once rather than after the long poll, so without a wait the serve loop
|
||||||
|
/// would retry as fast as hive-c0re answers.
|
||||||
|
const RECV_RETRY_FLOOR: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
/// Ceiling for that wait as it doubles over consecutive failures.
|
||||||
|
const RECV_RETRY_CAP: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// Default web UI port — used when `HIVE_PORT` env is unset.
|
/// Default web UI port — used when `HIVE_PORT` env is unset.
|
||||||
const DEFAULT_WEB_PORT: u16 = 8042;
|
const DEFAULT_WEB_PORT: u16 = 8042;
|
||||||
|
|
||||||
|
|
@ -627,6 +635,65 @@ fn share_session_handles(bus: &Bus, session: &turn::AgentSession) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When the serve loop may send its next `Recv`: at once while `Recv`s
|
||||||
|
/// succeed, and after each failed one [`RECV_RETRY_FLOOR`] later, doubling
|
||||||
|
/// up to [`RECV_RETRY_CAP`]. One that succeeds resets it.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecvBackoff {
|
||||||
|
/// Failed `Recv`s since the last one that succeeded.
|
||||||
|
failures: u32,
|
||||||
|
/// No `Recv` goes out before this. A deadline rather than a duration,
|
||||||
|
/// so a local wake that cancels the wait doesn't restart it.
|
||||||
|
retry_at: Option<tokio::time::Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecvBackoff {
|
||||||
|
/// The wait after the `failures`-th failure in a row.
|
||||||
|
fn delay(failures: u32) -> Duration {
|
||||||
|
RECV_RETRY_FLOOR
|
||||||
|
.saturating_mul(2u32.saturating_pow(failures.saturating_sub(1)))
|
||||||
|
.min(RECV_RETRY_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait out the backoff, run `recv`, and record whether it failed.
|
||||||
|
async fn recv(&mut self, recv: impl Future<Output = RecvOutcome>) -> RecvOutcome {
|
||||||
|
if let Some(at) = self.retry_at {
|
||||||
|
tokio::time::sleep_until(at).await;
|
||||||
|
}
|
||||||
|
let outcome = recv.await;
|
||||||
|
if matches!(outcome, RecvOutcome::TransportError) {
|
||||||
|
self.failures = self.failures.saturating_add(1);
|
||||||
|
self.retry_at = Some(tokio::time::Instant::now() + Self::delay(self.failures));
|
||||||
|
} else {
|
||||||
|
if self.failures > 0 {
|
||||||
|
tracing::info!(failures = self.failures, "recv succeeded again");
|
||||||
|
}
|
||||||
|
*self = Self::default();
|
||||||
|
}
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The serve loop's idle wait: race the broker `Recv`, held back by
|
||||||
|
/// `backoff`, against a local todo signal and local messages, so an
|
||||||
|
/// in-container producer drives a turn without any broker round-trip and
|
||||||
|
/// isn't held up by a broker that is failing. `biased` polls the broker
|
||||||
|
/// recv first, so a genuinely-ready inbox message is never dropped in
|
||||||
|
/// favour of the todo wake.
|
||||||
|
async fn next_outcome(
|
||||||
|
recv: impl Future<Output = RecvOutcome>,
|
||||||
|
backoff: &mut RecvBackoff,
|
||||||
|
todo_wake: &tokio::sync::Notify,
|
||||||
|
local_msg_rx: &mut tokio::sync::mpsc::UnboundedReceiver<hive_sh4re::inbox::DeliveredMessage>,
|
||||||
|
) -> RecvOutcome {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
o = backoff.recv(recv) => o,
|
||||||
|
() = todo_wake.notified() => RecvOutcome::LocalTodo,
|
||||||
|
Some(dm) = local_msg_rx.recv() => RecvOutcome::Message(dm),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The long-running message loop. Long-polls the broker via
|
/// The long-running message loop. Long-polls the broker via
|
||||||
/// `S::recv_next`, drives a turn per message, parks on auth-failed,
|
/// `S::recv_next`, drives a turn per message, parks on auth-failed,
|
||||||
/// otherwise retries.
|
/// otherwise retries.
|
||||||
|
|
@ -664,6 +731,7 @@ async fn serve_loop<S: Surface>(
|
||||||
// too, so a just-unparked agent gets a clean slate rather than being
|
// too, so a just-unparked agent gets a clean slate rather than being
|
||||||
// one miss away from an instant re-pause.
|
// one miss away from an instant re-pause.
|
||||||
let mut todo_miss_streak: u32 = 0;
|
let mut todo_miss_streak: u32 = 0;
|
||||||
|
let mut recv_backoff = RecvBackoff::default();
|
||||||
loop {
|
loop {
|
||||||
// Pause gate. While the marker is present this loop drives no
|
// Pause gate. While the marker is present this loop drives no
|
||||||
// turns at all.
|
// turns at all.
|
||||||
|
|
@ -704,19 +772,14 @@ async fn serve_loop<S: Surface>(
|
||||||
was_paused = false;
|
was_paused = false;
|
||||||
todo_miss_streak = 0;
|
todo_miss_streak = 0;
|
||||||
}
|
}
|
||||||
let next = match {
|
let next = match next_outcome(
|
||||||
// Idle wait: race the broker long-poll against a local
|
S::recv_next(socket),
|
||||||
// todo signal so an in-container producer's upsert drives a
|
&mut recv_backoff,
|
||||||
// turn without any broker round-trip. `biased` polls the
|
&todo_wake,
|
||||||
// broker recv first, so a genuinely-ready inbox message is
|
&mut local_msg_rx,
|
||||||
// never dropped in favour of the todo wake.
|
)
|
||||||
tokio::select! {
|
.await
|
||||||
biased;
|
{
|
||||||
o = S::recv_next(socket) => o,
|
|
||||||
() = todo_wake.notified() => RecvOutcome::LocalTodo,
|
|
||||||
Some(dm) = local_msg_rx.recv() => RecvOutcome::Message(dm),
|
|
||||||
}
|
|
||||||
} {
|
|
||||||
RecvOutcome::Message(first) => first,
|
RecvOutcome::Message(first) => first,
|
||||||
RecvOutcome::LocalTodo => {
|
RecvOutcome::LocalTodo => {
|
||||||
// Gate on `has_any()` before spawning a turn: a burst of
|
// Gate on `has_any()` before spawning a turn: a burst of
|
||||||
|
|
@ -762,8 +825,8 @@ async fn serve_loop<S: Surface>(
|
||||||
post_compact_wake_message(prompt)
|
post_compact_wake_message(prompt)
|
||||||
}
|
}
|
||||||
RecvOutcome::TransportError => {
|
RecvOutcome::TransportError => {
|
||||||
// `recv_next` already logged the detail; just retry.
|
// `recv_next` already logged the detail; `recv_backoff`
|
||||||
// No backoff: the long-poll wait is itself the throttle.
|
// holds the next `Recv` back.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
RecvOutcome::GracefulStop => {
|
RecvOutcome::GracefulStop => {
|
||||||
|
|
@ -1088,3 +1151,101 @@ async fn handle_turn_error_recovery<S: Surface>(
|
||||||
S::requeue_inflight(socket).await;
|
S::requeue_inflight(socket).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{RECV_RETRY_CAP, RecvBackoff, RecvOutcome, next_outcome};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
/// Run one `Recv` through `backoff` that answers `outcome`, and return
|
||||||
|
/// when it went out.
|
||||||
|
async fn recv_at(backoff: &mut RecvBackoff, outcome: RecvOutcome) -> Instant {
|
||||||
|
let mut sent = None;
|
||||||
|
backoff
|
||||||
|
.recv(async {
|
||||||
|
sent = Some(Instant::now());
|
||||||
|
outcome
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
sent.expect("the recv ran")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gaps(sent: &[Instant]) -> Vec<Duration> {
|
||||||
|
sent.windows(2).map(|w| w[1] - w[0]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn failed_recvs_back_off_doubling_up_to_the_cap() {
|
||||||
|
let mut backoff = RecvBackoff::default();
|
||||||
|
let mut sent = Vec::new();
|
||||||
|
for _ in 0..10 {
|
||||||
|
sent.push(recv_at(&mut backoff, RecvOutcome::TransportError).await);
|
||||||
|
}
|
||||||
|
let ms = Duration::from_millis;
|
||||||
|
assert_eq!(
|
||||||
|
gaps(&sent),
|
||||||
|
[
|
||||||
|
ms(250),
|
||||||
|
ms(500),
|
||||||
|
ms(1000),
|
||||||
|
ms(2000),
|
||||||
|
ms(4000),
|
||||||
|
ms(8000),
|
||||||
|
ms(16000),
|
||||||
|
RECV_RETRY_CAP,
|
||||||
|
RECV_RETRY_CAP,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_successful_recv_resets_the_backoff() {
|
||||||
|
let mut backoff = RecvBackoff::default();
|
||||||
|
for _ in 0..4 {
|
||||||
|
recv_at(&mut backoff, RecvOutcome::TransportError).await;
|
||||||
|
}
|
||||||
|
let sent = vec![
|
||||||
|
recv_at(&mut backoff, RecvOutcome::Empty).await,
|
||||||
|
recv_at(&mut backoff, RecvOutcome::TransportError).await,
|
||||||
|
recv_at(&mut backoff, RecvOutcome::Empty).await,
|
||||||
|
];
|
||||||
|
assert_eq!(gaps(&sent), [Duration::ZERO, Duration::from_millis(250)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A todo wake or a local message during a backoff is taken at once,
|
||||||
|
/// without sending the held-back `Recv`; with no backoff the `Recv`
|
||||||
|
/// goes first.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_local_wake_does_not_wait_out_the_backoff() {
|
||||||
|
let todo_wake = tokio::sync::Notify::new();
|
||||||
|
let (local_tx, mut local_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let mut backoff = RecvBackoff::default();
|
||||||
|
for _ in 0..10 {
|
||||||
|
recv_at(&mut backoff, RecvOutcome::TransportError).await;
|
||||||
|
}
|
||||||
|
let retry_at = backoff.retry_at;
|
||||||
|
let held_back = || async { panic!("recv sent inside its backoff") };
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
todo_wake.notify_one();
|
||||||
|
let woke = next_outcome(held_back(), &mut backoff, &todo_wake, &mut local_rx).await;
|
||||||
|
assert!(matches!(woke, RecvOutcome::LocalTodo));
|
||||||
|
local_tx.send(super::synthetic_todo_message(false)).unwrap();
|
||||||
|
let woke = next_outcome(held_back(), &mut backoff, &todo_wake, &mut local_rx).await;
|
||||||
|
assert!(matches!(woke, RecvOutcome::Message(_)));
|
||||||
|
assert_eq!(start.elapsed(), Duration::ZERO);
|
||||||
|
assert_eq!(backoff.retry_at, retry_at);
|
||||||
|
|
||||||
|
let mut backoff = RecvBackoff::default();
|
||||||
|
todo_wake.notify_one();
|
||||||
|
let woke = next_outcome(
|
||||||
|
async { RecvOutcome::Empty },
|
||||||
|
&mut backoff,
|
||||||
|
&todo_wake,
|
||||||
|
&mut local_rx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(woke, RecvOutcome::Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue