feat(core): settle the Store trait (flush, is_empty, conformance suite)

This commit is contained in:
2026-10-22 08:44:09 -07:00
parent 158689e265
commit 07907efec5
13 changed files with 416 additions and 40 deletions
+5
View File
@@ -15,3 +15,8 @@ categories = ["science::robotics"]
tines-core = { path = "../tines-core", version = "0.3.0" }
postcard = { version = "1", features = ["use-std"] }
serde = { version = "1", features = ["derive"] }
[features]
# Re-export tines-core's std-free conformance suite so a backend can exercise
# LogStore against it: `cargo test --features conformance`.
conformance = ["tines-core/conformance"]
+69 -21
View File
@@ -232,7 +232,6 @@ pub struct LogStore {
file: File,
index: BTreeMap<ObservationId, Entry>,
boot_count: u64,
now: Tick,
/// Number of corrupt records skipped while rebuilding the index on the last
/// [`Self::open`] (0 if the log was clean). Exposed for hosts to report.
skipped_records: usize,
@@ -441,7 +440,6 @@ impl LogStore {
file,
index,
boot_count,
now,
skipped_records: skipped,
})
}
@@ -467,30 +465,34 @@ impl LogStore {
/// Persist the current index state to disk.
///
/// This is the durability boundary: `flush()` advances each entry's age to
/// the current tick, appends a `Record::Clock` tick marker, and calls
/// `sync_all`, after which the appended observation and release records are
/// durable across a power loss. Because `insert` only reaches disk via a
/// subsequent `flush()`, hosts that need crash durability must call
/// `flush()` before shutdown (see the durability contract in the module
/// docs).
pub fn flush(&mut self) -> Result<(), StoreError> {
// Advance every entry's age up to this tick in memory, so each
/// This is the durability boundary: `flush(now)` advances each entry's age
/// to the caller's `now`, appends a `Record::Clock` tick marker stamped
/// with `now`, and calls `sync_all`, after which the appended observation
/// and release records are durable across a power loss. Because `insert`
/// only reaches disk via a subsequent `flush()`, hosts that need crash
/// durability must call `flush()` before shutdown (see the durability
/// contract in the module docs).
///
/// `now` is the caller's current tick, which the marker records: anchoring
/// the reopen fold to when the host actually flushed — rather than the last
/// insert tick — keeps ages exact across a reboot.
pub fn flush(&mut self, now: Tick) -> Result<(), StoreError> {
// Advance every entry's age up to the caller's `now` in memory, so each
// `entry.obs.age_ms` is the true current age and `stored_at` marks when
// that age was computed. Ages are recomputed from each entry's own
// `stored_at` on the next load (see [`LogStore::open`]), so this keeps
// the in-memory representation coherent within this run.
// `stored_at` on the next load (see [`LogStore::open`]).
for entry in self.index.values_mut() {
entry.obs.age_ms = entry
.obs
.age_ms
.saturating_add(self.now.saturating_sub(entry.stored_at));
entry.stored_at = self.now;
.saturating_add(now.saturating_sub(entry.stored_at));
entry.stored_at = now;
}
// Record a clock marker so the load-time age fold has a durable anchor
// for the time elapsed up to this flush.
let record = Record::Clock { tick: self.now };
// Record a clock marker stamped with the caller's `now` so the load-time
// age fold anchors to when the host actually flushed — not the last
// insert tick (that stale-marker bug was F18).
let record = Record::Clock { tick: now };
write_record(&mut self.file, &record)?;
self.file.sync_all().map_err(|_| StoreError::Backend)?;
Ok(())
@@ -703,7 +705,6 @@ impl Store for LogStore {
// Dedupe: keep the first-stored entry and its age lineage.
return Ok(false);
}
self.now = now;
// The id is recomputed from `obs` on load, so it is not stored here.
let record = Record::Obs {
obs: Box::new(obs.clone()),
@@ -949,7 +950,7 @@ mod tests {
assert!(s.insert(a, 1000).unwrap());
assert!(s.insert(b, 1001).unwrap());
assert!(s.insert(c, 1002).unwrap());
s.flush().unwrap();
s.flush(1002).unwrap();
}
// Flip a byte in the body of the second data record (Obs(b)).
corrupt_body(&path, 2);
@@ -980,7 +981,7 @@ mod tests {
let mut s = LogStore::open(&path, 0).unwrap();
let a = obs([1; 16], 1, 0, Some(10));
assert!(s.insert(a, 1000).unwrap());
s.flush().unwrap();
s.flush(1000).unwrap();
}
{
let mut f = std::fs::OpenOptions::new()
@@ -1044,3 +1045,50 @@ mod tests {
std::fs::write(path, data).unwrap();
}
}
/// Conformance-suite shim for [`LogStore`].
///
/// Each suite case opens a brand-new temp log, so a case never inherits
/// records (or an age-fold state) from a previous one. Run with:
/// `cargo test -p tines-store --features conformance`.
#[cfg(all(test, feature = "conformance"))]
mod conformance_tests {
use crate::log::LogStore;
use std::path::PathBuf;
use tines_core::store_conformance;
fn unique_path() -> PathBuf {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let key = n as u128 * 1_000_000 + nanos;
let mut p = std::env::temp_dir();
p.push(format!(
"tines-log-conformance-{}-{}",
std::process::id(),
key
));
p
}
fn make() -> impl FnMut() -> LogStore {
// A fresh file (and thus a fresh age-fold state) on every call.
move || LogStore::open(&unique_path(), 0).expect("open fresh log for conformance")
}
#[test]
fn log_conforms() {
store_conformance::insert_get(make());
store_conformance::dedupe(make());
store_conformance::age_lineage(make());
store_conformance::saturating_ticks(make());
store_conformance::release_surviving_get(make());
store_conformance::sig_round_trip(make());
store_conformance::trim_order(make());
store_conformance::no_tombstones(make());
store_conformance::idempotent_flush(make());
}
}
+8 -8
View File
@@ -77,7 +77,7 @@ fn reopen_preserves_store() {
assert!(!s.is_released(&ib));
s.release(&ib);
assert!(s.is_released(&ib));
s.flush().expect("flush before drop");
s.flush(1001).expect("flush before drop");
}
{
@@ -99,7 +99,7 @@ fn ages_rebase_on_load() {
let mut s = LogStore::open(&path, 1000).unwrap();
let o = obs([1; 16], 1, 500, Some(10));
assert!(s.insert(o, 1000).unwrap());
s.flush().unwrap();
s.flush(1000).unwrap();
}
{
@@ -130,7 +130,7 @@ fn ages_rebase_after_nonidle_flush() {
// non-idle.
let b = obs([2; 16], 2, 0, Some(20));
assert!(s.insert(b, 3000).unwrap());
s.flush().unwrap();
s.flush(3000).unwrap();
}
{
@@ -162,7 +162,7 @@ fn ages_include_time_to_clock_marker() {
// earlier (at 1000) and sits idle until the store is dropped.
let d = obs([2; 16], 2, 0, Some(20));
assert!(s.insert(d, 3000).unwrap());
s.flush().unwrap();
s.flush(3000).unwrap();
}
{
@@ -268,7 +268,7 @@ fn reopen_recovers_from_torn_trailing_record() {
let b = obs([2; 16], 2, 0, Some(20));
assert!(s.insert(a, 1000).unwrap());
assert!(s.insert(b, 1001).unwrap());
s.flush().expect("flush before drop");
s.flush(1001).expect("flush before drop");
}
// Simulate a torn append: write a length prefix claiming 64 body bytes but
@@ -305,7 +305,7 @@ fn reopen_recovers_from_oversized_record_length() {
let mut s = LogStore::open(&path, 0).expect("open fresh store");
let a = obs([1; 16], 1, 0, Some(10));
assert!(s.insert(a, 1000).unwrap());
s.flush().expect("flush before drop");
s.flush(1000).expect("flush before drop");
}
{
@@ -371,7 +371,7 @@ mod fleet_fixes_f5 {
assert!(s.insert(a_obs([1; 16], 1), 1000).unwrap());
assert!(s.insert(a_obs([2; 16], 2), 1001).unwrap());
assert!(s.insert(a_obs([3; 16], 3), 1002).unwrap());
s.flush().expect("flush");
s.flush(1002).expect("flush");
}
// Corrupt the middle observation (record index 2: Boot, obs-1, obs-2).
corrupt_record(&path, 2);
@@ -399,7 +399,7 @@ mod fleet_fixes_f5 {
let mut s = LogStore::open(&path, 0).expect("open fresh");
assert!(s.insert(a_obs([1; 16], 1), 1000).unwrap());
assert!(s.insert(a_obs([2; 16], 2), 1001).unwrap());
s.flush().expect("flush");
s.flush(1001).expect("flush");
}
// Append a record header (sync + len = 64) with only 20 of the body bytes.
{