knut_bifrost/listener.rs
1//! The sync loop: poll → transform → sink → advance state.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use crate::{
7 airflow::{AssetEmitter, round_to_events},
8 error::{BifrostError, Result},
9 mapping::{MappingSpec, RowFilter, SyncMode},
10 metrics::{Backoff, Metrics},
11 mutation::{GraphMutation, SinkReport},
12 registry::TransformRegistry,
13 sink::GraphSink,
14 source::{TableSource, TableSpec},
15 state::{SyncState, TableState},
16 transform::{TableContext, Transform, deletes_from_change_rows, edge_deletes_from_change_rows, partition_change_rows},
17};
18
19/// Outcome of one [`Listener::sync_once`] pass over all tables.
20#[derive(Debug, Default, Clone)]
21pub struct RoundReport {
22 /// (table, rows in, sink report) for each table that had news.
23 pub synced: Vec<(String, usize, SinkReport)>,
24 /// Tables that errored this round (left for the next round to retry).
25 pub failed: Vec<(String, String)>,
26}
27
28impl RoundReport {
29 /// Did this round make **no progress**? True when at least one table was
30 /// attempted and *every* attempted table failed (a fully-failed round). An
31 /// idle round — nothing synced and nothing failed — is **not** a failure
32 /// (the source simply had no news), so idle pollers keep their cadence
33 /// instead of backing off forever. Drives the poll-loop's [`Backoff`] and
34 /// the `rounds_failed` metric.
35 pub fn is_failure(&self) -> bool {
36 self.synced.is_empty() && !self.failed.is_empty()
37 }
38}
39
40/// One table's compiled, round-invariant work, prepared once at
41/// [`Listener::new`] so a poll never re-clones the spec (H7) or recompiles the
42/// pipeline (H6). The transforms are an `Arc`-shared slice so the whole list
43/// can be cheaply cloned out of `&self` and iterated while `&mut self` runs.
44struct CompiledTable {
45 table: String,
46 mode: SyncMode,
47 /// `Ok` once-compiled transforms, or the compile error's message. A
48 /// compile failure (empty pipeline / unknown custom transform) is
49 /// deterministic; we surface it as a per-round `failed` entry every round,
50 /// exactly as the old per-round `compile()` did.
51 pipeline: std::result::Result<Arc<[Arc<dyn Transform>]>, String>,
52 /// The **projection** this table's pipeline needs, resolved once at
53 /// construction (it is a pure function of the spec). Empty = read every
54 /// column, which is what a `custom` step forces — see
55 /// [`TableMapping::projected_columns`](crate::mapping::TableMapping::projected_columns).
56 columns: Vec<String>,
57 /// The optional scan **predicate** from the mapping, pushed down every round.
58 filter: Option<RowFilter>,
59}
60
61/// Watches every table in a [`MappingSpec`] and keeps a graph sink current.
62pub struct Listener<Src, Snk> {
63 /// Per-table work compiled once. `Arc`-shared so `sync_once` can clone the
64 /// (cheap) handle and iterate it while holding `&mut self` for the sync.
65 tables: Arc<[CompiledTable]>,
66 source: Src,
67 sink: Snk,
68 state: SyncState,
69 /// Optional AIP-82 asset emitter + URI prefix. When set, every successful
70 /// round's `table_synced` results are announced as Airflow asset events,
71 /// closing the loop: table ingested → asset event → downstream DAG.
72 asset_emitter: Option<(Arc<dyn AssetEmitter>, String)>,
73 /// Cumulative counters folded one round at a time (plan.md §7). Emitted as a
74 /// structured `metrics` tracing event after every round and exposed via
75 /// [`Listener::metrics`].
76 metrics: Metrics,
77}
78
79impl<Src: TableSource, Snk: GraphSink> Listener<Src, Snk> {
80 pub fn new(
81 spec: MappingSpec,
82 registry: TransformRegistry,
83 source: Src,
84 sink: Snk,
85 state: SyncState,
86 ) -> Self {
87 // H6/H7: compile every pipeline once here and store the round-invariant
88 // (name, mode, transforms) so polling never clones the spec or rebuilds
89 // the transforms. Compile errors are kept as messages and re-surfaced
90 // per round, preserving the old "table fails, retry next round" shape.
91 let tables: Arc<[CompiledTable]> = spec
92 .tables
93 .iter()
94 .map(|m| CompiledTable {
95 table: m.table.clone(),
96 mode: m.mode,
97 pipeline: m
98 .compile(®istry)
99 .map(Arc::<[Arc<dyn Transform>]>::from)
100 .map_err(|e| e.to_string()),
101 // Resolved ONCE: the projection is a pure function of the spec,
102 // so a poll never recomputes it (same reason the pipeline is
103 // compiled here).
104 columns: m.projected_columns().unwrap_or_default(),
105 filter: m.filter.clone(),
106 })
107 .collect();
108 Self {
109 tables,
110 source,
111 sink,
112 state,
113 asset_emitter: None,
114 metrics: Metrics::default(),
115 }
116 }
117
118 /// The cumulative run metrics so far (counters folded after every
119 /// [`sync_once`](Self::sync_once) / poll round). See [`Metrics`] and
120 /// [`crate::metrics::render_prometheus`].
121 pub fn metrics(&self) -> &Metrics {
122 &self.metrics
123 }
124
125 /// Attach an [`AssetEmitter`]: after every successful sync round the
126 /// listener emits one Airflow asset event per ingested table, with URIs
127 /// `{uri_prefix}{table}`. See [`crate::airflow`].
128 pub fn with_asset_emitter(
129 mut self,
130 emitter: Arc<dyn AssetEmitter>,
131 uri_prefix: impl Into<String>,
132 ) -> Self {
133 self.asset_emitter = Some((emitter, uri_prefix.into()));
134 self
135 }
136
137 /// One pass over all mapped tables. A failing table does not stop the
138 /// others; its state is not advanced, so the next pass retries it.
139 pub async fn sync_once(&mut self) -> Result<RoundReport> {
140 let mut report = RoundReport::default();
141 // H7: clone the cheap `Arc` handle (not the spec) so we can iterate the
142 // compiled tables while `sync_table` borrows `&mut self`.
143 let tables = Arc::clone(&self.tables);
144 for compiled in tables.iter() {
145 match self.sync_table(compiled).await {
146 Ok(Some((rows, sink_report))) => {
147 tracing::info!(
148 target: "bifrost::round",
149 event = "table_synced",
150 table = %compiled.table,
151 rows,
152 vertices = sink_report.vertices_written,
153 edges = sink_report.edges_written,
154 deleted = sink_report.deleted,
155 "table ingested",
156 );
157 report
158 .synced
159 .push((compiled.table.clone(), rows, sink_report));
160 }
161 Ok(None) => {}
162 Err(e) => {
163 tracing::warn!(
164 target: "bifrost::round",
165 event = "table_failed",
166 table = %compiled.table,
167 error = %e,
168 "table sync failed (will retry next round)",
169 );
170 report.failed.push((compiled.table.clone(), e.to_string()));
171 }
172 }
173 }
174 // H8: persist the file-backed ledger once for the whole round (and only
175 // if a table advanced). Each table's state was staged *after* its sink
176 // succeeded, so a failed table contributed nothing to flush — the
177 // crash-replay guarantee is preserved. (Valkey already wrote through
178 // per advance; flush is a no-op there.)
179 self.state.flush()?;
180 tracing::info!(
181 target: "bifrost::round",
182 event = "round_complete",
183 synced = report.synced.len(),
184 failed = report.failed.len(),
185 "sync round complete",
186 );
187
188 // Close the loop: announce each ingested table as an Airflow asset
189 // event so downstream DAGs can schedule on it. Emission failures are
190 // logged but never fail the round (the graph is already up to date).
191 if let Some((emitter, prefix)) = &self.asset_emitter {
192 let events = round_to_events(prefix, &report);
193 if !events.is_empty() {
194 match emitter.emit(&events).await {
195 Ok(()) => tracing::info!(
196 target: "bifrost::airflow",
197 event = "assets_emitted",
198 count = events.len(),
199 "emitted asset events",
200 ),
201 Err(e) => tracing::warn!(
202 target: "bifrost::airflow",
203 event = "asset_emit_failed",
204 error = %e,
205 "asset emission failed (round already applied)",
206 ),
207 }
208 }
209 }
210
211 // Fold the round into the cumulative counters and emit them as a
212 // structured `metrics` event (target `metrics`). Purely additive — it
213 // rides *after* the existing `round_complete`/`assets_emitted` events
214 // and never touches them, so the log contract is unchanged.
215 self.metrics.record_round(&report);
216 let m = &self.metrics;
217 tracing::info!(
218 target: "metrics",
219 event = "metrics",
220 rounds_total = m.rounds_total,
221 rounds_failed = m.rounds_failed,
222 tables_synced = m.tables_synced,
223 rows_ingested = m.rows_ingested,
224 vertices_written = m.vertices_written,
225 edges_written = m.edges_written,
226 deletes = m.deletes,
227 assets_emitted = m.assets_emitted,
228 "cumulative metrics",
229 );
230 Ok(report)
231 }
232
233 async fn sync_table(
234 &mut self,
235 compiled: &CompiledTable,
236 ) -> Result<Option<(usize, SinkReport)>> {
237 // H6: transforms were compiled once at construction. A compile error
238 // (empty pipeline / unknown custom step) is deterministic; reproduce it
239 // every round so the table keeps landing in `report.failed`, as before.
240 let transforms = compiled
241 .pipeline
242 .as_ref()
243 .map_err(|msg| BifrostError::Other(msg.clone()))?;
244 let spec = TableSpec {
245 table: compiled.table.clone(),
246 mode: compiled.mode,
247 // The pushdown ask: exactly the columns this table's pipeline reads,
248 // plus the mapping's optional row filter. A source that cannot prune
249 // ignores both and returns everything, as before.
250 columns: compiled.columns.clone(),
251 filter: compiled.filter.clone(),
252 };
253
254 // M3: borrow the recorded state from the in-memory cache instead of
255 // deep-cloning it (incl. a possibly-large `done_files`) every poll. An
256 // idle round now does zero allocation here. `prev` only borrows
257 // `self.state` immutably to build the source fetch below; its borrow
258 // ends at the `fetch_new` call (NLL), well before the mutable
259 // `advance_staged`. The never-synced case can't borrow the cache, so it
260 // falls back to a stack-local default (cheap: an empty `done_files`).
261 let default;
262 let prev = match self.state.get_ref(&compiled.table) {
263 Some(state) => state,
264 None => {
265 default = TableState::default();
266 &default
267 }
268 };
269 let Some((batches, mut new_state)) = self.source.fetch_new(&spec, prev).await? else {
270 return Ok(None);
271 };
272 // Stamp WHICH CDC backend served this table onto the state the round is
273 // about to persist, so `state_json` carries the decision (and any
274 // fallback, with the engine's reason) — not just the log. Done for every
275 // changelog-capable mode; `Full` has no CDC verdict to record.
276 if matches!(compiled.mode, SyncMode::Incremental | SyncMode::Cdc) {
277 new_state.cdc = self.source.cdc_capability(&compiled.table);
278 }
279 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
280
281 let ctx = TableContext {
282 table: compiled.table.clone(),
283 };
284 let mut mutation = GraphMutation::default();
285 // Changelog (CDC) mode: split the round's rows by `_change_type` into
286 // upserts and deletes before transforming. `partition_change_rows`
287 // strips the sentinel column, so upsert rows transform exactly like an
288 // append-only batch and a non-CDC batch passes straight through.
289 let change = partition_change_rows(&batches)?;
290 for transform in transforms.iter() {
291 mutation.merge(transform.apply(&ctx, &change.upserts)?);
292 }
293 mutation.deletes = deletes_from_change_rows(transforms, &ctx, &change.deletes)?;
294 mutation.edge_deletes = edge_deletes_from_change_rows(transforms, &ctx, &change.deletes)?;
295
296 let sink_report = if mutation.is_empty() {
297 SinkReport::default()
298 } else {
299 self.sink.apply(&mutation).await?
300 };
301
302 // Only after the sink succeeded — crash here replays (MERGE dedupes).
303 // Staged in memory; the round flushes the file once at the end (H8).
304 self.state
305 .advance_staged(&compiled.table, new_state)
306 .await?;
307 Ok(Some((rows, sink_report)))
308 }
309
310 /// Poll forever. The sleep between rounds is the base `interval` on the
311 /// happy path, but a fully-failed (no-progress) round backs the next sleep
312 /// off exponentially up to `interval * 10` (see [`Backoff`]); the first
313 /// round that makes progress resets it. Errors are carried in the round
314 /// reports handed to `on_round`; return `false` from it to stop.
315 ///
316 /// Signature-compatible with the original fixed-interval `run`; for a
317 /// custom cap use [`run_with_backoff`](Self::run_with_backoff).
318 pub async fn run(
319 &mut self,
320 interval: Duration,
321 on_round: impl FnMut(RoundReport) -> bool,
322 ) -> Result<()> {
323 // Default cap: 10× the base interval (plan.md §7).
324 self.run_with_backoff(
325 Backoff::new(interval, interval.saturating_mul(10)),
326 on_round,
327 )
328 .await
329 }
330
331 /// Poll forever with an explicit [`Backoff`] schedule. The happy-path
332 /// cadence is `backoff.interval()`; consecutive no-progress rounds grow the
333 /// inter-round sleep up to `backoff.max()`, resetting on the first round
334 /// that makes progress. `run` is this with a `interval … interval*10`
335 /// default. Return `false` from `on_round` to stop.
336 pub async fn run_with_backoff(
337 &mut self,
338 mut backoff: Backoff,
339 mut on_round: impl FnMut(RoundReport) -> bool,
340 ) -> Result<()> {
341 loop {
342 let report = self.sync_once().await?;
343 let failed = report.is_failure();
344 if !on_round(report) {
345 return Ok(());
346 }
347 let sleep = backoff.next_sleep(failed);
348 if sleep > backoff.interval() {
349 tracing::warn!(
350 target: "bifrost::round",
351 event = "backoff",
352 sleep_secs = sleep.as_secs(),
353 interval_secs = backoff.interval().as_secs(),
354 "round made no progress — backing off before retry",
355 );
356 }
357 tokio::time::sleep(sleep).await;
358 }
359 }
360}
361
362/// The **streaming/delta** entry point: watch an Iceberg warehouse and inject
363/// only the rows added since the last seen snapshot, directly into FalkorDB —
364/// pure Rust, no Spark. A thin wrapper that wires an [`IcebergSource`] (in the
365/// table's [`SyncMode`], so `incremental` ⇒ the manifest-level delta scan), a
366/// [`FalkorDbSink`], and a file-backed [`SyncState`] cursor into a [`Listener`]
367/// and polls forever at `interval` (with the default exponential backoff on
368/// idle/failed rounds).
369///
370/// The `MappingSpec`'s tables should use `mode: incremental` to get the delta
371/// path (`full` re-reads the whole table on every snapshot change). The cursor
372/// (per-table last-ingested `snapshot_id`) is persisted to `state_path` after
373/// each successful round, so a restart resumes from where it left off.
374///
375/// Return `false` from `on_round` to stop the loop.
376///
377/// ```no_run
378/// # #[cfg(feature = "skade")]
379/// # async fn demo() -> knut_bifrost::Result<()> {
380/// use std::time::Duration;
381/// use knut_bifrost::{MappingSpec, listener::watch_iceberg_delta};
382///
383/// let spec = MappingSpec::load("bifrost.yml")?; // tables: mode: incremental
384/// watch_iceberg_delta(
385/// spec,
386/// "/var/lib/lake", // skade Iceberg warehouse dir
387/// "redis://127.0.0.1:6379", // FalkorDB
388/// "social", // graph name
389/// "/var/lib/bifrost-state.json",
390/// Duration::from_secs(15),
391/// |round| { println!("{round:?}"); true },
392/// ).await
393/// # }
394/// ```
395#[cfg(feature = "skade")]
396#[allow(clippy::too_many_arguments)]
397pub async fn watch_iceberg_delta(
398 spec: MappingSpec,
399 warehouse_dir: impl Into<std::path::PathBuf>,
400 falkordb_url: &str,
401 graph: impl Into<String>,
402 state_path: impl Into<std::path::PathBuf>,
403 interval: Duration,
404 on_round: impl FnMut(RoundReport) -> bool,
405) -> Result<()> {
406 watch_iceberg_delta_with_identity(spec, warehouse_dir, falkordb_url, None, graph, state_path, interval, on_round).await
407}
408
409/// [`watch_iceberg_delta`] with an optional mutual-TLS client identity, for a `rediss://`
410/// FalkorDB running `tls-auth-clients yes`. `None` is the plain path, unchanged.
411#[cfg(feature = "skade")]
412#[allow(clippy::too_many_arguments)]
413pub async fn watch_iceberg_delta_with_identity(
414 spec: MappingSpec,
415 warehouse_dir: impl Into<std::path::PathBuf>,
416 falkordb_url: &str,
417 tls: Option<graphar_falkordb::FalkorTls>,
418 graph: impl Into<String>,
419 state_path: impl Into<std::path::PathBuf>,
420 interval: Duration,
421 on_round: impl FnMut(RoundReport) -> bool,
422) -> Result<()> {
423 use crate::{sink::FalkorDbSink, source::IcebergSource};
424 let mut listener = Listener::new(
425 spec,
426 TransformRegistry::new(),
427 IcebergSource::new(warehouse_dir),
428 FalkorDbSink::connect_with_identity(falkordb_url, graph, tls).await?,
429 SyncState::open(state_path.into())?,
430 );
431 listener.run(interval, on_round).await
432}