use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use time::OffsetDateTime;
use tracing::{debug, info, warn};
use crate::config::ValidatedTableConfig;
use crate::error::{Error, Result};
use crate::tiering::store::{BatchStream, ColdStore, HotStore, PartitionId, WriteHints};
use crate::watermark::{ArchivalWindow, TieringWatermark, next_window};
fn count_rows(batches: BatchStream, counter: Arc<AtomicU64>) -> BatchStream {
use futures::StreamExt;
Box::pin(batches.map(move |batch| {
if let Ok(ref b) = batch {
counter.fetch_add(b.num_rows() as u64, Ordering::Relaxed);
}
batch
}))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchivalOutcome {
pub window: Option<ArchivalWindow>,
pub rows: u64,
pub watermark: TieringWatermark,
pub orphans_reclaimed: usize,
pub partitions_created: usize,
pub lease_contended: bool,
}
impl ArchivalOutcome {
pub fn archived_anything(&self) -> bool {
self.window.is_some()
}
fn contended(watermark: TieringWatermark) -> Self {
Self {
window: None,
rows: 0,
watermark,
orphans_reclaimed: 0,
partitions_created: 0,
lease_contended: true,
}
}
}
pub struct Archiver<H, C> {
hot: H,
cold: C,
config: ValidatedTableConfig,
}
impl<H: HotStore, C: ColdStore> Archiver<H, C> {
pub fn new(hot: H, cold: C, config: ValidatedTableConfig) -> Self {
Self { hot, cold, config }
}
pub fn table(&self) -> &str {
self.config.name()
}
pub async fn run_once(&self, now: OffsetDateTime) -> Result<ArchivalOutcome> {
let started = std::time::Instant::now();
let metrics = crate::observe::metrics();
let attrs = crate::observe::table(self.config.name());
let Some(lease) = self.hot.try_archive_lease(self.config.name()).await? else {
let watermark = self.cold.watermark(self.config.name()).await?;
debug!(
table = self.config.name(),
"archive lease held elsewhere; skipping"
);
return Ok(ArchivalOutcome::contended(watermark));
};
let outcome = self.run_once_inner(now).await;
if let Err(e) = lease.release().await {
warn!(
table = self.config.name(),
error = %e,
"could not release the archive lease; it dies with this session"
);
}
if outcome.is_err() {
metrics.archival_failures.add(1, &attrs);
}
if let Ok(ref o) = outcome {
metrics.archival_rows.add(o.rows, &attrs);
metrics
.orphans_reclaimed
.add(o.orphans_reclaimed as u64, &attrs);
if o.archived_anything() {
metrics.partitions_dropped.add(1, &attrs);
metrics
.archival_duration
.record(started.elapsed().as_secs_f64(), &attrs);
}
metrics.watermark_lag.record(
(now - o.watermark.get()).whole_seconds().max(0) as u64,
&attrs,
);
}
outcome
}
async fn run_once_inner(&self, now: OffsetDateTime) -> Result<ArchivalOutcome> {
let table = self.config.name();
self.verify_schema(table).await?;
let orphans_reclaimed = self.reclaim_orphans(table).await?;
let partitions_created = self
.hot
.ensure_partitions(
table,
now,
now + self.config.partition_headroom(),
self.config.partition_step(),
)
.await?
.len();
crate::observe::metrics().hot_partitions_ahead.record(
self.config.expected_hot_partitions().max(0) as u64,
&crate::observe::table(table),
);
let watermark = self.cold.watermark(table).await?;
let Some(window) = next_window(
watermark,
now,
self.config.settlement_lag(),
self.config.archival_step(),
)?
else {
debug!(table, %watermark, "no closed window due");
return Ok(ArchivalOutcome {
window: None,
rows: 0,
watermark,
orphans_reclaimed,
partitions_created,
lease_contended: false,
});
};
let rows = self.archive_window(table, window).await?;
let watermark = watermark.advance_to(window.resulting_watermark())?;
info!(
table,
from = %window.from(),
to = %window.to(),
rows,
%watermark,
"archived window"
);
Ok(ArchivalOutcome {
window: Some(window),
rows,
watermark,
orphans_reclaimed,
partitions_created,
lease_contended: false,
})
}
async fn archive_window(&self, table: &str, window: ArchivalWindow) -> Result<u64> {
let partition = PartitionId::for_window(table, window);
if !self.hot.partition_exists(&partition).await? {
debug!(
table,
from = %window.from(),
"no partition for window; archiving as empty"
);
self.cold
.append_and_commit(
table,
crate::tiering::store::stream_of(Vec::new()),
WriteHints::default(),
window,
)
.await?;
return Ok(0);
}
self.hot.detach_partition(&partition).await?;
let hints = WriteHints {
distinct_malo_ids: self.hot.distinct_malo_ids(&partition).await?,
};
let batches = self
.hot
.scan_detached(&partition, &self.config.scan_spec())
.await?;
let scanned = Arc::new(AtomicU64::new(0));
let counted = count_rows(batches, Arc::clone(&scanned));
let commit = self
.cold
.append_and_commit(table, counted, hints, window)
.await?;
let rows = scanned.load(Ordering::Relaxed);
if commit.rows != rows {
return Err(Error::InvariantViolated {
table: table.to_string(),
detail: format!(
"cold store committed {} rows but {rows} were scanned",
commit.rows
),
});
}
self.hot.drop_partition(&partition).await?;
Ok(rows)
}
async fn verify_schema(&self, table: &str) -> Result<()> {
let Some(stored) = self.cold.stored_schema(table).await? else {
return Ok(());
};
let configured = crate::encode::schema::storage_schema(&self.config.extra_columns());
crate::evolution::compare(&configured, &stored).require_safe(table)
}
async fn reclaim_orphans(&self, table: &str) -> Result<usize> {
let orphans = self.hot.orphaned_partitions(table).await?;
if orphans.is_empty() {
return Ok(0);
}
let watermark = self.cold.watermark(table).await?;
let mut reclaimed = 0;
for partition in orphans {
if partition.start() < watermark.get() {
warn!(
table,
partition = %partition.relation_name()?,
"reclaiming orphaned partition from an interrupted run"
);
self.hot.drop_partition(&partition).await?;
reclaimed += 1;
} else {
return Err(Error::InvariantViolated {
table: table.to_string(),
detail: format!(
"partition {} is detached but not covered by watermark {watermark}; \
it must be re-attached before archival can continue",
partition.relation_name()?
),
});
}
}
Ok(reclaimed)
}
pub async fn catch_up(
&self,
now: OffsetDateTime,
max_windows: usize,
) -> Result<Vec<ArchivalOutcome>> {
let mut outcomes = Vec::new();
for _ in 0..max_windows {
let outcome = self.run_once(now).await?;
let done = !outcome.archived_anything();
outcomes.push(outcome);
if done {
break;
}
}
Ok(outcomes)
}
pub async fn verify_invariant(&self) -> Result<()> {
let table = self.config.name();
let watermark = self.cold.watermark(table).await?;
let violations = self.hot.invariant_violations(table, watermark).await?;
if violations > 0 {
return Err(Error::InvariantViolated {
table: table.to_string(),
detail: format!(
"{violations} rows are in the wrong tier for watermark {watermark}"
),
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::arrow::array::RecordBatch;
use crate::config::TableConfig;
use crate::tiering::store::CommitInfo;
use crate::tiering::store::ScanSpec;
use async_trait::async_trait;
use std::collections::BTreeMap;
use std::sync::Mutex;
use time::Duration;
use time::macros::datetime;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum FailAt {
#[default]
Never,
AfterDetach,
AfterColdCommit,
}
#[derive(Default)]
struct FakeHotInner {
live: BTreeMap<OffsetDateTime, u64>,
detached: BTreeMap<OffsetDateTime, u64>,
dropped: Vec<OffsetDateTime>,
created: Vec<OffsetDateTime>,
}
struct FakeHot {
inner: Mutex<FakeHotInner>,
fail_at: FailAt,
lease_held_elsewhere: bool,
}
impl FakeHot {
fn with_rows(rows: &[(OffsetDateTime, u64)]) -> Self {
let mut inner = FakeHotInner::default();
for (start, n) in rows {
inner.live.insert(*start, *n);
}
Self {
inner: Mutex::new(inner),
fail_at: FailAt::Never,
lease_held_elsewhere: false,
}
}
fn failing(mut self, at: FailAt) -> Self {
self.fail_at = at;
self
}
fn lease_held_elsewhere(mut self) -> Self {
self.lease_held_elsewhere = true;
self
}
fn dropped(&self) -> Vec<OffsetDateTime> {
self.inner.lock().unwrap().dropped.clone()
}
fn detached_starts(&self) -> Vec<OffsetDateTime> {
self.inner
.lock()
.unwrap()
.detached
.keys()
.copied()
.collect()
}
}
#[async_trait]
impl HotStore for FakeHot {
async fn append_reporting(
&self,
_table: &str,
_merge_key: &[String],
_batches: &[RecordBatch],
) -> Result<Vec<crate::session::Displacement>> {
Ok(Vec::new())
}
async fn drop_table(&self, _table: &str) -> Result<()> {
Ok(())
}
async fn try_archive_lease(
&self,
_table: &str,
) -> Result<Option<Box<dyn crate::tiering::store::ArchiveLease>>> {
Ok(if self.lease_held_elsewhere {
None
} else {
Some(Box::new(crate::tiering::store::UnenforcedLease))
})
}
async fn create_tables(
&self,
_table: &str,
_key: &[String],
_extra: &[crate::arrow::datatypes::Field],
) -> Result<()> {
Ok(())
}
async fn append(&self, _t: &str, _k: &[String], batches: &[RecordBatch]) -> Result<u64> {
Ok(batches.iter().map(|b| b.num_rows() as u64).sum())
}
async fn ensure_partitions(
&self,
table: &str,
from: OffsetDateTime,
until: OffsetDateTime,
step: Duration,
) -> Result<Vec<PartitionId>> {
let mut inner = self.inner.lock().unwrap();
let mut made = Vec::new();
let mut t = from;
while t < until {
if let std::collections::btree_map::Entry::Vacant(e) = inner.live.entry(t) {
e.insert(0);
inner.created.push(t);
made.push(PartitionId::new(table, t));
}
t += step;
}
Ok(made)
}
async fn scan_range(
&self,
_table: &str,
range: crate::planner::TimeRange,
_spec: &ScanSpec,
) -> Result<crate::tiering::store::BatchStream> {
let rows: u64 = {
let inner = self.inner.lock().unwrap();
inner
.live
.iter()
.filter(|(start, _)| {
range.start().is_none_or(|s| **start >= s)
&& range.end().is_none_or(|e| **start < e)
})
.map(|(_, n)| *n)
.sum()
};
Ok(if rows == 0 {
Box::pin(futures::stream::empty())
} else {
let b = fake_batch(rows);
Box::pin(futures::stream::once(async move { Ok(b) }))
})
}
async fn partition_exists(&self, partition: &PartitionId) -> Result<bool> {
let inner = self.inner.lock().unwrap();
Ok(inner.live.contains_key(&partition.start())
|| inner.detached.contains_key(&partition.start()))
}
async fn detach_partition(&self, partition: &PartitionId) -> Result<()> {
let mut inner = self.inner.lock().unwrap();
let rows = inner.live.remove(&partition.start()).unwrap_or(0);
inner.detached.insert(partition.start(), rows);
if self.fail_at == FailAt::AfterDetach {
return Err(Error::config("injected failure after detach"));
}
Ok(())
}
async fn scan_detached(
&self,
partition: &PartitionId,
_spec: &ScanSpec,
) -> Result<crate::tiering::store::BatchStream> {
let inner = self.inner.lock().unwrap();
let rows = *inner
.detached
.get(&partition.start())
.ok_or_else(|| Error::config("scan of a partition that is not detached"))?;
Ok(crate::tiering::store::stream_of(if rows == 0 {
vec![]
} else {
vec![fake_batch(rows)]
}))
}
async fn drop_partition(&self, partition: &PartitionId) -> Result<()> {
let mut inner = self.inner.lock().unwrap();
inner.detached.remove(&partition.start());
inner.dropped.push(partition.start());
Ok(())
}
async fn orphaned_partitions(&self, table: &str) -> Result<Vec<PartitionId>> {
let inner = self.inner.lock().unwrap();
Ok(inner
.detached
.keys()
.map(|s| PartitionId::new(table, *s))
.collect())
}
async fn invariant_violations(
&self,
_table: &str,
watermark: TieringWatermark,
) -> Result<u64> {
let inner = self.inner.lock().unwrap();
Ok(inner
.live
.iter()
.filter(|(start, rows)| **start < watermark.get() && **rows > 0)
.count() as u64)
}
}
struct FakeCold {
watermark: Mutex<TieringWatermark>,
committed: Mutex<Vec<(ArchivalWindow, u64)>>,
fail_at: FailAt,
}
impl FakeCold {
fn new(watermark: OffsetDateTime) -> Self {
Self {
watermark: Mutex::new(TieringWatermark::new(watermark)),
committed: Mutex::new(Vec::new()),
fail_at: FailAt::Never,
}
}
fn failing(mut self, at: FailAt) -> Self {
self.fail_at = at;
self
}
fn commits(&self) -> Vec<(ArchivalWindow, u64)> {
self.committed.lock().unwrap().clone()
}
}
#[async_trait]
impl ColdStore for FakeCold {
async fn purge_table(&self, _table: &str) -> Result<()> {
Ok(())
}
async fn create_tables(
&self,
_table: &str,
_identity: &[String],
_extra: &[crate::arrow::datatypes::Field],
) -> Result<()> {
Ok(())
}
async fn watermark(&self, _table: &str) -> Result<TieringWatermark> {
Ok(*self.watermark.lock().unwrap())
}
async fn append_and_commit(
&self,
_table: &str,
batches: crate::tiering::store::BatchStream,
_hints: WriteHints,
window: ArchivalWindow,
) -> Result<CommitInfo> {
let rows = drain(batches).await?;
*self.watermark.lock().unwrap() = window.resulting_watermark();
self.committed.lock().unwrap().push((window, rows));
if self.fail_at == FailAt::AfterColdCommit {
return Err(Error::config("injected failure after cold commit"));
}
Ok(CommitInfo {
snapshot_id: 1,
rows,
watermark: window.resulting_watermark(),
})
}
async fn expire_snapshots(
&self,
_t: &str,
_retain_for: time::Duration,
_retain_last: usize,
_now: OffsetDateTime,
) -> Result<usize> {
Ok(0)
}
async fn append_only(
&self,
_table: &str,
batches: crate::tiering::store::BatchStream,
_hints: WriteHints,
) -> Result<CommitInfo> {
let rows = drain(batches).await?;
Ok(CommitInfo {
snapshot_id: 2,
rows,
watermark: *self.watermark.lock().unwrap(),
})
}
}
async fn drain(batches: crate::tiering::store::BatchStream) -> Result<u64> {
use futures::StreamExt;
let mut stream = batches;
let mut rows = 0u64;
while let Some(batch) = stream.next().await {
rows += batch?.num_rows() as u64;
}
Ok(rows)
}
fn fake_batch(rows: u64) -> RecordBatch {
use crate::arrow::array::StringArray;
use std::sync::Arc;
let schema = crate::encode::schema::storage_schema(&[]);
let n = rows as usize;
RecordBatch::try_new(
schema.clone(),
schema
.fields()
.iter()
.map(|f| match f.data_type() {
crate::arrow::datatypes::DataType::Utf8 => {
Arc::new(StringArray::from(vec!["x"; n])) as _
}
crate::arrow::datatypes::DataType::UInt8 => {
Arc::new(crate::arrow::array::UInt8Array::from(vec![0u8; n])) as _
}
crate::arrow::datatypes::DataType::Timestamp(_, _) => Arc::new(
crate::arrow::array::TimestampMicrosecondArray::from(vec![0i64; n])
.with_timezone("UTC"),
)
as _,
crate::arrow::datatypes::DataType::Decimal128(p, s) => Arc::new(
crate::arrow::array::Decimal128Array::from(vec![0i128; n])
.with_precision_and_scale(*p, *s)
.unwrap(),
)
as _,
other => panic!("unhandled type {other:?}"),
})
.collect(),
)
.unwrap()
}
fn config() -> ValidatedTableConfig {
TableConfig::new("readings")
.settlement_lag(Duration::days(7))
.build()
.unwrap()
}
const D20: OffsetDateTime = datetime!(2026-07-20 00:00 UTC);
const D21: OffsetDateTime = datetime!(2026-07-21 00:00 UTC);
#[tokio::test]
async fn archives_one_closed_window() {
let hot = FakeHot::with_rows(&[(D20, 96)]);
let cold = FakeCold::new(D20);
let archiver = Archiver::new(hot, cold, config());
let out = archiver
.run_once(datetime!(2026-07-30 00:00 UTC))
.await
.unwrap();
assert!(out.archived_anything());
assert_eq!(out.rows, 96);
assert_eq!(out.watermark.get(), D21);
assert_eq!(archiver.hot.dropped(), vec![D20]);
assert_eq!(archiver.cold.commits().len(), 1);
}
#[tokio::test]
async fn does_nothing_when_no_window_is_closed() {
let hot = FakeHot::with_rows(&[(D20, 96)]);
let cold = FakeCold::new(D20);
let archiver = Archiver::new(hot, cold, config());
let out = archiver
.run_once(datetime!(2026-07-22 00:00 UTC))
.await
.unwrap();
assert!(!out.archived_anything());
assert_eq!(out.rows, 0);
assert!(archiver.hot.dropped().is_empty());
assert!(
archiver.cold.commits().is_empty(),
"nothing may be committed"
);
}
#[tokio::test]
async fn commits_cold_before_dropping_hot() {
let hot = FakeHot::with_rows(&[(D20, 96)]);
let cold = FakeCold::new(D20).failing(FailAt::AfterColdCommit);
let archiver = Archiver::new(hot, cold, config());
assert!(
archiver
.run_once(datetime!(2026-07-30 00:00 UTC))
.await
.is_err()
);
assert!(
archiver.hot.dropped().is_empty(),
"must not drop after a failed commit"
);
assert_eq!(
archiver.hot.detached_starts(),
vec![D20],
"partition must survive, detached and intact"
);
}
#[tokio::test]
async fn reclaims_an_orphan_left_by_an_interrupted_run() {
let hot = FakeHot::with_rows(&[(D20, 96)]);
let cold = FakeCold::new(D20).failing(FailAt::AfterColdCommit);
let archiver = Archiver::new(hot, cold, config());
let _ = archiver.run_once(datetime!(2026-07-30 00:00 UTC)).await;
let hot = FakeHot {
inner: Mutex::new(archiver.hot.inner.into_inner().unwrap()),
fail_at: FailAt::Never,
lease_held_elsewhere: false,
};
let cold = FakeCold {
watermark: Mutex::new(*archiver.cold.watermark.lock().unwrap()),
committed: Mutex::new(archiver.cold.commits()),
fail_at: FailAt::Never,
};
let archiver = Archiver::new(hot, cold, config());
let out = archiver
.run_once(datetime!(2026-07-30 00:00 UTC))
.await
.unwrap();
assert_eq!(out.orphans_reclaimed, 1);
assert!(archiver.hot.dropped().contains(&D20));
assert!(archiver.hot.detached_starts().is_empty());
}
#[tokio::test]
async fn refuses_to_drop_an_orphan_the_watermark_does_not_cover() {
let hot = FakeHot::with_rows(&[(D20, 96)]).failing(FailAt::AfterDetach);
let cold = FakeCold::new(D20);
let archiver = Archiver::new(hot, cold, config());
let _ = archiver.run_once(datetime!(2026-07-30 00:00 UTC)).await;
let hot = FakeHot {
inner: Mutex::new(archiver.hot.inner.into_inner().unwrap()),
fail_at: FailAt::Never,
lease_held_elsewhere: false,
};
let archiver = Archiver::new(hot, FakeCold::new(D20), config());
let err = archiver
.run_once(datetime!(2026-07-30 00:00 UTC))
.await
.unwrap_err();
assert!(matches!(err, Error::InvariantViolated { .. }));
assert!(
archiver.hot.dropped().is_empty(),
"must not drop uncommitted data"
);
}
#[tokio::test]
async fn archiving_is_idempotent_across_repeated_runs() {
let hot = FakeHot::with_rows(&[(D20, 96), (D21, 96)]);
let cold = FakeCold::new(D20);
let archiver = Archiver::new(hot, cold, config());
let now = datetime!(2026-07-30 00:00 UTC);
let first = archiver.catch_up(now, 10).await.unwrap();
let archived: u64 = first.iter().map(|o| o.rows).sum();
assert_eq!(archived, 192);
assert_eq!(archiver.cold.commits().len(), 3);
let second = archiver.catch_up(now, 10).await.unwrap();
assert!(second.iter().all(|o| !o.archived_anything()));
assert_eq!(
archiver.cold.commits().len(),
3,
"no window may be re-archived"
);
}
#[tokio::test]
async fn an_empty_window_still_advances_the_watermark() {
let hot = FakeHot::with_rows(&[]);
let cold = FakeCold::new(D20);
let archiver = Archiver::new(hot, cold, config());
let out = archiver
.run_once(datetime!(2026-07-30 00:00 UTC))
.await
.unwrap();
assert!(out.archived_anything());
assert_eq!(out.rows, 0);
assert_eq!(out.watermark.get(), D21);
}
#[tokio::test]
async fn catch_up_advances_gaplessly() {
let hot = FakeHot::with_rows(&[(D20, 10), (D21, 20)]);
let cold = FakeCold::new(D20);
let archiver = Archiver::new(hot, cold, config());
archiver
.catch_up(datetime!(2026-07-30 00:00 UTC), 10)
.await
.unwrap();
let commits = archiver.cold.commits();
for pair in commits.windows(2) {
assert_eq!(pair[0].0.to(), pair[1].0.from(), "windows must be gapless");
}
}
#[tokio::test]
async fn pre_creates_partitions_even_on_an_idle_cycle() {
let hot = FakeHot::with_rows(&[]);
let cold = FakeCold::new(datetime!(2026-07-29 00:00 UTC));
let archiver = Archiver::new(hot, cold, config());
let out = archiver
.run_once(datetime!(2026-07-30 00:00 UTC))
.await
.unwrap();
assert!(!out.archived_anything());
assert!(out.partitions_created > 0, "headroom must be maintained");
}
#[tokio::test]
async fn detects_rows_stranded_in_the_wrong_tier() {
let hot = FakeHot::with_rows(&[(D20, 96)]);
let cold = FakeCold::new(D21); let archiver = Archiver::new(hot, cold, config());
let err = archiver.verify_invariant().await.unwrap_err();
assert!(matches!(err, Error::InvariantViolated { .. }));
}
#[tokio::test]
async fn a_contended_lease_makes_the_run_a_no_op() {
let hot = FakeHot::with_rows(&[(D20, 96)]).lease_held_elsewhere();
let cold = FakeCold::new(D20);
let archiver = Archiver::new(hot, cold, config());
let out = archiver
.run_once(datetime!(2026-07-30 00:00 UTC))
.await
.unwrap();
assert!(out.lease_contended);
assert!(!out.archived_anything());
assert_eq!(out.watermark.get(), D20, "the boundary must not move");
assert!(archiver.hot.detached_starts().is_empty());
assert!(archiver.hot.dropped().is_empty());
assert!(archiver.cold.commits().is_empty());
}
#[tokio::test]
async fn a_granted_lease_is_reported_as_uncontended() {
let hot = FakeHot::with_rows(&[(D20, 96)]);
let cold = FakeCold::new(D20);
let archiver = Archiver::new(hot, cold, config());
let out = archiver
.run_once(datetime!(2026-07-30 00:00 UTC))
.await
.unwrap();
assert!(!out.lease_contended);
}
#[tokio::test]
async fn invariant_holds_after_a_clean_archival() {
let hot = FakeHot::with_rows(&[(D20, 96)]);
let cold = FakeCold::new(D20);
let archiver = Archiver::new(hot, cold, config());
archiver
.run_once(datetime!(2026-07-30 00:00 UTC))
.await
.unwrap();
archiver.verify_invariant().await.unwrap();
}
}