diff --git a/crates/vibescrobble-index/src/burn.rs b/crates/vibescrobble-index/src/burn.rs index 3e8253c..bb71d78 100644 --- a/crates/vibescrobble-index/src/burn.rs +++ b/crates/vibescrobble-index/src/burn.rs @@ -703,6 +703,12 @@ impl Store { /// rather than an empty store, since writing over it would lose every /// daily it holds. One in an older format is let go with its journal. pub fn open(path: &Path, days: u32) -> std::io::Result { + Self::open_at(path, days, OffsetDateTime::now_utc()) + } + + /// [`Self::open`], folding what is past the retention as of `now` rather + /// than the clock's time. + pub fn open_at(path: &Path, days: u32, now: OffsetDateTime) -> std::io::Result { let invalid = |err: serde_json::Error| std::io::Error::new(std::io::ErrorKind::InvalidData, err); let mut inner = Inner::default(); @@ -765,7 +771,7 @@ impl Store { path: Some(path.to_owned()), days, }; - store.compact(OffsetDateTime::now_utc())?; + store.compact(now)?; let (windows, dailies) = store.len(); info!( path = %path.display(), @@ -1793,6 +1799,30 @@ mod tests { assert!(inner.retired.is_empty(), "an emptied daily goes"); } + #[test] + fn a_store_folds_by_the_time_it_is_opened_at() { + let dir = std::env::temp_dir().join(format!( + "vibescrobble-burn-unit-{}-{}", + std::process::id(), + OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let said = Scrobble { + uri: "at://did:web:a.example/com.vibescrobble.scrobble/1".into(), + did: "did:web:a.example".into(), + created_at: "2026-10-21T09:00:00Z".into(), + usage: Some(flat(0, 1, 0, 0)), + ..Scrobble::default() + }; + let morning = time::macros::datetime!(2026-10-21 12:00 UTC); + let store = Store::open_at(&dir.join("now.json"), RETENTION_DAYS, morning).unwrap(); + assert!(store.put(&said), "inside the retention as of that morning"); + let later = morning + time::Duration::days(i64::from(RETENTION_DAYS) + 1); + let store = Store::open_at(&dir.join("later.json"), RETENTION_DAYS, later).unwrap(); + assert!(!store.put(&said), "folded as of a day past it"); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn an_older_store_is_let_go_and_a_newer_one_refused() { let dir = std::env::temp_dir().join(format!( diff --git a/crates/vibescrobble-index/tests/burn_gap.rs b/crates/vibescrobble-index/tests/burn_gap.rs index 993b912..a149487 100644 --- a/crates/vibescrobble-index/tests/burn_gap.rs +++ b/crates/vibescrobble-index/tests/burn_gap.rs @@ -33,6 +33,10 @@ const HEARD: usize = 10; /// Lines in the repository when it came back: more than one read reaches. const WRITTEN: usize = 80; +/// When every process here runs, whatever the clock says: the burn store +/// folds by it as it opens, and the lines are said this morning. +const NOW: time::OffsetDateTime = datetime!(2026-10-21 12:00 UTC); + /// A server whose answers can change while the index is down. #[derive(Debug, Default)] struct Fixture { @@ -179,7 +183,7 @@ fn scratch(name: &str) -> std::path::PathBuf { /// A process on the stores at `burn` and `ledgers`, from `kept`. async fn start(burn: &std::path::Path, ledgers: &std::path::Path, kept: Option) -> Shared { - let store = Store::open(burn, RETENTION_DAYS).expect("a burn store"); + let store = Store::open_at(burn, RETENTION_DAYS, NOW).expect("a burn store"); let shared = Shared::new().with_burn(Burn::Local(Arc::new(store))); let mut view = shared.view.lock().await; view.use_ledgers(ledgers::Store::disk(ledgers).expect("a ledger store")); @@ -228,10 +232,9 @@ async fn burn_counts_what_an_outage_hid_and_what_a_lost_store_held() { retry: Retry::NEVER, again: Retry::NEVER, }; - let now = datetime!(2026-10-21 12:00 UTC); let pass = |shared: Shared| { let (registry, indexer) = (®istry, &indexer); - async move { fill(&shared, registry, indexer, Duration::ZERO, PAGES, now).await } + async move { fill(&shared, registry, indexer, Duration::ZERO, PAGES, NOW).await } }; let (burn, ledger) = (scratch("burn.json"), scratch("ledgers.sqlite")); diff --git a/crates/vibescrobble-index/tests/burn_restart.rs b/crates/vibescrobble-index/tests/burn_restart.rs index 5a96a7e..d0e28b8 100644 --- a/crates/vibescrobble-index/tests/burn_restart.rs +++ b/crates/vibescrobble-index/tests/burn_restart.rs @@ -18,6 +18,10 @@ const SCROBBLE: &str = "com.vibescrobble.scrobble"; /// Lines said before the restart. const BEFORE: usize = 12; +/// When every process here runs, whatever the clock says: the burn store +/// folds by it as it opens, and the lines are said this morning. +const NOW: time::OffsetDateTime = datetime!(2026-10-21 12:00 UTC); + fn agent() -> Agent { Agent { did: KESTREL.to_owned(), @@ -78,7 +82,7 @@ fn scratch(name: &str) -> std::path::PathBuf { } fn open(path: &std::path::Path) -> Arc { - Arc::new(Store::open(path, RETENTION_DAYS).expect("a burn store")) + Arc::new(Store::open_at(path, RETENTION_DAYS, NOW).expect("a burn store")) } /// Takes in a line off the stream, as the index does. @@ -101,8 +105,7 @@ async fn restart(kept: Kept, store: &Arc) -> Shared { async fn all_time(shared: &Shared) -> (u64, u64) { let store = shared.burn.store().expect("a local store"); let view = shared.view.lock().await; - let summary = burn::summary(store, &view, KESTREL, datetime!(2026-10-21 12:00 UTC)) - .unwrap_or(Value::Null); + let summary = burn::summary(store, &view, KESTREL, NOW).unwrap_or(Value::Null); let all = &summary["own"]["all"]; ( all["scrobbles"].as_u64().unwrap_or(0),