Skip to main content

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(&registry)
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}