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 &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 &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.store.delete_many(&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
290impl Syncer {
291 /// List the retire logs and read the ones not read before. One LIST per
292 /// commit section; a GET per log, once.
293 pub(crate) async fn retire_ledger(&self, now: u64) -> LeanResult<RetireLedger> {
294 let listed = self.store.list(&manifest::retired_prefix(&self.cfg)).await?;
295 let mut cache = self.state.load_retire_cache();
296 let present: std::collections::BTreeSet<&str> = listed.iter().map(|o| o.key.as_str()).collect();
297 cache.retain(|k, _| present.contains(k.as_str()));
298 let mut ledger = RetireLedger::default();
299 for o in &listed {
300 let Some(at) = manifest::retire_log_at(&self.cfg, &o.key) else { continue };
301 let log = match cache.get(&o.key) {
302 Some(l) => l.clone(),
303 None => match self.store.get_whole(&o.key, None).await {
304 // A log nobody can parse spares nothing and is reaped
305 // when due: its handles fall to the write-age rule.
306 Ok((_, body)) => serde_json::from_slice(&body).unwrap_or_default(),
307 Err(StoreError::NotFound(_)) => continue,
308 Err(e) => return Err(e.into()),
309 },
310 };
311 cache.insert(o.key.clone(), log.clone());
312 if now.saturating_sub(at) >= self.cfg.retire_grace_secs {
313 ledger.due.push((o.key.clone(), log));
314 } else {
315 ledger.spared.extend(log.handles.iter().cloned());
316 ledger.spared.extend(log.chunks.iter().map(|a| self.cfg.chunk_key(a)));
317 }
318 }
319 self.state.save_retire_cache(&cache)?;
320 Ok(ledger)
321 }
322
323 /// Collect the handles the due logs name — a retired handle is never
324 /// cited again (a rename keeps its handle cited, so never retires it),
325 /// and `installed` is checked anyway — then the logs themselves. A due
326 /// log's CHUNKS are only no longer spared: the chunk reaper, with its
327 /// fence, decides them. Returns the handles collected.
328 pub(crate) async fn reap_retired(
329 &self,
330 ledger: &RetireLedger,
331 installed: &manifest::LeanManifest,
332 own_flush: &str,
333 ) -> LeanResult<usize> {
334 let cited: std::collections::HashSet<&str> = installed.entries.values().map(|e| e.key.as_str()).collect();
335 let mut handles: Vec<String> = ledger
336 .due
337 .iter()
338 .flat_map(|(_, l)| l.handles.iter().cloned())
339 .filter(|k| !cited.contains(k.as_str()) && !ledger.spared.contains(k))
340 .collect();
341 handles.sort();
342 handles.dedup();
343 let mut collected = 0;
344 if !handles.is_empty() {
345 let report = self.store.delete_many(&handles).await?;
346 collected = report.deleted;
347 let taken: Vec<&String> = handles.iter().filter(|k| !report.failed.iter().any(|(f, _)| f == *k)).collect();
348 // Named, as the orphan sweep's are: a trace check maps each key.
349 self.trace("sweep", serde_json::json!({"flush": own_flush, "what": "retired", "removed": report.deleted,
350 "refused": report.failed.len(), "keys": taken}));
351 if !report.failed.is_empty() {
352 // Keep the logs: their handles are retried next time.
353 return Ok(collected);
354 }
355 }
356 let mut cache = self.state.load_retire_cache();
357 for (key, _) in &ledger.due {
358 match self.store.delete(key).await {
359 Ok(()) | Err(StoreError::NotFound(_)) => {
360 cache.remove(key);
361 }
362 Err(e) => eprintln!("flint-sync: could not drop the retire log {key} ({e}); next time"),
363 }
364 }
365 self.state.save_retire_cache(&cache)?;
366 Ok(collected)
367 }
368}