use crate::types::GraphError;
pub const COMMIT_TIMES_MAGIC: [u8; 4] = *b"MTMS";
pub const COMMIT_TIMES_VERSION: u16 = 2;
pub const COMMIT_TIMES_SUPERSEDED_VERSIONS: &[u16] = &[1];
pub fn superseded_version(bytes: &[u8]) -> Option<u16> {
if bytes.len() < HEADER_LEN || bytes[..4] != COMMIT_TIMES_MAGIC {
return None;
}
let version = u16::from_le_bytes([bytes[4], bytes[5]]);
COMMIT_TIMES_SUPERSEDED_VERSIONS
.contains(&version)
.then_some(version)
}
pub const HEADER_LEN: usize = 4 + 2 + 8;
pub const ENTRY_LEN: usize = 8 + 8;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommitTimes {
floor_commit: u64,
entries: Vec<(u64, i64)>,
}
impl CommitTimes {
pub fn floor_commit(&self) -> u64 {
self.floor_commit
}
pub fn floor_ms(&self) -> Option<i64> {
self.entries.first().map(|&(_, ms)| ms)
}
pub fn newest_commit(&self) -> Option<u64> {
self.entries.last().map(|&(c, _)| c)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn max_ms(&self) -> Option<i64> {
self.entries.iter().map(|&(_, ms)| ms).max()
}
pub fn time_of(&self, commit: u64) -> Option<i64> {
self.entries
.binary_search_by_key(&commit, |&(c, _)| c)
.ok()
.map(|i| self.entries[i].1)
}
pub fn push(&mut self, commit: u64, unix_ms: i64) {
if let Some(newest) = self.newest_commit() {
if commit <= newest {
return;
}
} else {
self.floor_commit = commit;
}
self.entries.push((commit, unix_ms));
}
pub fn truncate_below(&mut self, commit: u64) {
self.entries.retain(|&(c, _)| c >= commit);
self.floor_commit = self.entries.first().map(|&(c, _)| c).unwrap_or(commit);
}
pub fn resolve_instant(&self, unix_ms: i64, wal_horizon_floor: u64) -> Result<u64, GraphError> {
let Some(&(_, first_ms)) = self.entries.first() else {
return Err(GraphError::NoRecordedTime);
};
if unix_ms < first_ms {
return Err(GraphError::TimeBeforeFloor {
floor_ms: first_ms,
floor_commit: self.floor_commit,
});
}
let mut answer = self.entries[0].0;
for &(commit, ms) in &self.entries {
if ms <= unix_ms {
answer = commit;
}
}
if answer < wal_horizon_floor {
return Err(GraphError::CommitOutOfRange {
commit: answer,
total: self.newest_commit().map(|c| c + 1).unwrap_or(0),
floor: wal_horizon_floor,
});
}
Ok(answer)
}
}
pub fn encode(t: &CommitTimes) -> Vec<u8> {
let mut out = Vec::with_capacity(HEADER_LEN + t.entries.len() * ENTRY_LEN);
out.extend_from_slice(&COMMIT_TIMES_MAGIC);
out.extend_from_slice(&COMMIT_TIMES_VERSION.to_le_bytes());
out.extend_from_slice(&t.floor_commit.to_le_bytes());
for &(commit, ms) in &t.entries {
out.extend_from_slice(&encode_entry(commit, ms));
}
out
}
pub fn encode_entry(commit: u64, unix_ms: i64) -> [u8; ENTRY_LEN] {
let mut buf = [0u8; ENTRY_LEN];
buf[..8].copy_from_slice(&commit.to_le_bytes());
buf[8..].copy_from_slice(&unix_ms.to_le_bytes());
buf
}
pub fn decode(bytes: &[u8]) -> Result<CommitTimes, GraphError> {
let corrupt = |detail: String| GraphError::Corrupt { detail };
if bytes.len() < HEADER_LEN {
return Err(corrupt(format!(
"commit_times: {} bytes is shorter than the {HEADER_LEN}-byte header",
bytes.len()
)));
}
if bytes[..4] != COMMIT_TIMES_MAGIC {
return Err(corrupt(format!(
"commit_times: bad magic {:02x?}, expected {:02x?}",
&bytes[..4],
COMMIT_TIMES_MAGIC
)));
}
let version = u16::from_le_bytes([bytes[4], bytes[5]]);
if version != COMMIT_TIMES_VERSION {
return Err(corrupt(format!(
"commit_times: unsupported version {version}, this build writes {COMMIT_TIMES_VERSION}"
)));
}
let floor_commit = u64::from_le_bytes(
bytes[6..14]
.try_into()
.map_err(|_| corrupt("commit_times: truncated floor".into()))?,
);
let body = &bytes[HEADER_LEN..];
if !body.len().is_multiple_of(ENTRY_LEN) {
return Err(corrupt(format!(
"commit_times: body is {} bytes, not a whole number of {ENTRY_LEN}-byte \
entries — a torn append",
body.len()
)));
}
let count = body.len() / ENTRY_LEN;
let mut entries = Vec::with_capacity(count);
let mut prev: Option<u64> = None;
for chunk in body.chunks_exact(ENTRY_LEN) {
let commit = u64::from_le_bytes(chunk[..8].try_into().expect("8 bytes"));
let ms = i64::from_le_bytes(chunk[8..].try_into().expect("8 bytes"));
if let Some(p) = prev {
if commit <= p {
return Err(corrupt(format!(
"commit_times: entries must ascend by commit; {commit} followed {p}"
)));
}
}
prev = Some(commit);
entries.push((commit, ms));
}
Ok(CommitTimes {
floor_commit,
entries,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_round_trip_preserves_every_entry() {
let mut t = CommitTimes::default();
t.push(1, 1_700_000_000_000);
t.push(2, 1_700_000_001_000);
let back = decode(&encode(&t)).expect("decode");
assert_eq!(back, t);
assert_eq!(back.newest_commit(), Some(2));
assert_eq!(back.floor_commit(), 1);
}
#[test]
fn a_backwards_clock_is_stored_as_observed() {
let mut t = CommitTimes::default();
t.push(1, 1_700_000_005_000);
t.push(2, 1_700_000_000_000);
let back = decode(&encode(&t)).expect("decode");
assert_eq!(back.newest_commit(), Some(2));
assert_eq!(back.len(), 2);
}
#[test]
fn a_repeated_commit_does_not_create_a_second_entry() {
let mut t = CommitTimes::default();
t.push(7, 1_000);
t.push(7, 9_999);
t.push(6, 9_999);
assert_eq!(t.len(), 1);
assert_eq!(t.resolve_instant(5_000, 0).unwrap(), 7);
}
#[test]
fn truncate_below_raises_the_floor_and_drops_entries() {
let mut t = CommitTimes::default();
for c in 1..=5 {
t.push(c, 1_700_000_000_000 + c as i64 * 1000);
}
t.truncate_below(3);
assert_eq!(t.floor_commit(), 3);
assert_eq!(t.newest_commit(), Some(5));
assert_eq!(t.len(), 3);
}
#[test]
fn truncating_past_the_end_empties_the_map_and_keeps_the_floor() {
let mut t = CommitTimes::default();
t.push(1, 1_000);
t.truncate_below(99);
assert!(t.is_empty());
assert_eq!(t.floor_commit(), 99);
assert!(matches!(
t.resolve_instant(1_000, 0),
Err(GraphError::NoRecordedTime)
));
}
#[test]
fn a_wrong_magic_is_corrupt_not_a_panic() {
let mut bytes = encode(&CommitTimes::default());
bytes[0] = b'X';
assert!(matches!(decode(&bytes), Err(GraphError::Corrupt { .. })));
}
#[test]
fn an_unknown_version_is_corrupt_not_a_panic() {
let mut bytes = encode(&CommitTimes::default());
bytes[4] = 0xFF;
bytes[5] = 0xFF;
assert!(matches!(decode(&bytes), Err(GraphError::Corrupt { .. })));
}
#[test]
fn a_truncated_body_is_corrupt_not_a_panic() {
let mut t = CommitTimes::default();
t.push(1, 1_700_000_000_000);
let mut bytes = encode(&t);
bytes.truncate(bytes.len() - 3);
assert!(matches!(decode(&bytes), Err(GraphError::Corrupt { .. })));
}
#[test]
fn an_empty_slice_is_corrupt_not_a_panic() {
assert!(matches!(decode(&[]), Err(GraphError::Corrupt { .. })));
}
#[test]
fn descending_entries_on_disk_are_refused() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&COMMIT_TIMES_MAGIC);
bytes.extend_from_slice(&COMMIT_TIMES_VERSION.to_le_bytes());
bytes.extend_from_slice(&1u64.to_le_bytes());
bytes.extend_from_slice(&encode_entry(5, 1_000));
bytes.extend_from_slice(&encode_entry(3, 2_000));
assert!(matches!(decode(&bytes), Err(GraphError::Corrupt { .. })));
}
#[test]
fn an_appended_entry_decodes_as_if_the_map_were_rewritten() {
let mut t = CommitTimes::default();
t.push(1, 1_000);
let mut on_disk = encode(&t);
on_disk.extend_from_slice(&encode_entry(2, 2_000));
let mut rewritten = CommitTimes::default();
rewritten.push(1, 1_000);
rewritten.push(2, 2_000);
assert_eq!(decode(&on_disk).expect("decode"), rewritten);
}
#[test]
fn a_torn_append_is_refused_not_silently_dropped() {
let mut t = CommitTimes::default();
t.push(1, 1_000);
let mut on_disk = encode(&t);
on_disk.extend_from_slice(&encode_entry(2, 2_000)[..9]);
assert!(matches!(decode(&on_disk), Err(GraphError::Corrupt { .. })));
}
#[test]
fn a_header_only_file_decodes_as_empty() {
let bytes = encode(&CommitTimes::default());
assert_eq!(bytes.len(), HEADER_LEN);
assert!(decode(&bytes).expect("decode").is_empty());
}
fn fixture() -> CommitTimes {
let mut t = CommitTimes::default();
t.push(10, 1_000);
t.push(20, 2_000);
t.push(30, 3_000);
t
}
#[test]
fn resolves_to_the_last_commit_at_or_before_the_instant() {
let t = fixture();
assert_eq!(t.resolve_instant(2_500, 0).unwrap(), 20);
assert_eq!(
t.resolve_instant(2_000, 0).unwrap(),
20,
"an exact match is inclusive"
);
}
#[test]
fn an_instant_after_the_newest_commit_resolves_to_it() {
assert_eq!(fixture().resolve_instant(9_999, 0).unwrap(), 30);
}
#[test]
fn an_instant_before_the_floor_names_the_range() {
match fixture().resolve_instant(500, 0) {
Err(GraphError::TimeBeforeFloor {
floor_ms,
floor_commit,
}) => assert_eq!((floor_ms, floor_commit), (1_000, 10)),
other => panic!("expected TimeBeforeFloor, got {other:?}"),
}
}
#[test]
fn an_empty_map_refuses_by_name() {
assert!(matches!(
CommitTimes::default().resolve_instant(1_000, 0),
Err(GraphError::NoRecordedTime)
));
}
#[test]
fn a_resolved_commit_is_never_below_the_wal_horizon() {
match fixture().resolve_instant(1_500, 25) {
Err(GraphError::CommitOutOfRange { floor, commit, .. }) => {
assert_eq!(floor, 25);
assert_eq!(commit, 10, "it names the commit it resolved to");
}
other => panic!("expected CommitOutOfRange, got {other:?}"),
}
}
#[test]
fn a_backwards_step_resolves_on_commit_order() {
let mut t = CommitTimes::default();
t.push(10, 1_000);
t.push(20, 500);
t.push(30, 3_000);
assert_eq!(t.resolve_instant(1_500, 0).unwrap(), 20);
}
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn digits(s: &str) -> Option<i64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse::<i64>().ok()
}
pub fn parse_rfc3339_ms(s: &str) -> Option<i64> {
let s = s.trim();
let (date, rest) = match s.find(['T', 't', ' ']) {
Some(i) => (&s[..i], Some(&s[i + 1..])),
None => (s, None),
};
let mut dp = date.split('-');
let (y, mo, d) = (dp.next()?, dp.next()?, dp.next()?);
if dp.next().is_some() {
return None;
}
let (y, mo, d) = (digits(y)?, digits(mo)?, digits(d)?);
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || y < 1 {
return None;
}
let days = days_from_civil(y, mo, d);
let Some(rest) = rest else {
return Some(days * 86_400_000);
};
let (time, offset_ms) = if let Some(t) = rest.strip_suffix('Z').or(rest.strip_suffix('z')) {
(t, 0i64)
} else if let Some(i) = rest.rfind(['+', '-']) {
let (t, off) = rest.split_at(i);
let sign = if off.starts_with('-') { -1 } else { 1 };
let off = &off[1..];
let (oh, om) = match off.split_once(':') {
Some((a, b)) => (digits(a)?, digits(b)?),
None if off.len() == 4 => (digits(&off[..2])?, digits(&off[2..])?),
_ => return None,
};
if !(0..=23).contains(&oh) || !(0..=59).contains(&om) {
return None;
}
(t, sign * (oh * 3_600_000 + om * 60_000))
} else {
(rest, 0i64)
};
let mut tp = time.split(':');
let h = digits(tp.next()?)?;
let mi = digits(tp.next()?)?;
let (sec, frac_ms) = match tp.next() {
None => (0i64, 0i64),
Some(sec_field) => match sec_field.split_once('.') {
None => (digits(sec_field)?, 0),
Some((whole, frac)) => {
if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let mut ms = 0i64;
for (i, b) in frac.bytes().take(3).enumerate() {
ms += (b - b'0') as i64 * 10i64.pow(2 - i as u32);
}
(digits(whole)?, ms)
}
},
};
if tp.next().is_some() {
return None;
}
if !(0..=23).contains(&h) || !(0..=59).contains(&mi) || !(0..=60).contains(&sec) {
return None;
}
Some(days * 86_400_000 + h * 3_600_000 + mi * 60_000 + sec * 1_000 + frac_ms - offset_ms)
}
pub fn parse_rfc3339_end_ms(s: &str) -> Option<i64> {
let t = s.trim();
let ms = parse_rfc3339_ms(t)?;
let date_only = !t.contains(['T', 't']) && !t.contains(' ');
Some(if date_only { ms + 86_400_000 - 1 } else { ms })
}
#[cfg(test)]
mod rfc3339_tests {
use super::*;
#[test]
fn a_bare_date_denotes_the_whole_day() {
let start = parse_rfc3339_ms("2026-07-14").unwrap();
let end = parse_rfc3339_end_ms("2026-07-14").unwrap();
assert_eq!(end - start, 86_400_000 - 1, "a date must cover its own day");
let nine = parse_rfc3339_ms("2026-07-14T09:00:00Z").unwrap();
assert!(nine > start && nine < end, "09:00 must fall within the day");
}
#[test]
fn a_named_instant_denotes_only_itself() {
for s in [
"2026-07-14T12:00:00Z",
"2026-07-14T12:00:00+01:00",
"2026-07-14 12:00:00Z",
] {
assert_eq!(
parse_rfc3339_end_ms(s),
parse_rfc3339_ms(s),
"{s} names a time, so its end is itself"
);
}
}
#[test]
fn the_epoch_is_zero() {
assert_eq!(parse_rfc3339_ms("1970-01-01"), Some(0));
assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00Z"), Some(0));
}
#[test]
fn a_bare_date_is_midnight_utc() {
let d = parse_rfc3339_ms("2026-06-19").expect("parse");
assert_eq!(d % 86_400_000, 0, "a bare date must land on midnight");
assert_eq!(parse_rfc3339_ms("2026-06-19T00:00:00Z"), Some(d));
}
#[test]
fn a_leap_day_parses() {
let feb29 = parse_rfc3339_ms("2024-02-29").expect("2024 is a leap year");
let mar01 = parse_rfc3339_ms("2024-03-01").expect("parse");
assert_eq!(mar01 - feb29, 86_400_000);
}
#[test]
fn a_century_non_leap_year_is_handled() {
let a = parse_rfc3339_ms("1900-03-01").expect("parse");
let b = parse_rfc3339_ms("1900-02-28").expect("parse");
assert_eq!(a - b, 86_400_000, "1900 had no Feb 29");
let c = parse_rfc3339_ms("2000-03-01").expect("parse");
let d = parse_rfc3339_ms("2000-02-28").expect("parse");
assert_eq!(c - d, 2 * 86_400_000, "2000 did have Feb 29");
}
#[test]
fn time_of_day_adds_up() {
let base = parse_rfc3339_ms("2026-06-19").unwrap();
assert_eq!(
parse_rfc3339_ms("2026-06-19T01:02:03Z").unwrap() - base,
3_600_000 + 2 * 60_000 + 3_000
);
}
#[test]
fn fractional_seconds_truncate_to_milliseconds() {
let base = parse_rfc3339_ms("2026-06-19T00:00:00Z").unwrap();
assert_eq!(
parse_rfc3339_ms("2026-06-19T00:00:00.5Z").unwrap() - base,
500
);
assert_eq!(
parse_rfc3339_ms("2026-06-19T00:00:00.789Z").unwrap() - base,
789
);
assert_eq!(
parse_rfc3339_ms("2026-06-19T00:00:00.9999Z").unwrap() - base,
999
);
}
#[test]
fn offsets_are_applied_in_the_right_direction() {
let utc = parse_rfc3339_ms("2026-06-19T12:00:00Z").unwrap();
assert_eq!(
parse_rfc3339_ms("2026-06-19T12:00:00+01:00").unwrap(),
utc - 3_600_000
);
assert_eq!(
parse_rfc3339_ms("2026-06-19T12:00:00-05:00").unwrap(),
utc + 5 * 3_600_000
);
assert_eq!(
parse_rfc3339_ms("2026-06-19T12:00:00+0100").unwrap(),
utc - 3_600_000
);
}
#[test]
fn a_space_separator_is_accepted() {
assert_eq!(
parse_rfc3339_ms("2026-06-19 12:00:00Z"),
parse_rfc3339_ms("2026-06-19T12:00:00Z")
);
}
#[test]
fn rubbish_is_refused_rather_than_guessed() {
for bad in [
"",
"not a date",
"2026",
"2026-06",
"2026-13-01",
"2026-06-32",
"2026-06-19T25:00:00Z",
"2026-06-19T12:60:00Z",
"2026-06-19T12:00:00+99:00",
"2026-06-19T12:00:00.Z",
"2026-06-19-01",
"0000-01-01",
] {
assert_eq!(parse_rfc3339_ms(bad), None, "{bad:?} must not parse");
}
}
}