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}