use std::collections::HashMap;
use std::sync::Arc;
use crate::dimensions::Dimensions;
use crate::merkle::MerkleAccumulator;
use crate::storage::{Cursor, LedgerStore, StoredEntry};
use crate::{Direction, Hash, Layer, LogIndex, Seal};
use arrow_array::{ArrayRef, Date32Array, Int32Array, Int64Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema as ArrowSchema};
use iceberg::spec::{DataFileFormat, Schema as IcebergSchema};
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, TableIdent};
use parquet::file::properties::WriterProperties;
pub const PROP_PERIOD: &str = "doubleentry.period";
pub const PROP_SEAL_HASH: &str = "doubleentry.seal_hash";
pub const PROP_TREE_ROOT: &str = "doubleentry.tree_root";
pub const PROP_TRIAL_BALANCE_ROOT: &str = "doubleentry.trial_balance_root";
pub const PROP_TRIAL_BALANCE_SIZE: &str = "doubleentry.trial_balance_size";
pub const PROP_ACCOUNTS_ROOT: &str = "doubleentry.accounts_root";
pub const PROP_ACCOUNTS_SIZE: &str = "doubleentry.accounts_size";
pub const PROP_ENTRY_COUNT: &str = "doubleentry.entry_count";
pub const PROP_ARCHIVED_THROUGH: &str = "doubleentry.archived_through";
pub const PROP_ACCUMULATOR: &str = "doubleentry.accumulator";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ColdTierError {
#[error(transparent)]
Iceberg(#[from] iceberg::Error),
#[error(transparent)]
Arrow(#[from] arrow_schema::ArrowError),
#[error("reading the ledger failed: {0}")]
Store(String),
#[error("entry {entry} is in the period but has not been sequenced")]
NotSequenced {
entry: String,
},
#[error(
"period {period} seals the log at size {seal_size}, \
but the archive already holds {archived}"
)]
SealBehindArchive {
period: String,
seal_size: u64,
archived: u64,
},
#[error("the archive's recorded state is malformed: {0}")]
MalformedState(String),
#[error(
"archive does not match the seal for period {period}: \
the seal committed to {expected}, the archive plus this delta hashes to {actual}"
)]
RootMismatch {
period: String,
expected: String,
actual: String,
},
}
#[must_use]
pub fn arrow_schema() -> ArrowSchema {
ArrowSchema::new(vec![
Field::new("period", DataType::Utf8, false),
Field::new("log_index", DataType::Int64, false),
Field::new("entry_id", DataType::Utf8, false),
Field::new("content_hash", DataType::Utf8, false),
Field::new("idempotency_key", DataType::Utf8, false),
Field::new("booking_date", DataType::Date32, false),
Field::new("value_date", DataType::Date32, false),
Field::new("description", DataType::Utf8, false),
Field::new("kind", DataType::Utf8, true),
Field::new("posting_index", DataType::Int32, false),
Field::new("account_index", DataType::Int64, false),
Field::new("direction", DataType::Utf8, false),
Field::new("amount_minor", DataType::Int64, false),
Field::new("currency", DataType::Utf8, false),
Field::new("layer", DataType::Utf8, false),
Field::new("dimensions", DataType::Utf8, true),
Field::new("provenance_actor", DataType::Utf8, true),
Field::new("provenance_source", DataType::Utf8, true),
Field::new("provenance_correlation", DataType::Utf8, true),
Field::new("document_id", DataType::Utf8, true),
Field::new("document_content_hash", DataType::Utf8, true),
Field::new("reverses", DataType::Utf8, true),
Field::new("original_booking_date", DataType::Date32, true),
])
}
fn dimensions_json(dimensions: &Dimensions) -> Option<String> {
if dimensions.is_empty() {
return None;
}
let mut out = String::from("{");
for (i, (axis, value)) in dimensions.iter().enumerate() {
if i > 0 {
out.push(',');
}
push_json_string(&mut out, axis.as_str());
out.push(':');
push_json_string(&mut out, value.as_str());
}
out.push('}');
Some(out)
}
fn push_json_string(out: &mut String, value: &str) {
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
other => out.push(other),
}
}
out.push('"');
}
pub fn iceberg_schema() -> Result<IcebergSchema, ColdTierError> {
Ok(iceberg::arrow::arrow_schema_to_schema_auto_assign_ids(
&arrow_schema(),
)?)
}
fn batch_schema() -> Result<ArrowSchema, ColdTierError> {
Ok(iceberg::arrow::schema_to_arrow_schema(&iceberg_schema()?)?)
}
const DATE32_EPOCH: time::Date = time::macros::date!(1970 - 01 - 01);
fn date32(date: time::Date) -> i32 {
date.to_julian_day()
.saturating_sub(DATE32_EPOCH.to_julian_day())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Compaction {
pub period: String,
pub archived_through: u64,
pub entries: u64,
pub postings: u64,
pub verified_root: Hash,
pub snapshot_id: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ArchiveState {
through: u64,
accumulator: MerkleAccumulator,
}
impl ArchiveState {
fn empty() -> Self {
Self {
through: 0,
accumulator: MerkleAccumulator::new(),
}
}
}
fn encode_accumulator(accumulator: &MerkleAccumulator) -> String {
accumulator
.subtrees()
.iter()
.map(|(height, node)| format!("{height}:{}", node.to_hex()))
.collect::<Vec<_>>()
.join(",")
}
fn decode_accumulator(raw: &str, size: u64) -> Result<MerkleAccumulator, ColdTierError> {
if raw.is_empty() {
return MerkleAccumulator::try_from_parts(Vec::new(), size)
.map_err(|e| ColdTierError::MalformedState(e.to_string()));
}
let mut subtrees = Vec::new();
for part in raw.split(',') {
let (height, hex) = part.split_once(':').ok_or_else(|| {
ColdTierError::MalformedState(format!("accumulator entry {part:?} has no height"))
})?;
let height: u8 = height.parse().map_err(|_| {
ColdTierError::MalformedState(format!("accumulator height {height:?} is not a number"))
})?;
let node = Hash::parse_hex(hex)
.map_err(|e| ColdTierError::MalformedState(format!("accumulator node: {e}")))?;
subtrees.push((height, node));
}
MerkleAccumulator::try_from_parts(subtrees, size)
.map_err(|e| ColdTierError::MalformedState(e.to_string()))
}
#[derive(Debug, Clone)]
pub struct ColdTier {
table: TableIdent,
}
impl ColdTier {
#[must_use]
pub fn new(table: TableIdent) -> Self {
Self { table }
}
#[must_use]
pub fn table(&self) -> &TableIdent {
&self.table
}
pub async fn compact<const P: u8, S, C>(
&self,
store: &S,
seal: &Seal,
catalog: &C,
) -> Result<Compaction, ColdTierError>
where
S: LedgerStore<P>,
C: Catalog,
{
let table = catalog.load_table(&self.table).await?;
let state = archive_state(&table)?;
if seal.tree_head.size < state.through {
return Err(ColdTierError::SealBehindArchive {
period: seal.period.to_string(),
seal_size: seal.tree_head.size,
archived: state.through,
});
}
let entries = self
.collect(store, state.through, seal.tree_head.size)
.await?;
let mut accumulator = state.accumulator.clone();
for record in &entries {
accumulator.push(record.content_hash);
}
let reached = accumulator.root();
if reached != seal.tree_head.root {
return Err(ColdTierError::RootMismatch {
period: seal.period.to_string(),
expected: seal.tree_head.root.to_hex(),
actual: reached.to_hex(),
});
}
if entries.is_empty() {
return Ok(Compaction {
period: seal.period.to_string(),
archived_through: state.through,
entries: 0,
postings: 0,
verified_root: reached,
snapshot_id: None,
});
}
let (batch, postings) = build_batch(&entries, seal)?;
let snapshot_id = write_snapshot(&table, catalog, seal, batch, &accumulator).await?;
Ok(Compaction {
period: seal.period.to_string(),
archived_through: seal.tree_head.size,
entries: entries.len() as u64,
postings,
verified_root: reached,
snapshot_id: Some(snapshot_id),
})
}
async fn collect<const P: u8, S: LedgerStore<P>>(
&self,
store: &S,
from: u64,
to: u64,
) -> Result<Vec<StoredEntry<P>>, ColdTierError> {
if from >= to {
return Ok(Vec::new());
}
let mut out = Vec::new();
let mut cursor = Some(match from.checked_sub(1) {
Some(previous) => Cursor::after(LogIndex::new(previous)),
None => Cursor::start(),
});
while let Some(c) = cursor {
let page = store
.page(c)
.await
.map_err(|e| ColdTierError::Store(e.to_string()))?;
if page.records.is_empty() {
break;
}
let mut done = false;
for record in page.records {
let index = record.index.ok_or_else(|| ColdTierError::NotSequenced {
entry: record.entry.id().to_string(),
})?;
if index.get() >= to {
done = true;
break;
}
out.push(record);
}
if done {
break;
}
cursor = page.next;
}
Ok(out)
}
}
fn archive_state(table: &Table) -> Result<ArchiveState, ColdTierError> {
let Some(snapshot) = table.metadata().current_snapshot() else {
return Ok(ArchiveState::empty());
};
let summary = &snapshot.summary().additional_properties;
let (Some(through), Some(raw)) = (
summary.get(PROP_ARCHIVED_THROUGH),
summary.get(PROP_ACCUMULATOR),
) else {
return Err(ColdTierError::MalformedState(format!(
"snapshot {} carries no archive state; \
the table holds data this crate did not write",
snapshot.snapshot_id()
)));
};
let through: u64 = through.parse().map_err(|_| {
ColdTierError::MalformedState(format!("archived_through {through:?} is not a number"))
})?;
Ok(ArchiveState {
through,
accumulator: decode_accumulator(raw, through)?,
})
}
async fn write_snapshot<C: Catalog>(
table: &Table,
catalog: &C,
seal: &Seal,
batch: RecordBatch,
accumulator: &MerkleAccumulator,
) -> Result<i64, ColdTierError> {
let location_generator = DefaultLocationGenerator::new(table.metadata())?;
let file_name_generator =
DefaultFileNameGenerator::new(seal.period.to_string(), None, DataFileFormat::Parquet);
let parquet_writer_builder = ParquetWriterBuilder::new(
WriterProperties::default(),
table.metadata().current_schema().clone(),
);
let rolling = RollingFileWriterBuilder::new_with_default_file_size(
parquet_writer_builder,
table.file_io().clone(),
location_generator,
file_name_generator,
);
let mut writer = DataFileWriterBuilder::new(rolling).build(None).await?;
writer.write(batch).await?;
let data_files = writer.close().await?;
let properties = HashMap::from([
(PROP_PERIOD.to_owned(), seal.period.to_string()),
(PROP_SEAL_HASH.to_owned(), seal.seal_hash.to_hex()),
(PROP_TREE_ROOT.to_owned(), seal.tree_head.root.to_hex()),
(
PROP_TRIAL_BALANCE_ROOT.to_owned(),
seal.trial_balance.root.to_hex(),
),
(
PROP_TRIAL_BALANCE_SIZE.to_owned(),
seal.trial_balance.size.to_string(),
),
(PROP_ACCOUNTS_ROOT.to_owned(), seal.accounts.root.to_hex()),
(
PROP_ACCOUNTS_SIZE.to_owned(),
seal.accounts.size.to_string(),
),
(PROP_ENTRY_COUNT.to_owned(), seal.entry_count.to_string()),
(
PROP_ARCHIVED_THROUGH.to_owned(),
seal.tree_head.size.to_string(),
),
(PROP_ACCUMULATOR.to_owned(), encode_accumulator(accumulator)),
]);
let tx = Transaction::new(table);
let action = tx
.fast_append()
.add_data_files(data_files)
.set_snapshot_properties(properties);
let committed = action.apply(tx)?.commit(catalog).await?;
Ok(committed
.metadata()
.current_snapshot()
.map_or(0, |s| s.snapshot_id()))
}
fn build_batch<const P: u8>(
entries: &[StoredEntry<P>],
seal: &Seal,
) -> Result<(RecordBatch, u64), ColdTierError> {
let mut period = Vec::new();
let mut log_index = Vec::new();
let mut entry_id = Vec::new();
let mut content_hash = Vec::new();
let mut idempotency_key = Vec::new();
let mut booking_date = Vec::new();
let mut value_date = Vec::new();
let mut description = Vec::new();
let mut kind: Vec<Option<String>> = Vec::new();
let mut posting_index = Vec::new();
let mut account_index = Vec::new();
let mut direction = Vec::new();
let mut amount_minor = Vec::new();
let mut currency = Vec::new();
let mut layer = Vec::new();
let mut dimensions: Vec<Option<String>> = Vec::new();
let mut actor: Vec<Option<String>> = Vec::new();
let mut source: Vec<Option<String>> = Vec::new();
let mut correlation: Vec<Option<String>> = Vec::new();
let mut document_id: Vec<Option<String>> = Vec::new();
let mut document_hash: Vec<Option<String>> = Vec::new();
let mut reverses: Vec<Option<String>> = Vec::new();
let mut original_booking_date: Vec<Option<i32>> = Vec::new();
for record in entries {
let index: LogIndex = record.index.ok_or_else(|| ColdTierError::NotSequenced {
entry: record.entry.id().to_string(),
})?;
let entry = &record.entry;
for (position, posting) in entry.postings().iter().enumerate() {
period.push(seal.period.to_string());
log_index.push(i64::try_from(index.get()).unwrap_or(i64::MAX));
entry_id.push(entry.id().to_string());
content_hash.push(record.content_hash.to_hex());
idempotency_key.push(entry.idempotency_key().to_hex());
booking_date.push(date32(entry.booking_date()));
value_date.push(date32(entry.value_date()));
description.push(entry.description().as_str().to_owned());
kind.push(entry.kind().map(ToString::to_string));
posting_index.push(i32::try_from(position).unwrap_or(i32::MAX));
account_index.push(i64::from(posting.account.index()));
direction.push(
match posting.direction {
Direction::Debit => "D",
Direction::Credit => "C",
}
.to_owned(),
);
amount_minor.push(posting.amount.to_minor());
currency.push(posting.currency.code().to_owned());
layer.push(
match posting.layer {
Layer::Settled => "settled",
Layer::Pending => "pending",
}
.to_owned(),
);
dimensions.push(dimensions_json(&posting.dimensions));
actor.push(entry.provenance().actor.as_ref().map(ToString::to_string));
source.push(entry.provenance().source.as_ref().map(ToString::to_string));
correlation.push(
entry
.provenance()
.correlation
.as_ref()
.map(ToString::to_string),
);
document_id.push(entry.document().map(|d| d.id.to_string()));
document_hash.push(
entry
.document()
.and_then(|d| d.content_hash.as_ref())
.map(Hash::to_hex),
);
reverses.push(entry.reverses().map(|r| r.to_string()));
original_booking_date.push(entry.original_booking_date().map(date32));
}
}
let postings = log_index.len() as u64;
let columns: Vec<ArrayRef> = vec![
Arc::new(StringArray::from(period)),
Arc::new(Int64Array::from(log_index)),
Arc::new(StringArray::from(entry_id)),
Arc::new(StringArray::from(content_hash)),
Arc::new(StringArray::from(idempotency_key)),
Arc::new(Date32Array::from(booking_date)),
Arc::new(Date32Array::from(value_date)),
Arc::new(StringArray::from(description)),
Arc::new(StringArray::from(kind)),
Arc::new(Int32Array::from(posting_index)),
Arc::new(Int64Array::from(account_index)),
Arc::new(StringArray::from(direction)),
Arc::new(Int64Array::from(amount_minor)),
Arc::new(StringArray::from(currency)),
Arc::new(StringArray::from(layer)),
Arc::new(StringArray::from(dimensions)),
Arc::new(StringArray::from(actor)),
Arc::new(StringArray::from(source)),
Arc::new(StringArray::from(correlation)),
Arc::new(StringArray::from(document_id)),
Arc::new(StringArray::from(document_hash)),
Arc::new(StringArray::from(reverses)),
Arc::new(Date32Array::from(original_booking_date)),
];
let batch = RecordBatch::try_new(Arc::new(batch_schema()?), columns)?;
Ok((batch, postings))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_schema_has_a_column_for_every_field_written() {
assert_eq!(arrow_schema().fields().len(), 23);
}
#[test]
fn the_archive_carries_every_field_the_content_hash_covers() {
let schema = arrow_schema();
let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
for required in [
"idempotency_key",
"booking_date",
"value_date",
"description",
"kind",
"posting_index",
"account_index",
"direction",
"amount_minor",
"currency",
"layer",
"dimensions",
"provenance_actor",
"provenance_source",
"provenance_correlation",
"document_id",
"document_content_hash",
"reverses",
"original_booking_date",
] {
assert!(names.contains(&required), "{required} is not archived");
}
}
#[test]
fn dimensions_render_as_a_canonical_json_object() {
use crate::Label;
assert_eq!(dimensions_json(&Dimensions::none()), None);
let dims = Dimensions::none()
.with(
Label::new("segment").expect("valid"),
Label::new("Electricity").expect("valid"),
)
.expect("fits")
.with(
Label::new("activity").expect("valid"),
Label::new("Network").expect("valid"),
)
.expect("fits");
assert_eq!(
dimensions_json(&dims).as_deref(),
Some(r#"{"activity":"Network","segment":"Electricity"}"#)
);
let quoted = Dimensions::none()
.with(
Label::new("note").expect("valid"),
Label::new(r#"a"b\c"#).expect("valid"),
)
.expect("fits");
assert_eq!(
dimensions_json("ed).as_deref(),
Some(r#"{"note":"a\"b\\c"}"#)
);
}
#[test]
fn dates_convert_to_the_arrow_epoch() {
assert_eq!(date32(time::macros::date!(1970 - 01 - 01)), 0);
assert_eq!(date32(time::macros::date!(1970 - 01 - 02)), 1);
assert_eq!(date32(time::macros::date!(1969 - 12 - 31)), -1);
assert_eq!(date32(time::macros::date!(2026 - 03 - 15)), 20_527);
}
}