flint_lean/gated.rs
1//! Gated manifest advance: durability split from visibility
2//! (boundary-verbs plan §2.4 — D6, D7, D8, D13).
3//!
4//! In `hybrid` (the default) uploads and citations coincide, so the
5//! uncited-object set is exactly today's crash-window set. `gated`
6//! splits them:
7//!
8//! - **Upload lane (durability), every floor tick.** Renew, verify
9//! epoch, consume the inbox, scan, stage-diff, guarded uploads — but
10//! the PUTs land **in place, as new object versions**, and no
11//! manifest CAS runs. RPO for bytes stays at the floor.
12//! - **Citation lane (visibility), at coherent points only.** ONE CAS
13//! installing the entire pending set — the versions already exist, so
14//! there is no copy phase — then version-scoped GC of superseded
15//! generations and the baseline rewrite.
16//!
17//! **Why versions and not a staging prefix.** The copy design rested on
18//! one premise: `upload_one` PUTs to `files/<path>`, so a gated eager
19//! upload landing in place would DESTROY the cited object. On a
20//! versioned bucket that premise is false — an in-place PUT destroys
21//! nothing, the cited generation survives as a version, and a citation
22//! becomes one CAS naming versions that already exist: O(1) requests
23//! per boundary instead of O(dirty files), atomic by construction
24//! rather than by a recovery protocol. What it deletes is machinery:
25//! the `eager/` namespace, per-citation `CopyObject` (and
26//! `UploadPartCopy` for >5 GiB files), the citation-intent document and
27//! successor roll-forward, the stage-NotFound arm, and the transient
28//! copy-phase reader window.
29//!
30//! **What it costs, stated rather than hidden.** Uncited generations
31//! are the CURRENT version of the real key, so any reader that does not
32//! resolve through the manifest — an import tool, `aws s3 cp`, a human —
33//! sees mid-logical-change bytes where it previously saw the last
34//! boundary. Flint's own readers qualify as manifest-resolving under
35//! D13, so the guarantee holds where it is promised, but the promise is
36//! scoped to manifest-resolving readers (§3 residual 11).
37//!
38//! **The lane opens NO HITL window.** The window exists to fence HITL
39//! out of the upload→CAS span; a lane with no CAS that opened a
40//! 180 s-deadline window every 60 s would refuse HITL admission
41//! essentially forever between citations — and it protects nothing,
42//! because a lane PUT creates a new version and destroys nothing HITL
43//! wrote. Window open/clear belong exclusively to the citation lane.
44
45use std::collections::{BTreeMap, BTreeSet};
46
47use serde::{Deserialize, Serialize};
48
49use flint_store::{crc64_nvme, crc64_to_b64, GenerationStamps, ListedVersion, StoreError};
50
51use super::manifest::{self, LeanEntry};
52use super::state::{BaselineEntry, ConflictRecord};
53use super::{inbox, now_unix, scan, BoundaryMode, LeanError, LeanResult, Syncer};
54
55const PENDING: &str = "pending.json";
56
57/// One staged-but-uncited generation.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct PendingEntry {
60 pub key: String,
61 pub etag: String,
62 /// The publisher's CRC over the bytes it staged (see
63 /// `LeanEntry::crc64_b64`).
64 pub crc64_b64: String,
65 pub size: u64,
66 pub mode: u32,
67 pub mtime_unix: i64,
68 pub generation: u64,
69 pub epoch: u64,
70 /// The version this staging PUT created.
71 pub version_id: Option<String>,
72 /// The version the BASELINE cited when we staged (D7's
73 /// re-validation guard). If the baseline's version has moved by
74 /// citation time, a HITL consume or sync landed after staging, and
75 /// installing our staged version would UNCITE the foreign bytes.
76 pub base_version_id: Option<String>,
77 pub staged_unix: u64,
78}
79
80/// The gated lane's durable bookkeeping — its own file, so
81/// `clear_intent_keys` cannot take it with the intent journal.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct PendingStage {
84 pub entries: BTreeMap<String, PendingEntry>,
85 /// Delete classifications WITHHELD from the manifest until a
86 /// citation pass (§2.4.2): a rename r→s must not become
87 /// reader-visible as r-gone/s-absent at an undeclared point.
88 #[serde(default)]
89 pub withheld_deletes: BTreeSet<String>,
90 pub last_citation_unix: u64,
91 /// Scan-to-scan stability tracking (§2.4.1's quiescence, as
92 /// corrected: "no scan diff vs baseline" could never fire once
93 /// anything was pending, since pending paths stay
94 /// classified-changed until citation — quiescence would have been
95 /// dead code and every citation would ride the lag cap).
96 pub last_scan_fingerprint: Option<String>,
97 pub stable_since_unix: u64,
98 /// Legacy-cited paths (no `version_id`) whose cited etag matched no
99 /// surviving version, so the backfill below could not make them
100 /// addressable. Remembered so one unresolvable entry does not buy a
101 /// whole version listing at every subsequent citation.
102 #[serde(default)]
103 pub legacy_unresolved: BTreeSet<String>,
104 /// Staged paths the last lane pass did not see on disk. A staged
105 /// path that was never CITED has no baseline entry, so
106 /// `scan::classify` cannot call it a delete — it cannot see it at
107 /// all — and the stage would carry its version into a boundary for
108 /// a file the agent removed. This is that path's own
109 /// two-observations memory, the rename-vs-walk guard applied where
110 /// classify cannot reach.
111 #[serde(default)]
112 pub staged_absent_once: BTreeSet<String>,
113 /// Versions this workspace superseded or cancelled between
114 /// citations, per path — RECORDED by the lane, DELETED only by the
115 /// guarded citation-time reaper.
116 ///
117 /// The lane used to delete these itself, with one guard ("not the
118 /// version I just wrote") and no reference to the installed
119 /// manifest. `citation_pass` clears the stage LAST — after the CAS,
120 /// the reaper, the baseline save and the intent clear — and four
121 /// ordinary `?` returns sit in that window, so one transient store
122 /// error leaves a stage naming versions the boundary now CITES. The
123 /// next lane pass then deleted the cited version. One guarded
124 /// delete site was the fix; this ledger is what keeps the
125 /// intermediate versions NAMEABLE while it is the only one.
126 ///
127 /// Without it, `base_version_id` is pinned to the cited generation
128 /// (it comes from the baseline, which gated mode advances only at
129 /// citation), so a path re-staged N times between citations leaves
130 /// versions 2..N named by nothing at all — falling through to
131 /// `noncurrentRetentionDays`, which D8 is explicit is a crash-window
132 /// backstop and not a GC policy, because it runs its clock against
133 /// live cited data.
134 #[serde(default)]
135 pub pending_reclaims: BTreeMap<String, Vec<String>>,
136}
137
138/// Which coherent point fired.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum CitationSource {
141 Sentinel,
142 Quiescence,
143 ForcedLagCap,
144 ForcedBacklogCap,
145 Cadence,
146 Drain,
147 Recovered,
148 /// A repair-only pass: nothing of the agent's is staged, but the
149 /// manifest cites an older object than the one this syncer has
150 /// already INTEGRATED (a consumed HITL write, checkout's S3-wins
151 /// arm). §2.4.2 exempts these from gating, and D13's promise to
152 /// pinned readers — "within one floor" — is this source.
153 Repair,
154}
155
156impl CitationSource {
157 pub fn as_str(&self) -> &'static str {
158 match self {
159 CitationSource::Sentinel => "sentinel",
160 CitationSource::Quiescence => "quiescence",
161 CitationSource::ForcedLagCap => "forced-lag-cap",
162 CitationSource::ForcedBacklogCap => "forced-backlog-cap",
163 CitationSource::Cadence => "cadence",
164 CitationSource::Drain => "drain",
165 CitationSource::Recovered => "recovered",
166 CitationSource::Repair => "repair",
167 }
168 }
169}
170
171/// Re-verify the lease every N paths inside the reaper. The loop is
172/// O(cited) sequential round trips with no renewal arm behind it, so a
173/// long sweep both starves the lease and outlives the holder's right to
174/// delete. 8 keeps the added epoch reads under ~12% of the LISTs the
175/// sweep already pays for.
176const RECLAIM_FENCE_EVERY: usize = 8;
177
178#[derive(Debug, Default)]
179pub struct LaneReport {
180 pub staged: Vec<String>,
181 pub parked: Vec<String>,
182 pub consumed: usize,
183 pub staged_bytes: u64,
184 /// Superseded uncited versions reclaimed by the lane itself (free,
185 /// version-scoped): uncited work holds at most one version per path
186 /// plus crash remnants.
187 /// Versions the lane RECORDED for the reaper to reclaim at the
188 /// next citation. The lane deletes nothing.
189 pub superseded_recorded: usize,
190 pub withheld_deletes: usize,
191 /// Paths this lane pass saw absent for the FIRST time — withheld to
192 /// the next scan by the rename-vs-walk guard, and so NOT part of
193 /// the boundary a citation from this pass would install.
194 pub first_absence: Vec<String>,
195 /// First-absence paths a DECLARED lane pass confirmed gone by
196 /// direct lstat and staged as ordinary withheld deletes.
197 pub absences_confirmed: usize,
198}
199
200#[derive(Debug, Default)]
201pub struct CitationReport {
202 pub seq: Option<u64>,
203 pub source: Option<String>,
204 pub cited: usize,
205 pub dropped_stale_base: Vec<String>,
206 /// Paths dropped because a HITL write landed between the lane's
207 /// consume and this citation's window. Unlike the stale-base drop,
208 /// the agent's latest bytes are NOT in the installed boundary — so
209 /// an ack for a declared point that names one of these is a lie
210 /// (D1's at-least guarantee).
211 pub dropped_inflight: Vec<String>,
212 /// Paths re-cited onto bytes this syncer had already integrated —
213 /// no data moved (§2.4.2's ungated repair).
214 pub repaired: Vec<String>,
215 /// Inherited entries this boundary made version-addressable before
216 /// stamping `pinned_reads` over them.
217 pub backfilled: Vec<String>,
218 pub deleted: Vec<String>,
219 /// Versions reaped by flint's EXACT per-citation GC — the reaper.
220 /// Lifecycle is only the backstop (D8), and on `files/` it cannot
221 /// tell cited from uncited at all.
222 pub versions_reclaimed: usize,
223 pub no_change: bool,
224}
225
226/// What one `recover-staged` pass found and installed (D9).
227#[derive(Debug, Default)]
228pub struct RecoverReport {
229 /// Whether D9's durable summary drove this pass (the cheap path) or
230 /// it fell back to the prefix-wide version LIST.
231 pub from_summary: bool,
232 /// Paths re-cited onto their surviving current version.
233 pub recited: Vec<String>,
234 /// Paths whose CITED version no longer exists — the D8
235 /// abandoned-mid-stage endgame, where the noncurrent backstop
236 /// reaped live cited data. A subset of `recited` when a newer
237 /// version survives to roll forward onto.
238 pub dangling: Vec<String>,
239 /// Dangling with NOTHING left to cite: the backstop reaped the
240 /// cited version and no newer generation exists. Named loudly
241 /// because no verb can fix it — the bytes are gone.
242 pub unrecoverable: Vec<String>,
243 pub seq: Option<u64>,
244 pub no_change: bool,
245}
246
247/// D9's durable orphan summary — the bucket-side answer to "is there
248/// work here that no manifest cites?", readable by the operator, a
249/// sibling cluster, or a human, long after the pod that staged it.
250#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
251pub struct OrphanDoc {
252 pub written_unix: u64,
253 /// The epoch that staged them — a summary from a deposed holder is
254 /// still true about the bytes, and saying whose it was lets a
255 /// reader tell a live backlog from a dead one.
256 pub epoch: u64,
257 pub boundary_mode: String,
258 pub candidates: Vec<OrphanCandidate>,
259}
260
261#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
262pub struct OrphanCandidate {
263 pub path: String,
264 pub version_id: Option<String>,
265 pub size: u64,
266 pub generation: u64,
267 pub epoch: u64,
268 pub staged_unix: u64,
269}
270
271impl Syncer {
272 fn stage_path(&self) -> std::path::PathBuf {
273 self.cfg.state_dir().join(PENDING)
274 }
275
276 pub fn load_stage(&self) -> LeanResult<PendingStage> {
277 let p = self.stage_path();
278 if !p.exists() {
279 return Ok(PendingStage::default());
280 }
281 let bytes = std::fs::read(&p)?;
282 Ok(serde_json::from_slice(&bytes).unwrap_or_default())
283 }
284
285 pub fn save_stage(&self, s: &PendingStage) -> LeanResult<()> {
286 let bytes =
287 serde_json::to_vec_pretty(s).map_err(|e| LeanError::State(format!("pending: {e}")))?;
288 super::control::write_atomic(&self.stage_path(), &bytes)
289 }
290
291 /// The key D9's durable orphan summary lives at.
292 pub fn orphans_key(&self) -> String {
293 format!("{}/{}/orphans.json", self.cfg.prefix, super::LEAN_DIR)
294 }
295
296 /// Surface the staged-but-uncited set into the BUCKET (D9).
297 ///
298 /// `pending.json` lives on the emptyDir, which is exactly the thing
299 /// a pure-spot replacement destroys — so on the routine failure of
300 /// this fleet the pending record can name nothing, and the operator
301 /// (a different process, often a different cluster) has no way to
302 /// know that durable work is sitting uncited. This is that summary,
303 /// and it is written where it survives cluster loss.
304 ///
305 /// Written only when the candidate set CHANGES: a re-PUT per tick
306 /// would be a request the design does not need, and an empty doc
307 /// rewritten forever would be worse than none. The clearing write
308 /// matters as much as the surfacing one — a stale summary claiming
309 /// candidates pages an operator about work that was cited minutes
310 /// ago.
311 pub async fn surface_orphans(&self, stage: &PendingStage) -> LeanResult<bool> {
312 let epoch = self.lease.as_ref().map(|l| l.epoch).unwrap_or(0);
313 let candidates: Vec<OrphanCandidate> = stage
314 .entries
315 .iter()
316 .map(|(path, e)| OrphanCandidate {
317 path: path.clone(),
318 version_id: e.version_id.clone(),
319 size: e.size,
320 generation: e.generation,
321 epoch: e.epoch,
322 staged_unix: e.staged_unix,
323 })
324 .collect();
325 let fingerprint = {
326 let mut h = flint_store::Crc64Nvme::new();
327 for c in &candidates {
328 h.update(c.path.as_bytes());
329 h.update(c.version_id.as_deref().unwrap_or("").as_bytes());
330 }
331 format!("{:016x}", h.finalize())
332 };
333 let marker = self.cfg.state_dir().join("orphans-fingerprint");
334 if std::fs::read_to_string(&marker).ok().as_deref() == Some(fingerprint.as_str()) {
335 return Ok(false);
336 }
337 let doc = OrphanDoc {
338 written_unix: super::now_unix(),
339 epoch,
340 boundary_mode: self.cfg.boundary_mode.as_str().to_string(),
341 candidates,
342 };
343 let bytes = bytes::Bytes::from(
344 serde_json::to_vec_pretty(&doc).map_err(|e| LeanError::State(format!("orphans: {e}")))?,
345 );
346 let crc = flint_store::crc64_nvme(&bytes);
347 let stamps = GenerationStamps {
348 generation: 0,
349 epoch,
350 flush_uuid: "orphan-summary".into(),
351 boundary_source: None,
352 posix: None,
353 };
354 // Unconditional: this is a summary, not a protocol cell. A
355 // successor's summary must overwrite a dead holder's, and a
356 // 412-parked orphan doc would strand the very information the
357 // successor needs.
358 let key = self.orphans_key();
359 let cond = match self.store.head(&key).await {
360 Ok(m) => flint_store::PutCondition::IfMatch(m.etag),
361 Err(_) => flint_store::PutCondition::IfNoneMatchAny,
362 };
363 match self.store.put_whole(&key, bytes, &cond, &stamps, crc).await {
364 Ok(_) => {}
365 // Losing the race is fine: whoever won wrote a summary at
366 // least as new as ours, and the next change re-runs this.
367 Err(StoreError::PreconditionFailed(_)) => return Ok(false),
368 Err(e) => return Err(e.into()),
369 }
370 let _ = super::control::write_atomic(&marker, fingerprint.as_bytes());
371 Ok(true)
372 }
373
374 /// The upload lane: durability, every floor tick, no citation.
375 pub async fn upload_lane(&mut self) -> LeanResult<LaneReport> {
376 self.lane_inner(false).await
377 }
378
379 /// The lane pass behind a DECLARED boundary — a publish sentinel
380 /// (D1 × D6) or the preStop drain (D10). The citation that follows
381 /// is going to be acked, so a delete the agent made before the
382 /// touch may not sit out the boundary waiting for a second walk:
383 /// `confirm_absences` takes that observation by lstat instead.
384 pub async fn declared_lane(&mut self) -> LeanResult<LaneReport> {
385 self.lane_inner(true).await
386 }
387
388 async fn lane_inner(&mut self, declared: bool) -> LeanResult<LaneReport> {
389 let epoch = self
390 .lease
391 .as_ref()
392 .map(|l| l.epoch)
393 .ok_or_else(|| LeanError::State("upload lane without a held lease".into()))?;
394 let mut report = LaneReport::default();
395 self.verify_not_deposed_pub().await?;
396
397 // Inbound HITL adoption stays exactly today's behavior: a HITL
398 // write is already a coherent, whole-object act by a foreign
399 // author, so it is not gated.
400 let consumed = self.consume_inbox().await?;
401 report.consumed = consumed.len();
402 if !consumed.is_empty() {
403 inbox::drop_entries(self.store.as_ref(), &self.cfg, epoch, &consumed).await?;
404 }
405
406 let mut baseline = self.state.load_baseline()?;
407 let scanned = scan::scan(&self.cfg.root)?;
408 let mut classified = scan::classify(&scanned, &baseline);
409 if declared {
410 report.absences_confirmed = self.confirm_absences(&mut classified)?;
411 }
412 report.first_absence = classified.first_absence.iter().cloned().collect();
413 let mut stage = self.load_stage()?;
414
415 // Stage-diff base is baseline ∪ PENDING. Diffing against the
416 // baseline alone — which gated mode leaves un-advanced until
417 // citation — would re-PUT every staged-but-quiet file every
418 // tick: 50 quiet files × 60 ticks = 3,000 PUTs/hour vs today's
419 // 50, which would falsify the plan's own economics.
420 let to_stage: Vec<String> = classified
421 .uploads
422 .iter()
423 .filter(|p| match stage.entries.get(*p) {
424 None => true,
425 Some(pe) => {
426 let s = &scanned[*p];
427 pe.size != s.size || pe.mtime_unix != s.mtime_unix
428 }
429 })
430 .cloned()
431 .collect();
432
433 let flush_uuid = uuid::Uuid::new_v4().to_string();
434 let mut intent = self.state.load_intent()?;
435 let prior_uuids = {
436 let mut v = intent.recent_uuids.clone();
437 if !intent.flush_uuid.is_empty() {
438 v.push(intent.flush_uuid.clone());
439 }
440 v
441 };
442 intent = super::state::IntentJournal {
443 flush_uuid: flush_uuid.clone(),
444 keys: to_stage.iter().map(|p| self.cfg.file_key(p)).collect(),
445 recent_uuids: prior_uuids.clone(),
446 installed_etag: intent.installed_etag.clone(),
447 };
448 self.state.save_intent(&intent)?;
449
450 for path in &to_stage {
451 let scanned_entry = &scanned[path];
452 let base = baseline.entries.get(path).cloned();
453 let superseded = stage.entries.get(path).and_then(|p| p.version_id.clone());
454 let outcome = self
455 .stage_one(path, scanned_entry, base.as_ref(), epoch, &flush_uuid, &prior_uuids)
456 .await?;
457 match outcome {
458 Some(entry) => {
459 report.staged_bytes += entry.size;
460 report.staged.push(path.clone());
461 // The lane reclaims the version it just superseded:
462 // free, version-scoped, and it keeps uncited work at
463 // one version per path plus crash remnants.
464 // RECORD, never delete. The lane cannot prove a
465 // version is uncited: it does not read the installed
466 // manifest, and its own stage may have outlived a
467 // citation. The reaper can, and does, under four
468 // guards.
469 if let Some(vid) = superseded {
470 if Some(&vid) != entry.version_id.as_ref() {
471 stage
472 .pending_reclaims
473 .entry(path.clone())
474 .or_default()
475 .push(vid);
476 report.superseded_recorded += 1;
477 }
478 }
479 // The path is back. A tombstone standing from an
480 // earlier tick would otherwise ride into the same
481 // citation as this upsert, and merge applies
482 // deletes last: delete-then-recreate inside one
483 // citation interval would install a boundary that
484 // omits a file present on disk and staged this
485 // pass.
486 stage.withheld_deletes.remove(path);
487 stage.entries.insert(path.clone(), entry);
488 }
489 None => report.parked.push(path.clone()),
490 }
491 }
492
493 // A staged version is cancelled by the file's departure, the
494 // mirror of the tombstone cancellation above. The lane has now
495 // seen the path GONE, later than it saw it present; a citation
496 // carrying both the stage entry and the tombstone would install
497 // one of them by accident of merge order, and merge has no way
498 // to know which came last. Reclaiming the version costs
499 // nothing — it was never cited.
500 let mut absent_now: BTreeSet<String> = BTreeSet::new();
501 let staged_paths: Vec<String> = stage.entries.keys().cloned().collect();
502 let mut cancel: Vec<String> = vec![];
503 for path in staged_paths {
504 if scanned.contains_key(&path) {
505 continue;
506 }
507 if classified.deletes.contains(&path) {
508 cancel.push(path);
509 } else if stage.staged_absent_once.contains(&path)
510 || (declared
511 && std::fs::symlink_metadata(self.cfg.root.join(&path)).is_err())
512 {
513 // Never cited, so classify cannot see it: two lane
514 // observations, or one direct lstat on a DECLARED
515 // boundary, stand in for the two-scan rule.
516 cancel.push(path);
517 } else {
518 absent_now.insert(path);
519 }
520 }
521 for path in cancel {
522 if let Some(stale) = stage.entries.remove(&path) {
523 // Same rule as the supersede site above: a cancelled
524 // path's staged version can be the CITED one if this
525 // stage outlived a citation, and this site had no
526 // manifest reference at all.
527 if let Some(vid) = stale.version_id {
528 stage.pending_reclaims.entry(path.clone()).or_default().push(vid);
529 report.superseded_recorded += 1;
530 }
531 }
532 }
533 stage.staged_absent_once = absent_now;
534
535 // Deletes are WITHHELD until a citation pass (unchanged rule).
536 for p in &classified.deletes {
537 stage.withheld_deletes.insert(p.clone());
538 }
539 report.withheld_deletes = stage.withheld_deletes.len();
540
541 // Quiescence = scan-to-scan STABILITY: no path's (size, mtime)
542 // changed between consecutive lane ticks and nothing new
543 // appeared.
544 let fingerprint = scan_fingerprint(&scanned);
545 if stage.last_scan_fingerprint.as_deref() == Some(fingerprint.as_str()) {
546 if stage.stable_since_unix == 0 {
547 stage.stable_since_unix = now_unix();
548 }
549 } else {
550 stage.last_scan_fingerprint = Some(fingerprint);
551 stage.stable_since_unix = 0;
552 }
553 if stage.last_citation_unix == 0 {
554 stage.last_citation_unix = now_unix();
555 }
556
557 // Mirror the cadence path's guard (barrier.rs, the no-change
558 // arm): `prev_scan` advances the two-consecutive-scans clock,
559 // but the baseline DOCUMENT is O(files) and this lane rewrote
560 // the whole thing every tick whether or not the scan set had
561 // moved — ~46 MB of emptyDir writeback per tick at the 250k cap,
562 // for an idle workspace. `prev_scan` is the ONLY field this
563 // function mutates, so the save is guarded on it alone.
564 // Both sides iterate in sorted order, so the sets compare
565 // without building one.
566 if scanned.len() != baseline.prev_scan.len()
567 || !scanned.keys().eq(baseline.prev_scan.iter())
568 {
569 baseline.prev_scan = scanned.keys().cloned().collect();
570 self.state.save_baseline(&baseline)?;
571 }
572 self.save_stage(&stage)?;
573 // D9: the same set, surfaced where a pure-spot replacement
574 // cannot destroy it. Best-effort by construction — a failed
575 // summary must never fail the lane that made the bytes durable.
576 if let Err(e) = self.surface_orphans(&stage).await {
577 eprintln!("flint-sync: orphan summary not written (retrying next tick): {e}");
578 }
579 // Durability moved even though visibility did not — the whole
580 // point of the split, and the number `rpo_secs` must track.
581 if !report.staged.is_empty() {
582 self.note_durable()?;
583 }
584 Ok(report)
585 }
586
587 /// One in-place staging PUT. The guard chain is unchanged (If-Match
588 /// on the recognized baseline etag, AdoptOwn, park on foreign); what
589 /// changes is that the response's version id is KEPT.
590 async fn stage_one(
591 &self,
592 path: &str,
593 scanned: &scan::ScanEntry,
594 base: Option<&BaselineEntry>,
595 epoch: u64,
596 flush_uuid: &str,
597 prior_uuids: &[String],
598 ) -> LeanResult<Option<PendingEntry>> {
599 let key = self.cfg.file_key(path);
600 match self.upload_one_pub(path, scanned, base, epoch, flush_uuid, prior_uuids).await? {
601 Some((entry, _be)) => Ok(Some(PendingEntry {
602 key,
603 etag: entry.etag,
604 crc64_b64: entry.crc64_b64,
605 size: entry.size,
606 mode: entry.mode,
607 mtime_unix: entry.mtime_unix,
608 generation: entry.generation,
609 epoch: entry.epoch,
610 version_id: entry.version_id,
611 base_version_id: base.and_then(|b| b.version_id.clone()),
612 staged_unix: now_unix(),
613 })),
614 None => {
615 self.state.append_conflict(&ConflictRecord {
616 path: path.to_string(),
617 foreign_etag: String::new(),
618 preserved_key: None,
619 kind: "stage-412-parked".into(),
620 at_unix: now_unix(),
621 })?;
622 Ok(None)
623 }
624 }
625 }
626
627 /// Paths whose INTEGRATED object (the baseline) differs from the
628 /// manifest's citation: a consumed HITL write, or checkout's
629 /// S3-wins adoption. The fused barrier has carried this repair
630 /// since the amputation leg; gated mode never runs the fused
631 /// barrier, so without the same pass here an acked HITL write is
632 /// adopted into the tree, scans clean forever after, and stays
633 /// cited at its PREVIOUS version — invisible to exactly the
634 /// pinned readers D13 governs.
635 pub fn repair_candidates(&self) -> LeanResult<Vec<String>> {
636 let baseline = self.state.load_baseline()?;
637 let stage = self.load_stage()?;
638 Ok(baseline
639 .entries
640 .iter()
641 .filter(|(p, be)| {
642 be.size != u64::MAX // consume-dirty sentinel: publishes via the lane
643 && !stage.entries.contains_key(*p)
644 && !stage.withheld_deletes.contains(*p)
645 && baseline.inst_base.get(*p) != Some(&be.etag)
646 })
647 .map(|(p, _)| p.clone())
648 .collect())
649 }
650
651 /// `(key, etag)` -> version id, over this subtree's whole version
652 /// history. One listing answers for every legacy entry at once,
653 /// including paths whose CURRENT version has already moved past
654 /// the citation — which a HEAD, by construction, cannot.
655 async fn version_index(&self) -> LeanResult<BTreeMap<(String, String), String>> {
656 let files_prefix = format!("{}/files/", self.cfg.prefix);
657 let mut ix: BTreeMap<(String, String), String> = BTreeMap::new();
658 // A backend that cannot list versions leaves every legacy entry
659 // unresolved, which the reader rule then refuses rather than
660 // adopts. Gated already refuses such a backend at startup.
661 for v in self.store.list_versions(&files_prefix).await.unwrap_or_default() {
662 if v.is_delete_marker {
663 continue;
664 }
665 ix.entry((v.key, v.etag)).or_insert(v.version_id);
666 }
667 Ok(ix)
668 }
669
670 /// Which coherent point, if any, is due right now.
671 pub fn citation_due(&self, sentinel_pending: bool) -> LeanResult<Option<CitationSource>> {
672 let stage = self.load_stage()?;
673 let now = now_unix();
674 if stage.entries.is_empty() && stage.withheld_deletes.is_empty() {
675 // Nothing of the agent's is staged — but a repair still
676 // owes the readers a boundary. Not a forced cap: nothing is
677 // late, and stamping these "forced" would misread the
678 // operator's own tell.
679 if !self.repair_candidates()?.is_empty()
680 && now.saturating_sub(stage.last_citation_unix) >= self.cfg.floor_secs
681 {
682 return Ok(Some(CitationSource::Repair));
683 }
684 return Ok(None);
685 }
686 if sentinel_pending {
687 return Ok(Some(CitationSource::Sentinel));
688 }
689 // The lag cap forces one even mid-change: unbounded citation
690 // staleness is impossible by construction, which is why `gated`
691 // is REFUSED without a bound rather than defaulted.
692 if let Some(bound) = self.cfg.visibility_lag_bound_secs {
693 if now.saturating_sub(stage.last_citation_unix) >= bound {
694 return Ok(Some(CitationSource::ForcedLagCap));
695 }
696 }
697 // The backlog cap bounds the preStop drain BY CONSTRUCTION.
698 let bytes: u64 = stage.entries.values().map(|e| e.size).sum();
699 // Recorded reclaims are counted as OBJECTS, because they are
700 // exactly the drain work this cap exists to bound: the citation
701 // the drain runs issues one `delete_version` per recorded id.
702 // Counting only staged PATHS would let one hot file rewritten
703 // every tick accumulate a version per tick forever — one stage
704 // entry, one size, and a cap that never fires — while
705 // `drain_need_secs` sized the grace from that same cap.
706 let recorded: u64 =
707 stage.pending_reclaims.values().map(|v| v.len() as u64).sum();
708 if stage.entries.len() as u64 + recorded >= self.cfg.staged_backlog_cap_objects
709 || bytes >= self.cfg.staged_backlog_cap_bytes
710 {
711 return Ok(Some(CitationSource::ForcedBacklogCap));
712 }
713 if stage.stable_since_unix > 0
714 && now.saturating_sub(stage.stable_since_unix) >= self.cfg.quiesce_bound_secs
715 {
716 return Ok(Some(CitationSource::Quiescence));
717 }
718 Ok(None)
719 }
720
721 /// The citation lane: ONE CAS installing the entire pending set.
722 ///
723 /// The versions already exist, so there is no copy phase — hence no
724 /// half-boundary, no intent document, no roll-forward, and no
725 /// interval in which a version-resolving reader can see
726 /// boundary-new bytes under old citations. `Inv_BoundaryAtomic`
727 /// holds by construction.
728 /// EXACT version reclamation (D8) — the reaper, not lifecycle.
729 ///
730 /// Reclaims ONLY the generations this workspace itself superseded:
731 /// for each cited path, the version its own pending record names as
732 /// `base_version_id`. Everything else — crash remnants, foreign
733 /// writes, a successor's work — is left to the noncurrent backstop.
734 ///
735 /// THE RULE USED TO BE "delete every version of a touched key that
736 /// is neither `keep` nor `is_current`", and that destroys committed
737 /// data. The `is_current` guard protects exactly ONE version, and
738 /// its reasoning ("a foreign write landed between the lane and this
739 /// citation") assumes at most one foreign generation. A successor in
740 /// gated mode does not stop at one: its cadence is stage → cite →
741 /// stage. So a straggler whose CAS landed and which then stalled in
742 /// this loop would find, on resuming, the successor's CITED version
743 /// sitting noncurrent-and-not-`keep` — and delete it. Every cited
744 /// version surviving a deposed straggler is the premise §8 Q2 chose
745 /// versioned staging on ("they destroy nothing"), so the old rule
746 /// falsified the reason the mode exists.
747 ///
748 /// Lifecycle cannot do this job on `files/`: gated staging makes the
749 /// CITED version noncurrent the moment a newer generation is staged,
750 /// so a `NoncurrentVersionExpiration` rule runs a clock against live
751 /// cited data and never reaches the newest uncited bytes, which are
752 /// current. That inversion is why the standing retention is a long
753 /// BACKSTOP and a shorter fleet-wide rule is a refusal condition.
754 pub(crate) async fn reclaim_superseded(
755 &mut self,
756 upserts: &BTreeMap<String, LeanEntry>,
757 installed: &super::manifest::LeanManifest,
758 stage: &PendingStage,
759 report: &mut CitationReport,
760 ) -> LeanResult<()> {
761 for (i, path) in upserts.keys().enumerate() {
762 // The loop is O(cited) round trips and the run loop's
763 // renewal arm cannot fire while it runs (one `select!`, the
764 // chosen branch awaited inline). Renew on our own account,
765 // and re-verify: a straggler that lost the lease mid-loop
766 // must stop deleting, not finish the sweep.
767 if i % RECLAIM_FENCE_EVERY == 0 {
768 self.renew_if_due().await?;
769 self.verify_not_deposed_pub().await?;
770 }
771 // The ONLY version this pass is entitled to reclaim: the one
772 // our own pending record says we superseded. If we cannot
773 // name it we reclaim nothing — the backstop exists for
774 // exactly the remnants we cannot name.
775 let Some(superseded) =
776 stage.entries.get(path).and_then(|pe| pe.base_version_id.clone())
777 else {
778 continue;
779 };
780 if superseded.is_empty() {
781 continue;
782 }
783 // FAIL CLOSED. If the installed manifest does not name a
784 // version for this path — an unversioned backend, a proxy
785 // that stripped the header, a merge that resolved
786 // foreign-wins — then we do not know what is cited, and
787 // reclaiming against it is guesswork.
788 let Some(keep) = installed
789 .entries
790 .get(path)
791 .and_then(|e| e.version_id.clone())
792 .filter(|v| !v.is_empty())
793 else {
794 continue;
795 };
796 // The merge may have resolved foreign-wins onto the very
797 // version we were about to reclaim.
798 if superseded == keep {
799 continue;
800 }
801 let key = self.cfg.file_key(path);
802 let Ok(versions) = self.store.list_versions(&key).await else { continue };
803 let Some(v) = versions
804 .iter()
805 .find(|v| v.key == key && !v.is_delete_marker && v.version_id == superseded)
806 else {
807 continue;
808 };
809 // NEVER the current version. If what we superseded is
810 // somehow current again, a foreign writer restored it and
811 // those are live bytes somebody is about to read.
812 if v.is_current {
813 continue;
814 }
815 let _ = self.store.delete_version(&key, &superseded).await;
816 report.versions_reclaimed += 1;
817 }
818
819 // Then everything the LANE recorded and did not delete. Same
820 // four guards; the only difference is where the candidate came
821 // from. `base_version_id` names exactly one version — the one
822 // superseded relative to the CITED generation — so without this
823 // pass every intermediate generation between two citations
824 // would be named by nothing and left to the retention backstop.
825 let recorded: Vec<(String, Vec<String>)> =
826 stage.pending_reclaims.iter().map(|(p, v)| (p.clone(), v.clone())).collect();
827 for (i, (path, vids)) in recorded.iter().enumerate() {
828 if i % RECLAIM_FENCE_EVERY == 0 {
829 self.renew_if_due().await?;
830 self.verify_not_deposed_pub().await?;
831 }
832 // Two distinct "no keep" cases, and they are NOT the same:
833 //
834 // - the installed manifest does not cite this path AT ALL
835 // (a withheld delete installed, a path that never made a
836 // boundary) — there is no cited version to protect, so
837 // the recorded ids are reclaimable;
838 // - it cites the path but names no version — an unversioned
839 // backend, a stripping proxy, a foreign-wins merge. We do
840 // not know what is cited, so FAIL CLOSED, exactly as the
841 // pass above does.
842 let keep = match installed.entries.get(path) {
843 None => None,
844 Some(e) => match e.version_id.clone().filter(|v| !v.is_empty()) {
845 Some(v) => Some(v),
846 None => continue,
847 },
848 };
849 let key = self.cfg.file_key(path);
850 let Ok(versions) = self.store.list_versions(&key).await else { continue };
851 for vid in vids {
852 if vid.is_empty() || Some(vid) == keep.as_ref() {
853 continue;
854 }
855 let Some(v) = versions
856 .iter()
857 .find(|v| v.key == key && !v.is_delete_marker && &v.version_id == vid)
858 else {
859 continue;
860 };
861 // The `is_current` guard is CONDITIONAL here, and the
862 // asymmetry is deliberate. Where the boundary cites this
863 // path, a recorded id that is current again means a
864 // foreign writer restored it and those are live bytes —
865 // skip, exactly as the pass above does. Where the
866 // boundary does not cite the path AT ALL — a withheld
867 // delete that installed, a scratch file that never made
868 // a boundary — current is the ORDINARY state of the last
869 // version this workspace staged before the file went
870 // away, and skipping it would leave precisely the litter
871 // this pass exists to collect. We delete by exact
872 // version id, so a foreign write is still safe: it would
873 // be a different id, and this one would not be current.
874 if keep.is_some() && v.is_current {
875 continue;
876 }
877 let _ = self.store.delete_version(&key, vid).await;
878 report.versions_reclaimed += 1;
879 }
880 }
881 Ok(())
882 }
883
884 pub async fn citation_pass(&mut self, source: CitationSource) -> LeanResult<CitationReport> {
885 let epoch = self
886 .lease
887 .as_ref()
888 .map(|l| l.epoch)
889 .ok_or_else(|| LeanError::State("citation without a held lease".into()))?;
890 let mut report = CitationReport { source: Some(source.as_str().into()), ..Default::default() };
891 self.verify_not_deposed_pub().await?;
892
893 let mut stage = self.load_stage()?;
894 let mut baseline = self.state.load_baseline()?;
895 let repairs = self.repair_candidates()?;
896 if stage.entries.is_empty() && stage.withheld_deletes.is_empty() && repairs.is_empty() {
897 report.no_change = true;
898 return Ok(report);
899 }
900
901 // Base-version re-validation (D7). If the baseline's cited
902 // version moved between staging and now, a HITL consume or sync
903 // landed in between; installing our staged version would UNCITE
904 // the foreign bytes. The damage class is softer than the copy
905 // design's — there the amputation DESTROYED the foreign object,
906 // here it only hides it and the version stays fetchable — but
907 // the rule stands.
908 let scanned = scan::scan(&self.cfg.root)?;
909 let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
910 let mut drop_paths: Vec<String> = vec![];
911 for (path, pe) in &stage.entries {
912 let cited_now = baseline.entries.get(path).and_then(|b| b.version_id.clone());
913 let stale = pe.base_version_id.is_some() && cited_now != pe.base_version_id;
914 if stale {
915 let still_differs = match (scanned.get(path), baseline.entries.get(path)) {
916 (Some(s), Some(b)) => s.size != b.size || s.mtime_unix != b.mtime_unix,
917 (Some(_), None) => true,
918 _ => false,
919 };
920 self.state.append_conflict(&ConflictRecord {
921 path: path.clone(),
922 foreign_etag: baseline
923 .entries
924 .get(path)
925 .map(|b| b.etag.clone())
926 .unwrap_or_default(),
927 preserved_key: None,
928 kind: format!(
929 "citation-stale-base (superseded generation {}, still-differs={})",
930 pe.generation, still_differs
931 ),
932 at_unix: now_unix(),
933 })?;
934 if !still_differs {
935 // The foreign bytes ARE the local bytes now: drop
936 // the staged citation rather than uncite them. The
937 // next lane tick re-stages if the tree moves again.
938 drop_paths.push(path.clone());
939 report.dropped_stale_base.push(path.clone());
940 continue;
941 }
942 }
943 upserts.insert(
944 path.clone(),
945 LeanEntry {
946 key: pe.key.clone(),
947 etag: pe.etag.clone(),
948 crc64_b64: pe.crc64_b64.clone(),
949 size: pe.size,
950 mode: pe.mode,
951 mtime_unix: pe.mtime_unix,
952 generation: pe.generation,
953 epoch: pe.epoch,
954 version_id: pe.version_id.clone(),
955 },
956 );
957 }
958 for p in &drop_paths {
959 stage.entries.remove(p);
960 }
961
962 // The ungated repair (§2.4.2). No bytes move: the object IS
963 // what this syncer already integrated, and the citation is
964 // catching up to it. The HEAD is also what supplies the VERSION
965 // id — a consume records none, and under `pinned_reads` a
966 // citation without one is unreadable by the very readers this
967 // pass exists for.
968 for path in &repairs {
969 if upserts.contains_key(path) {
970 continue;
971 }
972 let Some(be) = baseline.entries.get(path).cloned() else { continue };
973 let key = self.cfg.file_key(path);
974 let meta = match self.store.head(&key).await {
975 Ok(m) if m.etag == be.etag => m,
976 // Moved again or gone: citing a version this workspace
977 // has not integrated would be the lie the repair exists
978 // to prevent. The next consume reconciles it.
979 _ => continue,
980 };
981 let crc = super::barrier::repair_crc(path, &be, &meta)?;
982 let stamps = GenerationStamps::from_meta(&meta.meta);
983 let scan_entry = scanned.get(path);
984 upserts.insert(
985 path.clone(),
986 LeanEntry {
987 key,
988 etag: meta.etag.clone(),
989 crc64_b64: crc,
990 size: meta.size,
991 mode: stamps
992 .as_ref()
993 .and_then(|s| s.posix)
994 .map(|p| p.mode)
995 .or(scan_entry.map(|s| s.mode))
996 .unwrap_or(0o644),
997 mtime_unix: scan_entry.map(|s| s.mtime_unix).unwrap_or(be.mtime_unix),
998 generation: stamps.map(|s| s.generation).unwrap_or(be.generation),
999 epoch,
1000 version_id: meta.version_id.clone(),
1001 },
1002 );
1003 report.repaired.push(path.clone());
1004 }
1005
1006 // Window open/clear belong to THIS lane only.
1007 let deadline = now_unix() + self.cfg.window_slack_secs;
1008 let opened =
1009 inbox::open_window(self.store.as_ref(), &self.cfg, epoch, deadline).await?;
1010
1011 // A HITL write that landed AFTER the lane staged this path.
1012 //
1013 // The base-version check above cannot see it: it reads the
1014 // BASELINE, and the citation lane consumes nothing, so the
1015 // baseline has not moved. And the upload lane opens no window —
1016 // deliberately, because a lane that fenced HITL out every floor
1017 // tick would refuse admission essentially forever between
1018 // citations. So the gap is real and it is the mode's own doing.
1019 //
1020 // The inbox is what can see it, and the window CAS just loaded
1021 // it: zero added requests. Drop the path rather than cite bytes
1022 // that PREDATE the user's write over it. The entry stays queued,
1023 // so the next lane consumes it the ordinary way.
1024 //
1025 // (Found by the formal model — tranche 3 product 2. Before the
1026 // companion rule below, the reaper then DELETED the user's
1027 // version, because it was not the one the manifest cited.)
1028 let inflight: BTreeSet<String> =
1029 opened.doc.entries.iter().map(|e| e.path.clone()).collect();
1030 for path in &inflight {
1031 if upserts.remove(path).is_some() {
1032 stage.entries.remove(path);
1033 // Kept separate from the stale-base drop on purpose:
1034 // this one, and only this one, removes the agent's
1035 // LATEST bytes from a boundary it may be acked for.
1036 // The stale-base drop fires only when the foreign bytes
1037 // ARE the local bytes, so the point still carries the
1038 // agent's content.
1039 report.dropped_inflight.push(path.clone());
1040 self.state.append_conflict(&ConflictRecord {
1041 path: path.clone(),
1042 foreign_etag: String::new(),
1043 preserved_key: None,
1044 kind: "citation-hitl-inflight".into(),
1045 at_unix: now_unix(),
1046 })?;
1047 }
1048 }
1049
1050 let deletes = stage.withheld_deletes.clone();
1051 let parked: BTreeSet<String> = BTreeSet::new();
1052 let flush_uuid = uuid::Uuid::new_v4().to_string();
1053 let mut intent = self.state.load_intent()?;
1054 let prev_installed = intent.installed_etag.clone();
1055 let mut attempt = 0;
1056 let mut version_index: Option<BTreeMap<(String, String), String>> = None;
1057 let mut backfilled: Vec<String> = vec![];
1058 let mut unresolved: BTreeSet<String> = BTreeSet::new();
1059 let (installed, installed_etag, foreign) = loop {
1060 attempt += 1;
1061 if attempt > 4 {
1062 return Err(LeanError::State(
1063 "citation CAS lost 4 merge races — refusing this boundary".into(),
1064 ));
1065 }
1066 let current = manifest::load(self.store.as_ref(), &self.cfg).await?;
1067 let (theirs, expected) = match ¤t {
1068 // The handle carries the layout as well as the tag —
1069 // see the fused barrier's copy of this.
1070 Some(l) => (l.manifest.clone(), Some(l.handle())),
1071 None => (Default::default(), None),
1072 };
1073 // Same rule as the fused barrier: if the bucket is still at
1074 // the document we installed, that document IS the merge
1075 // base (`IntentJournal::installed_etag`). A citation pass
1076 // that crashed between its CAS and the baseline rewrite
1077 // would otherwise read its own boundary as foreign.
1078 let own_base;
1079 let base: &BTreeMap<String, String> =
1080 if prev_installed.is_some()
1081 && prev_installed.as_deref() == expected.as_ref().map(|h| h.etag.as_str())
1082 {
1083 own_base = theirs
1084 .entries
1085 .iter()
1086 .map(|(p, e)| (p.clone(), e.etag.clone()))
1087 .collect();
1088 &own_base
1089 } else {
1090 &baseline.inst_base
1091 };
1092 let (mut merged, foreign) =
1093 manifest::merge(base, &theirs, &upserts, &deletes, &parked);
1094 // D13: this citation's readers resolve by version, never by
1095 // S3-wins adoption of the current version.
1096 merged.pinned_reads = true;
1097 // Independent of D13 and cleared by `merge`, so it is
1098 // restated here for the same reason the cadence barrier
1099 // restates it: it describes the deployment, not the
1100 // citation.
1101 merged.sole_writer = self.cfg.sole_writer;
1102 // The mixed-manifest cell, where D7's entry schema and D13
1103 // collide. Stamping `pinned_reads` over an entry a pre-D7
1104 // writer cited leaves it addressable only by etag — and the
1105 // moment the lane stages that path, its current version is
1106 // uncited mid-change bytes that checkout's fallback arm
1107 // would adopt. Resolve the CITED etag in the bucket's own
1108 // version history before installing: one listing, and it
1109 // answers even where the current version has already moved.
1110 let legacy: Vec<String> = merged
1111 .entries
1112 .iter()
1113 .filter(|(p, e)| {
1114 e.version_id.is_none() && !stage.legacy_unresolved.contains(*p)
1115 })
1116 .map(|(p, _)| p.clone())
1117 .collect();
1118 if !legacy.is_empty() {
1119 if version_index.is_none() {
1120 version_index = Some(self.version_index().await?);
1121 }
1122 let ix = version_index.as_ref().expect("just built");
1123 for path in legacy {
1124 let Some(e) = merged.entries.get_mut(&path) else { continue };
1125 match ix.get(&(e.key.clone(), e.etag.clone())) {
1126 Some(vid) => {
1127 e.version_id = Some(vid.clone());
1128 backfilled.push(path);
1129 }
1130 None => {
1131 unresolved.insert(path);
1132 }
1133 }
1134 }
1135 }
1136 match manifest::cas_write_stamped(
1137 self.store.as_ref(),
1138 &self.cfg,
1139 &merged,
1140 expected.as_ref(),
1141 epoch,
1142 &flush_uuid,
1143 Some(source.as_str()),
1144 )
1145 .await
1146 {
1147 Ok(meta) => {
1148 intent.installed_etag = Some(meta.etag.clone());
1149 self.state.save_intent(&intent)?;
1150 break (merged, meta.etag, foreign);
1151 }
1152 Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1153 | Err(LeanError::Store(StoreError::Conflict(_))) => {
1154 self.verify_not_deposed_pub().await?;
1155 continue;
1156 }
1157 Err(e) => return Err(e),
1158 }
1159 };
1160 report.seq = Some(installed.seq);
1161 report.cited = upserts.len();
1162 backfilled.sort();
1163 backfilled.dedup();
1164 report.backfilled = backfilled;
1165 stage.legacy_unresolved.extend(unresolved);
1166 self.note_boundary(source.as_str(), installed.seq)?;
1167
1168 // GC of keys the NEW manifest no longer references — deletes
1169 // LAST, HEAD-guarded on the recognized ETag, unchanged.
1170 for path in &deletes {
1171 if installed.entries.contains_key(path) {
1172 continue;
1173 }
1174 let key = self.cfg.file_key(path);
1175 let recognized = baseline.entries.get(path).map(|b| b.etag.clone());
1176 match self.store.head(&key).await {
1177 Err(StoreError::NotFound(_)) => report.deleted.push(path.clone()),
1178 Ok(meta) if Some(&meta.etag) == recognized.as_ref() => {
1179 self.store.delete(&key).await?;
1180 report.deleted.push(path.clone());
1181 }
1182 Ok(meta) => {
1183 self.state.append_conflict(&ConflictRecord {
1184 path: path.clone(),
1185 foreign_etag: meta.etag,
1186 preserved_key: None,
1187 kind: "gc-skip".into(),
1188 at_unix: now_unix(),
1189 })?;
1190 }
1191 Err(e) => return Err(e.into()),
1192 }
1193 }
1194
1195 // EXACT version reclamation (D8) — this is the reaper, not
1196 // lifecycle. After the CAS, every version of a touched key that
1197 // neither the installed manifest nor the (now empty) pending set
1198 // names is deleted, version-scoped and free. Steady state ⇒ one
1199 // version per key.
1200 //
1201 // Lifecycle cannot do this job on `files/`: gated staging makes
1202 // the CITED version noncurrent the moment a newer generation is
1203 // staged, so a `NoncurrentVersionExpiration` rule runs a clock
1204 // against live cited data and never reaches the newest uncited
1205 // bytes, which are current. That inversion is why the standing
1206 // retention is a long BACKSTOP and a shorter fleet-wide rule is
1207 // a refusal condition.
1208 self.reclaim_superseded(&upserts, &installed, &stage, &mut report).await?;
1209
1210 // Baseline rewrite.
1211 for (path, e) in &installed.entries {
1212 // A repaired path keeps its integrated stat; only the
1213 // version id it is now cited by is new.
1214 if report.repaired.contains(path) {
1215 if let Some(be) = baseline.entries.get_mut(path) {
1216 be.version_id = e.version_id.clone();
1217 }
1218 }
1219 if let Some(pe) = stage.entries.get(path) {
1220 baseline.entries.insert(
1221 path.clone(),
1222 BaselineEntry {
1223 etag: e.etag.clone(),
1224 generation: e.generation,
1225 size: pe.size,
1226 mtime_unix: pe.mtime_unix,
1227 version_id: e.version_id.clone(),
1228 crc64_b64: Some(e.crc64_b64.clone()),
1229 },
1230 );
1231 }
1232 }
1233 for path in &report.deleted {
1234 baseline.entries.remove(path);
1235 }
1236 baseline.inst_base =
1237 installed.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
1238 baseline.seq = installed.seq;
1239 baseline.manifest_etag = Some(installed_etag);
1240 self.state.save_baseline(&baseline)?;
1241 self.state.clear_intent_keys()?;
1242
1243 stage.entries.clear();
1244 stage.withheld_deletes.clear();
1245 stage.pending_reclaims.clear();
1246 stage.last_citation_unix = now_unix();
1247 stage.stable_since_unix = 0;
1248 self.save_stage(&stage)?;
1249 // The CLEARING write matters as much as the surfacing one: a
1250 // stale summary claiming candidates pages an operator about
1251 // work that was cited minutes ago (D9).
1252 if let Err(e) = self.surface_orphans(&stage).await {
1253 eprintln!("flint-sync: orphan summary not cleared (retrying next tick): {e}");
1254 }
1255
1256 let queue: Vec<inbox::InboxEntry> = foreign
1257 .into_iter()
1258 .map(|(path, e)| inbox::InboxEntry {
1259 path,
1260 etag: e.etag,
1261 author: "merge-preserved".into(),
1262 added_unix: now_unix(),
1263 crc64_b64: Some(e.crc64_b64),
1264 })
1265 .collect();
1266 inbox::clear_window(self.store.as_ref(), &self.cfg, epoch, &queue).await?;
1267 Ok(report)
1268 }
1269
1270 /// The gated floor tick: lane, then a citation if one is due.
1271 ///
1272 /// Both halves are reported because the floor arm has to describe
1273 /// them separately — a tick that staged 40 files and cited nothing
1274 /// is the mode working, and a tick that staged nothing and cited
1275 /// nothing is idle. Collapsing them into one "no change" would make
1276 /// gated mode indistinguishable from a wedged one in the log.
1277 pub async fn gated_tick(
1278 &mut self,
1279 sentinel_pending: bool,
1280 ) -> LeanResult<(LaneReport, CitationReport)> {
1281 let lane = self.upload_lane().await?;
1282 let cite = match self.citation_due(sentinel_pending)? {
1283 Some(source) => self.citation_pass(source).await?,
1284 None => CitationReport { no_change: true, ..Default::default() },
1285 };
1286 Ok((lane, cite))
1287 }
1288
1289 /// The startup gate for gated mode (D8, D11): probe the version
1290 /// surface BEFORE a single byte is staged, and refuse rather than
1291 /// degrade. Inert in `cadence`/`hybrid`, which need no version
1292 /// surface at all — a gate that took every default workspace down
1293 /// with it would be worse than the hazard.
1294 pub async fn gated_startup_check(&mut self) -> LeanResult<()> {
1295 if !self.is_gated() {
1296 return Ok(());
1297 }
1298 self.versioning_conformance().await
1299 }
1300
1301 /// `flint-sync recover-staged` (D9): re-cite durable-but-uncited
1302 /// work as ONE flagged boundary — a manifest CAS with no data
1303 /// movement, because the versions already exist.
1304 ///
1305 /// The routine path into this verb is a pure-spot pod replacement:
1306 /// the emptyDir that held `pending.json` is gone, so the pending
1307 /// record can name nothing and **the bucket is the only source of
1308 /// truth**. Two shapes are recovered, and they overlap:
1309 ///
1310 /// - *uncited work* — the key's current version is not the one the
1311 /// manifest cites (or the key is not cited at all: a brand-new
1312 /// file staged and never installed);
1313 /// - *a dangling citation* — the cited version no longer exists,
1314 /// because gated staging made it NONCURRENT and the retention
1315 /// backstop ran its clock against live cited data (D8's
1316 /// inversion). Checkout refuses on this rather than serving a
1317 /// hole, so recovery is what makes the workspace usable again.
1318 ///
1319 /// Recovery rolls **forward**, onto the newer bytes, and says so:
1320 /// every re-citation writes a conflict record. It is deliberately
1321 /// NOT a three-way merge — a replacement pod has no merge base, and
1322 /// merging against an empty one would classify the entire existing
1323 /// manifest as foreign and queue the whole tree into the HITL
1324 /// inbox.
1325 pub async fn recover_staged(&mut self) -> LeanResult<RecoverReport> {
1326 let epoch = self
1327 .lease
1328 .as_ref()
1329 .map(|l| l.epoch)
1330 .ok_or_else(|| LeanError::State("recover-staged without a held lease".into()))?;
1331 let mut report = RecoverReport::default();
1332 let files_prefix = format!("{}/files/", self.cfg.prefix);
1333
1334 // D9's durable summary is the MECHANISM; the prefix-wide version
1335 // LIST is the fallback (review: U1). `flint-store`'s own trait
1336 // doc has said so since the version surface landed — "the
1337 // claim-time/DR fallback when `orphans.json` is missing or
1338 // stale — the expensive path, which is why the durable summary
1339 // is written eagerly" — but nothing in the syncer read the
1340 // summary, so recovery always took the expensive path and the
1341 // eager write bought nothing here.
1342 //
1343 // The summary narrows WHICH keys we list, never what we
1344 // conclude about them: every candidate is still resolved
1345 // against the bucket below. A stale or partial summary
1346 // therefore costs coverage, not correctness — so a missing one,
1347 // or one written under a different boundary mode, falls back to
1348 // the full sweep rather than trusting a narrower answer.
1349 let summary = match self.store.get_whole(&self.orphans_key(), None).await {
1350 Ok((_, bytes)) => serde_json::from_slice::<OrphanDoc>(&bytes).ok(),
1351 Err(_) => None,
1352 };
1353 // The manifest is read BEFORE the narrowing, because the
1354 // narrowed key set is the union of the summary's candidates and
1355 // everything the manifest already CITES.
1356 //
1357 // Getting that wrong is a data-loss-shaped bug, and leg B11b
1358 // caught it: `orphans.json` names only the STAGED candidates, so
1359 // narrowing to those alone left every cited-but-not-staged path
1360 // with no listed version at all — and recovery classifies a path
1361 // with no surviving version as UNRECOVERABLE. A workspace with
1362 // one quiet cited file would have been told its data was gone.
1363 let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
1364 let cited_now = loaded.as_ref().map(|l| l.manifest.clone()).unwrap_or_default();
1365
1366 let narrowed: Option<BTreeSet<String>> = summary.as_ref().and_then(|d| {
1367 if d.boundary_mode != self.cfg.boundary_mode.as_str() || d.candidates.is_empty() {
1368 return None;
1369 }
1370 let mut keys: BTreeSet<String> =
1371 d.candidates.iter().map(|c| self.cfg.file_key(&c.path)).collect();
1372 keys.extend(cited_now.entries.keys().map(|p| self.cfg.file_key(p)));
1373 Some(keys)
1374 });
1375 report.from_summary = narrowed.is_some();
1376
1377 let listed: Vec<ListedVersion> = match &narrowed {
1378 Some(keys) => {
1379 let mut out = vec![];
1380 for key in keys {
1381 out.extend(self.store.list_versions(key).await.unwrap_or_default());
1382 }
1383 out
1384 }
1385 None => self.store.list_versions(&files_prefix).await?,
1386 };
1387 let mut current: BTreeMap<String, ListedVersion> = BTreeMap::new();
1388 let mut known: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1389 for v in listed {
1390 known.entry(v.key.clone()).or_default().insert(v.version_id.clone());
1391 if v.is_current {
1392 current.insert(v.key.clone(), v);
1393 }
1394 }
1395
1396 let mut paths: BTreeSet<String> = cited_now.entries.keys().cloned().collect();
1397 for key in current.keys() {
1398 if let Some(p) = key.strip_prefix(&files_prefix) {
1399 // D0.2 again: a legacy `files/.flint/` citation must not
1400 // be recovered INTO the control namespace.
1401 if !p.is_empty() && !scan::is_control_path(p) {
1402 paths.insert(p.to_string());
1403 }
1404 }
1405 }
1406
1407 let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
1408 for path in paths {
1409 let key = self.cfg.file_key(&path);
1410 let cited = cited_now.entries.get(&path).cloned();
1411 let live = current.get(&key).filter(|v| !v.is_delete_marker).cloned();
1412
1413 // A citation dangles only if it names a version BY ID that
1414 // the bucket no longer holds. A legacy entry citing no
1415 // version resolves by etag and cannot dangle this way.
1416 let dangles = match &cited {
1417 Some(e) => match &e.version_id {
1418 Some(v) => !known.get(&key).map(|s| s.contains(v)).unwrap_or(false),
1419 None => false,
1420 },
1421 None => false,
1422 };
1423 if dangles {
1424 report.dangling.push(path.clone());
1425 }
1426 let Some(live) = live else {
1427 if dangles {
1428 report.unrecoverable.push(path.clone());
1429 }
1430 continue;
1431 };
1432 let already = match &cited {
1433 None => false,
1434 Some(e) => match &e.version_id {
1435 Some(v) => v == &live.version_id,
1436 None => e.etag == live.etag,
1437 },
1438 };
1439 if already {
1440 continue;
1441 }
1442
1443 // One HEAD per re-cited path to recover the stamps the
1444 // manifest entry needs. A HEAD is not data movement.
1445 let meta = self.store.head_version(&key, &live.version_id).await?;
1446 // The CRC the re-citation carries: the backend's attestation
1447 // when it offers one. A replacement pod has no baseline to
1448 // ask, so on a backend that attests nothing (Ozone; a HITL
1449 // upload sent without one) the bytes are the only source —
1450 // the one recovery step that moves data, and only there.
1451 let crc = match meta.crc64_b64.clone() {
1452 Some(c) => c,
1453 None => {
1454 let (_, body) = self.store.get_version(&key, &live.version_id).await?;
1455 crc64_to_b64(crc64_nvme(&body))
1456 }
1457 };
1458 let stamps = GenerationStamps::from_meta(&meta.meta);
1459 let posix = stamps.as_ref().and_then(|s| s.posix);
1460 upserts.insert(
1461 path.clone(),
1462 LeanEntry {
1463 key: key.clone(),
1464 etag: meta.etag.clone(),
1465 crc64_b64: crc,
1466 size: meta.size,
1467 mode: posix
1468 .map(|p| p.mode)
1469 .or_else(|| cited.as_ref().map(|e| e.mode))
1470 .unwrap_or(0o100_644),
1471 mtime_unix: posix
1472 .map(|p| p.mtime_unix)
1473 .or_else(|| meta.last_modified_unix.map(|u| u as i64))
1474 .unwrap_or(0),
1475 generation: stamps
1476 .as_ref()
1477 .map(|s| s.generation)
1478 .or_else(|| cited.as_ref().map(|e| e.generation + 1))
1479 .unwrap_or(1),
1480 epoch: stamps.as_ref().map(|s| s.epoch).unwrap_or(epoch),
1481 version_id: Some(live.version_id.clone()),
1482 },
1483 );
1484 self.state.append_conflict(&ConflictRecord {
1485 path: path.clone(),
1486 foreign_etag: cited.as_ref().map(|e| e.etag.clone()).unwrap_or_default(),
1487 preserved_key: None,
1488 kind: if dangles {
1489 "recovered-staged (dangling citation rolled forward)".into()
1490 } else {
1491 "recovered-staged".to_string()
1492 },
1493 at_unix: now_unix(),
1494 })?;
1495 report.recited.push(path.clone());
1496 }
1497
1498 if upserts.is_empty() {
1499 report.no_change = true;
1500 report.seq = loaded.map(|l| l.manifest.seq);
1501 return Ok(report);
1502 }
1503
1504 // The upserts are ABSOLUTE — derived from bucket versions, not
1505 // from a diff — so a lost CAS re-applies them onto whatever is
1506 // current without recomputing anything.
1507 let flush_uuid = uuid::Uuid::new_v4().to_string();
1508 let mut fresh = loaded;
1509 for attempt in 0..4u32 {
1510 let (mut m, expected) = match &fresh {
1511 Some(l) => (l.manifest.clone(), Some(l.handle())),
1512 None => (manifest::LeanManifest::default(), None),
1513 };
1514 for (p, e) in &upserts {
1515 m.entries.insert(p.clone(), e.clone());
1516 }
1517 m.seq += 1;
1518 m.pinned_reads = self.is_gated();
1519 match manifest::cas_write_stamped(
1520 self.store.as_ref(),
1521 &self.cfg,
1522 &m,
1523 expected.as_ref(),
1524 epoch,
1525 &flush_uuid,
1526 Some(CitationSource::Recovered.as_str()),
1527 )
1528 .await
1529 {
1530 Ok(_) => {
1531 report.seq = Some(m.seq);
1532 self.note_boundary(CitationSource::Recovered.as_str(), m.seq)?;
1533 return Ok(report);
1534 }
1535 Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1536 | Err(LeanError::Store(StoreError::Conflict(_))) if attempt < 3 => {
1537 self.verify_not_deposed_pub().await?;
1538 fresh = manifest::load(self.store.as_ref(), &self.cfg).await?;
1539 }
1540 Err(e) => return Err(e),
1541 }
1542 }
1543 Err(LeanError::State(
1544 "recover-staged lost 4 CAS races — a live writer is still publishing".into(),
1545 ))
1546 }
1547
1548 /// Is this workspace gated?
1549 pub fn is_gated(&self) -> bool {
1550 self.cfg.boundary_mode == BoundaryMode::Gated
1551 }
1552
1553 /// The versioning conformance probe (D8) — the SHARED
1554 /// implementation in `flint_store::probe`, because the operator
1555 /// runs the same probe on its reconcile cadence and the two
1556 /// verdicts must never disagree. A workspace the operator calls
1557 /// conformant and the syncer refuses to start is worse than
1558 /// either answer alone.
1559 ///
1560 /// A project-scoped proxy that strips `x-amz-version-id` degrades
1561 /// gated mode SILENTLY — pending entries would carry `None`, and
1562 /// citation would fall back to etag semantics on a key whose
1563 /// current version is uncited, which is precisely the torn view the
1564 /// mode exists to prevent. So the probe REFUSES rather than
1565 /// degrading, and is re-run at startup and on the operator cadence:
1566 /// proxies upgrade, and a bucket's posture changes under you.
1567 pub async fn versioning_conformance(&self) -> LeanResult<()> {
1568 let key = format!("{}/{}/probe/versioning", self.cfg.prefix, super::LEAN_DIR);
1569 flint_store::probe::probe_version_surface(self.store.as_ref(), &key)
1570 .await
1571 .map_err(|m| LeanError::State(format!("versioning conformance probe FAILED: {m}")))
1572 }
1573}
1574
1575/// A stable fingerprint of a scan: quiescence is scan-to-scan
1576/// STABILITY, so what matters is that this value stops changing.
1577fn scan_fingerprint(scanned: &BTreeMap<String, scan::ScanEntry>) -> String {
1578 let mut h = flint_store::Crc64Nvme::new();
1579 for (p, e) in scanned {
1580 h.update(p.as_bytes());
1581 h.update(&e.size.to_be_bytes());
1582 h.update(&e.mtime_unix.to_be_bytes());
1583 }
1584 format!("{:016x}", h.finalize())
1585}