use std::time::{Duration, Instant};
use arrow_array::RecordBatch;
use skade::iceberg::spec::Operation;
use skade::iceberg::table::Table as IceTable;
use crate::{
error::{BifrostError, Result},
sink::table::{SnapshotWindow, TableSink, TableWindow, deliver_window, refuse_full_reload},
state::SyncState,
transform::partition_change_rows,
};
#[derive(Debug, Clone)]
pub enum IcebergLocator {
Warehouse { dir: std::path::PathBuf, table: String },
#[cfg(feature = "skade-s3")]
S3(skade::connect::S3Table),
#[cfg(feature = "skade-rest")]
Rest { catalog: skade::connect::IcebergRest, table: String },
}
impl IcebergLocator {
pub fn source_id(&self) -> String {
match self {
IcebergLocator::Warehouse { dir, table } => format!("skade:{}#{table}", dir.display()),
#[cfg(feature = "skade-s3")]
IcebergLocator::S3(t) => format!("s3:{}", t.location.trim_end_matches('/')),
#[cfg(feature = "skade-rest")]
IcebergLocator::Rest { catalog, table } => format!(
"rest:{}#{}#{table}",
catalog.uri.trim_end_matches('/'),
catalog.warehouse.as_deref().unwrap_or("-")
),
}
}
pub async fn load(&self) -> Result<IceTable> {
let src = |e: skade::SkadeError| BifrostError::Source(format!("{}: {e}", self.source_id()));
match self {
IcebergLocator::Warehouse { dir, table } => {
let wh = skade::Warehouse::open(dir).await.map_err(src)?;
Ok(wh.table(table).await.map_err(src)?.inner().clone())
}
#[cfg(feature = "skade-s3")]
IcebergLocator::S3(t) => t.load().await.map_err(src),
#[cfg(feature = "skade-rest")]
IcebergLocator::Rest { catalog, table } => {
use skade::connect::CatalogConnector as _;
let ident = skade::iceberg::TableIdent::from_strs(table.split('.')).map_err(|e| {
BifrostError::Source(format!("{}: bad table name {table:?}: {e}", self.source_id()))
})?;
let cat = catalog.open().await.map_err(src)?;
cat.load_table(&ident)
.await
.map_err(|e| BifrostError::Source(format!("{}: {e}", self.source_id())))
}
}
}
}
#[derive(Debug, Clone)]
pub struct DeliveryOptions {
pub max_windows_per_round: usize,
}
impl Default for DeliveryOptions {
fn default() -> Self {
Self { max_windows_per_round: 64 }
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RoundOutcome {
pub windows: usize,
pub rows_loaded: usize,
pub rows_deleted: usize,
pub rows_duplicate: usize,
pub at_snapshot: Option<i64>,
pub behind: usize,
pub read: Duration,
pub load: Duration,
}
pub struct IcebergDelivery<S: TableSink> {
locator: IcebergLocator,
sink: S,
state: SyncState,
key: String,
opts: DeliveryOptions,
probed: bool,
}
impl<S: TableSink> IcebergDelivery<S> {
pub fn new(
locator: IcebergLocator,
destination: &str,
sink: S,
state: SyncState,
opts: DeliveryOptions,
) -> Self {
let key = format!("{} -> {destination}", locator.source_id());
Self { locator, sink, state, key, opts, probed: false }
}
pub fn cursor_key(&self) -> &str {
&self.key
}
pub fn cursor(&self) -> Option<i64> {
self.state.get(&self.key).snapshot_id
}
pub async fn round(&mut self) -> Result<RoundOutcome> {
let mut out = RoundOutcome::default();
let t0 = Instant::now();
let table = self.locator.load().await?;
out.read += t0.elapsed();
let from = self.cursor();
out.at_snapshot = from;
let Some(current) = table.metadata().current_snapshot().map(|s| s.snapshot_id()) else {
return Ok(out); };
if from == Some(current) {
return Ok(out);
}
let chain = table
.metadata()
.snapshots_added_between(from, current)
.map_err(|e| BifrostError::Source(format!("{}: snapshot lineage: {e}", self.key)))?;
let rewrites = chain.iter().any(|id| {
table
.metadata()
.snapshot_by_id(*id)
.is_some_and(|s| !matches!(s.summary().operation, Operation::Append | Operation::Delete))
});
if rewrites && from.is_none() {
tracing::warn!(
target: "bifrost::delivery",
event = "initial_full_snapshot",
table = %self.key,
to = current,
snapshots = chain.len(),
"history rewrites rows in place: the initial load is the whole current snapshot, held in memory in full",
);
self.deliver(&table, None, current, true, &mut out).await?;
out.behind = 0;
return Ok(out);
}
if rewrites {
refuse_full_reload(&self.key, true)?;
}
let mut prev = from;
for (i, &snap) in chain.iter().enumerate() {
if i == self.opts.max_windows_per_round {
out.behind = chain.len() - i;
break;
}
self.deliver(&table, prev, snap, false, &mut out).await?;
prev = Some(snap);
}
Ok(out)
}
async fn deliver(
&mut self,
table: &IceTable,
from: Option<i64>,
to: i64,
initial_full: bool,
out: &mut RoundOutcome,
) -> Result<()> {
let src = |e: skade::SkadeError| BifrostError::Source(format!("{e}"));
let t = Instant::now();
if !self.probed {
let schema = skade::arrow_schema_of(table).map_err(src)?;
self.sink.probe(&schema).await?;
self.probed = true;
}
let cl = skade::read_changelog(table, from, to).await.map_err(src)?;
let needs_full_reload = cl.plan.needs_full_reload && !(initial_full || from.is_none());
let rows = partition_change_rows(&cl.rows)?;
let deletes = project_to_keys(&rows.deletes, cl.key_columns())?;
out.read += t.elapsed();
let window = TableWindow {
table: &self.key,
snapshots: SnapshotWindow::new(from, to),
upserts: &rows.upserts,
deletes: &deletes,
needs_full_reload,
};
let t = Instant::now();
let ack = deliver_window(&mut self.sink, &mut self.state, &window).await?;
out.load += t.elapsed();
out.windows += 1;
out.rows_loaded += ack.rows_loaded;
out.rows_deleted += ack.rows_deleted;
out.rows_duplicate += ack.rows_rejected_duplicate;
out.at_snapshot = Some(ack.to_snapshot());
Ok(())
}
pub async fn run(
&mut self,
poll: Duration,
max_backoff: Duration,
stop: impl std::future::Future<Output = ()>,
) -> Result<()> {
let mut backoff = crate::metrics::Backoff::new(poll, max_backoff);
tokio::pin!(stop);
loop {
let result = self.round().await;
let failed = match &result {
Ok(o) => {
if o.windows > 0 {
tracing::info!(
target: "bifrost::delivery",
event = "round",
table = %self.key,
windows = o.windows,
loaded = o.rows_loaded,
deleted = o.rows_deleted,
duplicate = o.rows_duplicate,
at = ?o.at_snapshot,
behind = o.behind,
read_ms = o.read.as_millis() as u64,
load_ms = o.load.as_millis() as u64,
"delivered",
);
}
false
}
Err(e) => {
tracing::error!(target: "bifrost::delivery", event = "round_failed", table = %self.key, error = %e, "round failed, cursor held");
true
}
};
let behind = matches!(&result, Ok(o) if o.behind > 0);
let sleep = if behind { Duration::ZERO } else { backoff.next_sleep(failed) };
tokio::select! {
_ = &mut stop => return Ok(()),
_ = tokio::time::sleep(sleep) => {}
}
}
}
}
fn project_to_keys(deletes: &[RecordBatch], keys: &[String]) -> Result<Vec<RecordBatch>> {
if keys.is_empty() {
return Ok(deletes.to_vec());
}
deletes
.iter()
.map(|b| {
let idx: Vec<usize> = keys
.iter()
.map(|k| {
b.schema().index_of(k).map_err(|_| {
BifrostError::Source(format!("delete rows have no identity column {k:?}"))
})
})
.collect::<Result<_>>()?;
b.project(&idx).map_err(|e| BifrostError::Source(format!("projecting delete keys: {e}")))
})
.collect()
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use arrow_array::{Int64Array, StringArray};
use arrow_schema::{DataType, Field, Schema};
use async_trait::async_trait;
use super::*;
use crate::sink::table::{AckToken, LoadAck};
#[derive(Clone, Default)]
struct Recording {
windows: Arc<Mutex<Vec<(Option<i64>, i64, usize, usize)>>>,
probes: Arc<Mutex<usize>>,
fail_at: Option<i64>,
}
#[async_trait]
impl TableSink for Recording {
async fn probe(&mut self, _: &Schema) -> Result<()> {
*self.probes.lock().unwrap() += 1;
Ok(())
}
async fn apply(&mut self, w: &TableWindow<'_>, token: AckToken) -> Result<LoadAck> {
if self.fail_at == Some(w.snapshots.to) {
return Err(BifrostError::Other("sink down".into()));
}
self.windows.lock().unwrap().push((w.snapshots.from, w.snapshots.to, w.upsert_rows(), w.delete_rows()));
Ok(token.ack(w.snapshots.to, vec![]).with_counts(w.upsert_rows(), w.delete_rows(), 0))
}
}
fn people(ids: &[i64]) -> skade::arrow_array::RecordBatch {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("name", DataType::Utf8, true),
]));
let names: Vec<String> = ids.iter().map(|i| format!("p{i}")).collect();
RecordBatch::try_new(
schema,
vec![Arc::new(Int64Array::from(ids.to_vec())), Arc::new(StringArray::from(names))],
)
.unwrap()
}
async fn warehouse_with(commits: &[&[i64]]) -> (tempfile::TempDir, Vec<i64>) {
let dir = tempfile::tempdir().unwrap();
let wh = skade::Warehouse::open(dir.path()).await.unwrap();
let mut t = wh.create_table("people", &people(&[]).schema()).await.unwrap();
let mut snaps = Vec::new();
for ids in commits {
t.append(&[people(ids)]).await.unwrap();
snaps.push(t.current_snapshot_id().unwrap());
}
(dir, snaps)
}
fn delivery(dir: &std::path::Path, sink: Recording, opts: DeliveryOptions) -> IcebergDelivery<Recording> {
let state = SyncState::open(dir.join("cursor.json")).unwrap();
let loc = IcebergLocator::Warehouse { dir: dir.to_path_buf(), table: "people".into() };
IcebergDelivery::new(loc, "test:db.people", sink, state, opts)
}
#[tokio::test]
async fn one_window_per_snapshot_and_the_cursor_follows_each() {
let (dir, snaps) = warehouse_with(&[&[1, 2], &[3], &[4, 5, 6]]).await;
let sink = Recording::default();
let mut d = delivery(dir.path(), sink.clone(), DeliveryOptions::default());
let o = d.round().await.unwrap();
assert_eq!(o.windows, 3);
assert_eq!(o.rows_loaded, 6);
assert_eq!(o.at_snapshot, Some(snaps[2]));
assert_eq!(d.cursor(), Some(snaps[2]));
let w = sink.windows.lock().unwrap().clone();
assert_eq!(
w,
vec![(None, snaps[0], 2, 0), (Some(snaps[0]), snaps[1], 1, 0), (Some(snaps[1]), snaps[2], 3, 0)]
);
assert_eq!(*sink.probes.lock().unwrap(), 1, "probe runs once, before the first window");
let o = d.round().await.unwrap();
assert_eq!(o.windows, 0);
assert_eq!(d.cursor(), Some(snaps[2]));
}
#[tokio::test]
async fn a_round_is_bounded_and_says_how_far_behind_it_is() {
let (dir, snaps) = warehouse_with(&[&[1], &[2], &[3]]).await;
let mut d = delivery(dir.path(), Recording::default(), DeliveryOptions { max_windows_per_round: 2 });
let o = d.round().await.unwrap();
assert_eq!((o.windows, o.behind), (2, 1));
assert_eq!(d.cursor(), Some(snaps[1]));
let o = d.round().await.unwrap();
assert_eq!((o.windows, o.behind), (1, 0));
assert_eq!(d.cursor(), Some(snaps[2]));
}
#[tokio::test]
async fn a_failed_window_holds_the_cursor_on_the_last_delivered_one() {
let (dir, snaps) = warehouse_with(&[&[1], &[2], &[3]]).await;
let sink = Recording { fail_at: Some(snaps[1]), ..Recording::default() };
let mut d = delivery(dir.path(), sink, DeliveryOptions::default());
assert!(d.round().await.is_err());
assert_eq!(d.cursor(), Some(snaps[0]), "window 1 landed, window 2 failed: cursor on 1");
let sink = Recording::default();
let mut d = delivery(dir.path(), sink.clone(), DeliveryOptions::default());
assert_eq!(d.cursor(), Some(snaps[0]));
d.round().await.unwrap();
let w = sink.windows.lock().unwrap().clone();
assert_eq!(w.first().map(|w| (w.0, w.1)), Some((Some(snaps[0]), snaps[1])));
assert_eq!(d.cursor(), Some(snaps[2]));
}
#[test]
fn deletes_ship_only_their_identity_columns() {
let b = people(&[7, 8]);
let p = project_to_keys(&[b.clone()], &["id".to_string()]).unwrap();
assert_eq!(p[0].num_columns(), 1);
assert_eq!(p[0].schema().field(0).name(), "id");
assert_eq!(project_to_keys(&[b.clone()], &[]).unwrap()[0].num_columns(), 2);
assert!(project_to_keys(&[b], &["nope".to_string()]).is_err());
}
}