cultcache-rs: commit_if replaces only its own identity; a mint past 9999 refuses

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-13 20:10:56 +00:00
commit 034bf306de
2 changed files with 74 additions and 19 deletions

View file

@ -2084,7 +2084,7 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore {
fn push(&mut self, entry: &CultCacheEnvelope) -> Result<String> {
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<chrono::Utc>) -> 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<chrono::Utc>) -> Result<String> {
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<String> {
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<CultCacheEnvelope> {
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]

View file

@ -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(&note).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::<cultcache_rs::SchemaConflictError>().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");
}