From 034bf306def2f885f8569f5db24bed7b6c2ca1ae Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 20:10:56 +0000 Subject: [PATCH] cultcache-rs: commit_if replaces only its own identity; a mint past 9999 refuses Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-rs/src/lib.rs | 58 ++++++++++++++++++--------- packages/cultcache-rs/tests/update.rs | 35 ++++++++++++++++ 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 87d93d5b..cd18aea8 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -2084,7 +2084,7 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { fn push(&mut self, entry: &CultCacheEnvelope) -> Result { self.with_exclusive_lock(|| { let mut entries = self.read_all_unlocked()?; - let entry = restamped_over(entry, &entries); + let entry = restamped_over(entry, &entries)?; // A write replaces the record of its own type under its key; a second type under the key reaches the commit guard. entries.retain(|candidate| entry_id(candidate) != entry_id(&entry)); entries.push(entry.clone()); @@ -2114,8 +2114,9 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { if actual.as_deref() != expected { return Ok(CultCommitIf::Mismatch); } - let entry = restamped_over(entry, &entries); - entries.retain(|held| held.key != entry.key); + let entry = restamped_over(entry, &entries)?; + // Only the record of the entry's own identity is replaced; a second type under the key reaches the commit guard, as push does. + entries.retain(|held| entry_id(held) != entry_id(&entry)); entries.push(entry.clone()); entries.sort_by_key(entry_id); self.write_all_unlocked(&entries)?; @@ -2145,7 +2146,7 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { let mut merged = self.read_all_unlocked()?; let mut by_id = BTreeMap::<(String, String), CultCacheEnvelope>::new(); for entry in entries { - by_id.insert((schema_identity(entry).to_string(), entry.key.clone()), restamped_over(entry, &merged)); + by_id.insert((schema_identity(entry).to_string(), entry.key.clone()), restamped_over(entry, &merged)?); } let replaced: BTreeSet<_> = by_id.values().map(entry_id).collect(); merged.retain(|held| !replaced.contains(&entry_id(held))); @@ -4288,35 +4289,43 @@ fn record_version(entry: &CultCacheEnvelope) -> String { } /// A stamp strictly later than `previous` (to the microsecond): `now` truncated to the microsecond when that is later, -/// otherwise `previous` truncated plus one microsecond. An absent or unparseable `previous` gives `now`. -fn mint_stored_at(previous: Option<&str>, now: chrono::DateTime) -> String { +/// otherwise `previous` truncated plus one microsecond. An absent or unparseable `previous` gives `now`. A successor past +/// year 9999 does not exist as a stamp any runtime reads, so it is refused (C# `MintStoredAt` throws there). +fn mint_stored_at(previous: Option<&str>, now: chrono::DateTime) -> Result { use chrono::SubsecRound; let now = now.trunc_subsecs(6); let minted = match previous.and_then(|text| chrono::DateTime::parse_from_rfc3339(text).ok()) { Some(previous) => { let previous = previous.with_timezone(&chrono::Utc).trunc_subsecs(6); - if now <= previous { previous + chrono::Duration::microseconds(1) } else { now } + if now <= previous { + previous + .checked_add_signed(chrono::Duration::microseconds(1)) + .filter(|successor| chrono::Datelike::year(successor) <= 9999) + .ok_or_else(|| anyhow!("stored_at {previous} has no later stamp: the successor is past year 9999"))? + } else { + now + } } None => now, }; - minted.to_rfc3339_opts(chrono::SecondsFormat::Micros, true) + Ok(minted.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)) } /// The stamp a replacing write carries: `staged` stands only when both stamps parse and it is strictly later than `replaced`. -fn stored_at_over(staged: &str, replaced: &str) -> String { +fn stored_at_over(staged: &str, replaced: &str) -> Result { match (chrono::DateTime::parse_from_rfc3339(staged), chrono::DateTime::parse_from_rfc3339(replaced)) { - (Ok(staged_at), Ok(replaced_at)) if staged_at > replaced_at => staged.to_string(), + (Ok(staged_at), Ok(replaced_at)) if staged_at > replaced_at => Ok(staged.to_string()), _ => mint_stored_at(Some(replaced), chrono::Utc::now()), } } /// `entry` stamped later than the record the store holds at its key, of any type; unchanged where nothing is held. -fn restamped_over(entry: &CultCacheEnvelope, held: &[CultCacheEnvelope]) -> CultCacheEnvelope { +fn restamped_over(entry: &CultCacheEnvelope, held: &[CultCacheEnvelope]) -> Result { let mut entry = entry.clone(); if let Some(replaced) = held.iter().find(|candidate| candidate.key == entry.key) { - entry.stored_at = stored_at_over(&entry.stored_at, &replaced.stored_at); + entry.stored_at = stored_at_over(&entry.stored_at, &replaced.stored_at)?; } - entry + Ok(entry) } fn now_utc_second() -> String { @@ -5974,23 +5983,34 @@ mod tests { #[test] fn stamps_stand_only_when_strictly_later_and_otherwise_mint_over_the_replaced() { // A strictly later staged stamp stands as written. - assert_eq!(stored_at_over("2099-01-01T00:00:00Z", "2026-01-01T00:00:00Z"), "2099-01-01T00:00:00Z"); + assert_eq!(stored_at_over("2099-01-01T00:00:00Z", "2026-01-01T00:00:00Z").unwrap(), "2099-01-01T00:00:00Z"); // An equal or earlier one is replaced by a minted stamp later than the replaced, over C#'s seven digits too. for replaced in ["2026-01-01T00:00:00.1234567+00:00", "2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"] { for staged in [replaced, "2020-01-01T00:00:00Z"] { - let minted = stored_at_over(staged, replaced); + let minted = stored_at_over(staged, replaced).unwrap(); assert!(is_minted_text(&minted), "{minted}"); assert!(at(&minted) > at(replaced), "{minted} over {replaced}"); } } - assert_eq!(stored_at_over("2020-01-01T00:00:00Z", "2099-01-01T00:00:00Z"), "2099-01-01T00:00:00.000001Z"); + assert_eq!(stored_at_over("2020-01-01T00:00:00Z", "2099-01-01T00:00:00Z").unwrap(), "2099-01-01T00:00:00.000001Z"); // An unparseable stamp on either side is never kept: the minted stamp is the replaced's successor or now. - let minted = stored_at_over("not a stamp", "2099-01-01T00:00:00Z"); + let minted = stored_at_over("not a stamp", "2099-01-01T00:00:00Z").unwrap(); assert_eq!(minted, "2099-01-01T00:00:00.000001Z"); - let minted = stored_at_over("2099-01-01T00:00:00Z", "not a stamp"); + let minted = stored_at_over("2099-01-01T00:00:00Z", "not a stamp").unwrap(); assert!(is_minted_text(&minted)); assert!((chrono::Utc::now() - at(&minted).with_timezone(&chrono::Utc)).num_seconds().abs() < 60, "{minted}"); - assert!(is_minted_text(&mint_stored_at(None, chrono::Utc::now()))); + assert!(is_minted_text(&mint_stored_at(None, chrono::Utc::now()).unwrap())); + } + + #[test] + fn a_stamp_with_no_successor_in_year_9999_is_refused() { + for replaced in ["9999-12-31T23:59:59.9999999+00:00", "9999-12-31T23:59:59.999999Z"] { + for staged in [replaced, "2020-01-01T00:00:00Z"] { + assert!(stored_at_over(staged, replaced).is_err(), "{staged} over {replaced}"); + } + } + // One microsecond earlier still has a successor. + assert_eq!(stored_at_over("2020-01-01T00:00:00Z", "9999-12-31T23:59:59.999998Z").unwrap(), "9999-12-31T23:59:59.999999Z"); } #[test] diff --git a/packages/cultcache-rs/tests/update.rs b/packages/cultcache-rs/tests/update.rs index 93dd06f7..0cc19d95 100644 --- a/packages/cultcache-rs/tests/update.rs +++ b/packages/cultcache-rs/tests/update.rs @@ -733,3 +733,38 @@ fn the_cache_holds_a_reported_stamp_only_when_it_parses_as_rfc3339() { } } } + +#[test] +fn a_stamp_with_no_successor_refuses_every_single_file_write_and_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let cache = open(&path); + let mut store = SingleFileMessagePackBackingStore::new(&path); + let last = "9999-12-31T23:59:59.999999Z"; + store.push(&envelope_of(&cache, "c", &counter(1), last)).unwrap(); + let before = file(&path); + let version = store.read("c").unwrap().unwrap().version; + let next = envelope_of(&cache, "c", &counter(2), "2020-01-01T00:00:00Z"); + assert!(store.push(&next).is_err()); + assert!(store.push_all(std::slice::from_ref(&next), PushAllOptions::default()).is_err()); + assert!(store.commit_if(&next, Some(&version)).is_err()); + assert_eq!(file(&path), before, "a refused mint wrote"); +} + +#[test] +fn commit_if_refuses_a_retype_as_push_does() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let cache = open(&path); + let mut store = SingleFileMessagePackBackingStore::new(&path); + let note = envelope_of(&cache, "k", &Note { text: "n".into() }, "2020-01-01T00:00:00Z"); + store.push(¬e).unwrap(); + let version = store.read("k").unwrap().unwrap().version; + let before = file(&path); + let counter_at_k = envelope_of(&cache, "k", &counter(1), "2020-01-01T00:00:00Z"); + let error = store.commit_if(&counter_at_k, Some(&version)).expect_err("a retype"); + assert!(error.downcast_ref::().is_some(), "{error:#}"); + assert!(store.push(&counter_at_k).is_err()); + assert_eq!(file(&path), before, "a refused retype wrote"); + assert_eq!(store.read("k").unwrap().unwrap().envelope.r#type, "note"); +}