Skip to main content

flint_lean/
state.rs

1//! Durable emptyDir bookkeeping (plan §2.1 "restart matrix").
2//!
3//! Everything the barrier needs to survive a CONTAINER restart lives
4//! here as small JSON files under `<root>/.flint-sync/`, each written
5//! temp+rename. A POD replacement gets a fresh emptyDir and therefore a
6//! fresh identity — that asymmetry is deliberate (the plan's P4: the
7//! incarnation id is emptyDir-scoped, so only the same pod's restarted
8//! container may self-supersede the lease).
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16use super::{LeanError, LeanResult};
17
18/// One published path as this syncer last knew it: the recognized ETag
19/// is the If-Match guard for the next publish and the HEAD-guard for GC.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct BaselineEntry {
22    pub etag: String,
23    pub generation: u64,
24    pub size: u64,
25    pub mtime_unix: i64,
26    /// CRC-64/NVME (wire form) of the object bytes this syncer
27    /// integrated at this path — computed from the bytes it wrote or
28    /// uploaded, never from a HEAD. A citation repair cites it, which
29    /// is what lets the manifest carry a CRC for an object whose
30    /// backend returns none (Ozone) or whose HITL uploader sent none.
31    ///
32    /// `None` only on the consume-dirty sentinel (`size == u64::MAX`):
33    /// the bytes on disk are the LOCAL edit, about to publish under a
34    /// CRC of its own, and the sentinel never repairs.
35    pub crc64_b64: Option<String>,
36    /// The version the manifest cites for this path, when the bucket is
37    /// versioned (boundary-verbs plan D7). Carried so a gated citation
38    /// can re-validate its staging base: if this moved between staging
39    /// and citation, a HITL consume or sync landed in between and
40    /// installing the staged version would UNCITE the foreign bytes.
41    #[serde(default)]
42    pub version_id: Option<String>,
43}
44
45/// The persisted baseline snapshot: what this syncer believes the
46/// bucket holds AND has integrated locally. Distinct from `inst_base`
47/// (the manifest view at our last install — the three-way merge base):
48/// consuming a HITL entry advances the baseline for that path but not
49/// the merge base. The formal model carries the same split
50/// (baseline vs instBase in LeanSubtree.tla) — collapsing them made a
51/// syncer mistake its own consumed adoption for a foreign entry.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct Baseline {
54    /// Manifest seq at our last install/checkout.
55    pub seq: u64,
56    /// Manifest document ETag we expect at the next CAS.
57    pub manifest_etag: Option<String>,
58    pub entries: BTreeMap<String, BaselineEntry>,
59    /// The merge base: path -> ETag as cited by the manifest we last
60    /// installed (or checked out).
61    pub inst_base: BTreeMap<String, String>,
62    /// Paths present at the PREVIOUS scan (the two-consecutive-scans
63    /// deletion rule: absence must survive two scans).
64    pub prev_scan: BTreeSet<String>,
65}
66
67/// A rescope in flight (scoped-read design §4.3). `target` is the scope
68/// the workspace is moving TO; `None` means the whole tree.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70pub struct ScopeIntent {
71    pub target: Option<Vec<String>>,
72    /// The paths this rescope is removing from the held set, recorded
73    /// BEFORE the first mutation.
74    ///
75    /// Load-bearing, and the mutation check that found it: replay
76    /// cannot RE-DERIVE this set. Once a path is out of
77    /// `baseline.entries` it is indistinguishable from a file the agent
78    /// just created — same scan row, same absence from the baseline —
79    /// and the two have opposite correct answers: unlink the leftover,
80    /// KEEP the agent's new file. Deriving the drop set from the
81    /// baseline made a crash between the uncite and the unlink leave
82    /// six uncited files on disk forever; deriving it from the tree
83    /// would delete the agent's work instead.
84    #[serde(default)]
85    pub drop: Vec<String>,
86}
87
88/// The pod-incarnation identity + lease bookkeeping ({last_token,
89/// quiet_polls} persist so container restarts RESUME the takeover
90/// observation instead of resetting it — plan §2.1).
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Incarnation {
93    pub holder_id: String,
94    pub epoch: u64,
95    pub last_token: Option<String>,
96    pub quiet_polls: u32,
97}
98
99/// The intent journal written BEFORE uploads: which keys this barrier
100/// will touch and under which flush_uuid, so a restarted container can
101/// recognize its own crashed/torn PUT at the 412 (AdoptOwn) instead of
102/// mistaking it for a foreign write. `recent_uuids` keeps the last few
103/// barriers' uuids for the same reason.
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct IntentJournal {
106    pub flush_uuid: String,
107    pub keys: Vec<String>,
108    pub recent_uuids: Vec<String>,
109    /// The ETag of the manifest document THIS workspace last installed,
110    /// written immediately after the CAS.
111    ///
112    /// The merge base (`Baseline::inst_base`) and the baseline are both
113    /// rewritten at step 7, after the CAS and after the GC deletes. A
114    /// container restart in that window leaves the bucket holding a
115    /// document we wrote and our persisted merge base one generation
116    /// behind it — so at the next merge our own entries read as foreign
117    /// changes, delete/modify resolves conservatively against the
118    /// agent's own delete, and the path is queued into the inbox as a
119    /// conflict nobody else ever touched. Recording the installed ETag
120    /// costs one small local write and restores exactly what step 7 was
121    /// going to say: if the bucket is still at this document, the merge
122    /// base IS this document.
123    #[serde(default)]
124    pub installed_etag: Option<String>,
125    /// Paths whose DECLARED removal this barrier unlinked and is about
126    /// to cite out (delete/rename design §4). Journalled before the
127    /// window commitment, so a crash after the cell has been told and
128    /// before the manifest CAS still produces ONE generation without
129    /// them, instead of handing the deletion to the two-scan path and
130    /// citing a file the tree no longer has for a barrier.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub declared_deletes: Vec<String>,
133}
134
135/// One surfaced conflict: both versions stay recoverable (local bytes in
136/// the tree, foreign bytes preserved at `preserved_key` or still current
137/// under the data key). Never a silent winner.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ConflictRecord {
140    pub path: String,
141    pub foreign_etag: String,
142    /// Where the foreign bytes were preserved (server-side copy), when
143    /// the local version is about to overwrite them.
144    pub preserved_key: Option<String>,
145    pub kind: String, // "consume-dirty" | "upload-412-parked" | "sync-dirty" | "gc-skip"
146    pub at_unix: u64,
147}
148
149pub struct SyncerState {
150    dir: PathBuf,
151    /// The state-directory occupancy lock (flock, held for the process
152    /// lifetime). Self-recognition of the lease via the persisted
153    /// incarnation id is only sound because the PREVIOUS process is
154    /// gone — and this lock is what makes that true. Without it, a
155    /// second flint-sync on the same workspace self-recognizes,
156    /// deposes a LIVE sibling, and both write the tree concurrently
157    /// (observed on the 0b rig: a diagnostic re-run raced a live 1M
158    /// checkout into tmp-rename ENOENT collisions). The hub has the
159    /// identical gate (`state_backend::is_single_occupant`).
160    _lock: std::fs::File,
161}
162
163const MARKER: &str = "checkout-complete";
164/// Written by the SIGTERM drain only after it published: the node
165/// plugin's evidence that a worker that is now gone actually drained
166/// (audit 2026-09-03, finding 3). Absent ⇒ the tree is preserved.
167pub const DRAINED: &str = "drained.json";
168const LOCK: &str = "lock";
169const BASELINE: &str = "baseline.json";
170/// The admitted set of a SCOPED checkout. Deliberately its own
171/// document rather than a field on `Baseline`: the baseline is
172/// rewritten by every barrier, every sync and every consume, and a
173/// scope that rides along is one `Baseline::default()` away from
174/// silently widening a workspace to the whole tree. This file is
175/// written once, by checkout, and thereafter only read. Absent means
176/// UNSCOPED — the only encoding of "no restriction", since an empty
177/// admitted set is refused before it can be written.
178const SCOPE: &str = "scope.json";
179
180/// A rescope IN FLIGHT: the target scope, written before the first
181/// mutation and removed only after the last (scoped-read design §4.3).
182///
183/// Its own document and not a field on `scope.json`, because the two
184/// answer different questions — `scope.json` is what this workspace
185/// holds, this is what it is on its way to holding — and a crash must
186/// leave both answers readable.
187///
188/// `null` is a LEGAL target (the whole tree), so absence of the file
189/// means "no rescope in flight", never "unscoped". That is why the
190/// document wraps the target in a struct instead of being a bare
191/// `Option<Vec<String>>`: a bare `null` on disk and a missing file
192/// would deserialize to the same thing.
193const SCOPE_INTENT: &str = "scope-intent.json";
194const INCARNATION: &str = "incarnation.json";
195const INTENT: &str = "intent.json";
196const CONFLICTS: &str = "conflicts.jsonl";
197/// The rotated generation. `load_conflicts` reads it FIRST, so the
198/// sequence a reader sees is unbroken across a rotation — which matters
199/// because `honor_sync` takes the count before a sync and `skip`s it
200/// after, and a rotation that SHORTENED the list would make it skip past
201/// the very records the sync just produced.
202const CONFLICTS_PREV: &str = "conflicts.1.jsonl";
203/// How many records were dropped when a SECOND rotation overwrote the
204/// first. Truncation that nobody can count is truncation that reads as
205/// "there were no more".
206const CONFLICTS_DROPPED: &str = "conflicts.dropped";
207/// Rotate past this. The file is append-only and `load_conflicts` parses
208/// it WHOLE — twice per sync honor, again per status read and per
209/// scrape (U23) — so an unbounded file is an unbounded parse on a hot
210/// path, and a standing condition (a UI autosaving over a path the agent
211/// holds dirty) appends on every barrier. Two generations bound the
212/// resident set at ~2 MiB, which is thousands of records.
213const CONFLICTS_MAX_BYTES: u64 = 1 << 20;
214
215fn write_atomic(path: &Path, bytes: &[u8]) -> LeanResult<()> {
216    // The state dir lives in the same app-writable emptyDir.
217    super::safefs::check_parent(path)?;
218    let tmp = path.with_extension("tmp");
219    super::safefs::write_via_tmp(path, &tmp, bytes, None).map(|_| ())
220}
221
222impl SyncerState {
223    pub fn open(dir: PathBuf) -> LeanResult<SyncerState> {
224        fs::create_dir_all(&dir)?;
225        let lock = fs::OpenOptions::new()
226            .create(true)
227            .truncate(false)
228            .write(true)
229            .open(dir.join(LOCK))?;
230        #[cfg(unix)]
231        {
232            use std::os::fd::AsRawFd;
233            // SAFETY: flock on an owned, open fd.
234            let rc = unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
235            if rc != 0 {
236                return Err(LeanError::State(format!(
237                    "another flint-sync already holds this workspace ({}): \
238                     refusing to run two syncers over one tree",
239                    dir.display()
240                )));
241            }
242        }
243        Ok(SyncerState { dir, _lock: lock })
244    }
245
246    pub fn marker_present(&self) -> bool {
247        self.dir.join(MARKER).exists()
248    }
249
250    /// Flush the filesystem holding the tree (the state dir lives in
251    /// it) so everything materialised so far is on stable storage.
252    pub fn sync_tree(&self) -> LeanResult<()> {
253        super::safefs::sync_tree(&self.dir)
254    }
255
256    /// The drain's attestation. `seq` is the manifest the drain left
257    /// installed, when it knows it.
258    pub fn write_drained(&self, seq: Option<u64>, acks: usize) -> LeanResult<()> {
259        let doc = serde_json::json!({
260            "unix": super::now_unix(),
261            "seq": seq,
262            "acks": acks,
263        });
264        write_atomic(&self.dir.join(DRAINED), doc.to_string().as_bytes())
265    }
266
267    /// A fresh incarnation owes its own attestation; a stale one from
268    /// an earlier life of this tree must not vouch for it.
269    pub fn clear_drained(&self) -> LeanResult<()> {
270        match fs::remove_file(self.dir.join(DRAINED)) {
271            Ok(()) => Ok(()),
272            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
273            Err(e) => Err(LeanError::State(format!("clear {DRAINED}: {e}"))),
274        }
275    }
276
277    pub fn drained_path(&self) -> PathBuf {
278        self.dir.join(DRAINED)
279    }
280
281    /// Written LAST at checkout: the agent-start gate.
282    pub fn write_marker(&self) -> LeanResult<()> {
283        write_atomic(&self.dir.join(MARKER), b"ok\n")
284    }
285
286    pub fn load_baseline(&self) -> LeanResult<Baseline> {
287        let p = self.dir.join(BASELINE);
288        if !p.exists() {
289            return Ok(Baseline::default());
290        }
291        let bytes = fs::read(&p)?;
292        serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("baseline: {e}")))
293    }
294
295    /// COMPACT: the baseline is the other O(files) document, rewritten
296    /// on the cadence tick and re-parsed several times per barrier. The
297    /// agent-facing O(1) files (gauges, capabilities, acks) stay pretty
298    /// — those exist to be `cat`-ed.
299    pub fn save_baseline(&self, b: &Baseline) -> LeanResult<()> {
300        let bytes =
301            serde_json::to_vec(b).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
302        write_atomic(&self.dir.join(BASELINE), &bytes)
303    }
304
305    /// What this workspace was admitted to hold, or `None` for an
306    /// unscoped (whole-manifest) checkout.
307    ///
308    /// `fs::read` directly, with NO `exists()` pre-check: `exists()`
309    /// answers false for EACCES and EIO alike, and an unreadable scope
310    /// answered as "unscoped" is the same class of bug as an unreadable
311    /// path answered as "deleted" (`barrier.rs`, 14b3637c). Only
312    /// NotFound means absent.
313    pub fn load_scope(&self) -> LeanResult<Option<Vec<String>>> {
314        let p = self.dir.join(SCOPE);
315        let bytes = match fs::read(&p) {
316            Ok(b) => b,
317            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
318            Err(e) => {
319                return Err(LeanError::State(format!(
320                    "cannot read the checkout scope at {}: {e} — refusing to \
321                     guess what this workspace is holding",
322                    p.display()
323                )))
324            }
325        };
326        serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("scope: {e}")))
327    }
328
329    /// `Some` writes the admitted set; `None` REMOVES the document.
330    /// Removing matters: a scoped checkout that crashed before its
331    /// marker leaves a scope behind, and the whole-tree checkout that
332    /// replaces it must not inherit a claim it did not make.
333    pub fn save_scope(&self, scope: Option<&[String]>) -> LeanResult<()> {
334        let p = self.dir.join(SCOPE);
335        match scope {
336            Some(entries) => {
337                let bytes = serde_json::to_vec(entries)
338                    .map_err(|e| LeanError::State(format!("scope: {e}")))?;
339                write_atomic(&p, &bytes)
340            }
341            None => match fs::remove_file(&p) {
342                Ok(()) => Ok(()),
343                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
344                Err(e) => Err(LeanError::State(format!("clear {SCOPE}: {e}"))),
345            },
346        }
347    }
348
349    /// Read a rescope in flight. `None` = none in flight.
350    pub fn load_scope_intent(&self) -> LeanResult<Option<ScopeIntent>> {
351        let p = self.dir.join(SCOPE_INTENT);
352        let bytes = match fs::read(&p) {
353            Ok(b) => b,
354            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
355            Err(e) => {
356                return Err(LeanError::State(format!(
357                    "cannot read the rescope intent at {}: {e} — refusing to run a barrier \
358                     over a half-applied scope",
359                    p.display()
360                )))
361            }
362        };
363        serde_json::from_slice(&bytes)
364            .map(Some)
365            .map_err(|e| LeanError::State(format!("scope intent: {e}")))
366    }
367
368    pub fn save_scope_intent(&self, intent: &ScopeIntent) -> LeanResult<()> {
369        let bytes = serde_json::to_vec(intent)
370            .map_err(|e| LeanError::State(format!("scope intent: {e}")))?;
371        write_atomic(&self.dir.join(SCOPE_INTENT), &bytes)
372    }
373
374    /// Cleared LAST, after the new scope is durable. Clearing it first
375    /// would turn a crash into a workspace whose scope says one thing
376    /// and whose held set says another, with nothing left to replay.
377    pub fn clear_scope_intent(&self) -> LeanResult<()> {
378        match fs::remove_file(self.dir.join(SCOPE_INTENT)) {
379            Ok(()) => Ok(()),
380            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
381            Err(e) => Err(LeanError::State(format!("clear {SCOPE_INTENT}: {e}"))),
382        }
383    }
384
385    pub fn load_incarnation(&self) -> LeanResult<Option<Incarnation>> {
386        let p = self.dir.join(INCARNATION);
387        if !p.exists() {
388            return Ok(None);
389        }
390        let bytes = fs::read(&p)?;
391        serde_json::from_slice(&bytes)
392            .map(Some)
393            .map_err(|e| LeanError::State(format!("incarnation: {e}")))
394    }
395
396    pub fn save_incarnation(&self, i: &Incarnation) -> LeanResult<()> {
397        let bytes = serde_json::to_vec_pretty(i)
398            .map_err(|e| LeanError::State(format!("incarnation: {e}")))?;
399        write_atomic(&self.dir.join(INCARNATION), &bytes)
400    }
401
402    pub fn load_intent(&self) -> LeanResult<IntentJournal> {
403        let p = self.dir.join(INTENT);
404        if !p.exists() {
405            return Ok(IntentJournal::default());
406        }
407        let bytes = fs::read(&p)?;
408        serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("intent: {e}")))
409    }
410
411    pub fn save_intent(&self, j: &IntentJournal) -> LeanResult<()> {
412        let bytes =
413            serde_json::to_vec_pretty(j).map_err(|e| LeanError::State(format!("intent: {e}")))?;
414        write_atomic(&self.dir.join(INTENT), &bytes)
415    }
416
417    /// Clear the per-barrier key list but KEEP the uuid history (the
418    /// AdoptOwn recognizer needs uuids from completed barriers whose
419    /// baseline rewrite raced a crash).
420    pub fn clear_intent_keys(&self) -> LeanResult<()> {
421        let mut j = self.load_intent()?;
422        if !j.flush_uuid.is_empty() {
423            if !j.recent_uuids.contains(&j.flush_uuid) {
424                j.recent_uuids.push(j.flush_uuid.clone());
425            }
426            let excess = j.recent_uuids.len().saturating_sub(8);
427            if excess > 0 {
428                j.recent_uuids.drain(..excess);
429            }
430        }
431        j.flush_uuid = String::new();
432        j.keys.clear();
433        j.declared_deletes.clear();
434        self.save_intent(&j)
435    }
436
437    pub fn append_conflict(&self, c: &ConflictRecord) -> LeanResult<()> {
438        use std::io::Write;
439        let line =
440            serde_json::to_string(c).map_err(|e| LeanError::State(format!("conflict: {e}")))?;
441        let live = self.dir.join(CONFLICTS);
442        let mut f = fs::OpenOptions::new().create(true).append(true).open(&live)?;
443        writeln!(f, "{line}")?;
444        // Size off the handle we just wrote through — no extra stat of
445        // the path, and no line count, which would make every append
446        // O(records).
447        let len = f.metadata()?.len();
448        drop(f);
449        if len > CONFLICTS_MAX_BYTES {
450            self.rotate_conflicts()?;
451        }
452        Ok(())
453    }
454
455    /// Rotate live -> `.1`, counting whatever the previous `.1` held so
456    /// the loss is a NUMBER rather than a silence. Best effort: a
457    /// rotation that fails leaves a large file, which is a performance
458    /// problem; losing the record we just appended would be a
459    /// correctness one.
460    fn rotate_conflicts(&self) -> LeanResult<()> {
461        let live = self.dir.join(CONFLICTS);
462        let prev = self.dir.join(CONFLICTS_PREV);
463        // Only read the outgoing generation at ROTATION time — rare by
464        // construction, since it takes CONFLICTS_MAX_BYTES to get here.
465        if let Ok(text) = fs::read_to_string(&prev) {
466            let dropped = text.lines().filter(|l| !l.trim().is_empty()).count() as u64;
467            let total = self.conflicts_dropped()? + dropped;
468            write_atomic(&self.dir.join(CONFLICTS_DROPPED), total.to_string().as_bytes())?;
469        }
470        fs::rename(&live, &prev)?;
471        Ok(())
472    }
473
474    /// Records lost to rotation. A caller rendering conflicts owes the
475    /// user this number: a truncated list that does not say it was
476    /// truncated reads as a complete one.
477    pub fn conflicts_dropped(&self) -> LeanResult<u64> {
478        match fs::read_to_string(self.dir.join(CONFLICTS_DROPPED)) {
479            Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
480            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
481            Err(e) => Err(LeanError::State(format!("{CONFLICTS_DROPPED}: {e}"))),
482        }
483    }
484
485    /// The rotated generation first, then the live one: oldest to
486    /// newest, unbroken across a single rotation.
487    pub fn load_conflicts(&self) -> LeanResult<Vec<ConflictRecord>> {
488        let mut out = vec![];
489        for name in [CONFLICTS_PREV, CONFLICTS] {
490            let p = self.dir.join(name);
491            // `fs::read_to_string` directly: `exists()` answers false
492            // for EACCES and EIO alike, and a conflict log that reads as
493            // EMPTY because it is unreadable is the same shape of bug as
494            // an unreadable path reading as deleted.
495            let text = match fs::read_to_string(&p) {
496                Ok(t) => t,
497                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
498                Err(e) => {
499                    return Err(LeanError::State(format!(
500                        "cannot read {name}: {e} — refusing to report an unreadable \
501                         conflict log as an empty one"
502                    )))
503                }
504            };
505            for line in text.lines() {
506                if line.trim().is_empty() {
507                    continue;
508                }
509                out.push(
510                    serde_json::from_str(line)
511                        .map_err(|e| LeanError::State(format!("conflict line: {e}")))?,
512                );
513            }
514        }
515        Ok(out)
516    }
517}