mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Matt Toohey <contact@matttoohey.com> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: npub1hwqy0rnujtl25dzmlhn8qwux4kr8sjhas3ugltx9j5dm5dwkp2dsqjhytw <bb80478e7c92feaa345bfde6703b86ad86784afd84788facc5951bba35d60a9b@buzz.block.builderlab.xyz>
313 lines
10 KiB
Rust
313 lines
10 KiB
Rust
//! Lazy agent-pool lifecycle state.
|
|
//!
|
|
//! Relay connection, subscription, and event buffering live outside this
|
|
//! module. This state machine owns only whether a deferred pool has not started,
|
|
//! is waking, is ready, or is waiting to retry after a failed wake.
|
|
|
|
use std::time::Duration;
|
|
use tokio::time::Instant;
|
|
|
|
const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(5);
|
|
const MAX_RETRY_DELAY: Duration = Duration::from_secs(300);
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) enum PoolLifecycle<P> {
|
|
Listening,
|
|
Waking {
|
|
attempt: u32,
|
|
},
|
|
Ready(P),
|
|
Failed {
|
|
attempt: u32,
|
|
retry_at: Instant,
|
|
error: String,
|
|
},
|
|
}
|
|
|
|
impl<P> PoolLifecycle<P> {
|
|
pub(crate) fn listening() -> Self {
|
|
Self::Listening
|
|
}
|
|
|
|
/// Start the first wake, or a due retry, when buffered work exists.
|
|
///
|
|
/// Returns the attempt token exactly once per transition into `Waking`;
|
|
/// callers attach it to the single pool-initialization task and return it
|
|
/// with the result.
|
|
pub(crate) fn start_wake_if_due(
|
|
&mut self,
|
|
has_pending_work: bool,
|
|
now: Instant,
|
|
) -> Option<u32> {
|
|
if !has_pending_work {
|
|
return None;
|
|
}
|
|
|
|
let next_attempt = match self {
|
|
Self::Listening => Some(1),
|
|
Self::Failed {
|
|
attempt, retry_at, ..
|
|
} if now >= *retry_at => Some(attempt.saturating_add(1)),
|
|
Self::Waking { .. } | Self::Ready(_) | Self::Failed { .. } => None,
|
|
};
|
|
|
|
if let Some(attempt) = next_attempt {
|
|
*self = Self::Waking { attempt };
|
|
}
|
|
next_attempt
|
|
}
|
|
|
|
pub(crate) fn take_ready(&mut self) -> Option<P> {
|
|
match std::mem::replace(self, Self::Listening) {
|
|
Self::Ready(pool) => Some(pool),
|
|
other => {
|
|
*self = other;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn waking_attempt(&self) -> Option<u32> {
|
|
match self {
|
|
Self::Waking { attempt } => Some(*attempt),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn retry_at(&self) -> Option<Instant> {
|
|
match self {
|
|
Self::Failed { retry_at, .. } => Some(*retry_at),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn failed_error(&self) -> Option<&str> {
|
|
match self {
|
|
Self::Failed { error, .. } => Some(error),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn cancel_wake(&mut self, attempt: u32, error: String, now: Instant) -> bool {
|
|
self.complete_wake(attempt, Err(error), now).is_ok()
|
|
}
|
|
|
|
/// Complete the matching in-flight wake attempt.
|
|
///
|
|
/// A failure remains retryable. A result returned outside `Waking`, or from
|
|
/// an older attempt, is rejected: accepting it could replace a newer pool.
|
|
pub(crate) fn complete_wake(
|
|
&mut self,
|
|
completed_attempt: u32,
|
|
result: Result<P, String>,
|
|
now: Instant,
|
|
) -> Result<(), &'static str> {
|
|
let attempt = match self {
|
|
Self::Waking { attempt } if *attempt == completed_attempt => *attempt,
|
|
Self::Waking { .. } => return Err("wake result attempt did not match Waking attempt"),
|
|
_ => return Err("wake completed while lifecycle was not Waking"),
|
|
};
|
|
|
|
*self = match result {
|
|
Ok(pool) => Self::Ready(pool),
|
|
Err(error) => Self::Failed {
|
|
attempt,
|
|
retry_at: now + retry_delay(attempt),
|
|
error,
|
|
},
|
|
};
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn retry_delay(attempt: u32) -> Duration {
|
|
let exponent = attempt.saturating_sub(1).min(63);
|
|
let multiplier = 1_u64.checked_shl(exponent).unwrap_or(u64::MAX);
|
|
Duration::from_secs(
|
|
INITIAL_RETRY_DELAY
|
|
.as_secs()
|
|
.saturating_mul(multiplier)
|
|
.min(MAX_RETRY_DELAY.as_secs()),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn first_pending_event_starts_exactly_one_wake() {
|
|
let now = Instant::now();
|
|
let mut lifecycle = PoolLifecycle::<()>::listening();
|
|
|
|
assert_eq!(lifecycle.start_wake_if_due(false, now), None);
|
|
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
|
assert_eq!(lifecycle.start_wake_if_due(true, now), None);
|
|
assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 1 }));
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn failure_retries_only_when_work_exists_and_deadline_is_due() {
|
|
let now = Instant::now();
|
|
let mut lifecycle = PoolLifecycle::<()>::listening();
|
|
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
|
lifecycle
|
|
.complete_wake(1, Err("provider unavailable".into()), now)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
lifecycle.start_wake_if_due(true, now + Duration::from_secs(4)),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
lifecycle.start_wake_if_due(false, now + Duration::from_secs(5)),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
lifecycle.start_wake_if_due(true, now + Duration::from_secs(5)),
|
|
Some(2)
|
|
);
|
|
assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 2 }));
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn retry_backoff_doubles_and_caps_at_five_minutes() {
|
|
let mut now = Instant::now();
|
|
let mut lifecycle = PoolLifecycle::<()>::listening();
|
|
|
|
for attempt in 1..=9 {
|
|
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(attempt));
|
|
assert!(matches!(
|
|
lifecycle,
|
|
PoolLifecycle::Waking { attempt: actual } if actual == attempt
|
|
));
|
|
lifecycle
|
|
.complete_wake(attempt, Err("no brain".into()), now)
|
|
.unwrap();
|
|
|
|
let expected = retry_delay(attempt);
|
|
let retry_at = match &lifecycle {
|
|
PoolLifecycle::Failed { retry_at, .. } => *retry_at,
|
|
_ => panic!("failure must enter Failed"),
|
|
};
|
|
assert_eq!(retry_at, now + expected);
|
|
assert!(expected <= MAX_RETRY_DELAY);
|
|
now = retry_at;
|
|
}
|
|
|
|
assert_eq!(retry_delay(7), MAX_RETRY_DELAY);
|
|
assert_eq!(retry_delay(u32::MAX), MAX_RETRY_DELAY);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn successful_retry_consumes_pool_and_stops_future_wakes() {
|
|
let now = Instant::now();
|
|
let mut lifecycle = PoolLifecycle::listening();
|
|
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
|
lifecycle
|
|
.complete_wake(1, Err("first attempt failed".into()), now)
|
|
.unwrap();
|
|
|
|
let retry_at = match &lifecycle {
|
|
PoolLifecycle::Failed { retry_at, .. } => *retry_at,
|
|
_ => panic!("expected Failed"),
|
|
};
|
|
assert_eq!(lifecycle.start_wake_if_due(true, retry_at), Some(2));
|
|
lifecycle.complete_wake(2, Ok("pool"), retry_at).unwrap();
|
|
|
|
assert!(matches!(lifecycle, PoolLifecycle::Ready("pool")));
|
|
assert_eq!(
|
|
lifecycle.start_wake_if_due(true, retry_at + Duration::from_secs(600)),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn stale_or_duplicate_wake_result_is_rejected() {
|
|
let now = Instant::now();
|
|
let mut lifecycle = PoolLifecycle::<()>::listening();
|
|
assert_eq!(
|
|
lifecycle.complete_wake(1, Ok(()), now),
|
|
Err("wake completed while lifecycle was not Waking")
|
|
);
|
|
|
|
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
|
lifecycle.complete_wake(1, Ok(()), now).unwrap();
|
|
assert_eq!(
|
|
lifecycle.complete_wake(1, Ok(()), now),
|
|
Err("wake completed while lifecycle was not Waking")
|
|
);
|
|
assert!(matches!(lifecycle, PoolLifecycle::Ready(())));
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn stale_attempt_result_cannot_replace_current_wake() {
|
|
let now = Instant::now();
|
|
let mut lifecycle = PoolLifecycle::<&str>::listening();
|
|
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
|
lifecycle
|
|
.complete_wake(1, Err("attempt one failed".into()), now)
|
|
.unwrap();
|
|
|
|
let retry_at = match &lifecycle {
|
|
PoolLifecycle::Failed { retry_at, .. } => *retry_at,
|
|
_ => panic!("expected Failed"),
|
|
};
|
|
assert_eq!(lifecycle.start_wake_if_due(true, retry_at), Some(2));
|
|
assert_eq!(
|
|
lifecycle.complete_wake(1, Ok("stale pool"), retry_at),
|
|
Err("wake result attempt did not match Waking attempt")
|
|
);
|
|
assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 2 }));
|
|
lifecycle
|
|
.complete_wake(2, Ok("current pool"), retry_at)
|
|
.unwrap();
|
|
assert!(matches!(lifecycle, PoolLifecycle::Ready("current pool")));
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn cancelled_wake_enters_failed_and_can_retry() {
|
|
let now = Instant::now();
|
|
let mut lifecycle = PoolLifecycle::<()>::listening();
|
|
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
|
assert_eq!(lifecycle.waking_attempt(), Some(1));
|
|
assert!(lifecycle.cancel_wake(1, "task panicked".into(), now));
|
|
assert_eq!(lifecycle.failed_error(), Some("task panicked"));
|
|
assert_eq!(
|
|
lifecycle.start_wake_if_due(true, now + Duration::from_secs(5)),
|
|
Some(2)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn take_ready_transfers_pool_exactly_once() {
|
|
let now = Instant::now();
|
|
let mut lifecycle = PoolLifecycle::listening();
|
|
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
|
lifecycle.complete_wake(1, Ok("pool"), now).unwrap();
|
|
assert_eq!(lifecycle.take_ready(), Some("pool"));
|
|
assert_eq!(lifecycle.take_ready(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_state_preserves_attempt_deadline_and_error() {
|
|
let now = Instant::now();
|
|
let mut lifecycle = PoolLifecycle::<()>::listening();
|
|
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
|
lifecycle.complete_wake(1, Err("boom".into()), now).unwrap();
|
|
|
|
match lifecycle {
|
|
PoolLifecycle::Failed {
|
|
attempt,
|
|
retry_at,
|
|
error,
|
|
} => {
|
|
assert_eq!(attempt, 1);
|
|
assert_eq!(retry_at, now + Duration::from_secs(5));
|
|
assert_eq!(error, "boom");
|
|
}
|
|
_ => panic!("expected Failed"),
|
|
}
|
|
}
|
|
}
|