use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use arrow::array::RecordBatch;
use iceberg::spec::{Datum, PrimitiveLiteral};
use serde::{Deserialize, Serialize};
use crate::alter::update_properties;
use crate::append::AppendOptions;
use crate::commit::{Change, commit};
use crate::error::{Error, Result};
use crate::lake::Lake;
use crate::name::TableName;
use crate::storage::Uploads;
use crate::table::{LiveEntry, Table};
use crate::{format_duration, parse_duration, property, summary};
const ROWS_PER_COMMIT: usize = 1 << 20;
const NOTHING: i64 = -1;
const MIN_WINDOW_MICROS: i64 = 86_400_000_000;
pub(crate) type Ranges = Vec<(i64, i64)>;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Aggregate {
pub name: String,
pub function: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<String>,
}
pub(crate) const FUNCTIONS: &[&str] = &[
"count",
"sum",
"min",
"max",
"avg",
"first_value",
"last_value",
"m4",
"time_weighted_mean",
"time_weighted_mean:linear",
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Definition {
pub source: TableName,
pub width: i64,
pub bucket: String,
pub keys: Vec<String>,
pub aggregates: Vec<Aggregate>,
}
impl Definition {
pub(crate) fn of(rollup: &Table) -> Result<Self> {
let props = rollup.metadata().properties();
let get =
|key: &str| props.get(key).ok_or_else(|| Error::invalid(format!("{} is not a rollup", rollup.name())));
let source: TableName = get(property::ROLLUP_SOURCE)?.parse()?;
let width = micros(parse_duration(get(property::ROLLUP_BUCKET)?)?);
let aggregates: Vec<Aggregate> = serde_json::from_str(get(property::ROLLUP_AGGREGATES)?)?;
let contract = rollup.contract();
let bucket = contract.time_column.clone().ok_or_else(|| Error::invalid("a rollup needs a bucket column"))?;
Ok(Self { source, width, bucket, keys: contract.series_key.clone(), aggregates })
}
pub(crate) fn properties(&self) -> Result<HashMap<String, String>> {
let width = Duration::from_micros(u64::try_from(self.width).unwrap_or_default());
Ok(HashMap::from([
(property::ROLLUP_SOURCE.into(), self.source.to_string()),
(property::ROLLUP_BUCKET.into(), format_duration(width)),
(property::ROLLUP_AGGREGATES.into(), serde_json::to_string(&self.aggregates)?),
]))
}
}
pub(crate) fn micros(d: Duration) -> i64 {
i64::try_from(d.as_micros()).unwrap_or(i64::MAX)
}
pub(crate) fn recorded_sequence(rollup: &Table) -> Option<i64> {
rollup.summary_value(summary::ROLLUP_SOURCE_SEQUENCE)?.parse().ok()
}
pub(crate) fn in_history(raw: &Table, rollup: &Table) -> bool {
let (Some(id), Some(sequence)) = (
rollup.summary_value(summary::ROLLUP_SOURCE_SNAPSHOT).and_then(|s| s.parse::<i64>().ok()),
recorded_sequence(rollup),
) else {
return true;
};
let metadata = raw.metadata();
let mut snapshot = metadata.current_snapshot();
while let Some(s) = snapshot {
if s.snapshot_id() == id {
return true;
}
if s.sequence_number() <= sequence {
return false;
}
let Some(parent) = s.parent_snapshot_id() else { return false };
snapshot = metadata.snapshot_by_id(parent);
}
true
}
pub(crate) fn floor(t: i64, width: i64) -> i64 {
t.div_euclid(width).saturating_mul(width)
}
pub(crate) fn changed(raw: &Table, files: &[LiveEntry], after: Option<i64>, width: i64) -> Option<Ranges> {
let time = raw.contract().time_column.as_ref()?;
let id = raw.metadata().current_schema().field_by_name(time)?.id;
let bound = |b: Option<&Datum>| match b.map(Datum::literal) {
Some(PrimitiveLiteral::Long(t)) => Some(*t),
_ => None,
};
let mut ranges = vec![];
for f in files.iter().filter(|f| after.is_none_or(|s| f.sequence_number > s)) {
let (lo, hi) = (bound(f.data_file.lower_bounds().get(&id))?, bound(f.data_file.upper_bounds().get(&id))?);
ranges.push((floor(lo, width), floor(hi, width).saturating_add(width)));
}
Some(merge(ranges))
}
pub(crate) fn merge(mut ranges: Ranges) -> Ranges {
ranges.sort_unstable();
let mut out: Ranges = vec![];
for (lo, hi) in ranges {
match out.last_mut() {
Some(last) if lo <= last.1 => last.1 = last.1.max(hi),
_ => out.push((lo, hi)),
}
}
out
}
fn windows(ranges: &[(i64, i64)], width: i64) -> Ranges {
let window = (MIN_WINDOW_MICROS.saturating_add(width - 1) / width).max(1).saturating_mul(width);
let mut out = vec![];
for &(lo, hi) in ranges {
let mut start = lo;
while start < hi {
let end = start.saturating_add(window).min(hi);
out.push((start, end));
start = end;
}
}
out
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RollupRefresh {
#[default]
Always,
Due,
Never,
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct Refreshed {
pub rollups: usize,
pub rows: u64,
}
pub(crate) async fn refresh_all(raw: &Table, mode: RollupRefresh) -> Result<Refreshed> {
let mut done = Refreshed::default();
if mode == RollupRefresh::Never {
return Ok(done);
}
let mut rollups = vec![];
for name in &raw.contract().rollups {
let Ok(name) = name.parse::<TableName>() else { continue };
let width = match raw.lake().table(&name).await {
Ok(t) => Definition::of(&t).map_or(i64::MAX, |d| d.width),
Err(_) => i64::MAX,
};
rollups.push((width, name));
}
rollups.sort();
for (_, name) in rollups {
if mode == RollupRefresh::Due && !due(raw, &name) {
continue;
}
match refresh(raw.lake(), &name, Some(raw), mode).await {
Ok(rows) if rows > 0 => {
done.rollups += 1;
done.rows += rows;
}
Ok(_) | Err(Error::NotFound(_)) => {}
Err(e) => return Err(e),
}
}
Ok(done)
}
fn due(raw: &Table, name: &TableName) -> bool {
let Some((_, rollup)) = raw.lake().caches().tables.get(name) else { return true };
let current = raw.current_snapshot().map(|s| s.sequence_number());
if recorded_sequence(&rollup).is_some_and(|r| current.is_some_and(|c| r >= c)) {
return false;
}
is_due(&rollup)
}
fn is_due(rollup: &Table) -> bool {
let Some(interval) = rollup.contract().rollup_refresh_interval else { return false };
let last = rollup.current_snapshot().map_or(0, |s| s.timestamp_ms());
let age = chrono::Utc::now().timestamp_millis().saturating_sub(last);
u128::try_from(age).unwrap_or(0) >= interval.as_millis()
}
pub(crate) fn refresh<'a>(
lake: &'a Lake,
name: &'a TableName,
raw: Option<&'a Table>,
mode: RollupRefresh,
) -> Pin<Box<dyn Future<Output = Result<u64>> + Send + 'a>> {
Box::pin(refresh_now(lake, name, raw, mode))
}
async fn refresh_now(lake: &Lake, name: &TableName, raw: Option<&Table>, mode: RollupRefresh) -> Result<u64> {
let mut rollup = lake.load(name).await?;
if mode == RollupRefresh::Due && !is_due(&rollup) {
return Ok(0);
}
let def = Definition::of(&rollup)?;
let raw = match raw {
Some(t) if *t.name() == def.source => t.clone(),
_ => lake.load(&def.source).await?,
};
let Some(snapshot) = raw.current_snapshot() else { return Ok(0) };
let (snapshot_id, sequence) = (snapshot.snapshot_id(), snapshot.sequence_number());
let mut recorded = recorded_sequence(&rollup);
if !in_history(&raw, &rollup) {
let removed = rollup.files().await?;
let record = HashMap::from([
(summary::ROLLUP_SOURCE_SNAPSHOT.to_string(), "none".to_string()),
(summary::ROLLUP_SOURCE_SEQUENCE.to_string(), NOTHING.to_string()),
]);
let change = Change::Delete { removed, lvi_cutoff: Some(i64::MAX) };
rollup = commit(&rollup, change, record, Uploads::default()).await?.0;
recorded = Some(NOTHING);
} else if recorded.is_some_and(|r| r >= sequence) {
return Ok(0);
}
let files = raw.files().await?;
let windows: Vec<Option<(i64, i64)>> = match changed(&raw, &files, recorded, def.width) {
Some(ranges) => windows(&ranges, def.width).into_iter().map(Some).collect(),
None => vec![None],
};
let record = HashMap::from([
(summary::ROLLUP_SOURCE_SNAPSHOT.to_string(), snapshot_id.to_string()),
(summary::ROLLUP_SOURCE_SEQUENCE.to_string(), sequence.to_string()),
]);
let session = crate::query::refresh_session(lake, raw.name(), name).await?;
let (mut pending, mut rows): (Vec<RecordBatch>, u64) = (vec![], 0);
for (i, &window) in windows.iter().enumerate() {
let ranges = window.map(|w| vec![w]);
let batches = crate::query::rollup_frame(&session, &raw, &def, ranges.as_deref())?.collect().await?;
pending.extend(batches.into_iter().filter(|b| b.num_rows() > 0));
let last = i + 1 == windows.len();
let pending_rows: usize = pending.iter().map(RecordBatch::num_rows).sum();
if pending_rows == 0 || !(last || pending_rows >= ROWS_PER_COMMIT) {
continue;
}
let properties = if last { record.clone() } else { HashMap::new() };
let (next, result) =
rollup.append(&std::mem::take(&mut pending), AppendOptions { properties, ..Default::default() }).await?;
rollup = next;
rows += u64::try_from(result.rows).unwrap_or(u64::MAX);
}
Ok(rows)
}
pub(crate) async fn create(
raw: &Table,
name: &TableName,
def: &Definition,
schema: &arrow::datatypes::Schema,
opts: crate::TableOptions,
) -> Result<u64> {
let lake = raw.lake();
lake.create_table_with(name, schema, opts, def.properties()?).await?;
let rows = refresh(lake, name, Some(raw), RollupRefresh::Always).await?;
let raw = lake.load(raw.name()).await?;
let name = name.to_string();
update_properties(&raw, |raw| {
let mut names = raw.contract().rollups.clone();
if !names.contains(&name) {
names.push(name.clone());
}
(vec![(property::ROLLUPS.into(), names.join(","))], vec![])
})
.await?;
Ok(rows)
}
pub(crate) async fn drop(lake: &Lake, name: &TableName) -> Result<()> {
let rollup = lake.load(name).await?;
let def = Definition::of(&rollup)?;
match lake.load(&def.source).await {
Ok(raw) if raw.contract().rollups.contains(&name.to_string()) => {
let name = name.to_string();
update_properties(&raw, |raw| {
let names: Vec<&str> =
raw.contract().rollups.iter().map(String::as_str).filter(|n| *n != name).collect();
if names.is_empty() {
(vec![], vec![property::ROLLUPS.to_string()])
} else {
(vec![(property::ROLLUPS.to_string(), names.join(","))], vec![])
}
})
.await?;
}
Ok(_) | Err(Error::NotFound(_)) => {}
Err(e) => return Err(e),
}
lake.drop_table(name).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ranges_merge_and_split_into_windows() {
assert_eq!(merge(vec![(5, 7), (0, 2), (2, 3), (6, 9)]), vec![(0, 3), (5, 9)]);
let day = MIN_WINDOW_MICROS;
let hour = day / 24;
assert_eq!(windows(&[(0, 2 * day + hour)], hour), vec![(0, day), (day, 2 * day), (2 * day, 2 * day + hour)]);
assert_eq!(windows(&[(0, 4 * day)], 2 * day), vec![(0, 2 * day), (2 * day, 4 * day)]);
assert_eq!(floor(-1, 60), -60);
}
}