use std::collections::HashMap;
use async_trait::async_trait;
use iceberg::spec::{DataFileFormat, FormatVersion};
use iceberg::table::Table;
use iceberg::transaction::{ApplyTransactionAction, Transaction};
use iceberg::writer::base_writer::data_file_writer::DataFileWriterBuilder;
use iceberg::writer::file_writer::ParquetWriterBuilder;
use iceberg::writer::file_writer::location_generator::{
DefaultFileNameGenerator, DefaultLocationGenerator,
};
use iceberg::writer::file_writer::rolling_writer::RollingFileWriterBuilder;
use iceberg::writer::{IcebergWriter, IcebergWriterBuilder};
use iceberg::{Catalog, NamespaceIdent, TableCreation, TableIdent};
use time::OffsetDateTime;
use tracing::{debug, info};
use crate::arrow::array::RecordBatch;
use crate::encode::schema;
use crate::error::{Error, Result};
use crate::planner::SnapshotSelector;
use crate::tiering::store::{BatchStream, ColdStore, CommitInfo, SnapshotInfo, WriteHints};
use crate::watermark::{
ARCHIVED_RANGE_PROPERTY, ArchivalWindow, ROW_COUNT_PROPERTY, TieringWatermark,
WATERMARK_PROPERTY,
};
pub struct IcebergCold {
catalog: std::sync::Arc<dyn Catalog>,
namespace: NamespaceIdent,
target_file_size: usize,
}
impl std::fmt::Debug for IcebergCold {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IcebergCold")
.field("namespace", &self.namespace)
.field("target_file_size", &self.target_file_size)
.finish_non_exhaustive()
}
}
impl IcebergCold {
pub fn new(
catalog: std::sync::Arc<dyn Catalog>,
namespace: NamespaceIdent,
target_file_size: usize,
) -> Self {
Self {
catalog,
namespace,
target_file_size,
}
}
fn ident(&self, table: &str) -> TableIdent {
TableIdent::new(self.namespace.clone(), table.to_string())
}
pub async fn create_table(&self, table: &str) -> Result<Table> {
self.create_table_with(table, &[], &[]).await
}
pub async fn create_table_with(
&self,
table: &str,
extra: &[crate::arrow::datatypes::Field],
identity: &[String],
) -> Result<Table> {
if !self
.catalog
.namespace_exists(&self.namespace)
.await
.map_err(ice)?
{
self.catalog
.create_namespace(&self.namespace, HashMap::new())
.await
.map_err(ice)?;
}
let ident = self.ident(table);
if self.catalog.table_exists(&ident).await.map_err(ice)? {
let existing = self.catalog.load_table(&ident).await.map_err(ice)?;
check_partition_spec(table, &existing, identity)?;
return Ok(existing);
}
let arrow_schema = schema::storage_schema(extra);
let iceberg_schema =
iceberg::arrow::arrow_schema_to_schema_auto_assign_ids(arrow_schema.as_ref())
.map_err(ice)?;
let creation = TableCreation::builder()
.name(table.to_string())
.partition_spec(partition_spec(&iceberg_schema, identity)?)
.schema(iceberg_schema)
.build();
let created = self
.catalog
.create_table(&self.namespace, creation)
.await
.map_err(ice)?;
let version = created.metadata().format_version();
if version != FormatVersion::V2 {
return Err(Error::config(format!(
"cold table {table} was created as format version {version:?}, expected V2"
)));
}
info!(table, "cold table created");
Ok(created)
}
pub async fn table_provider(
self: &std::sync::Arc<Self>,
table: &str,
) -> Result<std::sync::Arc<dyn datafusion::catalog::TableProvider>> {
let loaded = self.load(table).await?;
let schema = std::sync::Arc::new(
iceberg::arrow::schema_to_arrow_schema(loaded.metadata().current_schema())
.map_err(ice)?,
);
Ok(std::sync::Arc::new(RefreshingProvider {
cold: std::sync::Arc::clone(self),
table: table.to_string(),
schema,
}))
}
pub async fn expire_snapshots(
&self,
table: &str,
retain_for: time::Duration,
retain_last: usize,
now: OffsetDateTime,
) -> Result<usize> {
let loaded = self.load(table).await?;
let before = loaded.metadata().snapshots().count();
let cutoff = now - retain_for;
let cutoff_ms = cutoff.unix_timestamp() * 1_000;
let txn = Transaction::new(&loaded);
let action = txn
.expire_snapshots()
.expire_older_than_ms(cutoff_ms)
.retain_last(retain_last.max(1));
let committed = action
.apply(txn)
.map_err(ice)?
.commit(self.catalog.as_ref())
.await
.map_err(ice)?;
let after = committed.metadata().snapshots().count();
let expired = before.saturating_sub(after);
if expired > 0 {
info!(table, expired, retain_last, "snapshots expired");
}
Ok(expired)
}
pub async fn version_stats(
&self,
table: &str,
range: (OffsetDateTime, OffsetDateTime),
) -> Result<Vec<Option<crate::planner::VersionStats>>> {
use crate::encode::schema::col;
use crate::planner::VersionStats;
let loaded = self.load(table).await?;
let metadata = loaded.metadata();
let Some(snapshot) = metadata.current_snapshot() else {
return Ok(Vec::new());
};
let schema = metadata.current_schema();
let field_id = |name: &str| schema.field_by_name(name).map(|f| f.id);
let (Some(version_id), Some(from_id)) = (field_id(col::VERSION), field_id(col::FROM))
else {
return Ok(vec![None]);
};
let file_io = loaded.file_io();
let manifest_list = loaded
.manifest_list_reader(snapshot)
.load()
.await
.map_err(ice)?;
let mut stats = Vec::new();
for manifest_file in manifest_list.entries() {
let manifest = manifest_file.load_manifest(file_io).await.map_err(ice)?;
for entry in manifest.entries() {
if !entry.is_alive() {
continue;
}
let data_file = entry.data_file();
if let (Some(lo), Some(hi)) = (
data_file.lower_bounds().get(&from_id),
data_file.upper_bounds().get(&from_id),
) && let (Some(lo), Some(hi)) = (as_timestamp(lo), as_timestamp(hi))
&& (hi < range.0 || lo >= range.1)
{
continue;
}
stats.push(
match (
data_file.lower_bounds().get(&version_id),
data_file.upper_bounds().get(&version_id),
) {
(Some(lo), Some(hi)) => match (as_i128(lo), as_i128(hi)) {
(Some(min), Some(max)) => Some(VersionStats { min, max }),
_ => None,
},
_ => None,
},
);
}
}
Ok(stats)
}
pub async fn snapshots(&self, table: &str) -> Result<Vec<SnapshotInfo>> {
let loaded = self.load(table).await?;
let metadata = loaded.metadata();
let mut out: Vec<SnapshotInfo> = metadata
.snapshots()
.map(|s| {
let properties = &s.summary().additional_properties;
SnapshotInfo {
snapshot_id: s.snapshot_id(),
committed_at: OffsetDateTime::from_unix_timestamp_nanos(
i128::from(s.timestamp_ms()) * 1_000_000,
)
.unwrap_or(OffsetDateTime::UNIX_EPOCH),
watermark: properties
.get(WATERMARK_PROPERTY)
.and_then(|v| TieringWatermark::from_property(v).ok()),
rows: properties
.get(ROW_COUNT_PROPERTY)
.and_then(|v| v.parse().ok()),
}
})
.collect();
out.sort_by(|a, b| {
b.committed_at
.cmp(&a.committed_at)
.then(b.snapshot_id.cmp(&a.snapshot_id))
});
Ok(out)
}
async fn resolve_snapshot(&self, table: &str, at: SnapshotSelector) -> Result<i64> {
let loaded = self.load(table).await?;
let metadata = loaded.metadata();
match at {
SnapshotSelector::Id(id) => {
if metadata.snapshot_by_id(id).is_none() {
return Err(Error::config(format!(
"snapshot {id} is not in the history of {table}: it was either never \
committed, or expired โ see the snapshot_retention setting, which is a \
compliance decision rather than a cleanup knob"
)));
}
Ok(id)
}
SnapshotSelector::Timestamp(instant) => {
let cutoff_ms = instant.unix_timestamp() * 1_000 + i64::from(instant.millisecond());
metadata
.snapshots()
.filter(|s| s.timestamp_ms() <= cutoff_ms)
.max_by_key(|s| (s.timestamp_ms(), s.snapshot_id()))
.map(|s| s.snapshot_id())
.ok_or_else(|| {
Error::config(format!(
"{table} has no snapshot at or before {instant}: the requested \
instant predates the table's history, so there is nothing to \
reproduce"
))
})
}
}
}
pub async fn load(&self, table: &str) -> Result<Table> {
self.catalog
.load_table(&self.ident(table))
.await
.map_err(ice)
}
async fn write_data_files(
&self,
table: &Table,
mut batches: BatchStream,
hints: WriteHints,
) -> Result<(Vec<iceberg::spec::DataFile>, u64)> {
use futures::StreamExt;
let props = super::parquet::writer_properties(
hints.distinct_malo_ids.unwrap_or(DEFAULT_BLOOM_FILTER_NDV),
);
let iceberg_schema = table.metadata().current_schema().clone();
let write_schema = std::sync::Arc::new(
iceberg::arrow::schema_to_arrow_schema(&iceberg_schema).map_err(ice)?,
);
let location = DefaultLocationGenerator::new(table.metadata()).map_err(ice)?;
let names = DefaultFileNameGenerator::new(
"data".to_string(),
Some(uuid_suffix()),
DataFileFormat::Parquet,
);
let rolling = RollingFileWriterBuilder::new(
ParquetWriterBuilder::new(props, iceberg_schema.clone()),
self.target_file_size,
table.file_io().clone(),
location,
names,
);
let files = DataFileWriterBuilder::new(rolling);
let spec = table.metadata().default_partition_spec().clone();
let mut rows = 0u64;
let mut wrote_anything = false;
let data_files = if spec.is_unpartitioned() {
let mut writer = files.build(None).await.map_err(ice)?;
while let Some(batch) = batches.next().await {
let batch = batch?;
if batch.num_rows() == 0 {
continue;
}
rows += batch.num_rows() as u64;
wrote_anything = true;
writer
.write(align(&batch, &write_schema)?)
.await
.map_err(ice)?;
}
if !wrote_anything {
return Ok((Vec::new(), 0));
}
writer.close().await.map_err(ice)?
} else {
use iceberg::arrow::{PartitionValueCalculator, RecordBatchPartitionSplitter};
use iceberg::writer::partitioning::{PartitioningWriter, fanout_writer::FanoutWriter};
let calculator =
PartitionValueCalculator::try_new(&spec, &iceberg_schema).map_err(ice)?;
let splitter = RecordBatchPartitionSplitter::try_new(
iceberg_schema.clone(),
spec.clone(),
Some(calculator),
)
.map_err(ice)?;
let mut writer = FanoutWriter::new(files);
while let Some(batch) = batches.next().await {
let batch = batch?;
if batch.num_rows() == 0 {
continue;
}
rows += batch.num_rows() as u64;
wrote_anything = true;
for (key, part) in splitter
.split(&align(&batch, &write_schema)?)
.map_err(ice)?
{
writer.write(key, part).await.map_err(ice)?;
}
}
if !wrote_anything {
return Ok((Vec::new(), 0));
}
writer.close().await.map_err(ice)?
};
Ok((data_files, rows))
}
async fn commit_with_properties(
&self,
table: Table,
data_files: Vec<iceberg::spec::DataFile>,
properties: HashMap<String, String>,
) -> Result<i64> {
let txn = Transaction::new(&table);
let action = txn
.fast_append()
.add_data_files(data_files)
.set_snapshot_properties(properties);
let committed = action
.apply(txn)
.map_err(ice)?
.commit(self.catalog.as_ref())
.await
.map_err(ice)?;
Ok(committed
.metadata()
.current_snapshot()
.map(|s| s.snapshot_id())
.unwrap_or_default())
}
}
struct RefreshingProvider {
cold: std::sync::Arc<IcebergCold>,
table: String,
schema: crate::arrow::datatypes::SchemaRef,
}
impl std::fmt::Debug for RefreshingProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RefreshingProvider")
.field("table", &self.table)
.finish_non_exhaustive()
}
}
#[async_trait]
impl datafusion::catalog::TableProvider for RefreshingProvider {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn schema(&self) -> crate::arrow::datatypes::SchemaRef {
self.schema.clone()
}
fn table_type(&self) -> datafusion::datasource::TableType {
datafusion::datasource::TableType::Base
}
async fn scan(
&self,
state: &dyn datafusion::catalog::Session,
projection: Option<&Vec<usize>>,
filters: &[datafusion::logical_expr::Expr],
limit: Option<usize>,
) -> datafusion::common::Result<std::sync::Arc<dyn datafusion::physical_plan::ExecutionPlan>>
{
let external = |e: Error| datafusion::common::DataFusionError::External(Box::new(e));
let loaded = self.cold.load(&self.table).await.map_err(external)?;
let provider = iceberg_datafusion::IcebergStaticTableProvider::try_new_from_table(loaded)
.await
.map_err(|e| external(ice(e)))?;
provider.scan(state, projection, filters, limit).await
}
fn supports_filters_pushdown(
&self,
filters: &[&datafusion::logical_expr::Expr],
) -> datafusion::common::Result<Vec<datafusion::logical_expr::TableProviderFilterPushDown>>
{
Ok(vec![
datafusion::logical_expr::TableProviderFilterPushDown::Inexact;
filters.len()
])
}
}
fn align(
batch: &RecordBatch,
write_schema: &crate::arrow::datatypes::SchemaRef,
) -> Result<RecordBatch> {
if batch.num_columns() != write_schema.fields().len() {
return Err(Error::encode(
"cold batch",
format!(
"batch has {} columns but the table's schema has {}: {:?} vs {:?}",
batch.num_columns(),
write_schema.fields().len(),
batch
.schema()
.fields()
.iter()
.map(|f| f.name().clone())
.collect::<Vec<_>>(),
write_schema
.fields()
.iter()
.map(|f| f.name().clone())
.collect::<Vec<_>>(),
),
));
}
let columns = batch
.columns()
.iter()
.zip(write_schema.fields())
.map(|(array, field)| crate::arrow::compute::cast(array, field.data_type()))
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(RecordBatch::try_new(write_schema.clone(), columns)?)
}
const DEFAULT_BLOOM_FILTER_NDV: u64 = 100_000;
fn uuid_suffix() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
format!("{nanos:x}")
}
fn partition_spec(
schema: &iceberg::spec::Schema,
identity: &[String],
) -> Result<iceberg::spec::UnboundPartitionSpec> {
use iceberg::spec::{Transform, UnboundPartitionSpec};
let field_id = |name: &str| -> Result<i32> {
schema
.field_by_name(name)
.map(|f| f.id)
.ok_or_else(|| Error::config(format!("partition column {name:?} is not in the schema")))
};
let mut builder = UnboundPartitionSpec::builder().with_spec_id(0);
for name in identity {
builder = builder
.add_partition_field(field_id(name)?, name.clone(), Transform::Identity)
.map_err(ice)?;
}
builder = builder
.add_partition_field(
field_id(schema::col::FROM)?,
format!("{}_month", schema::col::FROM),
Transform::Month,
)
.map_err(ice)?;
Ok(builder.build())
}
fn check_partition_spec(table: &str, existing: &Table, identity: &[String]) -> Result<()> {
let actual: Vec<&str> = existing
.metadata()
.default_partition_spec()
.fields()
.iter()
.map(|f| f.name.as_str())
.collect();
let month = format!("{}_month", schema::col::FROM);
let expected: Vec<&str> = identity
.iter()
.map(String::as_str)
.chain(std::iter::once(month.as_str()))
.collect();
if actual == expected {
return Ok(());
}
Err(Error::config(format!(
"cold table {table} is partitioned by [{}] but this configuration wants \
[{}]. Iceberg has no partition-spec evolution here, so the existing table \
cannot acquire the difference: every scan would prune as the stored spec \
allows, not as the configuration implies. Recreate the table, or declare \
the identity columns the table was built with.",
actual.join(", "),
expected.join(", "),
)))
}
fn ice(e: iceberg::Error) -> Error {
Error::Storage(e.to_string())
}
#[async_trait]
impl ColdStore for IcebergCold {
async fn create_tables(
&self,
table: &str,
identity: &[String],
extra: &[crate::arrow::datatypes::Field],
) -> Result<()> {
self.create_table_with(table, extra, identity)
.await
.map(|_| ())
}
async fn purge_table(&self, table: &str) -> Result<()> {
let ident = self.ident(table);
if !self.catalog.table_exists(&ident).await.map_err(ice)? {
return Ok(());
}
self.catalog.purge_table(&ident).await.map_err(ice)?;
info!(table, "cold table purged");
Ok(())
}
async fn watermark(&self, table: &str) -> Result<TieringWatermark> {
let loaded = self.load(table).await?;
watermark_of(&loaded)
}
async fn append_and_commit(
&self,
table: &str,
batches: BatchStream,
hints: WriteHints,
window: ArchivalWindow,
) -> Result<CommitInfo> {
let loaded = self.load(table).await?;
let watermark = window.resulting_watermark();
watermark_of(&loaded)?
.advance_to(watermark)
.map_err(|_| Error::InvariantViolated {
table: table.to_string(),
detail: format!(
"archiving [{}, {}) would move the watermark backwards from {}",
window.from(),
window.to(),
watermark_of(&loaded).unwrap_or(TieringWatermark::empty()),
),
})?;
let (data_files, rows) = self.write_data_files(&loaded, batches, hints).await?;
let properties = HashMap::from([
(WATERMARK_PROPERTY.to_string(), watermark.to_property()?),
(ARCHIVED_RANGE_PROPERTY.to_string(), window.to_property()?),
(ROW_COUNT_PROPERTY.to_string(), rows.to_string()),
]);
let snapshot_id = self
.commit_with_properties(loaded, data_files, properties)
.await?;
debug!(table, rows, %watermark, snapshot_id, "cold commit");
Ok(CommitInfo {
snapshot_id,
rows,
watermark,
})
}
async fn expire_snapshots(
&self,
table: &str,
retain_for: time::Duration,
retain_last: usize,
now: OffsetDateTime,
) -> Result<usize> {
IcebergCold::expire_snapshots(self, table, retain_for, retain_last, now).await
}
async fn version_stats(
&self,
table: &str,
range: (OffsetDateTime, OffsetDateTime),
) -> Result<Vec<Option<crate::planner::VersionStats>>> {
IcebergCold::version_stats(self, table, range).await
}
async fn snapshot_provider(
&self,
table: &str,
at: SnapshotSelector,
) -> Result<std::sync::Arc<dyn datafusion::catalog::TableProvider>> {
let snapshot_id = self.resolve_snapshot(table, at).await?;
let loaded = self.load(table).await?;
let provider = iceberg_datafusion::IcebergStaticTableProvider::try_new_from_table_snapshot(
loaded,
snapshot_id,
)
.await
.map_err(ice)?;
debug!(table, snapshot_id, %at, "pinned cold provider");
Ok(std::sync::Arc::new(provider))
}
async fn snapshots(&self, table: &str) -> Result<Vec<SnapshotInfo>> {
IcebergCold::snapshots(self, table).await
}
async fn stored_schema(
&self,
table: &str,
) -> Result<Option<crate::arrow::datatypes::SchemaRef>> {
if !self
.catalog
.table_exists(&self.ident(table))
.await
.map_err(ice)?
{
return Ok(None);
}
let loaded = self.load(table).await?;
let arrow = iceberg::arrow::schema_to_arrow_schema(loaded.metadata().current_schema())
.map_err(ice)?;
Ok(Some(std::sync::Arc::new(arrow)))
}
async fn append_only(
&self,
table: &str,
batches: BatchStream,
hints: WriteHints,
) -> Result<CommitInfo> {
let loaded = self.load(table).await?;
let watermark = watermark_of(&loaded)?;
let (data_files, rows) = self.write_data_files(&loaded, batches, hints).await?;
let properties = HashMap::from([
(WATERMARK_PROPERTY.to_string(), watermark.to_property()?),
(ROW_COUNT_PROPERTY.to_string(), rows.to_string()),
]);
let snapshot_id = self
.commit_with_properties(loaded, data_files, properties)
.await?;
debug!(table, rows, snapshot_id, "cold correction append");
Ok(CommitInfo {
snapshot_id,
rows,
watermark,
})
}
}
fn watermark_of(table: &Table) -> Result<TieringWatermark> {
let metadata = table.metadata();
let Some(current) = metadata.current_snapshot() else {
return Ok(TieringWatermark::empty());
};
let mut snapshot = current.clone();
for _ in 0..=metadata.snapshots().count() {
if let Some(value) = snapshot
.summary()
.additional_properties
.get(WATERMARK_PROPERTY)
{
return TieringWatermark::from_property(value);
}
let Some(parent) = snapshot
.parent_snapshot_id()
.and_then(|id| metadata.snapshot_by_id(id))
else {
break;
};
snapshot = parent.clone();
}
Err(Error::InvariantViolated {
table: table.identifier().name().to_string(),
detail: format!(
"no snapshot in the history of {} carries {WATERMARK_PROPERTY}; this table \
was not written by MeterStore and the tier boundary cannot be determined",
table.identifier()
),
})
}
fn as_i128(datum: &iceberg::spec::Datum) -> Option<i128> {
use iceberg::spec::PrimitiveLiteral;
match datum.literal() {
PrimitiveLiteral::Int128(v) => Some(*v),
PrimitiveLiteral::Long(v) => Some(i128::from(*v)),
PrimitiveLiteral::Int(v) => Some(i128::from(*v)),
_ => None,
}
}
fn as_timestamp(datum: &iceberg::spec::Datum) -> Option<OffsetDateTime> {
use iceberg::spec::PrimitiveLiteral;
let PrimitiveLiteral::Long(micros) = datum.literal() else {
return None;
};
OffsetDateTime::from_unix_timestamp_nanos(i128::from(*micros) * 1_000).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_name_suffixes_differ_between_calls() {
assert_ne!(uuid_suffix(), {
std::thread::sleep(std::time::Duration::from_nanos(1));
uuid_suffix()
});
}
}