Luigit
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);
    }
}