use std::sync::Arc;
use std::time::Duration;
use crate::{
airflow::{AssetEmitter, round_to_events},
error::{BifrostError, Result},
mapping::{MappingSpec, RowFilter, SyncMode},
metrics::{Backoff, Metrics},
mutation::{GraphMutation, SinkReport},
registry::TransformRegistry,
sink::GraphSink,
source::{TableSource, TableSpec},
state::{SyncState, TableState},
transform::{TableContext, Transform, deletes_from_change_rows, edge_deletes_from_change_rows, partition_change_rows},
};
#[derive(Debug, Default, Clone)]
pub struct RoundReport {
pub synced: Vec<(String, usize, SinkReport)>,
pub failed: Vec<(String, String)>,
}
impl RoundReport {
pub fn is_failure(&self) -> bool {
self.synced.is_empty() && !self.failed.is_empty()
}
}
struct CompiledTable {
table: String,
mode: SyncMode,
pipeline: std::result::Result<Arc<[Arc<dyn Transform>]>, String>,
columns: Vec<String>,
filter: Option<RowFilter>,
}
pub struct Listener<Src, Snk> {
tables: Arc<[CompiledTable]>,
source: Src,
sink: Snk,
state: SyncState,
asset_emitter: Option<(Arc<dyn AssetEmitter>, String)>,
metrics: Metrics,
}
impl<Src: TableSource, Snk: GraphSink> Listener<Src, Snk> {
pub fn new(
spec: MappingSpec,
registry: TransformRegistry,
source: Src,
sink: Snk,
state: SyncState,
) -> Self {
let tables: Arc<[CompiledTable]> = spec
.tables
.iter()
.map(|m| CompiledTable {
table: m.table.clone(),
mode: m.mode,
pipeline: m
.compile(®istry)
.map(Arc::<[Arc<dyn Transform>]>::from)
.map_err(|e| e.to_string()),
columns: m.projected_columns().unwrap_or_default(),
filter: m.filter.clone(),
})
.collect();
Self {
tables,
source,
sink,
state,
asset_emitter: None,
metrics: Metrics::default(),
}
}
pub fn metrics(&self) -> &Metrics {
&self.metrics
}
pub fn with_asset_emitter(
mut self,
emitter: Arc<dyn AssetEmitter>,
uri_prefix: impl Into<String>,
) -> Self {
self.asset_emitter = Some((emitter, uri_prefix.into()));
self
}
pub async fn sync_once(&mut self) -> Result<RoundReport> {
let mut report = RoundReport::default();
let tables = Arc::clone(&self.tables);
for compiled in tables.iter() {
match self.sync_table(compiled).await {
Ok(Some((rows, sink_report))) => {
tracing::info!(
target: "bifrost::round",
event = "table_synced",
table = %compiled.table,
rows,
vertices = sink_report.vertices_written,
edges = sink_report.edges_written,
deleted = sink_report.deleted,
"table ingested",
);
report
.synced
.push((compiled.table.clone(), rows, sink_report));
}
Ok(None) => {}
Err(e) => {
tracing::warn!(
target: "bifrost::round",
event = "table_failed",
table = %compiled.table,
error = %e,
"table sync failed (will retry next round)",
);
report.failed.push((compiled.table.clone(), e.to_string()));
}
}
}
self.state.flush()?;
tracing::info!(
target: "bifrost::round",
event = "round_complete",
synced = report.synced.len(),
failed = report.failed.len(),
"sync round complete",
);
if let Some((emitter, prefix)) = &self.asset_emitter {
let events = round_to_events(prefix, &report);
if !events.is_empty() {
match emitter.emit(&events).await {
Ok(()) => tracing::info!(
target: "bifrost::airflow",
event = "assets_emitted",
count = events.len(),
"emitted asset events",
),
Err(e) => tracing::warn!(
target: "bifrost::airflow",
event = "asset_emit_failed",
error = %e,
"asset emission failed (round already applied)",
),
}
}
}
self.metrics.record_round(&report);
let m = &self.metrics;
tracing::info!(
target: "metrics",
event = "metrics",
rounds_total = m.rounds_total,
rounds_failed = m.rounds_failed,
tables_synced = m.tables_synced,
rows_ingested = m.rows_ingested,
vertices_written = m.vertices_written,
edges_written = m.edges_written,
deletes = m.deletes,
assets_emitted = m.assets_emitted,
"cumulative metrics",
);
Ok(report)
}
async fn sync_table(
&mut self,
compiled: &CompiledTable,
) -> Result<Option<(usize, SinkReport)>> {
let transforms = compiled
.pipeline
.as_ref()
.map_err(|msg| BifrostError::Other(msg.clone()))?;
let spec = TableSpec {
table: compiled.table.clone(),
mode: compiled.mode,
columns: compiled.columns.clone(),
filter: compiled.filter.clone(),
};
let default;
let prev = match self.state.get_ref(&compiled.table) {
Some(state) => state,
None => {
default = TableState::default();
&default
}
};
let Some((batches, mut new_state)) = self.source.fetch_new(&spec, prev).await? else {
return Ok(None);
};
if matches!(compiled.mode, SyncMode::Incremental | SyncMode::Cdc) {
new_state.cdc = self.source.cdc_capability(&compiled.table);
}
let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
let ctx = TableContext {
table: compiled.table.clone(),
};
let mut mutation = GraphMutation::default();
let change = partition_change_rows(&batches)?;
for transform in transforms.iter() {
mutation.merge(transform.apply(&ctx, &change.upserts)?);
}
mutation.deletes = deletes_from_change_rows(transforms, &ctx, &change.deletes)?;
mutation.edge_deletes = edge_deletes_from_change_rows(transforms, &ctx, &change.deletes)?;
let sink_report = if mutation.is_empty() {
SinkReport::default()
} else {
self.sink.apply(&mutation).await?
};
self.state
.advance_staged(&compiled.table, new_state)
.await?;
Ok(Some((rows, sink_report)))
}
pub async fn run(
&mut self,
interval: Duration,
on_round: impl FnMut(RoundReport) -> bool,
) -> Result<()> {
self.run_with_backoff(
Backoff::new(interval, interval.saturating_mul(10)),
on_round,
)
.await
}
pub async fn run_with_backoff(
&mut self,
mut backoff: Backoff,
mut on_round: impl FnMut(RoundReport) -> bool,
) -> Result<()> {
loop {
let report = self.sync_once().await?;
let failed = report.is_failure();
if !on_round(report) {
return Ok(());
}
let sleep = backoff.next_sleep(failed);
if sleep > backoff.interval() {
tracing::warn!(
target: "bifrost::round",
event = "backoff",
sleep_secs = sleep.as_secs(),
interval_secs = backoff.interval().as_secs(),
"round made no progress — backing off before retry",
);
}
tokio::time::sleep(sleep).await;
}
}
}
#[cfg(feature = "skade")]
#[allow(clippy::too_many_arguments)]
pub async fn watch_iceberg_delta(
spec: MappingSpec,
warehouse_dir: impl Into<std::path::PathBuf>,
falkordb_url: &str,
graph: impl Into<String>,
state_path: impl Into<std::path::PathBuf>,
interval: Duration,
on_round: impl FnMut(RoundReport) -> bool,
) -> Result<()> {
watch_iceberg_delta_with_identity(spec, warehouse_dir, falkordb_url, None, graph, state_path, interval, on_round).await
}
#[cfg(feature = "skade")]
#[allow(clippy::too_many_arguments)]
pub async fn watch_iceberg_delta_with_identity(
spec: MappingSpec,
warehouse_dir: impl Into<std::path::PathBuf>,
falkordb_url: &str,
tls: Option<graphar_falkordb::FalkorTls>,
graph: impl Into<String>,
state_path: impl Into<std::path::PathBuf>,
interval: Duration,
on_round: impl FnMut(RoundReport) -> bool,
) -> Result<()> {
use crate::{sink::FalkorDbSink, source::IcebergSource};
let mut listener = Listener::new(
spec,
TransformRegistry::new(),
IcebergSource::new(warehouse_dir),
FalkorDbSink::connect_with_identity(falkordb_url, graph, tls).await?,
SyncState::open(state_path.into())?,
);
listener.run(interval, on_round).await
}