use std::fmt;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::error::{Error, Result};
pub const WATERMARK_PROPERTY: &str = "meterstore.tiering_watermark";
pub const ARCHIVED_RANGE_PROPERTY: &str = "meterstore.archived_range";
pub const ROW_COUNT_PROPERTY: &str = "meterstore.row_count";
pub const ARCHIVED_AT_PROPERTY: &str = "meterstore.archived_at";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Tier {
Cold,
Hot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct TieringWatermark(OffsetDateTime);
impl TieringWatermark {
pub const fn empty() -> Self {
Self(OffsetDateTime::UNIX_EPOCH)
}
pub const fn new(at: OffsetDateTime) -> Self {
Self(at)
}
pub const fn get(self) -> OffsetDateTime {
self.0
}
pub fn tier_for(self, from: OffsetDateTime) -> Tier {
if from < self.0 { Tier::Cold } else { Tier::Hot }
}
pub fn can_advance_to(self, next: Self) -> bool {
next.0 >= self.0
}
pub fn advance_to(self, table: &str, next: Self) -> Result<Self> {
if !self.can_advance_to(next) {
return Err(Error::InvariantViolated {
table: table.to_string(),
detail: format!("watermark would move backwards: {self} -> {next}"),
});
}
Ok(next)
}
pub fn to_property(self) -> Result<String> {
self.0
.format(&Rfc3339)
.map_err(|e| Error::encode(WATERMARK_PROPERTY, e.to_string()))
}
pub fn from_property(value: &str) -> Result<Self> {
OffsetDateTime::parse(value, &Rfc3339)
.map(Self)
.map_err(|e| Error::decode(WATERMARK_PROPERTY, format!("{value:?}: {e}")))
}
}
impl fmt::Display for TieringWatermark {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0.format(&Rfc3339) {
Ok(s) => f.write_str(&s),
Err(_) => write!(f, "{:?}", self.0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArchivalWindow {
from: OffsetDateTime,
to: OffsetDateTime,
}
impl ArchivalWindow {
pub fn new(from: OffsetDateTime, to: OffsetDateTime) -> Result<Self> {
if to <= from {
return Err(Error::config(format!(
"archival window end {to} must be after start {from}"
)));
}
Ok(Self { from, to })
}
pub const fn from(self) -> OffsetDateTime {
self.from
}
pub const fn to(self) -> OffsetDateTime {
self.to
}
pub const fn resulting_watermark(self) -> TieringWatermark {
TieringWatermark(self.to)
}
pub fn contains(self, from: OffsetDateTime) -> bool {
from >= self.from && from < self.to
}
pub fn to_property(self) -> Result<String> {
let f = |t: OffsetDateTime| {
t.format(&Rfc3339)
.map_err(|e| Error::encode(ARCHIVED_RANGE_PROPERTY, e.to_string()))
};
Ok(format!("{}/{}", f(self.from)?, f(self.to)?))
}
}
pub fn align_to_step(ts: OffsetDateTime, step: time::Duration) -> Result<OffsetDateTime> {
let secs = ts.unix_timestamp();
let step_s = step.whole_seconds().max(1);
OffsetDateTime::from_unix_timestamp(secs - secs.rem_euclid(step_s)).map_err(|e| {
Error::config(format!(
"cannot align {ts} to a step of {step_s} seconds: {e}"
))
})
}
pub fn next_window(
watermark: TieringWatermark,
now: OffsetDateTime,
settlement_lag: time::Duration,
step: time::Duration,
) -> Result<Option<ArchivalWindow>> {
if step <= time::Duration::ZERO {
return Err(Error::config("archival step must be positive"));
}
if settlement_lag < time::Duration::ZERO {
return Err(Error::config("settlement lag must not be negative"));
}
let from = watermark.get();
if align_to_step(from, step)? != from {
return Err(Error::config(format!(
"watermark {from} is not aligned to an archival step of {} seconds, so an \
archival window would not correspond to a hot partition. The step of a \
table that has already archived cannot be changed in place: create a new \
table at the new step",
step.whole_seconds(),
)));
}
let horizon = now - settlement_lag;
let to = from + step;
if to > horizon {
return Ok(None);
}
Ok(Some(ArchivalWindow::new(from, to)?))
}
#[cfg(test)]
mod tests {
use super::*;
use time::Duration;
use time::macros::datetime;
const DAY: Duration = Duration::DAY;
fn wm(s: &str) -> TieringWatermark {
TieringWatermark::from_property(s).unwrap()
}
#[test]
fn tier_boundary_is_half_open() {
let w = TieringWatermark::new(datetime!(2026-07-20 00:00 UTC));
assert_eq!(w.tier_for(datetime!(2026-07-19 23:59:59 UTC)), Tier::Cold);
assert_eq!(w.tier_for(datetime!(2026-07-20 00:00 UTC)), Tier::Hot);
assert_eq!(w.tier_for(datetime!(2026-07-20 00:00:01 UTC)), Tier::Hot);
}
#[test]
fn empty_watermark_puts_everything_in_the_hot_tier() {
let w = TieringWatermark::empty();
assert_eq!(w.tier_for(datetime!(1970-01-01 00:00 UTC)), Tier::Hot);
assert_eq!(w.tier_for(datetime!(2026-07-20 00:00 UTC)), Tier::Hot);
}
#[test]
fn watermark_is_monotonic() {
let w = TieringWatermark::new(datetime!(2026-07-20 00:00 UTC));
let back = TieringWatermark::new(datetime!(2026-07-19 00:00 UTC));
let fwd = TieringWatermark::new(datetime!(2026-07-21 00:00 UTC));
assert!(!w.can_advance_to(back));
assert!(w.can_advance_to(fwd));
assert!(w.can_advance_to(w), "idempotent re-advance is legal");
let err = w.advance_to("readings_versions", back).unwrap_err();
assert!(
err.to_string().contains("readings_versions"),
"an invariant violation must name its table: {err}"
);
assert_eq!(w.advance_to("readings_versions", fwd).unwrap(), fwd);
}
#[test]
fn watermark_round_trips_through_the_snapshot_property() {
let w = TieringWatermark::new(datetime!(2026-07-20 12:34:56 UTC));
assert_eq!(wm(&w.to_property().unwrap()), w);
}
#[test]
fn watermark_rejects_malformed_property() {
assert!(TieringWatermark::from_property("not-a-timestamp").is_err());
assert!(TieringWatermark::from_property("").is_err());
}
#[test]
fn window_rejects_empty_and_inverted_ranges() {
let t = datetime!(2026-07-20 00:00 UTC);
assert!(ArchivalWindow::new(t, t).is_err());
assert!(ArchivalWindow::new(t, t - DAY).is_err());
assert!(ArchivalWindow::new(t, t + DAY).is_ok());
}
#[test]
fn window_containment_is_half_open() {
let w = ArchivalWindow::new(
datetime!(2026-07-20 00:00 UTC),
datetime!(2026-07-21 00:00 UTC),
)
.unwrap();
assert!(w.contains(datetime!(2026-07-20 00:00 UTC)));
assert!(w.contains(datetime!(2026-07-20 23:59:59 UTC)));
assert!(!w.contains(datetime!(2026-07-21 00:00 UTC)));
assert!(!w.contains(datetime!(2026-07-19 23:59:59 UTC)));
}
#[test]
fn window_resulting_watermark_is_its_exclusive_end() {
let w = ArchivalWindow::new(
datetime!(2026-07-20 00:00 UTC),
datetime!(2026-07-21 00:00 UTC),
)
.unwrap();
let next = w.resulting_watermark();
assert_eq!(next.get(), w.to());
assert_eq!(next.tier_for(datetime!(2026-07-20 12:00 UTC)), Tier::Cold);
assert_eq!(next.tier_for(datetime!(2026-07-21 00:00 UTC)), Tier::Hot);
}
#[test]
fn next_window_waits_until_a_full_step_is_below_the_horizon() {
let w = TieringWatermark::new(datetime!(2026-07-01 00:00 UTC));
let lag = Duration::days(7);
let now = datetime!(2026-07-08 00:00 UTC);
assert!(next_window(w, now, lag, DAY).unwrap().is_none());
let now = datetime!(2026-07-09 00:00 UTC);
let got = next_window(w, now, lag, DAY).unwrap().unwrap();
assert_eq!(got.from(), datetime!(2026-07-01 00:00 UTC));
assert_eq!(got.to(), datetime!(2026-07-02 00:00 UTC));
}
#[test]
fn next_window_never_reaches_into_the_settlement_lag() {
let w = TieringWatermark::new(datetime!(2026-07-01 00:00 UTC));
let now = datetime!(2026-07-09 12:00 UTC);
let lag = Duration::days(7);
let win = next_window(w, now, lag, DAY).unwrap().unwrap();
assert!(
win.to() <= now - lag,
"window must close at or before the horizon"
);
}
#[test]
fn next_window_advances_one_step_at_a_time() {
let lag = Duration::ZERO;
let now = datetime!(2026-07-10 00:00 UTC);
let mut w = TieringWatermark::new(datetime!(2026-07-01 00:00 UTC));
let mut windows = Vec::new();
while let Some(win) = next_window(w, now, lag, DAY).unwrap() {
windows.push(win);
w = win.resulting_watermark();
}
assert_eq!(windows.len(), 9);
for pair in windows.windows(2) {
assert_eq!(pair[0].to(), pair[1].from());
}
assert_eq!(w.get(), now);
}
#[test]
fn alignment_matches_the_hot_tier_partition_bounds() {
assert_eq!(
align_to_step(datetime!(2026-07-20 13:47:03 UTC), DAY).unwrap(),
datetime!(2026-07-20 00:00 UTC)
);
assert_eq!(
align_to_step(datetime!(2026-07-20 00:00 UTC), DAY).unwrap(),
datetime!(2026-07-20 00:00 UTC)
);
assert_eq!(
align_to_step(datetime!(2026-07-20 13:47 UTC), Duration::hours(6)).unwrap(),
datetime!(2026-07-20 12:00 UTC)
);
assert_eq!(
align_to_step(datetime!(1969-12-31 13:00 UTC), DAY).unwrap(),
datetime!(1969-12-31 00:00 UTC)
);
}
#[test]
fn a_watermark_off_the_step_grid_is_refused_rather_than_walked_past() {
let misaligned = TieringWatermark::new(datetime!(2026-07-20 06:00 UTC));
let now = datetime!(2026-08-01 00:00 UTC);
let err = next_window(misaligned, now, Duration::ZERO, DAY).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("aligned"), "{msg}");
assert!(
next_window(misaligned, now, Duration::ZERO, Duration::hours(6))
.unwrap()
.is_some()
);
}
#[test]
fn next_window_rejects_nonsensical_configuration() {
let w = TieringWatermark::empty();
let now = datetime!(2026-07-10 00:00 UTC);
assert!(next_window(w, now, Duration::ZERO, Duration::ZERO).is_err());
assert!(next_window(w, now, -Duration::days(1), DAY).is_err());
}
}