repositories / smith
smith
There are many coding harnesses - but this one is fast
owned by admin
smith-ai/src/refresh.rs
Raw//! Cancellable catalog refresh with generation-checked publication
//! (`SMH-SPEC-SPEC0001`, Providers: catalogs).
//!
//! Refresh is a pure state machine over an injected fetch closure, so the
//! semantics hold without a network: a cancelled fetch keeps the cached
//! snapshot, a stale completion cannot replace a newer publication, and
//! offline startup always has the built-in snapshot available.
use crate::models::Catalog;
use smith::tool::CancelHandle;
use std::time::SystemTime;
/// One published catalog snapshot with its generation and origin.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CatalogSnapshot {
/// The catalog data.
pub catalog: Catalog,
/// Monotonic publication generation.
pub generation: u64,
/// When this snapshot was published.
pub published_at: Option<SystemTime>,
}
/// Owned catalog state with generation-checked publication.
#[derive(Clone, Debug)]
pub struct CatalogState {
published: CatalogSnapshot,
}
impl CatalogState {
/// Publish the built-in catalog as generation zero.
///
/// # Errors
///
/// Returns the built-in catalog's load error unchanged.
pub fn with_builtin() -> smith::error::Result<Self> {
Ok(Self {
published: CatalogSnapshot {
catalog: Catalog::builtin()?,
generation: 0,
published_at: None,
},
})
}
/// Start from an explicit snapshot, for tests.
#[must_use]
pub const fn from_snapshot(snapshot: CatalogSnapshot) -> Self {
Self {
published: snapshot,
}
}
/// The currently published snapshot.
#[must_use]
pub const fn snapshot(&self) -> &CatalogSnapshot {
&self.published
}
}
/// Outcome of one refresh attempt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RefreshOutcome {
/// A newer snapshot was published.
Published {
/// Generation of the new snapshot.
generation: u64,
},
/// The fetch failed; the cached snapshot stays usable.
KeptCache,
/// The fetch was cancelled; the cached snapshot stays usable.
Cancelled,
/// The fetch completed, but a newer snapshot had already been published.
StaleDiscarded,
}
/// Runs one refresh against an injected fetch.
///
/// The fetch closure receives the generation it must beat to publish; a
/// returning generation at or below the published one is discarded, so slow
/// work can never roll the catalog back.
///
/// # Errors
///
/// Returns fetch errors only for propagation by the caller; the cached
/// snapshot is untouched in every error case.
/// An injected catalog fetch: beat `generation`, honoring cancellation.
pub type Fetch = dyn Fn(u64, &CancelHandle) -> smith::error::Result<(u64, Catalog)>;
/// Run one refresh attempt against an injected fetch; fetch failures and
/// cancellation are outcomes with the cache untouched.
pub fn refresh(state: &mut CatalogState, fetch: &Fetch, cancel: &CancelHandle) -> RefreshOutcome {
if cancel.is_cancelled() {
return RefreshOutcome::Cancelled;
}
let attempt = fetch(state.published.generation, cancel);
if cancel.is_cancelled() {
return RefreshOutcome::Cancelled;
}
let Ok((fetched_generation, catalog)) = attempt else {
return RefreshOutcome::KeptCache;
};
// Only the newest generation may publish.
if fetched_generation <= state.published.generation {
return RefreshOutcome::StaleDiscarded;
}
state.published = CatalogSnapshot {
catalog,
generation: fetched_generation,
published_at: Some(SystemTime::now()),
};
RefreshOutcome::Published {
generation: fetched_generation,
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "refresh tests assert on fixed generations"
)]
mod tests {
use super::*;
fn snapshot(generation: u64) -> CatalogSnapshot {
CatalogSnapshot {
catalog: Catalog::builtin().unwrap(),
generation,
published_at: None,
}
}
#[test]
fn offline_startup_has_a_usable_snapshot() {
let state = CatalogState::with_builtin();
assert!(state.is_ok());
assert_eq!(state.unwrap().snapshot().generation, 0);
}
#[test]
fn newer_publication_wins_and_stale_work_is_discarded() {
let mut state = CatalogState::from_snapshot(snapshot(0));
let cancel = CancelHandle::new();
// Fast refresh publishes generation 5.
let outcome = refresh(
&mut state,
&|_gen, _cancel| Ok((5, Catalog::builtin().unwrap())),
&cancel,
);
assert_eq!(outcome, RefreshOutcome::Published { generation: 5 });
// Slow refresh from generation 0 completes later with generation 2:
// stale, must not replace generation 5.
let outcome = refresh(
&mut state,
&|_gen, _cancel| Ok((2, Catalog::builtin().unwrap())),
&cancel,
);
assert_eq!(outcome, RefreshOutcome::StaleDiscarded);
assert_eq!(state.snapshot().generation, 5);
}
#[test]
fn cancelled_and_failed_fetches_keep_the_cache() {
let mut state = CatalogState::from_snapshot(snapshot(3));
let cancel = CancelHandle::new();
let cancelled_handle = CancelHandle::new();
cancelled_handle.cancel();
let outcome = refresh(
&mut state,
&|_gen, _cancel| Ok((9, Catalog::builtin().unwrap())),
&cancelled_handle,
);
assert_eq!(outcome, RefreshOutcome::Cancelled);
let outcome = refresh(
&mut state,
&|_gen, _cancel| {
Err(smith::error::SmithError::Provider {
fault: smith::error::ProviderFault::Transient {
message: "offline".to_string(),
},
})
},
&cancel,
);
assert_eq!(outcome, RefreshOutcome::KeptCache);
assert_eq!(state.snapshot().generation, 3);
}
}