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:
parent
0fb56286dc
commit
034bf306de
2 changed files with 74 additions and 19 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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::<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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue