Skip to main content

flint_lean/
untracked.rs

1//! Finding 10: an upload whose commit never came.
2//!
3//! Uploads hold no lease. A writer lost for good between an upload and its
4//! commit — its pod replaced, its node gone — leaves bytes at a fresh
5//! handle, and nothing tracks them: no manifest cites them, and the writer
6//! that would have cited them is gone.
7//! Nothing reads them either — every checkout and consume fetches the
8//! handle the manifest cites (the S3-wins arm that once served such bytes
9//! is gone) — so the work they hold is lost unless something cites it.
10//!
11//! The sweep tracks such an object the way the gateway takes a UI save
12//! (P2, 2026-09-25): it COMMITS a copy, and every writer's tree takes it as
13//! any peer's change (adopted where the path is clean, preserved beside the
14//! agent's edit where it is not). The formal model checks the rule: tracked at
15//! ANY time, a live writer's upload still in flight included, every
16//! invariant holds (`LeanBarrierLeaseOrphanTracked`), so the grace below is
17//! churn control rather than a safety margin; without it, a world in which
18//! every syncer has gone quiet still holds such bytes
19//! (`LeanBarrierLeaseOrphanDiverges`).
20
21use std::collections::HashMap;
22
23use flint_store::{crc64_nvme, crc64_to_b64, GenerationStamps, PutCondition, StoreError};
24
25use super::{manifest, now_unix, LeanResult, Syncer};
26
27/// How long an object nothing cites may sit before a sweep takes it —
28/// the ingress sweep's and the orphan sweep's grace, seconds. Churn control,
29/// not a safety margin: the commit re-reads what it cites (R4a).
30pub const UNTRACKED_GRACE_SECS: u64 = 600;
31
32/// The flush id of an ingress object's adopted copy: named after the
33/// ingress etag, so the copy of one ingress version has one handle and
34/// a second sweep finds it rather than minting another.
35pub fn ingress_flush(etag: &str) -> String {
36    let clean: String = etag.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
37    format!("ingress-{clean}")
38}
39
40impl Syncer {
41    /// The INGRESS sweep (immutable-handles design 2026-09-19, R5): track
42    /// every object at a BARE path key under `files/` — an outside
43    /// writer's `aws s3 cp`, the one write nothing in this crate makes any
44    /// more — that has sat there for at least `untracked_grace_secs` and
45    /// that no entry has adopted. The object is copied server-side to a
46    /// handle of its own, named after the ingress etag so a second sweep
47    /// finds its copy already there, and ONE manifest CAS cites the copy
48    /// (`manifest::commit_edit`, as the gateway commits a save) — only while
49    /// the path still cites what the sweep saw; a citation that moved since
50    /// leaves the object to the next sweep. The ingress
51    /// object itself is never deleted and never needs a conditional
52    /// DELETE: a later outside overwrite is a new ingress with a new etag,
53    /// adopted the same way.
54    ///
55    /// Before handles this sweep tracked a lost writer's upload at the
56    /// cited key (finding 10). Under handles such an upload is an orphan
57    /// HANDLE, and the orphan sweep collects it (R4) — never adopts it.
58    ///
59    /// One manifest read and a LIST of the files prefix, then one commit per
60    /// object adopted. Returns the paths adopted.
61    pub async fn track_untracked(&self, now: u64) -> LeanResult<Vec<String>> {
62        let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
63        let cited: HashMap<&str, &str> = loaded
64            .as_ref()
65            .map(|l| l.manifest.entries.iter().map(|(p, e)| (p.as_str(), e.key.as_str())).collect())
66            .unwrap_or_default();
67        let listed = self.store.list(&self.cfg.files_prefix()).await?;
68        let mut tracked = vec![];
69        for obj in listed {
70            // A handle is a writer's or the gateway's, never ingress.
71            if self.cfg.handle_parts(&obj.key).is_some() {
72                continue;
73            }
74            let Some(path) = obj.key.strip_prefix(&self.cfg.files_prefix()) else { continue };
75            if path.is_empty() || path.ends_with('/') {
76                continue;
77            }
78            // No modification time, no judgement of age: leave it.
79            let Some(modified) = obj.last_modified_unix else { continue };
80            if now.saturating_sub(modified) < self.cfg.untracked_grace_secs {
81                continue;
82            }
83            let handle = self.cfg.handle_key(path, &ingress_flush(&obj.etag));
84            // Adopted already: its copy is cited.
85            if cited.get(path).is_some_and(|k| *k == handle.as_str()) {
86                continue;
87            }
88            let stamps = GenerationStamps {
89                generation: 0,
90                epoch: 0,
91                flush_uuid: ingress_flush(&obj.etag),
92                boundary_source: None,
93                posix: None,
94            };
95            let copied = match self
96                .store
97                .copy_object(&obj.key, Some(&obj.etag), &handle, &PutCondition::IfNoneMatchAny, &stamps)
98                .await
99            {
100                Ok(m) => m,
101                // The copy is already there (a sweep that died before its
102                // append): use it as it stands.
103                Err(StoreError::PreconditionFailed(_)) => match self.store.head(&handle).await {
104                    Ok(m) => m,
105                    // The ingress object moved between the LIST and the
106                    // copy: a new etag, the next sweep's business.
107                    Err(StoreError::NotFound(_)) => continue,
108                    Err(e) => return Err(e.into()),
109                },
110                Err(StoreError::NotFound(_)) => continue,
111                Err(e) => return Err(e.into()),
112            };
113            // Cited entries carry a CRC every reader verifies against: the
114            // backend's attestation, else the bytes' own, read once.
115            let crc64_b64 = match copied.crc64_b64.clone() {
116                Some(c) => c,
117                None => {
118                    let (_, bytes) = self.store.get_whole(&handle, Some(&copied.etag)).await?;
119                    crc64_to_b64(crc64_nvme(&bytes))
120                }
121            };
122            let seen = cited.get(path).map(|k| k.to_string());
123            let committed = manifest::commit_edit(self.store.as_ref(), &self.cfg, &ingress_flush(&obj.etag), |_, doc| {
124                let was = doc.entries.get(path);
125                if was.map(|e| e.key.clone()) != seen {
126                    return Err(()); // the citation moved since the sweep looked
127                }
128                let entry = manifest::LeanEntry {
129                    key: handle.clone(),
130                    etag: copied.etag.clone(),
131                    crc64_b64: crc64_b64.clone(),
132                    size: copied.size,
133                    mode: was.map(|e| e.mode).unwrap_or(0o644),
134                    mtime_unix: now_unix() as i64,
135                    generation: was.map(|e| e.generation).unwrap_or(0) + 1,
136                    epoch: 0,
137                };
138                doc.tombstones.remove(path);
139                doc.entries.insert(path.to_string(), entry);
140                Ok(())
141            })
142            .await;
143            match committed {
144                Ok(()) => {
145                    self.trace("untracked", serde_json::json!({"path": path, "etag": obj.etag, "handle": handle,
146                        "cited": seen}));
147                    tracked.push(path.to_string());
148                }
149                Err(manifest::EditError::Refused(())) | Err(manifest::EditError::Contended) => continue,
150                Err(manifest::EditError::Store(e)) => return Err(e),
151            }
152        }
153        Ok(tracked)
154    }
155
156    /// The floor's hook: sweep when `untracked_sweep_secs` have passed since
157    /// the last sweep (0 = never). A writer's first tick starts the clock
158    /// instead of sweeping — its own checkout has just read every citation.
159    /// Best-effort: a sweep that fails is retried on the next tick and never
160    /// fails the floor.
161    pub async fn sweep_untracked_if_due(&self, now: u64) {
162        if self.cfg.untracked_sweep_secs == 0 || self.cfg.access.is_read() {
163            return;
164        }
165        let last = match self.state.load_untracked_sweep_at() {
166            Ok(t) => t,
167            Err(e) => {
168                eprintln!("flint-sync: untracked sweep clock unreadable (skipping): {e}");
169                return;
170            }
171        };
172        if last == 0 {
173            let _ = self.state.save_untracked_sweep_at(now);
174            return;
175        }
176        if now.saturating_sub(last) < self.cfg.untracked_sweep_secs {
177            return;
178        }
179        match self.track_untracked(now).await {
180            Ok(paths) => {
181                if !paths.is_empty() {
182                    eprintln!(
183                        "flint-sync: tracked {} upload(s) no commit cited, left by a lost writer: {paths:?}",
184                        paths.len()
185                    );
186                }
187                let _ = self.state.save_untracked_sweep_at(now);
188            }
189            Err(e) => eprintln!("flint-sync: untracked sweep failed (retrying next tick): {e}"),
190        }
191    }
192
193    /// The ORPHAN sweep (immutable-handles design 2026-09-19, R4): a
194    /// handle the installed document does not cite, no inbox entry names,
195    /// and whose writer never committed — its pod replaced between an
196    /// upload and its CAS — is garbage, and is collected, never adopted.
197    ///
198    /// Runs INSIDE a commit section, under the cell: a live writer's
199    /// uploads precede its claim and carry no lease, so the only thing
200    /// that keeps a sweep from taking one is that the sweeper holds the
201    /// cell while that writer's commit re-reads its own uploads before its
202    /// CAS (`LeanImmutableSweepLeaseFree` is the sweep without the cell;
203    /// `LeanImmutableCasCitesBlind` the commit without the re-read). The
204    /// grace is churn control, not a safety margin: a live writer whose
205    /// upload sat longer than it is withheld at its commit and re-uploads.
206    /// This barrier's own handles are never candidates. Bare keys — the
207    /// ingress namespace — are not handles and are left to
208    /// `track_untracked`.
209    ///
210    /// One LIST of the files prefix per sweep, on the untracked sweep's
211    /// cadence (`untracked_sweep_secs`); best effort by design.
212    pub(crate) async fn sweep_orphans_if_due(
213        &mut self,
214        installed: &manifest::LeanManifest,
215        own_flush: &str,
216        now: u64,
217        spared: &std::collections::HashSet<String>,
218    ) -> LeanResult<usize> {
219        if self.cfg.untracked_sweep_secs == 0 {
220            return Ok(0);
221        }
222        let last = self.state.load_orphan_sweep_at()?;
223        if last == 0 {
224            // A writer's first commit starts the clock instead of sweeping.
225            self.state.save_orphan_sweep_at(now)?;
226            return Ok(0);
227        }
228        if now.saturating_sub(last) < self.cfg.untracked_sweep_secs {
229            return Ok(0);
230        }
231        let n = self.sweep_orphans(installed, own_flush, now, spared).await?;
232        self.state.save_orphan_sweep_at(now)?;
233        Ok(n)
234    }
235
236    /// The sweep itself, whenever the caller decides it is due.
237    pub(crate) async fn sweep_orphans(
238        &mut self,
239        installed: &manifest::LeanManifest,
240        own_flush: &str,
241        now: u64,
242        spared: &std::collections::HashSet<String>,
243    ) -> LeanResult<usize> {
244        let cited: std::collections::HashSet<&str> =
245            installed.entries.values().map(|e| e.key.as_str()).collect();
246        let listed = self.store.list(&self.cfg.files_prefix()).await?;
247        let mut orphans: Vec<String> = vec![];
248        for obj in listed {
249            let Some((_, flush)) = self.cfg.handle_parts(&obj.key) else { continue };
250            // Retired less than G ago (M1): a lagging reader may still fetch it.
251            if flush == own_flush || cited.contains(obj.key.as_str()) || spared.contains(&obj.key) {
252                continue;
253            }
254            // No modification time, no judgement of age: leave it.
255            let Some(modified) = obj.last_modified_unix else { continue };
256            if now.saturating_sub(modified) < self.cfg.untracked_grace_secs {
257                continue;
258            }
259            orphans.push(obj.key);
260        }
261        if orphans.is_empty() {
262            return Ok(0);
263        }
264        let report = self.delete_many_renewing(&orphans).await?;
265        // The handles taken, named: a trace check maps each to the version
266        // it held (`TraceCore.tla`'s Sweep takes one handle per step).
267        let taken: Vec<&String> = orphans.iter().filter(|k| !report.failed.iter().any(|(f, _)| f == *k)).collect();
268        self.trace("sweep", serde_json::json!({"what": "orphans", "candidates": orphans.len(),
269            "removed": report.deleted, "refused": report.failed.len(), "keys": taken}));
270        if report.deleted > 0 {
271            eprintln!(
272                "flint-sync: collected {} orphan handle(s) no commit cited, left by a lost writer",
273                report.deleted
274            );
275        }
276        Ok(report.deleted)
277    }
278}
279
280/// The retire logs as a writer's sweeps read them (M1, `manifest::RetireLog`).
281#[derive(Default)]
282pub(crate) struct RetireLedger {
283    /// Every handle and chunk KEY a log younger than the retire age names:
284    /// the orphan sweep and the chunk reaper spare these.
285    pub spared: std::collections::HashSet<String>,
286    /// The logs at least that old, by key.
287    pub due: Vec<(String, manifest::RetireLog)>,
288}
289
290/// Keys per `delete_many` call inside a commit section: one S3
291/// DeleteObjects request, and a renew between two of them.
292pub(crate) const DELETE_RENEW_BATCH: usize = 1000;
293
294impl Syncer {
295    /// `delete_many` in batches with a renew between them (review
296    /// 2026-09-18, M3): a mass delete is the longest stretch of a commit
297    /// section, and nothing else moves the cell's token during it. A
298    /// deposal stops it: what is left is a leak the next holder's sweeps
299    /// take, never a deletion under a successor's cell.
300    pub(crate) async fn delete_many_renewing(&mut self, keys: &[String]) -> LeanResult<flint_store::DeleteManyReport> {
301        let mut all = flint_store::DeleteManyReport::default();
302        for batch in keys.chunks(DELETE_RENEW_BATCH) {
303            super::lease::renew_if_due(self).await?;
304            let r = self.store.delete_many(batch).await?;
305            all.deleted += r.deleted;
306            all.failed.extend(r.failed);
307        }
308        Ok(all)
309    }
310
311    /// List the retire logs and read the ones not read before. One LIST per
312    /// collection pass; a GET per log, once, the new ones in parallel — a
313    /// pass that runs every few minutes (`collect_every_secs`) meets every
314    /// log written since the last, and reads them under the lease.
315    pub(crate) async fn retire_ledger(&self, now: u64) -> LeanResult<RetireLedger> {
316        use futures::stream::{self, StreamExt};
317        let listed = self.store.list(&manifest::retired_prefix(&self.cfg)).await?;
318        let mut cache = self.state.load_retire_cache();
319        let present: std::collections::BTreeSet<&str> = listed.iter().map(|o| o.key.as_str()).collect();
320        cache.retain(|k, _| present.contains(k.as_str()));
321        let unread: Vec<&str> = listed
322            .iter()
323            .filter(|o| manifest::retire_log_at(&self.cfg, &o.key).is_some() && !cache.contains_key(&o.key))
324            .map(|o| o.key.as_str())
325            .collect();
326        let fetched: Vec<(&str, Result<(flint_store::ObjectMeta, bytes::Bytes), StoreError>)> =
327            stream::iter(unread.into_iter().map(|key| async move { (key, self.store.get_whole(key, None).await) }))
328                .buffer_unordered(self.cfg.chunk_fetch_parallelism.max(1))
329                .collect()
330                .await;
331        for (key, got) in fetched {
332            match got {
333                // A log nobody can parse spares nothing and is reaped
334                // when due: its handles fall to the write-age rule.
335                Ok((_, body)) => {
336                    cache.insert(key.to_string(), serde_json::from_slice(&body).unwrap_or_default());
337                }
338                // Reaped by another writer since the LIST.
339                Err(StoreError::NotFound(_)) => {}
340                Err(e) => return Err(e.into()),
341            }
342        }
343        let mut ledger = RetireLedger::default();
344        for o in &listed {
345            let Some(at) = manifest::retire_log_at(&self.cfg, &o.key) else { continue };
346            let Some(log) = cache.get(&o.key).cloned() else { continue };
347            if now.saturating_sub(at) >= self.cfg.retire_grace_secs {
348                ledger.due.push((o.key.clone(), log));
349            } else {
350                ledger.spared.extend(log.handles.iter().cloned());
351                ledger.spared.extend(log.chunks.iter().map(|a| self.cfg.chunk_key(a)));
352            }
353        }
354        self.state.save_retire_cache(&cache)?;
355        Ok(ledger)
356    }
357
358    /// Collect the handles the due logs name — a retired handle is never
359    /// cited again (a rename keeps its handle cited, so never retires it),
360    /// and `installed` is checked anyway — then the logs themselves. A due
361    /// log's CHUNKS are only no longer spared: the chunk reaper, with its
362    /// fence, decides them. Returns the handles collected.
363    pub(crate) async fn reap_retired(
364        &mut self,
365        ledger: &RetireLedger,
366        installed: &manifest::LeanManifest,
367        own_flush: &str,
368    ) -> LeanResult<usize> {
369        let cited: std::collections::HashSet<&str> = installed.entries.values().map(|e| e.key.as_str()).collect();
370        let mut handles: Vec<String> = ledger
371            .due
372            .iter()
373            .flat_map(|(_, l)| l.handles.iter().cloned())
374            .filter(|k| !cited.contains(k.as_str()) && !ledger.spared.contains(k))
375            .collect();
376        handles.sort();
377        handles.dedup();
378        let mut collected = 0;
379        if !handles.is_empty() {
380            let report = self.delete_many_renewing(&handles).await?;
381            collected = report.deleted;
382            let taken: Vec<&String> = handles.iter().filter(|k| !report.failed.iter().any(|(f, _)| f == *k)).collect();
383            // Named, as the orphan sweep's are: a trace check maps each key.
384            self.trace("sweep", serde_json::json!({"flush": own_flush, "what": "retired", "removed": report.deleted,
385                "refused": report.failed.len(), "keys": taken}));
386            if !report.failed.is_empty() {
387                // Keep the logs: their handles are retried next time.
388                return Ok(collected);
389            }
390        }
391        // The logs are deleted under the cell too: renew first, and a
392        // deposal leaves them for the next holder (M3).
393        super::lease::renew_if_due(self).await?;
394        let mut cache = self.state.load_retire_cache();
395        for (key, _) in &ledger.due {
396            match self.store.delete(key).await {
397                Ok(()) | Err(StoreError::NotFound(_)) => {
398                    cache.remove(key);
399                }
400                Err(e) => eprintln!("flint-sync: could not drop the retire log {key} ({e}); next time"),
401            }
402        }
403        self.state.save_retire_cache(&cache)?;
404        Ok(collected)
405    }
406}