use std::collections::{HashMap, HashSet};
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, warn};
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,
}
}
pub fn catalog(&self) -> std::sync::Arc<dyn Catalog> {
std::sync::Arc::clone(&self.catalog)
}
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 self.disable_library_commit_retry(existing).await;
}
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)
.properties(HashMap::from([(
COMMIT_RETRIES_PROPERTY.to_string(),
"0".to_string(),
)]))
.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> {
self.reassert_watermark(table).await?;
let loaded = self.load(table).await?;
let metadata = loaded.metadata();
let before = metadata.snapshots().count();
let cutoff_ms = (now - retain_for).unix_timestamp() * 1_000;
let mut ordered: Vec<_> = metadata.snapshots().collect();
ordered.sort_by_key(|s| (s.timestamp_ms(), s.snapshot_id()));
let mut protected: std::collections::HashSet<i64> = Default::default();
let mut walk = metadata.current_snapshot().cloned();
while let Some(snapshot) = walk {
protected.insert(snapshot.snapshot_id());
if snapshot
.summary()
.additional_properties
.contains_key(WATERMARK_PROPERTY)
{
break;
}
walk = snapshot
.parent_snapshot_id()
.and_then(|id| metadata.snapshot_by_id(id))
.cloned();
}
protected.extend(
ordered
.iter()
.rev()
.take(retain_last.max(1))
.map(|s| s.snapshot_id()),
);
let doomed: Vec<i64> = ordered
.iter()
.filter(|s| s.timestamp_ms() < cutoff_ms)
.map(|s| s.snapshot_id())
.filter(|id| !protected.contains(id))
.collect();
if doomed.is_empty() {
return Ok(0);
}
let txn = Transaction::new(&loaded);
let action = txn.expire_snapshots().expire_snapshot_ids(doomed);
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 reassert_watermark(&self, table: &str) -> Result<Option<CommitInfo>> {
let loaded = self.load(table).await?;
let Some(current) = loaded.metadata().current_snapshot() else {
return Ok(None);
};
if current
.summary()
.additional_properties
.contains_key(WATERMARK_PROPERTY)
{
return Ok(None);
}
let watermark = watermark_of(&loaded)?;
info!(
table,
%watermark,
"current snapshot carries no boundary; restating it after an out-of-band commit"
);
self.append_with_summary(
table,
crate::tiering::store::stream_of(Vec::new()),
WriteHints::default(),
Summary::Preserve,
)
.await
.map(Some)
}
pub async fn version_stats(
&self,
table: &str,
range: (OffsetDateTime, OffsetDateTime),
) -> Result<Vec<crate::planner::FileStats>> {
use crate::encode::schema::col;
use crate::planner::{FileStats, 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![FileStats::unknown()]);
};
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();
let interval = match (
data_file.lower_bounds().get(&from_id),
data_file.upper_bounds().get(&from_id),
) {
(Some(lo), Some(hi)) => match (as_timestamp(lo), as_timestamp(hi)) {
(Some(lo), Some(hi)) => Some((lo, hi)),
_ => None,
},
_ => None,
};
if let Some((lo, hi)) = interval
&& (hi < range.0 || lo >= range.1)
{
continue;
}
let version = 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,
};
stats.push(FileStats { version, interval });
}
}
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(file_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 disable_library_commit_retry(&self, table: Table) -> Result<Table> {
if table.metadata().properties().get(COMMIT_RETRIES_PROPERTY) == Some(&"0".to_string()) {
return Ok(table);
}
let txn = Transaction::new(&table);
let action = txn
.update_table_properties()
.set(COMMIT_RETRIES_PROPERTY.to_string(), "0".to_string());
let updated = action
.apply(txn)
.map_err(ice)?
.commit(self.catalog.as_ref())
.await
.map_err(ice)?;
debug!(
table = table.identifier().name(),
"library commit retry disabled; the watermark-preserving retry is ours"
);
Ok(updated)
}
async fn append_with_summary(
&self,
table: &str,
batches: BatchStream,
hints: WriteHints,
summary: Summary,
) -> Result<CommitInfo> {
let mut base = self.load(table).await?;
let (data_files, rows) = self.write_data_files(&base, batches, hints).await?;
for attempt in 0..=COMMIT_ATTEMPTS {
let watermark = summary.watermark_for(table, &base)?;
let mut properties = HashMap::from([
(WATERMARK_PROPERTY.to_string(), watermark.to_property()?),
(ROW_COUNT_PROPERTY.to_string(), rows.to_string()),
]);
if let Summary::Advance(window) = summary {
properties.insert(ARCHIVED_RANGE_PROPERTY.to_string(), window.to_property()?);
}
let txn = Transaction::new(&base);
let action = txn
.fast_append()
.add_data_files(data_files.clone())
.set_snapshot_properties(properties);
match action
.apply(txn)
.map_err(ice)?
.commit(self.catalog.as_ref())
.await
{
Ok(committed) => {
let snapshot_id = committed
.metadata()
.current_snapshot()
.map(|s| s.snapshot_id())
.unwrap_or_default();
debug!(table, rows, %watermark, snapshot_id, "cold commit");
return Ok(CommitInfo {
snapshot_id,
rows,
watermark,
});
}
Err(e)
if e.kind() == iceberg::ErrorKind::CatalogCommitConflicts
&& attempt < COMMIT_ATTEMPTS =>
{
debug!(
table,
attempt, "commit conflict; re-deriving against a fresh base"
);
tokio::time::sleep(std::time::Duration::from_millis(50 << attempt.min(6)))
.await;
base = self.load(table).await?;
}
Err(e) => {
self.discard(table, &base, &data_files).await;
return Err(ice(e));
}
}
}
self.discard(table, &base, &data_files).await;
Err(Error::Storage(format!(
"{table}: {COMMIT_ATTEMPTS} commit attempts all lost the compare-and-swap; \
another writer is committing continuously"
)))
}
async fn discard(&self, table: &str, base: &Table, files: &[iceberg::spec::DataFile]) {
if files.is_empty() {
return;
}
let referenced = match self.referenced_paths(table).await {
Ok(paths) => paths,
Err(e) => {
warn!(
table,
error = %e,
files = files.len(),
"could not confirm the failed commit left its data files unreferenced; \
leaving them in place"
);
return;
}
};
let io = base.file_io();
let (mut removed, mut kept) = (0usize, 0usize);
for file in files {
if referenced.contains(file.file_path()) {
kept += 1;
continue;
}
match io.delete(file.file_path()).await {
Ok(()) => removed += 1,
Err(e) => warn!(table, path = file.file_path(), error = %e, "orphan not removed"),
}
}
if kept > 0 {
warn!(
table,
kept, "the failed commit had in fact landed; its data files are live and were kept"
);
}
if removed > 0 {
info!(
table,
removed, "removed data files from a commit that never landed"
);
}
}
async fn referenced_paths(&self, table: &str) -> Result<HashSet<String>> {
let loaded = self.load(table).await?;
let Some(snapshot) = loaded.metadata().current_snapshot() else {
return Ok(HashSet::new());
};
let file_io = loaded.file_io();
let manifest_list = loaded
.manifest_list_reader(snapshot)
.load()
.await
.map_err(ice)?;
let mut paths = HashSet::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() {
paths.insert(entry.data_file().file_path().to_string());
}
}
}
Ok(paths)
}
}
#[derive(Debug, Clone, Copy)]
enum Summary {
Advance(ArchivalWindow),
Preserve,
}
impl Summary {
fn watermark_for(self, table: &str, base: &Table) -> Result<TieringWatermark> {
let current = watermark_of(base)?;
match self {
Self::Preserve => Ok(current),
Self::Advance(window) => {
let next = window.resulting_watermark();
current
.advance_to(next)
.map_err(|_| Error::InvariantViolated {
table: table.to_string(),
detail: format!(
"archiving [{}, {}) would move the watermark backwards from {current}",
window.from(),
window.to(),
),
})
}
}
}
}
const COMMIT_RETRIES_PROPERTY: &str = "commit.retry.num-retries";
const COMMIT_ATTEMPTS: u32 = 4;
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 file_suffix() -> String {
let mut bytes = [0u8; 8];
getrandom::fill(&mut bytes).expect("OS entropy source unavailable");
format!("{:016x}", u64::from_be_bytes(bytes))
}
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> {
self.append_with_summary(table, batches, hints, Summary::Advance(window))
.await
}
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<crate::planner::FileStats>> {
IcebergCold::version_stats(self, table, range).await
}
async fn reassert_watermark(&self, table: &str) -> Result<Option<CommitInfo>> {
IcebergCold::reassert_watermark(self, table).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> {
self.append_with_summary(table, batches, hints, Summary::Preserve)
.await
}
}
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_do_not_depend_on_the_clock() {
let suffixes: std::collections::HashSet<String> =
(0..1_000).map(|_| file_suffix()).collect();
assert_eq!(suffixes.len(), 1_000);
assert!(suffixes.iter().all(|s| s.len() == 16));
}
}