use serde_json::{json, Value};
use std::fmt;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReadinessStatus {
#[default]
Ready,
TimedOut,
Failed,
}
impl fmt::Display for ReadinessStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReadinessStatus::Ready => write!(f, "ready"),
ReadinessStatus::TimedOut => write!(f, "timed_out"),
ReadinessStatus::Failed => write!(f, "failed"),
}
}
}
#[derive(Debug, Clone)]
pub struct Deadline {
started_at: Instant,
timeout: Duration,
}
impl Deadline {
#[must_use]
pub fn new(timeout: Duration) -> Self {
Self {
started_at: Instant::now(),
timeout,
}
}
#[must_use]
pub fn timeout(&self) -> Duration {
self.timeout
}
#[must_use]
pub fn elapsed(&self) -> Duration {
self.started_at.elapsed()
}
#[must_use]
pub fn elapsed_ms(&self) -> u128 {
self.elapsed().as_millis()
}
#[must_use]
pub fn remaining(&self) -> Duration {
self.timeout.saturating_sub(self.elapsed())
}
#[must_use]
pub fn expired(&self) -> bool {
self.remaining().is_zero()
}
pub async fn sleep_at_most(&self, requested: Duration) {
let capped = requested.min(self.remaining());
if !capped.is_zero() {
tokio::time::sleep(capped).await;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeadlineOutcome<T> {
Completed(T),
TimedOut,
}
impl<T> DeadlineOutcome<T> {
#[must_use]
pub fn timed_out(&self) -> bool {
matches!(self, Self::TimedOut)
}
#[must_use]
pub fn value(self) -> Option<T> {
match self {
Self::Completed(value) => Some(value),
Self::TimedOut => None,
}
}
}
pub async fn run_within_deadline<F, T>(deadline: &Deadline, operation: F) -> DeadlineOutcome<T>
where
F: std::future::Future<Output = T>,
{
let remaining = deadline.remaining();
if remaining.is_zero() {
return DeadlineOutcome::TimedOut;
}
match tokio::time::timeout(remaining, operation).await {
Ok(value) => DeadlineOutcome::Completed(value),
Err(_) => DeadlineOutcome::TimedOut,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CheckRecord {
pub name: String,
pub satisfied: bool,
pub skipped: bool,
pub started_at_ms: u128,
pub elapsed_ms: u128,
pub detail: Value,
}
impl CheckRecord {
#[must_use]
pub fn satisfied(name: impl Into<String>, detail: Value) -> Self {
Self {
name: name.into(),
satisfied: true,
skipped: false,
started_at_ms: 0,
elapsed_ms: 0,
detail,
}
}
#[must_use]
pub fn unsatisfied(name: impl Into<String>, detail: Value) -> Self {
Self {
name: name.into(),
satisfied: false,
skipped: false,
started_at_ms: 0,
elapsed_ms: 0,
detail,
}
}
#[must_use]
pub fn skipped(name: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
name: name.into(),
satisfied: false,
skipped: true,
started_at_ms: 0,
elapsed_ms: 0,
detail: json!({ "reason": reason.into() }),
}
}
#[must_use]
pub fn with_timing(mut self, started_at_ms: u128, elapsed_ms: u128) -> Self {
self.started_at_ms = started_at_ms;
self.elapsed_ms = elapsed_ms;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ReadinessOutcome {
pub status: ReadinessStatus,
pub ready: bool,
pub satisfied: Vec<String>,
pub failed: Vec<String>,
pub skipped: Vec<String>,
pub pending: Vec<String>,
pub evidence: Vec<CheckRecord>,
pub elapsed_ms: u128,
pub timeout_ms: u128,
}
impl ReadinessOutcome {
#[must_use]
pub fn new(deadline: &Deadline) -> Self {
Self {
status: ReadinessStatus::Ready,
ready: true,
timeout_ms: deadline.timeout().as_millis(),
..Self::default()
}
}
pub fn record(&mut self, record: CheckRecord) {
if record.skipped {
self.skipped.push(record.name.clone());
} else if record.satisfied {
self.satisfied.push(record.name.clone());
} else {
self.failed.push(record.name.clone());
}
self.evidence.push(record);
}
pub fn defer(&mut self, name: impl Into<String>) {
self.pending.push(name.into());
}
#[must_use]
pub fn finish(mut self, deadline: &Deadline) -> Self {
self.ready = self.failed.is_empty() && self.pending.is_empty();
self.elapsed_ms = deadline.elapsed_ms();
self.status = if self.ready {
ReadinessStatus::Ready
} else if deadline.expired() {
ReadinessStatus::TimedOut
} else {
ReadinessStatus::Failed
};
self
}
#[must_use]
pub fn summary(&self) -> String {
if self.ready {
return format!("page ready after {}ms", self.elapsed_ms);
}
format!(
"page not ready ({}) after {}ms; failed=[{}] pending=[{}]",
self.status,
self.elapsed_ms,
self.failed.join(", "),
self.pending.join(", ")
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn run_within_deadline_returns_a_value_that_arrives_in_time() {
let deadline = Deadline::new(Duration::from_secs(1));
let outcome = run_within_deadline(&deadline, async { "done" }).await;
assert!(!outcome.timed_out());
assert_eq!(outcome.value(), Some("done"));
}
#[tokio::test]
async fn run_within_deadline_reports_expiry_instead_of_waiting_the_operation_out() {
let deadline = Deadline::new(Duration::from_millis(30));
let started = Instant::now();
let outcome = run_within_deadline(&deadline, async {
tokio::time::sleep(Duration::from_secs(5)).await;
"too late"
})
.await;
assert!(outcome.timed_out());
assert!(
started.elapsed() < Duration::from_secs(2),
"the budget, not the operation, has to end the wait"
);
}
#[tokio::test]
async fn run_within_deadline_does_not_start_an_operation_with_no_budget_left() {
let deadline = Deadline::new(Duration::ZERO);
let started = std::sync::atomic::AtomicBool::new(false);
let outcome = run_within_deadline(&deadline, async {
started.store(true, std::sync::atomic::Ordering::SeqCst);
})
.await;
assert!(outcome.timed_out());
assert!(
!started.load(std::sync::atomic::Ordering::SeqCst),
"an operation with no budget left only delays the answer we have"
);
}
#[test]
fn a_deadline_never_hands_out_more_budget_than_it_was_given() {
let deadline = Deadline::new(Duration::from_millis(10));
assert!(deadline.remaining() <= Duration::from_millis(10));
assert_eq!(deadline.timeout(), Duration::from_millis(10));
}
#[test]
fn remaining_saturates_at_zero() {
let deadline = Deadline::new(Duration::ZERO);
assert!(deadline.expired());
assert_eq!(deadline.remaining(), Duration::ZERO);
}
#[tokio::test]
async fn sleeping_is_capped_by_the_remaining_budget() {
let deadline = Deadline::new(Duration::from_millis(20));
deadline.sleep_at_most(Duration::from_secs(30)).await;
assert!(deadline.elapsed() < Duration::from_secs(1));
}
#[test]
fn an_unsatisfied_check_never_reports_ready() {
let deadline = Deadline::new(Duration::from_secs(5));
let mut outcome = ReadinessOutcome::new(&deadline);
outcome.record(CheckRecord::unsatisfied(
"url_stable_for",
json!({ "url": "https://example.com/" }),
));
let outcome = outcome.finish(&deadline);
assert!(!outcome.ready);
assert_eq!(outcome.status, ReadinessStatus::Failed);
assert_eq!(outcome.failed, vec!["url_stable_for".to_string()]);
assert!(outcome.summary().contains("url_stable_for"));
}
#[test]
fn an_expired_budget_reports_timed_out() {
let deadline = Deadline::new(Duration::ZERO);
let mut outcome = ReadinessOutcome::new(&deadline);
outcome.record(CheckRecord::unsatisfied("url_stable_for", json!({})));
let outcome = outcome.finish(&deadline);
assert_eq!(outcome.status, ReadinessStatus::TimedOut);
}
#[test]
fn a_skipped_check_does_not_block_readiness() {
let deadline = Deadline::new(Duration::from_secs(5));
let mut outcome = ReadinessOutcome::new(&deadline);
outcome.record(CheckRecord::skipped("network_idle_for", "no tracker"));
let outcome = outcome.finish(&deadline);
assert!(outcome.ready);
assert_eq!(outcome.skipped, vec!["network_idle_for".to_string()]);
}
#[test]
fn checks_that_never_ran_are_pending_not_satisfied() {
let deadline = Deadline::new(Duration::from_secs(5));
let mut outcome = ReadinessOutcome::new(&deadline);
outcome.record(CheckRecord::satisfied("url_stable_for", json!({})));
outcome.defer("verify_navigation");
let outcome = outcome.finish(&deadline);
assert!(!outcome.ready);
assert_eq!(outcome.pending, vec!["verify_navigation".to_string()]);
}
#[test]
fn evidence_carries_the_timing_window_of_each_check() {
let record = CheckRecord::satisfied("url_stable_for", json!({})).with_timing(5, 12);
assert_eq!(record.started_at_ms, 5);
assert_eq!(record.elapsed_ms, 12);
}
#[test]
fn statuses_render_the_documented_names() {
assert_eq!(ReadinessStatus::Ready.to_string(), "ready");
assert_eq!(ReadinessStatus::TimedOut.to_string(), "timed_out");
assert_eq!(ReadinessStatus::Failed.to_string(), "failed");
}
}