#![allow(clippy::unwrap_used, reason = "test code")]
mod common;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use bytes::Bytes;
use common::{DAY, T0, batch, opts, rows, scalar_i64, schema, temp_lake};
use timelake::storage::Storage;
use timelake::{AppendOptions, Lake, MaintainOptions, Session};
use timelake_format::append::{AppendOptions as SharedAppend, append};
use timelake_format::error::{WriteError, WriteResult};
use timelake_format::host::{Host, Listed, Upload};
use timelake_format::table::TableState;
struct EngineHost {
storage: Storage,
seed: AtomicU64,
}
fn err(e: &timelake::Error) -> WriteError {
WriteError::Store(e.to_string())
}
struct BufferedUpload<'a> {
storage: &'a Storage,
uri: String,
bytes: Vec<u8>,
}
impl Upload for BufferedUpload<'_> {
fn put_part(&mut self, bytes: Vec<u8>) -> impl Future<Output = WriteResult<()>> {
self.bytes.extend(bytes);
std::future::ready(Ok(()))
}
async fn complete(self) -> WriteResult<()> {
self.storage.write(&self.uri, Bytes::from(self.bytes)).await.map_err(|e| err(&e))
}
async fn abort(self) {}
}
impl EngineHost {
fn new(lake: &Lake) -> Self {
Self { storage: lake.storage().clone(), seed: AtomicU64::new(7) }
}
}
impl Host for &EngineHost {
type Upload = BufferedUpload<'static>;
async fn get(&self, uri: &str) -> WriteResult<Option<Vec<u8>>> {
Ok(self.storage.read_opt(uri).await.map_err(|e| err(&e))?.map(|b| b.to_vec()))
}
async fn get_range(&self, uri: &str, start: u64, len: u64) -> WriteResult<Vec<u8>> {
let bytes = self.storage.read(uri).await.map_err(|e| err(&e))?;
let start = usize::try_from(start).unwrap().min(bytes.len());
let end = start.saturating_add(usize::try_from(len).unwrap()).min(bytes.len());
Ok(bytes[start..end].to_vec())
}
async fn exists(&self, uri: &str) -> WriteResult<bool> {
self.storage.exists(uri).await.map_err(|e| err(&e))
}
async fn put(&self, uri: &str, bytes: Vec<u8>) -> WriteResult<()> {
self.storage.write(uri, Bytes::from(bytes)).await.map_err(|e| err(&e))
}
async fn put_if_absent(&self, uri: &str, bytes: Vec<u8>) -> WriteResult<bool> {
self.storage.write_if_absent(uri, Bytes::from(bytes)).await.map_err(|e| err(&e))
}
fn upload(&self, uri: &str) -> impl Future<Output = WriteResult<BufferedUpload<'static>>> {
let storage: &'static Storage = Box::leak(Box::new(self.storage.clone()));
std::future::ready(Ok(BufferedUpload { storage, uri: uri.to_string(), bytes: vec![] }))
}
async fn list(&self, prefix: &str) -> WriteResult<Vec<Listed>> {
let dir = &prefix[..prefix.rfind('/').unwrap()];
let listed = self.storage.list(dir).await.map_err(|e| err(&e))?;
Ok(listed
.into_iter()
.filter(|o| o.uri.starts_with(prefix))
.map(|o| Listed { uri: o.uri, size: o.size })
.collect())
}
async fn delete(&self, uris: &[String]) -> WriteResult<()> {
self.storage.delete_many(uris.to_vec()).await.map(drop).map_err(|e| err(&e))
}
fn now_ms(&self) -> i64 {
chrono::Utc::now().timestamp_millis()
}
fn random(&self) -> u64 {
let s = self.seed.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed);
s ^ (s >> 31) ^ chrono::Utc::now().timestamp_nanos_opt().unwrap().unsigned_abs()
}
async fn sleep(&self, ms: u64) {
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
}
}
fn no_maintenance() -> AppendOptions {
AppendOptions { maintain: Some(false), ..AppendOptions::default() }
}
fn shared_no_maintenance() -> SharedAppend {
SharedAppend { maintain: Some(false), ..SharedAppend::default() }
}
fn reopen(dir: &tempfile::TempDir) -> Lake {
Lake::open(dir.path().to_str().unwrap(), std::collections::HashMap::new()).unwrap()
}
#[tokio::test(flavor = "multi_thread")]
async fn engine_and_shared_writer_take_turns() {
let (dir, lake) = temp_lake();
let t = lake.create_table("iot.r", &schema(), opts()).await.unwrap();
let location = t.location().to_string();
let (t, _) =
t.append(&[batch(T0, 24, 3_600_000_000, 10, |d, i| f64::from(d) + i as f64)], no_maintenance()).await.unwrap();
let host = EngineHost::new(&lake);
timelake::flush().await;
let state = TableState::load(&&host, &location).await.unwrap();
assert_eq!((state.version, state.files.as_ref().map(Vec::len)), (t.version(), Some(1)));
assert_eq!(state.schema, t.schema(), "the same Arrow schema as iceberg-rust gives");
let rewrite = batch(T0, 1, 3_600_000_000, 1, |_, _| -1.0);
let next = batch(T0 + DAY, 24, 3_600_000_000, 10, |d, i| 100.0 + f64::from(d) + i as f64);
let (_, report) = append(&&host, state, &[rewrite, next], &shared_no_maintenance()).await.unwrap();
assert_eq!((report.rows, report.files, report.commit.version), (241, 2, t.version() + 1));
for lake in [reopen(&dir), reopen(&dir)] {
let s = Session::new(lake);
assert_eq!(scalar_i64(&s, "SELECT count(*) FROM iot.r").await, 480);
let v = rows(&s, "SELECT value FROM iot.r WHERE device = 0 AND time = TIMESTAMP '2026-01-01T00:00:00Z'").await;
assert_eq!(common::f64_values(&v), [-1.0], "the newer write wins");
let latest = s.latest("iot.r").await.unwrap().collect().await.unwrap();
assert_eq!(latest.iter().map(arrow::array::RecordBatch::num_rows).sum::<usize>(), 10);
assert!(common::f64_values(&latest).iter().all(|v| *v >= 123.0), "latest rows are the second day's");
}
let t = reopen(&dir).table("iot.r").await.unwrap();
let (t, _) = t.append(&[batch(T0 + 2 * DAY, 1, 1, 10, |_, _| 7.0)], no_maintenance()).await.unwrap();
timelake::flush().await;
let mut state = TableState::load(&&host, &location).await.unwrap();
assert_eq!(state.version, t.version());
for i in 0..8 {
let b = batch(T0 + 2 * DAY + 1 + i, 1, 1, 10, |_, _| 8.0);
let opts = SharedAppend { batch_id: Some(format!("b{i}")), ..shared_no_maintenance() };
state = append(&&host, state, &[b], &opts).await.unwrap().0;
}
let again = SharedAppend { batch_id: Some("b3".into()), ..shared_no_maintenance() };
let (state, report) = append(&&host, state, &[batch(T0, 1, 1, 1, |_, _| 0.0)], &again).await.unwrap();
assert!(report.commit.duplicate);
assert_eq!(state.manifests(&&host).await.unwrap().len(), 4, "manifests were merged");
let t = reopen(&dir).table("iot.r").await.unwrap();
assert_eq!(t.version(), state.version);
let s = Session::new(reopen(&dir));
assert_eq!(scalar_i64(&s, "SELECT count(*) FROM iot.r").await, 480 + 90);
let (t, report) = t.maintain(MaintainOptions { expire: false, ..MaintainOptions::default() }).await.unwrap();
assert_eq!(report.duplicates_removed, 1, "the engine's row the shared writer rewrote");
assert_eq!(t.files().await.unwrap().len(), 3, "one file per day");
assert_eq!(scalar_i64(&Session::new(reopen(&dir)), "SELECT count(*) FROM iot.r").await, 570);
}
#[tokio::test(flavor = "multi_thread")]
async fn shared_maintenance_compacts_and_expires() {
use timelake_format::maintain::{MaintainOptions as SharedMaintain, maintain};
let (dir, lake) = temp_lake();
let mut o = opts();
o.properties.insert(timelake::property::COMPACT_MIN_FILES.into(), "4".into());
o.properties.insert(timelake::property::SNAPSHOT_RETENTION.into(), "0s".into());
o.properties.insert(timelake::property::MIN_SNAPSHOTS.into(), "1".into());
let t = lake.create_table("iot.r", &schema(), o).await.unwrap();
let location = t.location().to_string();
let host = EngineHost::new(&lake);
let mut state = TableState::load(&&host, &location).await.unwrap();
let mut merged = 0;
for i in 0..6 {
let b = batch(T0 + i * 3_600_000_000, 6, 600_000_000, 5, |d, j| f64::from(d) + j as f64);
let (next, report) = append(&&host, state, &[b], &SharedAppend::default()).await.unwrap();
state = next;
if let Some(Ok(m)) = report.maintenance {
merged += m.files_rewritten;
}
}
assert_eq!(merged, 4, "one tier of four files merged");
assert_eq!(reopen(&dir).table("iot.r").await.unwrap().files().await.unwrap().len(), 3);
let rewrite = batch(T0, 1, 1, 1, |_, _| -5.0);
let (state, _) = append(&&host, state, &[rewrite], &shared_no_maintenance()).await.unwrap();
let full = SharedMaintain { expire: false, ..SharedMaintain::default() };
let (state, report) = maintain(&&host, state, &full).await.unwrap();
assert_eq!((report.files_rewritten, report.files_written, report.duplicates_removed), (4, 1, 1));
let s = Session::new(reopen(&dir));
assert_eq!(scalar_i64(&s, "SELECT count(*) FROM iot.r").await, 180);
let v = rows(&s, "SELECT value FROM iot.r WHERE device = 0 AND time = TIMESTAMP '2026-01-01T00:00:00Z'").await;
assert_eq!(common::f64_values(&v), [-5.0]);
let latest = s.latest("iot.r").await.unwrap().collect().await.unwrap();
assert_eq!(latest.iter().map(arrow::array::RecordBatch::num_rows).sum::<usize>(), 5);
let expire_only = SharedMaintain { compact: false, retention: false, ..SharedMaintain::default() };
let (_, report) = maintain(&&host, state, &expire_only).await.unwrap();
assert!(report.snapshots_expired >= 7 && report.objects_deleted > 0, "{report:?}");
let data = walk(dir.path()).into_iter().filter(|p| p.to_string_lossy().contains("/data/")).count();
assert_eq!(data, 1, "only the compacted data file is left");
let s = Session::new(reopen(&dir));
assert_eq!(scalar_i64(&s, "SELECT count(*) FROM iot.r").await, 180);
let t = reopen(&dir).table("iot.r").await.unwrap();
let (t, _) = t.append(&[batch(T0 + DAY, 1, 1, 5, |_, _| 1.0)], AppendOptions::default()).await.unwrap();
t.maintain(MaintainOptions::default()).await.unwrap();
assert_eq!(scalar_i64(&Session::new(reopen(&dir)), "SELECT count(*) FROM iot.r").await, 185);
}
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = vec![];
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
out.extend(walk(&p));
} else {
out.push(p);
}
}
out
}
#[tokio::test(flavor = "multi_thread")]
async fn windowed_compaction_reads_every_row_once() {
use timelake_format::maintain::{MaintainOptions as SharedMaintain, maintain};
let (dir, lake) = temp_lake();
let mut o = opts();
o.dedup = Some(false);
let t = lake.create_table("iot.r", &schema(), o).await.unwrap();
let location = t.location().to_string();
let host = EngineHost::new(&lake);
let mut state = TableState::load(&&host, &location).await.unwrap();
for i in 0..3 {
let b =
batch(T0 + i, 6_000, 3_000_000, 10, |d, j| ((j * 7919 + i64::from(d) * 104_729) % 100_003) as f64 / 7.0);
state = append(&&host, state, &[b], &shared_no_maintenance()).await.unwrap().0;
}
let s = Session::new(reopen(&dir));
let before =
rows(&s, "SELECT count(*) AS n, sum(device) AS d, sum(CAST(round(value * 7) AS BIGINT)) AS v FROM iot.r").await;
let (_, report) =
maintain(&&host, state, &SharedMaintain { expire: false, ..SharedMaintain::default() }).await.unwrap();
assert_eq!((report.files_rewritten, report.files_written, report.rows_rewritten), (3, 1, 180_000));
let s = Session::new(reopen(&dir));
assert_eq!(
rows(&s, "SELECT count(*) AS n, sum(device) AS d, sum(CAST(round(value * 7) AS BIGINT)) AS v FROM iot.r").await,
before
);
}