flint_lean/barrier.rs
1//! The publish barrier (plan §2.1, seven steps) — the machine
2//! `lean/formal/LeanSubtree.tla` checks. Order is load-bearing:
3//! consume → scan → intent/window → uploads → manifest CAS (merge) →
4//! GC deletes LAST → baseline rewrite.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::Path;
8
9use bytes::Bytes;
10
11use flint_store::{
12 EpochState,
13 crc64_nvme, crc64_to_b64, GenerationStamps, PosixStamps, PutCondition, StoreError,
14};
15
16use super::inbox::{self, InboxEntry};
17use super::manifest::{self, LeanEntry};
18use super::scan;
19use super::state::{BaselineEntry, ConflictRecord, IntentJournal};
20use super::{now_unix, LeanError, LeanResult, Syncer};
21
22/// The last gateway request this workspace acted on, per verb. A
23/// watermark rather than a consumed queue — see `note_verb_requests`.
24#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
25pub struct VerbWatermark {
26 #[serde(default)]
27 pub boundary_unix: u64,
28 #[serde(default)]
29 pub sync_unix: u64,
30}
31
32#[derive(Debug, Default)]
33pub struct BarrierReport {
34 pub seq: Option<u64>,
35 pub uploaded: Vec<String>,
36 pub deleted: Vec<String>,
37 pub parked: Vec<String>,
38 pub consumed: usize,
39 pub foreign_queued: usize,
40 /// Paths whose transfer drifted or was swept mid-compose: nothing
41 /// published, nothing advanced; the next scan re-queues them.
42 pub deferred: Vec<String>,
43 pub no_change: bool,
44 /// Bytes this barrier actually published — the input to the
45 /// sentinel work meter (boundary-verbs plan D3.1). Metering work
46 /// rather than calls is what keeps a sentinel storm from
47 /// un-coalescing a hot large file's republish: a counted budget
48 /// charges a 2 GiB checkpoint the same one unit as a 4 KiB file.
49 pub published_bytes: u64,
50 /// The manifest ETag this barrier installed (the ack's CAS token).
51 pub manifest_etag: Option<String>,
52 /// What the manifest HEAD/install told us the bucket is at, for the
53 /// news ticker (D5) — free, off requests the barrier already made.
54 pub observed_seq: Option<u64>,
55 pub observed_etag: Option<String>,
56 /// Paths this barrier saw absent for the FIRST time: their deletes
57 /// are withheld to the next scan (the rename-vs-walk guard). On a
58 /// declared boundary a non-empty set means the confirmation lstat
59 /// found them back on disk — the race the guard exists for.
60 pub first_absence: Vec<String>,
61 /// First-absence paths a DECLARED boundary confirmed gone by direct
62 /// lstat and published as ordinary deletes (`confirm_absences`).
63 pub absences_confirmed: usize,
64 /// This barrier finished a rescope a crash had left half-applied
65 /// before it did anything else. Reported so a run that looks like
66 /// an ordinary barrier can be told from one that had to converge a
67 /// workspace first.
68 pub rescope_replayed: bool,
69}
70
71/// Upload waves per chunk. The chunk is `fanout * this`, so a wave
72/// still saturates fan-out and the sync point between chunks is rare.
73const UPLOAD_CHUNK_WAVES: usize = 16;
74
75/// How stale the lease may get inside a barrier before it is renewed.
76/// Deliberately BELOW the run loop's `renew_every` (min(floor, 30) s) so
77/// a long barrier keeps the same heartbeat cadence the loop would have,
78/// and comfortably below the 6-poll (~60 s) takeover window.
79const RENEW_WITHIN_SECS: u64 = 20;
80
81impl Syncer {
82 fn lease_epoch(&self) -> LeanResult<u64> {
83 self.lease
84 .as_ref()
85 .map(|l| l.epoch)
86 .ok_or_else(|| LeanError::State("barrier without a held lease".into()))
87 }
88
89 /// The in-loop honor paths' fence check (boundary-verbs plan D2):
90 /// `Syncer::sync` carries no lease/epoch check of its own, so a
91 /// straggler consuming a sync sentinel between deposal and its next
92 /// cooperative fence would apply the successor's manifest onto its
93 /// zombie tree and ack SUCCESS.
94 pub async fn verify_not_deposed_pub(&self) -> LeanResult<()> {
95 self.verify_not_deposed().await
96 }
97
98 /// Verify the cell still names us at OUR epoch; anything else is a
99 /// fence. Read-verify before the manifest CAS (the per-request
100 /// validation the gateway will also enforce).
101 async fn verify_not_deposed(&self) -> LeanResult<()> {
102 let lease = self
103 .lease
104 .as_ref()
105 .ok_or_else(|| LeanError::State("no lease".into()))?;
106 match self.store.epoch_read(&self.cfg.epoch_key()).await? {
107 Some(state) if state.epoch == lease.epoch && state.holder_id == lease.holder_id => {
108 Ok(())
109 }
110 Some(state) => Err(LeanError::Fenced(format!(
111 "cell at epoch {} holder {} (we are epoch {})",
112 state.epoch, state.holder_id, lease.epoch
113 ))),
114 None => Err(LeanError::Fenced("epoch cell vanished".into())),
115 }
116 }
117
118 /// Step 1: consume the inbox. A barrier NEVER runs against an
119 /// unconsumed inbox — this is what makes HITL uploads structurally
120 /// un-amputatable. Returns the entries integrated (dropped from the
121 /// cell at the window-open CAS).
122 pub async fn consume_inbox(&mut self) -> LeanResult<Vec<InboxEntry>> {
123 let loaded = inbox::load(self.store.as_ref(), &self.cfg).await?;
124 // §2.5's layered doors ride the inbox GET this function already
125 // pays for: promptness is one tick, and the added request count
126 // is ZERO. A failure here must never fail the consume — the
127 // request is idempotent state and the next tick re-reads it.
128 if let Err(e) = self.note_verb_requests(&loaded.doc) {
129 eprintln!("flint-sync: verb request not consumed (retrying next tick): {e}");
130 }
131 let mut consumed = vec![];
132 let mut baseline = self.state.load_baseline()?;
133 for entry in &loaded.doc.entries {
134 let key = self.cfg.file_key(&entry.path);
135 // Containment BEFORE anything else: a path we could never
136 // safely materialize must be surfaced and dropped, not
137 // routed through the locally-dirty branch — which would
138 // "preserve" it with a GET+PUT and leave a baseline entry
139 // for a path the scanner can never see (the planted-symlink
140 // shape: `inputs -> /root/.aws` reads as locally-present,
141 // therefore dirty, therefore a conflict-preserve of someone
142 // else's file).
143 if let Err(e) = check_contained(&self.cfg.root, &entry.path) {
144 self.state.append_conflict(&ConflictRecord {
145 path: entry.path.clone(),
146 foreign_etag: entry.etag.clone(),
147 preserved_key: None,
148 kind: format!("consume-refused-containment: {e}"),
149 at_unix: now_unix(),
150 })?;
151 consumed.push(entry.clone());
152 continue;
153 }
154 // Already integrated (a crashed earlier consume): idempotent.
155 if baseline.entries.get(&entry.path).map(|b| b.etag == entry.etag).unwrap_or(false) {
156 consumed.push(entry.clone());
157 continue;
158 }
159 let head = match self.store.head(&key).await {
160 Ok(m) => m,
161 Err(StoreError::NotFound(_)) => {
162 // The object vanished under a tracked entry: surface
163 // it — the bytes are NOT recoverable from here.
164 self.state.append_conflict(&ConflictRecord {
165 path: entry.path.clone(),
166 foreign_etag: entry.etag.clone(),
167 preserved_key: None,
168 kind: "consume-object-missing".into(),
169 at_unix: now_unix(),
170 })?;
171 consumed.push(entry.clone());
172 continue;
173 }
174 Err(e) => return Err(e.into()),
175 };
176 if head.etag != entry.etag {
177 // Superseded by a newer write (its own inbox entry
178 // follows, or it is the syncer's): drop.
179 consumed.push(entry.clone());
180 continue;
181 }
182 let local_path = self.cfg.root.join(&entry.path);
183 let dirty = local_dirty(&local_path, baseline.entries.get(&entry.path));
184 if dirty {
185 // Locally-dirty wins; preserve the FOREIGN bytes first
186 // (a conflict record must keep both versions
187 // recoverable), then advance the recognized ETag so our
188 // eventual publish supersedes it KNOWINGLY.
189 let preserved = self.preserve_conflict_copy(&entry.path, &head.etag).await?;
190 self.state.append_conflict(&ConflictRecord {
191 path: entry.path.clone(),
192 foreign_etag: entry.etag.clone(),
193 preserved_key: Some(preserved),
194 kind: "consume-dirty".into(),
195 at_unix: now_unix(),
196 })?;
197 let stamps = GenerationStamps::from_meta(&head.meta);
198 baseline.entries.insert(
199 entry.path.clone(),
200 BaselineEntry {
201 etag: head.etag.clone(),
202 generation: stamps.map(|s| s.generation).unwrap_or(0),
203 // Deliberately NOT the local file's stat: the
204 // path must still scan as dirty so the local
205 // version publishes.
206 size: u64::MAX,
207 mtime_unix: 0,
208 version_id: None,
209 // The sentinel's bytes are the LOCAL edit; the
210 // publish that supersedes hashes them itself.
211 crc64_b64: None,
212 },
213 );
214 } else {
215 // Clean: adopt the foreign content into the tree.
216 let (meta, body) =
217 self.store.get_whole(&key, Some(&entry.etag)).await.map_err(|e| match e {
218 StoreError::PreconditionFailed(m) => {
219 StoreError::Other(format!("consume raced a newer write: {m}"))
220 }
221 other => other,
222 })?;
223 let mode = PosixStamps::from_meta(&meta.meta).map(|p| p.mode);
224 // VERIFIED before it is written, as checkout's fresh
225 // fetch is: against the writer's CRC when the inbox
226 // entry carries one (the gateway hashes what it sent),
227 // else the backend's attestation when it offers one.
228 // Ozone offers none, so the inbox's is what a HITL
229 // upload gets checked against there. What the baseline
230 // records — and the next citation repair cites — is
231 // OURS, over the bytes actually written.
232 let got = crc64_to_b64(crc64_nvme(&body));
233 let want = entry.crc64_b64.clone().or_else(|| meta.crc64_b64.clone());
234 if let Some(want) = want {
235 if want != got {
236 // NOT consumed: the entry stays in the cell and
237 // the record repeats — a permanent, visible
238 // contradiction is the right failure for
239 // bytes nobody vouches for.
240 self.state.append_conflict(&ConflictRecord {
241 path: entry.path.clone(),
242 foreign_etag: entry.etag.clone(),
243 preserved_key: None,
244 kind: format!(
245 "consume-refused-checksum: etag {} is attested with CRC-64 \
246 {want} but the bytes fetched under it hash to {got}",
247 entry.etag
248 ),
249 at_unix: now_unix(),
250 })?;
251 continue;
252 }
253 }
254 // CONTAINMENT and I/O are split, because the answers are
255 // opposite. Containment was already decided above by
256 // `check_contained`, so a refusal reaching here is a
257 // path that only became unsafe once parents were
258 // created — still a permanent property of the path, so
259 // it is surfaced and DROPPED, exactly as above.
260 //
261 // Everything else — ENOSPC, EACCES, EIO, a read-only
262 // filesystem — is TRANSIENT, and dropping the entry for
263 // one is silent permanent loss: the foreign bytes stay
264 // in the bucket, the workspace never adopts them, and
265 // nothing ever re-offers the entry. It used to be
266 // recorded as `consume-refused-containment`, which sent
267 // whoever read it looking for a hostile path that was
268 // never there. A full disk is not a planted symlink.
269 let target = match contained_path(&self.cfg.root, &entry.path) {
270 Ok(t) => t,
271 Err(e) => {
272 self.state.append_conflict(&ConflictRecord {
273 path: entry.path.clone(),
274 foreign_etag: entry.etag.clone(),
275 preserved_key: None,
276 kind: format!("consume-refused-containment: {e}"),
277 at_unix: now_unix(),
278 })?;
279 consumed.push(entry.clone());
280 continue;
281 }
282 };
283 if let Err(e) = write_file_atomic(&target, &body, mode) {
284 // NOT consumed: the entry stays in the cell so the
285 // next barrier retries it. A visible conflict record
286 // that repeats is a far better failure than one
287 // silent drop.
288 self.state.append_conflict(&ConflictRecord {
289 path: entry.path.clone(),
290 foreign_etag: entry.etag.clone(),
291 preserved_key: None,
292 kind: format!("consume-write-failed (will retry): {e}"),
293 at_unix: now_unix(),
294 })?;
295 continue;
296 }
297 let st = std::fs::metadata(&local_path)?;
298 let stamps = GenerationStamps::from_meta(&meta.meta);
299 baseline.entries.insert(
300 entry.path.clone(),
301 BaselineEntry {
302 etag: meta.etag.clone(),
303 generation: stamps.map(|s| s.generation).unwrap_or(0),
304 size: st.len(),
305 mtime_unix: mtime_of(&st),
306 version_id: None,
307 crc64_b64: Some(got),
308 },
309 );
310 baseline.prev_scan.insert(entry.path.clone());
311 }
312 consumed.push(entry.clone());
313 }
314 // The files this baseline vouches for reached the disk before
315 // the baseline does (audit 2026-09-03, finding 9).
316 self.state.sync_tree()?;
317 self.state.save_baseline(&baseline)?;
318 Ok(consumed)
319 }
320
321 /// Act on the inbox document's two request fields (§2.5, D14).
322 ///
323 /// The asymmetry is deliberate and is about blast radius, not
324 /// principle: a boundary request is PERFORMED (it publishes what is
325 /// already on disk and mutates nothing local), a sync request is
326 /// CARRIED into the ticker as advisory news and the agent decides.
327 ///
328 /// Both are watermarked by `requested_unix` rather than consumed by
329 /// a clearing CAS. The field is idempotent state, so a repeat is a
330 /// no-op and a lost clear cannot double-publish; and not writing
331 /// means the doors cost no request at all, which is what §2.5
332 /// promises.
333 fn note_verb_requests(&mut self, doc: &inbox::InboxDoc) -> LeanResult<()> {
334 let mut mark = self.load_verb_watermark()?;
335 if let Some(r) = &doc.sync_request {
336 if r.requested_unix > mark.sync_unix {
337 self.carry_sync_request(r.requested_unix, &r.requestor)?;
338 mark.sync_unix = r.requested_unix;
339 }
340 }
341 if let Some(r) = &doc.boundary_request {
342 if r.requested_unix > mark.boundary_unix {
343 self.request_boundary(
344 &format!("gw:{}:{}", r.requested_unix, r.requestor),
345 Some(format!("boundary requested by {}", r.requestor)),
346 )?;
347 mark.boundary_unix = r.requested_unix;
348 }
349 }
350 self.save_verb_watermark(&mark)
351 }
352
353 fn verb_watermark_path(&self) -> std::path::PathBuf {
354 self.cfg.state_dir().join("verb-requests.json")
355 }
356
357 pub(crate) fn load_verb_watermark(&self) -> LeanResult<VerbWatermark> {
358 let p = self.verb_watermark_path();
359 if !p.exists() {
360 return Ok(VerbWatermark::default());
361 }
362 Ok(std::fs::read(&p)
363 .ok()
364 .and_then(|b| serde_json::from_slice(&b).ok())
365 .unwrap_or_default())
366 }
367
368 fn save_verb_watermark(&self, m: &VerbWatermark) -> LeanResult<()> {
369 let bytes = serde_json::to_vec_pretty(m)
370 .map_err(|e| LeanError::State(format!("verb watermark: {e}")))?;
371 super::control::write_atomic(&self.verb_watermark_path(), &bytes)
372 }
373
374 async fn preserve_conflict_copy(&self, path: &str, etag: &str) -> LeanResult<String> {
375 let key = self.cfg.file_key(path);
376 let dst = self.cfg.conflict_key(&uuid::Uuid::new_v4().to_string(), path);
377 // v1: client-side copy (GET + guarded PUT). The server-side
378 // CopyObject lever is the designed v2 optimization for large
379 // files.
380 let (meta, body) = self.store.get_whole(&key, Some(etag)).await?;
381 let crc = crc64_nvme(&body);
382 let stamps = GenerationStamps {
383 generation: 0,
384 epoch: self.lease.as_ref().map(|l| l.epoch).unwrap_or(0),
385 flush_uuid: "conflict-preserve".into(),
386 boundary_source: None,
387 posix: PosixStamps::from_meta(&meta.meta),
388 };
389 self.store
390 .put_whole(&dst, body, &PutCondition::IfNoneMatchAny, &stamps, crc)
391 .await?;
392 Ok(dst)
393 }
394
395 /// Take the SECOND absence observation now, by direct `lstat`.
396 ///
397 /// `scan::classify` withholds a path's delete until absence has
398 /// survived two consecutive scans — the rename-vs-walk race guard
399 /// (`lib.rs:37`). The hazard that rule names is the WALK missing a
400 /// file renamed under it, not deletion itself. On the cadence path
401 /// the second walk arrives at the next floor tick and nobody has
402 /// been promised otherwise. On a DECLARED boundary it cannot wait:
403 /// the ack would claim a coherent point (D1 — "everything
404 /// ordered-before T") while the manifest still cites a file the
405 /// agent removed before it touched the sentinel, and it would say
406 /// so with `report.deleted: 0` and `status: "ok"`.
407 ///
408 /// A direct `lstat` is exactly what the rename-vs-walk guard asks
409 /// for and is immune to the walk race by construction, so the
410 /// second observation costs one syscall per transiently-absent
411 /// path — never a second full pass. The cadence path is unchanged
412 /// and still waits for the second walk.
413 pub(crate) fn confirm_absences(&self, classified: &mut scan::Classified) -> LeanResult<usize> {
414 if classified.first_absence.is_empty() {
415 return Ok(0);
416 }
417 let mut confirmed = 0;
418 for path in std::mem::take(&mut classified.first_absence) {
419 // ONLY NotFound is absence. Every other errno — EACCES on a
420 // parent, EIO, EMFILE, ELOOP — used to read as "the agent
421 // deleted it", and this is the oracle that PUBLISHES the
422 // delete and then DELETES THE OBJECT. An unreadable file is
423 // the one case where guessing is unrecoverable, so it fails
424 // CLOSED, exactly as the walk does at `scan.rs`'s
425 // `symlink_metadata(&path)?`. Two call sites, one syscall:
426 // they must not have opposite error policies.
427 let gone = match std::fs::symlink_metadata(self.cfg.root.join(&path)) {
428 Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
429 Err(e) => {
430 classified.first_absence.insert(path.clone());
431 return Err(LeanError::State(format!(
432 "cannot confirm the absence of {path:?}: {e} — refusing to publish \
433 a deletion on an unreadable path"
434 )));
435 }
436 Ok(_) => false,
437 };
438 if gone {
439 classified.deletes.insert(path);
440 confirmed += 1;
441 } else {
442 // Back on disk: the rename-vs-walk race, caught exactly
443 // as the rule intends. Still only a first absence.
444 classified.first_absence.insert(path);
445 }
446 }
447 Ok(confirmed)
448 }
449
450 /// Renew the lease if it has gone stale MID-BARRIER, and hand back
451 /// the cell as it was read so the CALLER can fence on it.
452 ///
453 /// Costs nothing on a short barrier: the renewal is skipped unless
454 /// it was already due, so the request count is unchanged — this
455 /// moves WHEN the heartbeat can happen, not how often.
456 ///
457 /// What this does NOT do, stated because the previous comment
458 /// claimed it did: it never raises `Fenced` for a deposed holder.
459 /// `lease::renew` can only 412 when the cell is still at OUR epoch
460 /// with a token we do not hold; a cell at a FOREIGN epoch is
461 /// skipped here (renewing it would be nonsense), so a deposed
462 /// straggler passed straight through. The 2026-09-03 audit found
463 /// the cadence barrier relying on exactly that phantom fence
464 /// between upload chunks and completing every remaining data PUT
465 /// after a takeover. The cell is returned so the caller can compare
466 /// it against its lease — the gated lanes already pay a separate
467 /// `verify_not_deposed_pub` for the same purpose; the cadence
468 /// barrier uses the read it already paid for. `None` when there is
469 /// no lease or the read failed: neither is a fence, and the
470 /// ordinary renewal arm will try again.
471 pub(crate) async fn renew_if_due(&mut self) -> LeanResult<Option<EpochState>> {
472 let Some(lease) = self.lease.clone() else { return Ok(None) };
473 let state = match self.store.epoch_read(&self.cfg.epoch_key()).await {
474 Ok(Some(s)) => s,
475 _ => return Ok(None),
476 };
477 let now = super::now_unix();
478 let fresh = state
479 .last_renew_unix
480 .map(|t| now.saturating_sub(t) < RENEW_WITHIN_SECS)
481 .unwrap_or(false);
482 if fresh || state.epoch != lease.epoch {
483 // Not due, or not ours to renew. The caller decides what a
484 // foreign cell means; this function only knows it cannot
485 // renew one.
486 return Ok(Some(state));
487 }
488 super::lease::renew(self).await?;
489 Ok(Some(state))
490 }
491
492 /// The between-chunk fence of the cadence barrier: a cell that no
493 /// longer names this holder at this epoch stops the upload set
494 /// HERE, before the next chunk's PUTs. Every one of those PUTs
495 /// carries If-Match on a baseline etag that still matches — the
496 /// successor has published nothing yet — so without this a deposed
497 /// straggler overwrites the cited generation of every key it still
498 /// had to upload, and every reader's S3-wins arm then adopts the
499 /// uncited bytes silently (audit 2026-09-03, finding 1).
500 fn fence_on_cell(&self, cell: &EpochState) -> LeanResult<()> {
501 let Some(lease) = self.lease.as_ref() else {
502 return Err(LeanError::Fenced("lease dropped mid-barrier".into()));
503 };
504 if cell.epoch != lease.epoch || cell.holder_id != lease.holder_id {
505 return Err(LeanError::Fenced(format!(
506 "deposed between upload chunks: cell at epoch {} holder {} (we are epoch {})",
507 cell.epoch, cell.holder_id, lease.epoch
508 )));
509 }
510 Ok(())
511 }
512
513 /// Steps 2–7. `barrier` = one full publish cycle (the cadence arm).
514 pub async fn run_barrier(&mut self) -> LeanResult<BarrierReport> {
515 self.barrier_inner(false, "manual").await
516 }
517
518 /// The floor tick's barrier. Stamped `cadence` so the bucket can
519 /// answer "which clock installed this boundary?" without the ack —
520 /// the ack is a LOCAL file, and the fleet-visible question is asked
521 /// by operators and by the gateway's `/status`, neither of which
522 /// can see it.
523 pub async fn cadence_barrier(&mut self) -> LeanResult<BarrierReport> {
524 self.barrier_inner(false, "cadence").await
525 }
526
527 /// A barrier whose result somebody is going to ACK: a sentinel
528 /// honor (D1) or the preStop drain (D10). Identical to
529 /// `run_barrier` except that it confirms first-absence paths rather
530 /// than acking a boundary that withholds them.
531 pub async fn declared_barrier(&mut self) -> LeanResult<BarrierReport> {
532 self.barrier_inner(true, "sentinel").await
533 }
534
535 /// `declared_barrier` under an explicit provenance stamp — the
536 /// drain and the budget-deferred honor are the same barrier
537 /// reporting a different clock.
538 pub async fn declared_barrier_as(&mut self, source: &str) -> LeanResult<BarrierReport> {
539 self.barrier_inner(true, source).await
540 }
541
542 async fn barrier_inner(&mut self, declared: bool, source: &str) -> LeanResult<BarrierReport> {
543 let epoch = self.lease_epoch()?;
544 let mut report = BarrierReport::default();
545
546 // STEP 0: finish any rescope a crash left half-applied, before
547 // the scan reads the tree (scoped-read design §4.3). This is the
548 // gate that makes the narrow verb safe at all: a half-applied
549 // narrow is a tree whose files and whose citations disagree, and
550 // `classify` reads that disagreement as an upload or as a
551 // DELETE depending on which half landed. Replay is idempotent,
552 // so the cost on the normal path is one `stat` of a file that
553 // is not there.
554 if let Some(r) = self.replay_scope_intent().await? {
555 report.rescope_replayed = true;
556 eprintln!(
557 "flint-sync: finished an interrupted rescope before the barrier \
558 (uncited {}, unlinked {}, materialised {})",
559 r.uncited, r.unlinked, r.materialized
560 );
561 }
562
563 // Cooperative deposal check BEFORE any write (a thawed
564 // straggler fences here instead of landing data PUTs). This
565 // narrows the window; the per-request epoch validation that
566 // CLOSES it is the gateway's (P5) — the model's LeanNoEpochCheck
567 // mutation is the proof rotation alone does not cover the data
568 // path.
569 self.verify_not_deposed().await?;
570
571 // Step 1.
572 let consumed = self.consume_inbox().await?;
573 report.consumed = consumed.len();
574
575 // Step 2: scan-diff against the persisted baseline.
576 let mut baseline = self.state.load_baseline()?;
577 let scanned = scan::scan(&self.cfg.root)?;
578 let mut classified = scan::classify(&scanned, &baseline);
579 if declared {
580 report.absences_confirmed = self.confirm_absences(&mut classified)?;
581 }
582 report.first_absence = classified.first_absence.iter().cloned().collect();
583
584 // Skip-on-no-diff: nothing local, nothing consumed, no pending
585 // citation repair, and the bucket manifest where we left it.
586 let repairs_pending = baseline
587 .entries
588 .iter()
589 .any(|(p, be)| be.size != u64::MAX && baseline.inst_base.get(p) != Some(&be.etag));
590 if classified.uploads.is_empty()
591 && classified.deletes.is_empty()
592 && consumed.is_empty()
593 && classified.first_absence.is_empty()
594 && !repairs_pending
595 {
596 // Read the POINTER, never the entries: the 0b rig measured
597 // the idle tick at 1M entries as 27 s / 1.3 GiB — dominated
598 // by fetching and parsing a 264 MiB document just to read
599 // `seq`. The pointer is a few hundred bytes and carries the
600 // seq in its body, so a GET of it costs one round trip and
601 // answers strictly more than a HEAD of the old object did.
602 //
603 // Legacy workspaces (no pointer yet) keep the HEAD they had.
604 let unchanged = match manifest::load_pointer(self.store.as_ref(), &self.cfg).await? {
605 Some(manifest::LoadedPointer { pointer: p, etag, .. }) => {
606 // The news ticker rides this request for free — D5's
607 // "zero added bucket requests" is still literal.
608 report.observed_seq = Some(p.seq);
609 report.observed_etag = Some(etag.clone());
610 baseline.manifest_etag.as_deref() == Some(etag.as_str())
611 }
612 None => match self.store.head(&self.cfg.manifest_key()).await {
613 Ok(meta) => {
614 report.observed_seq =
615 GenerationStamps::from_meta(&meta.meta).map(|s| s.generation);
616 report.observed_etag = Some(meta.etag.clone());
617 baseline.manifest_etag.as_deref() == Some(meta.etag.as_str())
618 }
619 Err(StoreError::NotFound(_)) => baseline.manifest_etag.is_none(),
620 Err(e) => return Err(e.into()),
621 },
622 };
623 if unchanged {
624 report.no_change = true;
625 report.seq = Some(baseline.seq);
626 // prev_scan still advances (the two-scan rule's clock) —
627 // but the baseline document (hundreds of MB at 1M
628 // entries) is rewritten only when the scan SET moved.
629 // Compare without building the set: both sides iterate
630 // sorted, and the allocation this used to make was the
631 // size of the very document it was trying not to write.
632 if scanned.len() != baseline.prev_scan.len()
633 || !scanned.keys().eq(baseline.prev_scan.iter())
634 {
635 baseline.prev_scan = scanned.keys().cloned().collect();
636 self.state.save_baseline(&baseline)?;
637 }
638 return Ok(report);
639 }
640 }
641
642 // Step 3: intent journal, then the window (the commitment
643 // point: the same CAS drops the consumed entries).
644 let flush_uuid = uuid::Uuid::new_v4().to_string();
645 let mut intent = self.state.load_intent()?;
646 let prior_uuids = {
647 let mut v = intent.recent_uuids.clone();
648 if !intent.flush_uuid.is_empty() {
649 v.push(intent.flush_uuid.clone());
650 }
651 v
652 };
653 // Carried forward, not dropped: it stays true until we install
654 // again, and the merge below is what reads it.
655 let prev_installed = intent.installed_etag.clone();
656 intent = IntentJournal {
657 flush_uuid: flush_uuid.clone(),
658 keys: classified.uploads.iter().map(|p| self.cfg.file_key(p)).collect(),
659 recent_uuids: prior_uuids.clone(),
660 installed_etag: prev_installed.clone(),
661 };
662 self.state.save_intent(&intent)?;
663 let deadline = now_unix() + self.cfg.window_slack_secs;
664 inbox::open_window(self.store.as_ref(), &self.cfg, epoch, deadline).await?;
665 if !consumed.is_empty() {
666 // Drop consumed entries now that they are durably in the
667 // baseline (idempotent if this crashes: re-consume skips).
668 inbox::drop_entries(self.store.as_ref(), &self.cfg, epoch, &consumed).await?;
669 }
670
671 // Step 4: guarded uploads, fanned out under a bounded window
672 // (each key's guard chain is independent; the 412 policy and
673 // conflict records are applied to the collected results below,
674 // in deterministic path order).
675 let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
676 let mut parked: BTreeSet<String> = BTreeSet::new();
677 let mut new_baseline_entries: BTreeMap<String, BaselineEntry> = BTreeMap::new();
678 let outcomes: Vec<(String, LeanResult<UploadOutcome>)> = {
679 use futures::stream::{self, StreamExt};
680 // CHUNKED, and the reason is the lease rather than memory.
681 //
682 // The run loop is one `select!`, so while this call runs the
683 // renewal arm CANNOT fire — branches are mutually exclusive.
684 // An unchunked upload phase therefore starves the heartbeat
685 // for its whole duration, which does two bad things: a
686 // deposed straggler cannot learn it was deposed (the CAS
687 // that would tell it never runs), and a HEALTHY syncer can
688 // outrun the 60 s takeover window and have a standby take
689 // the lease off a live writer. The 0b numbers put a 1M-file
690 // checkout at 7 m 05 s, so this is reachable, not exotic.
691 //
692 // Between chunks nothing borrows `self`, so the renewal can
693 // run on its ordinary ≤30 s cadence — it costs NO extra
694 // requests, because that renewal was already due. The chunk
695 // is a multiple of fan-out so a wave still saturates it.
696 //
697 // Residual, stated rather than hidden: starvation is now
698 // bounded by ONE CHUNK's duration instead of the whole
699 // barrier. A chunk of very large files can still exceed the
700 // window; closing that needs the renewal on its own task,
701 // which needs the lease behind a shared cell.
702 let mut outcomes: Vec<(String, LeanResult<UploadOutcome>)> = Vec::new();
703 // NOT cfg.fanout. That knob was raised to 128 for the READ
704 // path, where a byte semaphore (fetch_inflight_max_bytes)
705 // bounds what concurrency can hold in memory. THIS path has
706 // no such gate — `buffer_unordered` is the only bound — so
707 // riding the same number would have quadrupled both the
708 // bodies held in memory and `chunk`, widening the
709 // lease-fence window from 512 files to 2048 and making the
710 // hazard named in the comment above four times likelier.
711 // The read-side measurement (2.5x on 20k small files) says
712 // nothing about either, so uploads keep the value they were
713 // measured at until someone measures them.
714 let fanout = self.cfg.upload_fanout.max(1);
715 let chunk = fanout.saturating_mul(UPLOAD_CHUNK_WAVES).max(1);
716 let all: Vec<&String> = classified.uploads.iter().collect();
717 for group in all.chunks(chunk) {
718 {
719 let this: &Syncer = &*self;
720 let mut part: Vec<(String, LeanResult<UploadOutcome>)> =
721 stream::iter(group.iter().map(|path| {
722 let path = *path;
723 let scanned_entry = &scanned[path];
724 let base = baseline.entries.get(path);
725 let flush_uuid = &flush_uuid;
726 let prior_uuids = &prior_uuids;
727 async move {
728 let r = this
729 .upload_one(
730 path, scanned_entry, base, epoch, flush_uuid,
731 prior_uuids,
732 )
733 .await;
734 (path.clone(), r)
735 }
736 }))
737 .buffer_unordered(fanout)
738 .collect()
739 .await;
740 outcomes.append(&mut part);
741 }
742 // Now that the borrow is released: keep the lease alive,
743 // and FENCE on the cell that read returned. A deposed
744 // straggler stops here, before the next chunk's PUTs —
745 // the renewal alone never fenced a foreign cell (see
746 // `renew_if_due`), which is how a straggler used to run
747 // its upload set to completion after a takeover.
748 if let Some(cell) = self.renew_if_due().await? {
749 self.fence_on_cell(&cell)?;
750 }
751 }
752 outcomes
753 };
754 let mut outcomes = outcomes;
755 outcomes.sort_by(|a, b| a.0.cmp(&b.0));
756 for (path, outcome) in outcomes {
757 let path = &path;
758 match outcome? {
759 UploadOutcome::Published { entry, baseline_entry } => {
760 report.published_bytes += entry.size;
761 upserts.insert(path.clone(), entry);
762 new_baseline_entries.insert(path.clone(), baseline_entry);
763 report.uploaded.push(path.clone());
764 }
765 UploadOutcome::Parked { foreign_etag } => {
766 parked.insert(path.clone());
767 self.state.append_conflict(&ConflictRecord {
768 path: path.clone(),
769 foreign_etag,
770 preserved_key: None, // parking preserves in place
771 kind: "upload-412-parked".into(),
772 at_unix: now_unix(),
773 })?;
774 report.parked.push(path.clone());
775 }
776 UploadOutcome::Deferred => {
777 report.deferred.push(path.clone());
778 }
779 }
780 }
781
782 // Citation repairs: paths whose integrated object (the
783 // baseline) differs from the manifest's citation — consumed
784 // HITL adoptions and checkout's S3-wins arm. No bytes move; the
785 // manifest re-cites what this syncer already integrated.
786 // Without this, an adopted upload is clean-vs-baseline, never
787 // enters the upload set, and the manifest silently drops it —
788 // the battery's amputation leg caught exactly that.
789 let repair_candidates: Vec<String> = baseline
790 .entries
791 .iter()
792 .filter(|(path, be)| {
793 !classified.uploads.contains(*path)
794 && !classified.deletes.contains(*path)
795 && be.size != u64::MAX // consume-dirty sentinel: publishes via upload
796 && baseline.inst_base.get(*path) != Some(&be.etag)
797 })
798 .map(|(path, _)| path.clone())
799 .collect();
800 for path in repair_candidates {
801 let key = self.cfg.file_key(&path);
802 let be = baseline.entries[&path].clone();
803 match self.store.head(&key).await {
804 Ok(meta) if meta.etag == be.etag => {
805 let crc = repair_crc(&path, &be, &meta)?;
806 let stamps = GenerationStamps::from_meta(&meta.meta);
807 let scan_entry = scanned.get(&path);
808 upserts.insert(
809 path.clone(),
810 LeanEntry {
811 key,
812 etag: meta.etag.clone(),
813 crc64_b64: crc,
814 size: meta.size,
815 mode: stamps
816 .as_ref()
817 .and_then(|s| s.posix)
818 .map(|p| p.mode)
819 .or(scan_entry.map(|s| s.mode))
820 .unwrap_or(0o644),
821 mtime_unix: scan_entry.map(|s| s.mtime_unix).unwrap_or(0),
822 generation: stamps.map(|s| s.generation).unwrap_or(be.generation),
823 epoch,
824 version_id: meta.version_id.clone(),
825 },
826 );
827 }
828 // Moved again or gone: the next consume reconciles it.
829 _ => {}
830 }
831 }
832
833 // Step 5: the manifest CAS (three-way merge; bounded retries).
834 self.verify_not_deposed().await?;
835 let mut foreign_entries: Vec<(String, LeanEntry)> = vec![];
836 let mut attempt = 0;
837 let (installed, installed_etag) = loop {
838 attempt += 1;
839 if attempt > 4 {
840 return Err(LeanError::State(
841 "manifest CAS lost 4 merge races — refusing this barrier".into(),
842 ));
843 }
844 let current = manifest::load(self.store.as_ref(), &self.cfg).await?;
845 let (theirs, expected) = match ¤t {
846 // The handle carries the LAYOUT as well as the tag: a
847 // workspace still on the legacy single object CASes a
848 // pointer that must not exist yet, not one it read.
849 Some(l) => (l.manifest.clone(), Some(l.handle())),
850 None => (Default::default(), None),
851 };
852 // If the bucket is still at the document THIS workspace
853 // installed, that document IS the merge base — whatever the
854 // persisted one says. Step 7 rewrites the merge base after
855 // the CAS, so a restart in between leaves it behind a
856 // document we wrote, and every entry in it would read as
857 // somebody else's change. See `IntentJournal::installed_etag`.
858 let own_base;
859 let base: &BTreeMap<String, String> =
860 if prev_installed.is_some()
861 && prev_installed.as_deref() == expected.as_ref().map(|h| h.etag.as_str())
862 {
863 own_base = theirs
864 .entries
865 .iter()
866 .map(|(p, e)| (p.clone(), e.etag.clone()))
867 .collect();
868 &own_base
869 } else {
870 &baseline.inst_base
871 };
872 let (mut merged, foreign) =
873 manifest::merge(base, &theirs, &upserts, &classified.deletes, &parked);
874 // `merge` clears it; the installing pass owns it. A mirror
875 // is a property of how this workspace is DEPLOYED, so it
876 // comes from config on every publish rather than being
877 // inherited from whatever wrote last.
878 merged.sole_writer = self.cfg.sole_writer;
879 match manifest::cas_write_stamped(
880 self.store.as_ref(),
881 &self.cfg,
882 &merged,
883 expected.as_ref(),
884 epoch,
885 &flush_uuid,
886 Some(source),
887 )
888 .await
889 {
890 Ok(meta) => {
891 // Before the deletes and before step 7: this is the
892 // only record that survives a crash in that window.
893 intent.installed_etag = Some(meta.etag.clone());
894 self.state.save_intent(&intent)?;
895 foreign_entries = foreign;
896 break (merged, meta.etag);
897 }
898 Err(LeanError::Store(StoreError::PreconditionFailed(_)))
899 | Err(LeanError::Store(StoreError::Conflict(_))) => {
900 // Re-verify the cell before retrying: a rotation is
901 // exactly this 412, and re-merging past it would be
902 // the straggler install.
903 self.verify_not_deposed().await?;
904 continue;
905 }
906 Err(e) => return Err(e),
907 }
908 };
909 report.seq = Some(installed.seq);
910 // A fused install IS a coherent point, and cadence/hybrid have
911 // exactly one source. The gauges must not report "no boundary
912 // ever" on a workspace that publishes every minute.
913 self.note_boundary(super::gated::CitationSource::Cadence.as_str(), installed.seq)?;
914 report.manifest_etag = Some(installed_etag.clone());
915 report.observed_seq = Some(installed.seq);
916 report.observed_etag = Some(installed_etag.clone());
917 report.foreign_queued = foreign_entries.len();
918
919 // Step 6: deletes LAST — GC of keys the NEW manifest no longer
920 // references, HEAD-guarded on the recognized ETag.
921 for path in &classified.deletes {
922 if installed.entries.contains_key(path) {
923 continue; // delete/modify resolved foreign-wins: not garbage
924 }
925 let key = self.cfg.file_key(path);
926 let recognized = baseline.entries.get(path).map(|b| b.etag.clone());
927 match self.store.head(&key).await {
928 Err(StoreError::NotFound(_)) => {
929 report.deleted.push(path.clone());
930 }
931 Ok(meta) if Some(&meta.etag) == recognized.as_ref() => {
932 self.store.delete(&key).await?;
933 report.deleted.push(path.clone());
934 }
935 Ok(meta) => {
936 // An ETag this syncer does not recognize is NEVER
937 // deleted (a HITL re-create landed after our CAS).
938 self.state.append_conflict(&ConflictRecord {
939 path: path.clone(),
940 foreign_etag: meta.etag,
941 preserved_key: None,
942 kind: "gc-skip".into(),
943 at_unix: now_unix(),
944 })?;
945 }
946 Err(e) => return Err(e.into()),
947 }
948 }
949
950 // Step 7: baseline rewrite, intent clear, window clear (+ queue
951 // the merge-preserved foreign entries for the next consume).
952 for (path, be) in new_baseline_entries {
953 baseline.entries.insert(path, be);
954 }
955 for path in &report.deleted {
956 baseline.entries.remove(path);
957 }
958 baseline.inst_base =
959 installed.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
960 baseline.seq = installed.seq;
961 baseline.manifest_etag = Some(installed_etag);
962 baseline.prev_scan = scanned.keys().cloned().collect();
963 self.state.save_baseline(&baseline)?;
964 self.state.clear_intent_keys()?;
965 let queue: Vec<InboxEntry> = foreign_entries
966 .into_iter()
967 .map(|(path, e)| InboxEntry {
968 path,
969 etag: e.etag,
970 author: "merge-preserved".into(),
971 added_unix: now_unix(),
972 crc64_b64: Some(e.crc64_b64),
973 })
974 .collect();
975 inbox::clear_window(self.store.as_ref(), &self.cfg, epoch, &queue).await?;
976 // Reap superseded generations. Immutable metadata that is never
977 // collected is a leak that grows by a whole manifest per
978 // publish, and this also collects the orphan a crash between the
979 // entries PUT and the pointer CAS leaves behind. Best effort by
980 // design: a publish that succeeded is not un-done by a failure
981 // to tidy up after it.
982 match manifest::sweep_generations(self.store.as_ref(), &self.cfg).await {
983 Ok(0) => {}
984 Ok(n) => eprintln!("flint-sync: reaped {n} superseded manifest generation(s)"),
985 Err(e) => eprintln!("flint-sync: generation sweep: {e}"),
986 }
987 // And the CHUNK reaper, same reason, same best-effort terms.
988 // `sweep_chunks` returns 0 without a request on a workspace that
989 // is not chunked, so this costs nothing until the layout moves.
990 //
991 // It was written to four model-established rules, guarded by six
992 // mutation configs, and called from nothing but its own tests —
993 // so with chunking on, every publish left its superseded chunks
994 // in the bucket forever. The rules ran in the model and in the
995 // suite and never once in production. `the_barrier_reaps...`
996 // below is the test that asks whether this line exists at all.
997 match manifest::sweep_chunks(self.store.as_ref(), &self.cfg).await {
998 Ok(0) => {}
999 Ok(n) => eprintln!("flint-sync: reaped {n} unreferenced manifest chunk(s)"),
1000 Err(e) => eprintln!("flint-sync: chunk sweep: {e}"),
1001 }
1002 Ok(report)
1003 }
1004
1005 /// The > whole_put_max path: contiguous `PartSource::Local` chunks
1006 /// through `compose_generation` (streaming multipart; the store
1007 /// aborts its partial assembly on every failure path). The CRC is
1008 /// computed by a streaming pass first; a writer racing the compose
1009 /// fails server-side validation and the path DEFERS to the next
1010 /// barrier — publish-possibly-torn is put_whole's documented
1011 /// dilemma, not this path's.
1012 #[allow(clippy::too_many_arguments)]
1013 async fn upload_compose(
1014 &self,
1015 path: &str,
1016 key: &str,
1017 local_path: &Path,
1018 size: u64,
1019 scanned: &scan::ScanEntry,
1020 condition: PutCondition,
1021 stamps: GenerationStamps,
1022 generation: u64,
1023 epoch: u64,
1024 ) -> LeanResult<UploadOutcome> {
1025 // NO PRE-PASS. The store accumulates the full-object CRC from
1026 // the parts as it reads them for upload, so this file is read
1027 // ONCE. It used to be read TWICE — a streaming hash of the whole
1028 // file, and then again part by part — which on the 2026-09-10
1029 // door drill was roughly 11 s of the `mixed` workload's 24.3 s,
1030 // all of it at the disk's read ceiling and none of it on the
1031 // wire.
1032 //
1033 // That pre-pass was not laziness: asking the store to accumulate
1034 // used to pin its part uploads to a sequential loop, because a
1035 // CRC-64 taken out of order is a different number. `crc64_combine`
1036 // removed the choice — parts are hashed independently and folded
1037 // in part order — so the single read and the parallel upload are
1038 // no longer alternatives.
1039
1040 // Part grid: within [min_part_size, ...], at most max_parts,
1041 // contiguous from 0.
1042 let min_part = self.store.min_part_size().max(1);
1043 let max_parts = self.store.max_parts().max(1) as u64;
1044 let mut chunk = self.cfg.whole_put_max.max(min_part);
1045 let need = size.div_ceil(chunk);
1046 if need > max_parts {
1047 chunk = size.div_ceil(max_parts).div_ceil(min_part) * min_part;
1048 }
1049 let mut parts = vec![];
1050 let mut off = 0u64;
1051 while off < size {
1052 let len = chunk.min(size - off);
1053 parts.push(flint_store::PartSource::Local { offset: off, len });
1054 off += len;
1055 }
1056
1057 let spec = flint_store::ComposeSpec {
1058 progress: None,
1059 key,
1060 local_path,
1061 parts,
1062 base_key: None,
1063 base_etag: None,
1064 condition,
1065 stamps: stamps.clone(),
1066 crc64: None,
1067 };
1068 match self.store.compose_generation(&spec).await {
1069 Ok(meta) => {
1070 // The store computed it; refuse to cite bytes nothing
1071 // vouched for rather than invent a number for the
1072 // manifest. A backend that returns no checksum has not
1073 // validated the publish server-side either.
1074 let crc = meta
1075 .crc64_b64
1076 .as_deref()
1077 .and_then(flint_store::crc64_from_b64)
1078 .ok_or_else(|| {
1079 LeanError::State(format!(
1080 "compose of {key} returned no full-object checksum — refusing to \
1081 cite bytes nothing vouched for"
1082 ))
1083 })?;
1084 Ok(UploadOutcome::published(
1085 path, key.to_string(), meta.etag, crc, scanned, generation, epoch,
1086 meta.version_id,
1087 ))
1088 }
1089 Err(StoreError::ChecksumMismatch(_)) | Err(StoreError::NoSuchUpload(_)) => {
1090 // Drift mid-compose, or the operator sweep aborted us:
1091 // nothing published; the next barrier re-queues.
1092 Ok(UploadOutcome::Deferred)
1093 }
1094 Err(StoreError::PreconditionFailed(_)) => {
1095 // The AdoptOwn recognizer compares OUR bytes' checksum
1096 // against what landed, and without the pre-pass we do
1097 // not have one — so the file is read HERE, on the rare
1098 // recovery path, instead of on every publish.
1099 let crc = file_crc(local_path)?;
1100 let head = self.store.head(key).await?;
1101 let head_stamps = GenerationStamps::from_meta(&head.meta);
1102 let own = head_stamps
1103 .as_ref()
1104 .map(|s| s.flush_uuid == stamps.flush_uuid)
1105 .unwrap_or(false);
1106 if own || head.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) {
1107 // Our own torn earlier Complete with these bytes:
1108 // cite it.
1109 let g = head_stamps.map(|s| s.generation).unwrap_or(generation);
1110 return Ok(UploadOutcome::published(
1111 path, key.to_string(), head.etag, crc, scanned, g, epoch,
1112 head.version_id,
1113 ));
1114 }
1115 Ok(UploadOutcome::Parked { foreign_etag: head.etag })
1116 }
1117 Err(e) => Err(e.into()),
1118 }
1119 }
1120
1121 /// The gated staging lane's entry point into the shipped guard
1122 /// chain — same 412 policy, same AdoptOwn recognizer, same park;
1123 /// only the caller's use of the response differs. `None` = parked
1124 /// on a foreign etag.
1125 #[allow(clippy::too_many_arguments)]
1126 pub(super) async fn upload_one_pub(
1127 &self,
1128 path: &str,
1129 scanned: &scan::ScanEntry,
1130 base: Option<&BaselineEntry>,
1131 epoch: u64,
1132 flush_uuid: &str,
1133 prior_uuids: &[String],
1134 ) -> LeanResult<Option<(LeanEntry, BaselineEntry)>> {
1135 match self.upload_one(path, scanned, base, epoch, flush_uuid, prior_uuids).await? {
1136 UploadOutcome::Published { entry, baseline_entry } => {
1137 Ok(Some((entry, baseline_entry)))
1138 }
1139 UploadOutcome::Parked { .. } | UploadOutcome::Deferred => Ok(None),
1140 }
1141 }
1142
1143 async fn upload_one(
1144 &self,
1145 path: &str,
1146 scanned: &scan::ScanEntry,
1147 base: Option<&BaselineEntry>,
1148 epoch: u64,
1149 flush_uuid: &str,
1150 prior_uuids: &[String],
1151 ) -> LeanResult<UploadOutcome> {
1152 let key = self.cfg.file_key(path);
1153 let local_path = self.cfg.root.join(path);
1154 let generation = base.map(|b| b.generation + 1).unwrap_or(1);
1155 let posix = std::fs::metadata(&local_path).ok().map(|m| PosixStamps::from_metadata(&m));
1156 let stamps = GenerationStamps {
1157 generation,
1158 epoch,
1159 flush_uuid: flush_uuid.to_string(),
1160 boundary_source: None,
1161 posix,
1162 };
1163 let condition = match base {
1164 Some(b) => PutCondition::IfMatch(b.etag.clone()),
1165 None => PutCondition::IfNoneMatchAny,
1166 };
1167 let size = std::fs::metadata(&local_path)
1168 .map_err(|e| LeanError::State(format!("stat {}: {e}", local_path.display())))?
1169 .len();
1170 if size > self.cfg.whole_put_max {
1171 // Streaming multipart compose: put_whole is never fed past
1172 // whole_put_max (unbounded memory + S3's 5 GiB wall).
1173 return self
1174 .upload_compose(path, &key, &local_path, size, scanned, condition, stamps, generation, epoch)
1175 .await;
1176 }
1177 // `Bytes::from(Vec<u8>)` TAKES OWNERSHIP without copying, so the
1178 // old `Bytes::from(body.clone())` was a full memcpy of every
1179 // published file body — bought solely to leave `body` intact for
1180 // the 412 retry below, which is reached almost never. Build the
1181 // `Bytes` once; the retry gets a refcount clone.
1182 let body = Bytes::from(std::fs::read(&local_path)?);
1183 let crc = crc64_nvme(&body);
1184 match self
1185 .store
1186 .put_whole(&key, body.clone(), &condition, &stamps, crc)
1187 .await
1188 {
1189 Ok(meta) => Ok(UploadOutcome::published(
1190 path, key, meta.etag, crc, scanned, generation, epoch, meta.version_id,
1191 )),
1192 Err(StoreError::PreconditionFailed(_)) => {
1193 // The 412 policy: my own crashed/torn PUT ⇒ adopt;
1194 // foreign ⇒ park. NEVER the inherited LOCAL-WINS
1195 // overwrite.
1196 let head = self.store.head(&key).await?;
1197 let head_stamps = GenerationStamps::from_meta(&head.meta);
1198 let own = head_stamps
1199 .as_ref()
1200 .map(|s| s.flush_uuid == flush_uuid || prior_uuids.contains(&s.flush_uuid))
1201 .unwrap_or(false);
1202 if !own {
1203 return Ok(UploadOutcome::Parked { foreign_etag: head.etag });
1204 }
1205 if head.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) {
1206 // Bytes already there (torn response): cite it.
1207 let g = head_stamps.map(|s| s.generation).unwrap_or(generation);
1208 return Ok(UploadOutcome::published(
1209 path, key, head.etag, crc, scanned, g, epoch, head.version_id,
1210 ));
1211 }
1212 // Our earlier PUT, older content: supersede it knowingly.
1213 let meta = self
1214 .store
1215 .put_whole(&key, body, &PutCondition::IfMatch(head.etag), &stamps, crc)
1216 .await?;
1217 Ok(UploadOutcome::published(
1218 path, key, meta.etag, crc, scanned, generation, epoch, meta.version_id,
1219 ))
1220 }
1221 Err(e) => Err(e.into()),
1222 }
1223 }
1224}
1225
1226enum UploadOutcome {
1227 Published { entry: LeanEntry, baseline_entry: BaselineEntry },
1228 Parked { foreign_etag: String },
1229 /// The source drifted mid-transfer (checksum refused server-side)
1230 /// or the assembly was swept: publish nothing, advance nothing —
1231 /// the next scan re-queues the path.
1232 Deferred,
1233}
1234
1235impl UploadOutcome {
1236 #[allow(clippy::too_many_arguments)]
1237 fn published(
1238 path: &str,
1239 key: String,
1240 etag: String,
1241 crc: u64,
1242 scanned: &scan::ScanEntry,
1243 generation: u64,
1244 epoch: u64,
1245 version_id: Option<String>,
1246 ) -> UploadOutcome {
1247 let _ = path;
1248 UploadOutcome::Published {
1249 entry: LeanEntry {
1250 key,
1251 etag: etag.clone(),
1252 crc64_b64: crc64_to_b64(crc),
1253 size: scanned.size,
1254 mode: scanned.mode,
1255 mtime_unix: scanned.mtime_unix,
1256 generation,
1257 epoch,
1258 version_id: version_id.clone(),
1259 },
1260 baseline_entry: BaselineEntry {
1261 etag,
1262 generation,
1263 version_id,
1264 // The PRE-read stat: if the agent wrote during our read,
1265 // the next scan sees the drift and re-queues (the
1266 // re-stat/re-queue valve).
1267 size: scanned.size,
1268 mtime_unix: scanned.mtime_unix,
1269 crc64_b64: Some(crc64_to_b64(crc)),
1270 },
1271 }
1272 }
1273}
1274
1275fn local_dirty(local: &Path, base: Option<&BaselineEntry>) -> bool {
1276 match (std::fs::metadata(local), base) {
1277 (Err(_), None) => false, // both absent: clean
1278 (Err(_), Some(_)) => true, // locally deleted vs baseline
1279 (Ok(_), None) => true, // local exists, never published
1280 (Ok(m), Some(b)) => m.len() != b.size || mtime_of(&m) != b.mtime_unix,
1281 }
1282}
1283
1284/// Full-object CRC-64/NVME of a local file, streamed.
1285///
1286/// Reached only by the 412 recovery path: the ordinary publish gets its
1287/// checksum from the store, which computes one while uploading.
1288/// The CRC a citation repair cites: the bytes this syncer integrated,
1289/// as the baseline recorded them when it wrote or uploaded them. The
1290/// HEAD's own checksum is a cross-check when the backend offers one,
1291/// never the source — Ozone offers none, a HITL uploader may have sent
1292/// none, and the manifest must carry a CRC either way, because every
1293/// reader now verifies a fresh fetch against it.
1294///
1295/// A baseline entry with no CRC is refused loudly rather than cited
1296/// bare: the only entry built without one is the consume-dirty sentinel,
1297/// which the candidate filters exclude, so reaching this is a defect.
1298/// A HEAD that attests a DIFFERENT value is refused the same way — the
1299/// workspace holds bytes the object does not, and citing either would
1300/// make one reader or another refuse the path forever.
1301pub(crate) fn repair_crc(
1302 path: &str,
1303 be: &BaselineEntry,
1304 head: &flint_store::ObjectMeta,
1305) -> LeanResult<String> {
1306 let Some(ours) = be.crc64_b64.clone() else {
1307 return Err(LeanError::State(format!(
1308 "citation repair of {path}: the baseline records no CRC for the bytes it \
1309 integrated at etag {} — refusing to cite bytes nothing hashed",
1310 be.etag
1311 )));
1312 };
1313 if let Some(theirs) = head.crc64_b64.as_deref() {
1314 if theirs != ours {
1315 return Err(LeanError::State(format!(
1316 "citation repair of {path}: the store attests CRC-64 {theirs} for etag {} but \
1317 the bytes this workspace integrated under that etag hash to {ours} — the \
1318 workspace holds bytes the object does not; refusing to cite either",
1319 be.etag
1320 )));
1321 }
1322 }
1323 Ok(ours)
1324}
1325
1326fn file_crc(local_path: &Path) -> LeanResult<u64> {
1327 use std::io::Read;
1328 let mut crc = flint_store::Crc64Nvme::new();
1329 let mut f = std::fs::File::open(local_path)?;
1330 let mut buf = vec![0u8; 4 << 20];
1331 loop {
1332 let n = f.read(&mut buf)?;
1333 if n == 0 {
1334 break;
1335 }
1336 crc.update(&buf[..n]);
1337 }
1338 Ok(crc.finalize())
1339}
1340
1341pub(super) fn mtime_of(m: &std::fs::Metadata) -> i64 {
1342 m.modified()
1343 .ok()
1344 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1345 .map(|d| d.as_secs() as i64)
1346 .unwrap_or(0)
1347}
1348
1349/// Containment-safe workspace write (boundary-verbs plan §2.2 security
1350/// gate). `rel` is a workspace-relative path; the target may not escape
1351/// the root, traverse a symlink component, or land in the reserved
1352/// control namespace.
1353///
1354/// The hazard this closes is pre-existing and reachable from three
1355/// callers (checkout, inbox consume, sync): `write_file_atomic` did
1356/// `create_dir_all(parent)` + write with no `O_NOFOLLOW` and no
1357/// root-containment check, while the scanner SKIPS symlinks — so a
1358/// planted symlink is invisible to the syncer. An unprivileged app
1359/// that plants `inputs -> /root/.aws`, lands an object at
1360/// `inputs/<path>` and drops a scoped sync turns the credential-holding
1361/// syncer into an on-demand arbitrary-file-write primitive outside the
1362/// workspace.
1363pub(super) fn write_file_atomic_in(
1364 root: &Path,
1365 rel: &str,
1366 bytes: &[u8],
1367 mode: Option<u32>,
1368) -> LeanResult<()> {
1369 let target = contained_path(root, rel)?;
1370 write_file_atomic(&target, bytes, mode).map(|_| ())
1371}
1372
1373/// Validate `rel` under `root` WITHOUT creating anything — for callers
1374/// that must decide whether a path is writable before doing any work
1375/// (inbox consume refuses a refused path outright rather than routing it
1376/// through conflict-preserve, which would cost a GET+PUT and leave the
1377/// path in the baseline).
1378pub(super) fn check_contained(root: &Path, rel: &str) -> LeanResult<()> {
1379 resolve_contained(root, rel, false).map(|_| ())
1380}
1381
1382/// Resolve `rel` under `root`, refusing escapes, creating missing parent
1383/// directories. Walks the parent chain component by component: a
1384/// component that EXISTS as a symlink is a refusal (never followed).
1385pub(super) fn contained_path(root: &Path, rel: &str) -> LeanResult<std::path::PathBuf> {
1386 resolve_contained(root, rel, true).map(|(p, _)| p)
1387}
1388
1389/// As `contained_path`, and also what the walk already learned about
1390/// the FINAL component: its `lstat` if it exists, `None` if it does
1391/// not. The walk stats every component to refuse a symlink; a caller
1392/// that then asked `exists()` and `metadata()` paid two more stats for
1393/// an answer it was already holding (checkout's resume check did, on
1394/// every one of 20,000 files).
1395pub(super) fn contained_path_stat(
1396 root: &Path,
1397 rel: &str,
1398) -> LeanResult<(std::path::PathBuf, Option<std::fs::Metadata>)> {
1399 resolve_contained(root, rel, true)
1400}
1401
1402fn resolve_contained(
1403 root: &Path,
1404 rel: &str,
1405 create_dirs: bool,
1406) -> LeanResult<(std::path::PathBuf, Option<std::fs::Metadata>)> {
1407 use std::path::Component;
1408 let refuse = |why: &str| {
1409 Err(LeanError::State(format!(
1410 "refusing write to {rel:?}: {why} (containment)"
1411 )))
1412 };
1413 if rel.is_empty() {
1414 return refuse("empty path");
1415 }
1416 if super::scan::is_control_path(rel) {
1417 // The reserved namespace is the syncer's own; a citation or
1418 // inbox entry naming it is surfaced, never materialized (D0.3).
1419 return refuse("reserved control namespace");
1420 }
1421 let relp = Path::new(rel);
1422 let mut cur = root.to_path_buf();
1423 let mut comps: Vec<&std::ffi::OsStr> = vec![];
1424 for c in relp.components() {
1425 match c {
1426 Component::Normal(n) => comps.push(n),
1427 Component::CurDir => {}
1428 Component::ParentDir => return refuse("`..` component"),
1429 Component::RootDir | Component::Prefix(_) => return refuse("absolute path"),
1430 }
1431 }
1432 if comps.is_empty() {
1433 return refuse("no path components");
1434 }
1435 let last = comps.len() - 1;
1436 let mut final_meta = None;
1437 for (i, c) in comps.iter().enumerate() {
1438 cur.push(c);
1439 match std::fs::symlink_metadata(&cur) {
1440 Ok(m) if m.file_type().is_symlink() => {
1441 return refuse("path traverses a symlink");
1442 }
1443 Ok(m) if i < last && !m.is_dir() => {
1444 return refuse("parent component is not a directory");
1445 }
1446 Ok(m) => {
1447 if i == last {
1448 final_meta = Some(m);
1449 }
1450 }
1451 Err(_) if i < last => {
1452 if create_dirs {
1453 if let Err(e) = std::fs::create_dir(&cur) {
1454 // A SIBLING may have created it between the stat
1455 // above and this mkdir: fetches run on their own
1456 // tasks, so the first files of one directory
1457 // race here, and the loser's EEXIST is not a
1458 // containment refusal. It is fine only if what
1459 // exists now is a plain directory — a symlink
1460 // that appeared in the window is refused exactly
1461 // as one the stat found.
1462 match std::fs::symlink_metadata(&cur) {
1463 Ok(m) if m.file_type().is_symlink() => {
1464 return refuse("path traverses a symlink");
1465 }
1466 Ok(m) if m.is_dir() => {}
1467 _ => {
1468 return Err(LeanError::State(format!(
1469 "mkdir {}: {e}",
1470 cur.display()
1471 )))
1472 }
1473 }
1474 }
1475 }
1476 }
1477 Err(_) => {}
1478 }
1479 }
1480 Ok((cur, final_meta))
1481}
1482
1483/// Write `bytes` to `path` through an exclusive temp sibling and a
1484/// rename. The parent already exists — containment created it — and
1485/// one that vanished since is recreated on the retry path in `safefs`,
1486/// so the common path pays no `mkdir` and no `stat` for it. Returns
1487/// the written file's metadata.
1488pub(super) fn write_file_atomic(
1489 path: &Path,
1490 bytes: &[u8],
1491 mode: Option<u32>,
1492) -> LeanResult<std::fs::Metadata> {
1493 super::safefs::check_parent(path)?;
1494 // NOT with_extension(): that REPLACES the final extension, so
1495 // "a.txt" and "a.md" would collide on one tmp name.
1496 let tmp = path.with_file_name(format!(
1497 "{}.flint-sync-tmp",
1498 path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
1499 ));
1500 // The temp sibling is computed AFTER containment ran, so the walk
1501 // never saw it: it needs its own refusal, not a plain write.
1502 // FAST (no per-file fsync): materialisations are made durable by
1503 // `sync_tree` before the marker or baseline that vouches for them.
1504 super::safefs::write_via_tmp_fast(path, &tmp, bytes, mode)
1505}