//! 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, } /// 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 { 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); } }