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}