Skip to main content

knut_bifrost/
state.rs

1//! Per-table sync progress — "what is already done".
2//!
3//! Two backends behind one [`SyncState`]:
4//!
5//! * **File** (default) — one JSON file, written atomically (tmp + rename)
6//!   **after** a sink round succeeds, so a crash before the write replays the
7//!   round. Single-process.
8//! * **Valkey** — the ledger lives in a Valkey hash (Valkey 9+, the Redis-API/
9//!   RESP-conformant store FalkorDB itself runs on; reached via the `redis`
10//!   crate). The whole ledger is loaded into memory on open and written through
11//!   on each advance, so **multiple bifröst instances share one ledger** and a
12//!   restart resumes where any instance left off.
13//!
14//! Both serve `get` from an in-memory cache (sync); `advance` is async (the
15//! Valkey backend does a network write-through).
16
17use std::collections::HashMap;
18use std::path::PathBuf;
19
20use redis::aio::MultiplexedConnection;
21use serde::{Deserialize, Serialize};
22
23use crate::error::Result;
24
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26pub struct TableState {
27    /// Last fully processed Iceberg snapshot id.
28    pub snapshot_id: Option<i64>,
29    /// Last watermark value, for watermark-column sources.
30    pub watermark: Option<String>,
31    /// Files already ingested, for file/asset sources (the AIP-82 "which
32    /// files are done" ledger). Empty for snapshot/watermark sources.
33    #[serde(default)]
34    pub done_files: Vec<String>,
35    /// ISO-8601 of the last successful round.
36    pub synced_at: Option<String>,
37    /// **Which CDC backend served this table, and why** — recorded on every
38    /// changelog round so an operator reads the decision out of `state_json`
39    /// instead of inferring it from timings. Carries the Spark version, whether
40    /// the native 4.2 path was expected, whether it was actually usable, and —
41    /// when it was not — that it **fell back** and the engine's own reason.
42    ///
43    /// `#[serde(default)]` + `skip_serializing_if`: a ledger written before this
44    /// field existed still loads, and a non-CDC table does not grow the file.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub cdc: Option<crate::cdc::CdcCapability>,
47}
48
49/// The sync-progress ledger: table name → [`TableState`], over a file or a
50/// Valkey hash. Construct with [`SyncState::open`] (file) or
51/// [`SyncState::open_valkey`].
52#[derive(Debug)]
53pub enum SyncState {
54    File {
55        path: PathBuf,
56        tables: HashMap<String, TableState>,
57        /// Set when a staged advance has changed the in-memory ledger but the
58        /// file has not been rewritten yet. [`SyncState::flush`] persists and
59        /// clears it; a clean ledger skips the write entirely (H8).
60        dirty: bool,
61    },
62    Valkey {
63        conn: MultiplexedConnection,
64        /// Valkey hash key holding `table → JSON(TableState)`.
65        key: String,
66        tables: HashMap<String, TableState>,
67    },
68}
69
70impl SyncState {
71    /// Open (or initialise) a **file**-backed ledger.
72    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
73        let path = path.into();
74        let tables = match std::fs::read_to_string(&path) {
75            Ok(s) => serde_json::from_str(&s)?,
76            Err(e) if e.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
77            Err(e) => return Err(e.into()),
78        };
79        Ok(SyncState::File {
80            path,
81            tables,
82            dirty: false,
83        })
84    }
85
86    /// Open a **Valkey**-backed ledger at hash `key` on `url` (e.g.
87    /// `redis://127.0.0.1:6379`). Loads the existing ledger into memory so a
88    /// fresh instance resumes from whatever any prior instance committed.
89    pub async fn open_valkey(url: &str, key: impl Into<String>) -> Result<Self> {
90        let key = key.into();
91        let client = redis::Client::open(url)?;
92        let mut conn = client.get_multiplexed_async_connection().await?;
93        let raw: HashMap<String, String> = redis::cmd("HGETALL")
94            .arg(&key)
95            .query_async(&mut conn)
96            .await?;
97        let mut tables = HashMap::with_capacity(raw.len());
98        for (table, json) in raw {
99            tables.insert(table, serde_json::from_str(&json)?);
100        }
101        Ok(SyncState::Valkey { conn, key, tables })
102    }
103
104    fn cache(&self) -> &HashMap<String, TableState> {
105        match self {
106            SyncState::File { tables, .. } | SyncState::Valkey { tables, .. } => tables,
107        }
108    }
109
110    /// The recorded state for `table` (default if never synced). Served from
111    /// the in-memory cache — no IO. Clones the state; the round loop uses
112    /// [`Self::get_ref`] instead to avoid cloning `done_files` every poll (M3).
113    pub fn get(&self, table: &str) -> TableState {
114        self.cache().get(table).cloned().unwrap_or_default()
115    }
116
117    /// Borrow the recorded state for `table` from the in-memory cache, or
118    /// `None` if the table was never synced. No IO, no clone — the hot path
119    /// (`Listener::sync_table`, every poll round) reads through this so an idle
120    /// round never deep-clones a possibly-large `done_files` ledger (M3). The
121    /// missing case is left to the caller, which substitutes a borrowed default
122    /// rather than allocating one here.
123    pub fn get_ref(&self, table: &str) -> Option<&TableState> {
124        self.cache().get(table)
125    }
126
127    /// Record a completed round and persist immediately (write-through for
128    /// Valkey, atomic file replace for File). Kept for callers that advance a
129    /// single table and want it durable at once; the listener's round loop uses
130    /// [`Self::advance_staged`] + [`Self::flush`] instead (H8).
131    pub async fn advance(&mut self, table: &str, mut state: TableState) -> Result<()> {
132        state.synced_at = Some(now_iso8601());
133        match self {
134            SyncState::File {
135                path,
136                tables,
137                dirty,
138            } => {
139                tables.insert(table.to_string(), state);
140                persist_file(path, tables)?;
141                *dirty = false;
142                Ok(())
143            }
144            SyncState::Valkey { conn, key, tables } => {
145                write_through_valkey(conn, key, tables, table, state).await
146            }
147        }
148    }
149
150    /// Like [`Self::advance`] but, for the **file** backend, only updates the
151    /// in-memory ledger and marks it dirty — the round persists once at the end
152    /// via [`Self::flush`] (H8: one file write per round, not per table).
153    ///
154    /// The **Valkey** backend still writes through per advance: a shared ledger
155    /// must be durable per table for multi-instance correctness, so its
156    /// behaviour is identical to [`Self::advance`].
157    ///
158    /// As with `advance`, the caller invokes this only *after* the sink for
159    /// `table` succeeded, so a table that errors leaves no staged state and the
160    /// crash-replay guarantee holds (the un-flushed in-memory change is lost on
161    /// crash exactly like the old un-written one was).
162    pub async fn advance_staged(&mut self, table: &str, mut state: TableState) -> Result<()> {
163        state.synced_at = Some(now_iso8601());
164        match self {
165            SyncState::File { tables, dirty, .. } => {
166                tables.insert(table.to_string(), state);
167                *dirty = true;
168                Ok(())
169            }
170            SyncState::Valkey { conn, key, tables } => {
171                write_through_valkey(conn, key, tables, table, state).await
172            }
173        }
174    }
175
176    /// Persist any staged file-backend changes, then clear the dirty flag. A
177    /// no-op when nothing changed (skips the write — H8) and for Valkey (which
178    /// already wrote through per advance).
179    pub fn flush(&mut self) -> Result<()> {
180        match self {
181            SyncState::File {
182                path,
183                tables,
184                dirty,
185            } => {
186                if *dirty {
187                    persist_file(path, tables)?;
188                    *dirty = false;
189                }
190                Ok(())
191            }
192            SyncState::Valkey { .. } => Ok(()),
193        }
194    }
195}
196
197/// Valkey write-through: HSET the one table then update the in-memory cache.
198async fn write_through_valkey(
199    conn: &mut MultiplexedConnection,
200    key: &str,
201    tables: &mut HashMap<String, TableState>,
202    table: &str,
203    state: TableState,
204) -> Result<()> {
205    let json = serde_json::to_string(&state)?;
206    redis::cmd("HSET")
207        .arg(key)
208        .arg(table)
209        .arg(json)
210        .query_async::<()>(conn)
211        .await?;
212    tables.insert(table.to_string(), state);
213    Ok(())
214}
215
216/// Atomic file write: tmp + rename. Compact JSON (it's a machine file).
217fn persist_file(path: &PathBuf, tables: &HashMap<String, TableState>) -> Result<()> {
218    if let Some(parent) = path.parent() {
219        std::fs::create_dir_all(parent)?;
220    }
221    let tmp = path.with_extension("tmp");
222    std::fs::write(&tmp, serde_json::to_vec(tables)?)?;
223    std::fs::rename(&tmp, path)?;
224    Ok(())
225}
226
227/// Current time as an RFC3339 / ISO-8601 UTC string, e.g.
228/// `2026-06-30T12:00:00+00:00`. Stored in [`TableState::synced_at`], whose doc
229/// promises ISO-8601; the previous `@<unix_secs>` encoding was not parsable as
230/// a timestamp by any consumer.
231fn now_iso8601() -> String {
232    let secs = std::time::SystemTime::now()
233        .duration_since(std::time::UNIX_EPOCH)
234        .map(|d| d.as_secs())
235        .unwrap_or(0);
236    chrono::DateTime::<chrono::Utc>::from_timestamp(secs as i64, 0)
237        .unwrap_or_else(|| chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap())
238        .to_rfc3339()
239}
240
241#[cfg(test)]
242mod now_iso8601_tests {
243    use super::now_iso8601;
244
245    #[test]
246    fn synced_at_is_parsable_iso8601() {
247        // FAIL-ON-BUG: the old `@<unix_secs>` form ("@1751299200") is not
248        // ISO-8601 and `parse::<DateTime>()` rejects it. PASS-ON-FIX: a real
249        // RFC3339/ISO-8601 string round-trips through chrono's parser.
250        let s = now_iso8601();
251        assert!(
252            !s.starts_with('@'),
253            "synced_at must not use the @epoch encoding, got {s:?}"
254        );
255        let parsed = chrono::DateTime::parse_from_rfc3339(&s);
256        assert!(parsed.is_ok(), "synced_at {s:?} must parse as RFC3339");
257    }
258}