flint_lean/checkout.rs
1//! Checkout + the restart matrix (plan §2.1).
2//!
3//! | State on wake | Action |
4//! |--------------------------|-----------------------------------------|
5//! | No marker, empty tree | full checkout |
6//! | No marker, partial tree | resume (local-wins skips present paths) |
7//! | Marker present | NEVER re-materialize: reload baseline, |
8//! | | rescan rebuilds dirt, lease self- |
9//! | | recognizes via the persisted id |
10//!
11//! Re-checkout over a live tree is forbidden: local-wins protects only
12//! PRESENT paths, so it would resurrect the agent's unpublished deletes
13//! (`LeanRematerialize.cfg` rediscovers exactly that). The marker is
14//! written LAST — it is the agent-start gate.
15
16use std::collections::BTreeSet;
17
18use flint_store::StoreError;
19
20use super::barrier::{contained_path, contained_path_stat, mtime_nanos_of, mtime_of, write_file_atomic};
21use super::manifest;
22use super::state::BaselineEntry;
23use super::{LeanError, LeanResult, Syncer};
24
25#[derive(Debug, Default)]
26pub struct CheckoutReport {
27 pub materialized: usize,
28 pub skipped_present: usize,
29 pub bytes: u64,
30 /// Restart-matrix row taken.
31 pub resumed_live_tree: bool,
32 /// Citations left in place rather than materialized (D0.3 legacy
33 /// `.flint/` paths, containment refusals). Never a silent drop:
34 /// each has a conflict record.
35 pub refused: usize,
36 /// Phase attribution for the agent-blocking path. Without these the
37 /// checkout wall clock cannot be split between the manifest GET,
38 /// the fan-out fetch window, and the local commit — which is the
39 /// one fact any read-path decision here rests on.
40 pub manifest_secs: f64,
41 pub fetch_secs: f64,
42 pub commit_secs: f64,
43 /// The admitted set this checkout was scoped to, or `None` for the
44 /// whole manifest.
45 pub scope: Option<Vec<String>>,
46 /// Citations the scope DECLINED. Zero for an unscoped checkout, and
47 /// the number that says a scope did anything at all: a scope that
48 /// admits everything reads exactly like no scope.
49 pub out_of_scope: usize,
50 /// Objects materialised through the RANGED path. On a cluster
51 /// nothing else can tell "ranging did not help" from "ranging did
52 /// not run" — the knob is a threshold and a threshold that never
53 /// fires reads exactly like an optimisation that does not work.
54 pub ranged: usize,
55}
56
57/// Materialise one object as PARALLEL RANGES, writing each at its
58/// offset as it lands.
59///
60/// Returns `Ok(None)` when the caller should fall through to the
61/// whole-object arm — either because ranging does not apply, or because
62/// the store answered with one of the two POLICY-BEARING errors. That
63/// second case is deliberate: `PreconditionFailed` and `NotFound` mean
64/// two different things here depending on `sole_writer`,
65/// and all three refusals are written once, in the whole-object arm
66/// below. Re-deciding them here would put the same policy in two
67/// places, which is how the two drift apart. The cost is one wasted
68/// request on a path that is already an incident.
69///
70/// Every other error propagates after the per-range retries: a transport
71/// failure is a failure, not a quiet fallback that would hide a broken
72/// network behind a slower code path.
73async fn fetch_ranged(
74 store: &std::sync::Arc<dyn flint_store::ObjectStore>,
75 key: &str,
76 etag: &str,
77 size: u64,
78 target: &std::path::Path,
79 tmp: &std::path::Path,
80 mode: Option<u32>,
81 chunk_bytes: u64,
82 parallelism: usize,
83 expect_crc: u64,
84) -> LeanResult<Option<u64>> {
85 use futures::stream::StreamExt;
86
87 /// A range that failed for a reason the caller must distinguish.
88 enum RangeFail {
89 /// 412/404 — the whole-object arm owns this policy.
90 Fallback,
91 Fatal(LeanError),
92 }
93
94 const RANGE_RETRIES: u32 = 3;
95 let chunk_bytes = chunk_bytes.max(1);
96 let mut parts: Vec<(u64, u64)> = Vec::new();
97 let mut at = 0u64;
98 while at < size {
99 let len = chunk_bytes.min(size - at);
100 parts.push((at, len));
101 at += len;
102 }
103 if parts.len() < 2 {
104 // One range is one whole GET with extra steps.
105 return Ok(None);
106 }
107
108 // SHARED so each range writes ITSELF, on the blocking pool. Before
109 // this the write ran on the task that polls the fan-out, so a pwrite
110 // of one range stalled the FETCH of every other range in flight —
111 // concurrency that could never overlap I/O with I/O. Measured on
112 // i4i.large against an in-memory S3: system time beat user time 6:1
113 // and the process never exceeded 1.31 of 2 cores, flat from 4
114 // streams to 32.
115 let sink = std::sync::Arc::new(super::safefs::RangedTmp::create(target, tmp, size, mode)?);
116 let mut ranges = futures::stream::iter(parts.into_iter().map(|(off, len)| {
117 let store = store.clone();
118 let key = key.to_string();
119 let etag = etag.to_string();
120 let sink = sink.clone();
121 async move {
122 let mut attempt: u32 = 0;
123 let b = loop {
124 // EVERY range carries the same If-Match, which is what
125 // makes the assembled file one object rather than a
126 // splice of two: a write between range 3 and range 4
127 // fails range 4 instead of silently interleaving
128 // generations.
129 match store.get_range_segments(&key, off, len, &etag).await {
130 Ok(b) => break b,
131 Err(StoreError::PreconditionFailed(_)) | Err(StoreError::NotFound(_)) => {
132 return Err(RangeFail::Fallback)
133 }
134 Err(_) if attempt < RANGE_RETRIES => {
135 attempt += 1;
136 // The retry is per RANGE: a cut connection at
137 // range N of a multi-GiB object must not throw
138 // away N ranges of progress.
139 tokio::time::sleep(std::time::Duration::from_millis(
140 300 * u64::from(attempt),
141 ))
142 .await;
143 }
144 Err(e) => {
145 return Err(RangeFail::Fatal(LeanError::State(format!(
146 "get_range {key} at {off}+{len} after {RANGE_RETRIES} retries: {e}"
147 ))))
148 }
149 }
150 };
151 // The SDK's own frames, never flattened into one buffer.
152 // Summed rather than `b.is_empty()`: an empty range can also
153 // arrive as one zero-length segment, which is the same hole.
154 let n: u64 = b.iter().map(|seg| seg.len() as u64).sum();
155 // Checked HERE rather than at the drain: a hole must be
156 // refused before it is written, not after.
157 if n == 0 {
158 return Err(RangeFail::Fatal(LeanError::State(format!(
159 "get_range {key} at {off} returned an empty range before the object's \
160 end — refusing a hole"
161 ))));
162 }
163 let crc = tokio::task::spawn_blocking(move || {
164 // Segments are contiguous and in order within the range,
165 // so each lands at the running offset. The file is
166 // positional throughout, so siblings still cannot race.
167 // The range's CRC-64 is taken here, on the same pass, so
168 // the drain can fold the ranges in offset order and
169 // compare the whole against the manifest before the
170 // rename makes the file visible.
171 let mut h = flint_store::Crc64Nvme::new();
172 let mut at = off;
173 for seg in &b {
174 sink.write_at(at, seg)?;
175 h.update(seg);
176 at += seg.len() as u64;
177 }
178 Ok::<u64, LeanError>(h.finalize())
179 })
180 .await
181 .map_err(|e| {
182 RangeFail::Fatal(LeanError::State(format!(
183 "write task for {key} at {off} did not complete: {e}"
184 )))
185 })?
186 .map_err(RangeFail::Fatal)?;
187 Ok((off, n, crc))
188 }
189 }))
190 .buffer_unordered(parallelism.max(1));
191
192 let mut bytes = 0u64;
193 let mut crcs: Vec<(u64, u64, u64)> = Vec::new();
194 while let Some(next) = ranges.next().await {
195 match next {
196 Ok((off, n, crc)) => {
197 bytes += n;
198 crcs.push((off, n, crc));
199 }
200 Err(RangeFail::Fallback) => {
201 drop(ranges);
202 let _ = std::fs::remove_file(tmp);
203 return Ok(None);
204 }
205 Err(RangeFail::Fatal(e)) => {
206 drop(ranges);
207 let _ = std::fs::remove_file(tmp);
208 return Err(e);
209 }
210 }
211 }
212 // Every range future is finished and dropped, so this is the last
213 // reference. try_unwrap rather than a clone-tolerant commit: if a
214 // writer were somehow still alive, renaming now would publish a
215 // file still being written into.
216 drop(ranges);
217 let sink = std::sync::Arc::try_unwrap(sink).map_err(|_| {
218 LeanError::State(format!(
219 "ranged materialisation of {}: a range writer outlived the fan-out",
220 target.display()
221 ))
222 })?;
223 // The whole object's CRC-64 from its ranges: `crc64_combine`
224 // composes A-then-B, so the fold runs in offset order whatever
225 // order the ranges landed in. Checked BEFORE the rename, for the
226 // same reason a hole is: a wrong file must never become visible.
227 // Unconditional: this arm fetches only under If-Match on the cited
228 // etag, so the bytes are always the manifest's to describe.
229 crcs.sort_unstable_by_key(|(off, _, _)| *off);
230 let mut folded: Option<u64> = None;
231 for (_, len, crc) in &crcs {
232 folded = Some(match folded {
233 None => *crc,
234 Some(acc) => flint_store::crc64_combine(acc, *crc, *len),
235 });
236 }
237 if folded != Some(expect_crc) {
238 drop(sink);
239 let _ = std::fs::remove_file(tmp);
240 return Err(LeanError::State(format!(
241 "manifest cites {key} at etag {etag} with CRC-64 {}, but the {} ranges fetched \
242 under that etag fold to {} — the object is corrupt or the store returned the \
243 wrong bytes; refusing to materialise {} (nothing renamed into place)",
244 flint_store::crc64_to_b64(expect_crc),
245 crcs.len(),
246 folded.map(flint_store::crc64_to_b64).unwrap_or_else(|| "nothing".into()),
247 target.display()
248 )));
249 }
250 sink.commit()?;
251 Ok(Some(bytes))
252}
253
254/// CRC-64/NVME of a local file, in the same base64 form the manifest
255/// carries. Streamed: a resumed checkout may be verifying a 20 GiB
256/// workspace and must not hold it in memory.
257fn local_crc64_b64(path: &std::path::Path) -> Option<String> {
258 use std::io::Read;
259 let mut f = std::fs::File::open(path).ok()?;
260 let mut h = flint_store::Crc64Nvme::new();
261 let mut buf = vec![0u8; 1 << 20];
262 loop {
263 let n = f.read(&mut buf).ok()?;
264 if n == 0 {
265 break;
266 }
267 h.update(&buf[..n]);
268 }
269 Some(flint_store::crc64_to_b64(h.finalize()))
270}
271
272/// One admitted citation's outcome from the fan-out materializer.
273 struct Fetched {
274 /// Took the ranged path (report-only).
275 ranged: bool,
276 path: String,
277 be: Option<BaselineEntry>,
278 skipped: bool,
279 bytes: u64,
280 /// A citation this checkout refused to materialize (D0.3 /
281 /// containment): left cited, surfaced as a conflict.
282 refused: Option<String>,
283 }
284
285/// Names a scope for a human in an error: the whole manifest, or the
286/// entries themselves. The entries matter — "a different scope" sends
287/// the reader to go and diff two things they cannot see.
288fn describe_scope(s: Option<&[String]>) -> String {
289 match s {
290 None => "UNSCOPED (the whole manifest)".to_string(),
291 Some(e) => format!("scoped to {:?}", e),
292 }
293}
294
295impl Syncer {
296 /// Materialize an ADMITTED SET under a bounded fan-out window:
297 /// each entry's guarded fetch + atomic write is independent (the
298 /// 0b rig measured the sequential loop at ~1,000-2,000 files/s and
299 /// 3.3 s/GiB; fan-out multiplies directly against both).
300 ///
301 /// Takes the admitted set rather than the manifest, so the one
302 /// decision about WHICH citations get materialized lives at the call
303 /// site and every caller inherits the same LPT ordering, the same
304 /// in-flight byte bound, and the same D0.3 refusals. A caller
305 /// that admits a subset owes its own budget arithmetic over that
306 /// subset — this function refuses nothing on size.
307 async fn materialize<'a>(
308 &'a self,
309 mut admission: Vec<(&'a String, &'a super::manifest::LeanEntry)>,
310 sole_writer: bool,
311 ) -> Vec<LeanResult<Fetched>> {
312 // LARGEST FIRST. `m.entries` is a BTreeMap, so iterating it
313 // admits in PATH order, which appends the biggest object's
314 // transfer to the tail of the fan-out window: a multi-GiB
315 // checkpoint whose name sorts late is a whole transfer of pure
316 // makespan after every other slot has drained. Longest-
317 // processing-time-first bounds that at (4/3 - 1/3k) x optimal.
318 // `size` is already in the entry, so this costs no request and
319 // one sort; on a size-uniform tree it is exactly a no-op.
320 //
321 // Nothing downstream may depend on admission order:
322 // `buffer_unordered` already yields in COMPLETION order, and the
323 // budget refusals above ran over the whole map before this.
324 admission.sort_unstable_by(|a, b| b.1.size.cmp(&a.1.size).then_with(|| a.0.cmp(b.0)));
325 // The in-flight BYTE bound (see `fetch_inflight_max_bytes`).
326 // Permits are 1 MiB units; an entry larger than the whole budget
327 // clamps to the budget rather than deadlocking on a permit count
328 // the semaphore can never grant.
329 const FETCH_UNIT: u64 = 1 << 20;
330 let budget_units =
331 (self.cfg.fetch_inflight_max_bytes / FETCH_UNIT).clamp(1, u32::MAX as u64) as u32;
332 let gate = std::sync::Arc::new(tokio::sync::Semaphore::new(budget_units as usize));
333 use futures::stream::{self, StreamExt};
334 let this: &super::Syncer = self;
335 let range_min = this.cfg.range_get_min_bytes;
336 let range_chunk = this.cfg.range_get_chunk_bytes;
337 let range_par = this.cfg.range_get_parallelism;
338 // SHARDED DRIVERS. Every fetch future used to be polled by ONE
339 // `buffer_unordered` task on the block_on thread, so each
340 // request's SDK work (build, sign, orchestrate, parse, collect,
341 // checksum) ran on one thread whatever `fanout` said: the main
342 // thread saturated at ~8,000 files/s on a 2-vCPU VM with half a
343 // core idle. Spawning every fetch as its own task fixed that and
344 // then lost to the single driver on a 6-vCPU loopback rig (7.6k
345 // vs 11.4k) — with no RTT to hide behind, a wake per response
346 // per task costs more than the parallelism buys.
347 //
348 // So: `fetch_drivers` driver tasks, each running its own
349 // `buffer_unordered` over a round-robin slice of the (already
350 // largest-first) admission list. A driver's fetches stay on one
351 // task — one wake path, the batching the single driver had —
352 // and the per-request work spreads over that many cores.
353 let drivers = this.cfg.fetch_drivers.max(1);
354 let per_driver = (this.cfg.fanout.max(1) / drivers).max(1);
355 let mut shards: Vec<Vec<(String, super::manifest::LeanEntry)>> =
356 (0..drivers).map(|_| Vec::new()).collect();
357 for (i, (path, entry)) in admission.into_iter().enumerate() {
358 shards[i % drivers].push((path.clone(), entry.clone()));
359 }
360 let store_all = this.store.clone();
361 let root_all = this.cfg.root.clone();
362 let mut set = tokio::task::JoinSet::new();
363 for shard in shards {
364 let store_d = store_all.clone();
365 let root_d = root_all.clone();
366 let gate_d = gate.clone();
367 set.spawn(async move {
368 stream::iter(shard.into_iter().map(|(path, entry)| {
369 let store = store_d.clone();
370 let root = root_d.clone();
371 let local = root_d.join(&path);
372 let gate = gate_d.clone();
373 // THE CHARGE MUST DESCRIBE THE PATH THAT WILL RUN.
374 //
375 // `fetch_inflight_max_bytes` is a bound on BYTES HELD IN
376 // MEMORY, and the two arms hold wildly different amounts.
377 // `get_whole` returns the entire object, so charging
378 // `entry.size` is exactly right for it. The ranged arm does
379 // not: it is `buffer_unordered(range_par)` over per-chunk
380 // `get_range` calls, each written at its offset as it
381 // lands, so it holds `range_par * range_chunk` — 64 MiB at
382 // the shipped defaults — WHATEVER the object's size.
383 //
384 // Charging a ranged 1 GiB fetch 1 GiB of a 512 MiB budget
385 // therefore mis-states its footprint by ~16x, and the
386 // `clamp` turns that into the pathology: an entry at or
387 // over the budget takes the WHOLE semaphore, so object
388 // concurrency collapses to ONE and `fanout` is dead code.
389 // On a 6 x 1 GiB tree that leaves 4 streams on a 25 Gbps
390 // link — measured at 325 MiB/s, 11% of the guarantee, and
391 // identical on a node with 12x the vCPU and 32x the
392 // bandwidth. The bound was never the memory it names.
393 let ranged_eligible = range_min > 0 && entry.size >= range_min;
394 let whole_units =
395 entry.size.div_ceil(FETCH_UNIT).clamp(1, budget_units as u64) as u32;
396 let ranged_units = (range_par.max(1) as u64)
397 .saturating_mul(range_chunk.max(1))
398 .min(entry.size)
399 .div_ceil(FETCH_UNIT)
400 .clamp(1, budget_units as u64) as u32;
401 let want = if ranged_eligible { ranged_units } else { whole_units };
402 async move {
403 // Held until this entry's bytes have reached disk.
404 let mut permit = gate
405 .clone()
406 .acquire_many_owned(want)
407 .await
408 .map_err(|_| LeanError::State("fetch budget closed".into()))?;
409 // D0.3: a legacy `files/.flint/...` citation is
410 // never materialized (it would collide with the
411 // control files); it stays cited and a conflict
412 // record names it. Same arm refuses a citation
413 // whose path escapes the workspace.
414 // The walk hands back the final component's lstat as
415 // well, which is the resume check's whole question.
416 let (target, present) = match contained_path_stat(&root, &path) {
417 Ok(t) => t,
418 Err(e) => {
419 return Ok(Fetched {
420 ranged: false,
421 path: path.clone(),
422 be: None,
423 skipped: true,
424 bytes: 0,
425 refused: Some(e.to_string()),
426 })
427 }
428 };
429 if let Some(st) = present {
430 // Resume: a present path is one THIS checkout
431 // already fetched, so re-downloading it would
432 // pay bucket GETs for bytes that are on disk.
433 //
434 // But only if it is the same bytes. A checkout
435 // that died halfway leaves generation N on
436 // disk, and the manifest can MOVE before the
437 // replacement pod resumes (a HITL write, a
438 // sibling's barrier — routine on this fleet).
439 // Adopting then would stamp the baseline with
440 // the NEW entry's etag over the OLD content:
441 // the scan reads the file as clean and never
442 // uploads it, a sync reads baseline == manifest
443 // and never re-fetches it, and the workspace
444 // holds bytes nothing will ever reconcile. The
445 // divergence is silent and permanent.
446 //
447 // The check is local-only: size from the stat
448 // the walk already took, then crc of the local
449 // file. No bucket request either way — which is
450 // why it can be unconditional rather than a knob.
451 let same = st.len() == entry.size
452 && local_crc64_b64(&local)
453 .map(|got| got == entry.crc64_b64)
454 .unwrap_or(false);
455 if same {
456 return Ok(Fetched {
457 ranged: false,
458 path: path.clone(),
459 be: Some(BaselineEntry {
460 etag: entry.etag.clone(),
461 generation: entry.generation,
462 size: st.len(),
463 mtime_unix: mtime_of(&st),
464 mtime_nanos: Some(mtime_nanos_of(&st)),
465 crc64_b64: Some(entry.crc64_b64.clone()),
466 }),
467 skipped: true,
468 bytes: 0,
469 refused: None,
470 });
471 }
472 // Fall through and re-materialize.
473 }
474 // RANGED FIRST, when the object is big enough to
475 // pay for it.
476 let ranged_bytes = if range_min > 0 && entry.size >= range_min {
477 let tmp = target.with_file_name(format!(
478 "{}.flint-sync-tmp",
479 target.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
480 ));
481 let want = flint_store::crc64_from_b64(&entry.crc64_b64).ok_or_else(|| {
482 LeanError::State(format!(
483 "manifest cites {} with a CRC-64 that is not one: {:?}",
484 entry.key, entry.crc64_b64
485 ))
486 })?;
487 fetch_ranged(
488 &store,
489 &entry.key,
490 &entry.etag,
491 entry.size,
492 &target,
493 &tmp,
494 Some(entry.mode),
495 range_chunk,
496 range_par,
497 want,
498 )
499 .await?
500 } else {
501 None
502 };
503 if let Some(n) = ranged_bytes {
504 let st = std::fs::metadata(&local)?;
505 return Ok(Fetched {
506 ranged: true,
507 path: path.clone(),
508 be: Some(BaselineEntry {
509 etag: entry.etag.clone(),
510 generation: entry.generation,
511 size: st.len(),
512 mtime_unix: mtime_of(&st),
513 mtime_nanos: Some(mtime_nanos_of(&st)),
514 // The fold above equalled it, or we would
515 // not be here.
516 crc64_b64: Some(entry.crc64_b64.clone()),
517 }),
518 skipped: false,
519 bytes: n,
520 refused: None,
521 });
522 }
523
524 // TOP UP BEFORE BUFFERING THE WHOLE OBJECT.
525 //
526 // Reaching here having been charged the ranged rate
527 // means `fetch_ranged` declined — either the object is
528 // one chunk (harmless: it holds less than the ranged
529 // charge) or it hit the 412/404 policy fallback, which
530 // hands the whole object to `get_whole` below. In that
531 // second case we are about to hold `entry.size` while
532 // charged for 64 MiB, and N concurrent fallbacks would
533 // walk RSS straight through the bound this semaphore
534 // exists to enforce.
535 //
536 // Released BEFORE re-acquiring, never upgraded in
537 // place: holding `want` while waiting for
538 // `whole_units` is a deadlock the moment two entries
539 // do it at once against a full budget.
540 if want < whole_units {
541 drop(permit);
542 permit = gate
543 .clone()
544 .acquire_many_owned(whole_units)
545 .await
546 .map_err(|_| LeanError::State("fetch budget closed".into()))?;
547 }
548 let _permit = permit;
549
550 // `cited` is whether these are the bytes the manifest
551 // describes — every arm but S3-wins adoption.
552 let ((meta, body), cited) = match store.get_whole(&entry.key, Some(&entry.etag)).await {
553 Ok(ok) => (ok, true),
554 Err(StoreError::PreconditionFailed(_)) if sole_writer => {
555 // Nothing was staged here, the citation is
556 // intact, and the thing to go and find is
557 // the second writer.
558 return Err(LeanError::State(format!(
559 "manifest cites {} at an etag the object no longer \
560 carries, and this workspace is published by a SOLE \
561 WRITER — so something other than its publisher wrote \
562 that object. Refusing to adopt bytes no manifest \
563 cites. If this is forge's legible export, look for a \
564 read-write mount over its prefix; the export \
565 republishes only what git changed and will not repair \
566 this on its own",
567 entry.key
568 )));
569 }
570 Err(StoreError::PreconditionFailed(_)) => {
571 // S3-wins: the object moved past the
572 // manifest (a HITL write not yet
573 // re-cited). Adopt the CURRENT version
574 // — its inbox entry reconciles the
575 // manifest at the next barrier. Reached
576 // only for cadence/hybrid/legacy
577 // manifests, so the shipped
578 // `hitl_upload_survives_two_barriers`
579 // behaviour is untouched in the default
580 // mode.
581 (store.get_whole(&entry.key, None).await?, false)
582 }
583 Err(StoreError::NotFound(_)) => {
584 return Err(LeanError::State(format!(
585 "manifest cites {} but the object is gone — refusing a \
586 silent hole (mixed-writer bucket?)",
587 entry.key
588 )));
589 }
590 Err(e) => return Err(e.into()),
591 };
592 // OFF the fan-out task, for the same reason the ranged arm
593 // is: write_file_atomic is a blocking create+write+fsync+
594 // rename, and running it inline here froze every other
595 // in-flight fetch sharing this task. Small files ALL come
596 // through this arm — 8 KiB is far below the 16 MiB
597 // effective ranged threshold — which is why raising fanout
598 // stopped paying above 128 with half a core sitting idle:
599 // 3,200 files/s at 1.29 of 2 cores, flat from 128 to 512.
600 //
601 // And VERIFIED against the manifest before it is written.
602 // This is the one integrity check that does not depend on
603 // the backend: S3 returns a checksum header the SDK
604 // validates, Ozone returns no CRC-64 at all so the SDK
605 // validates nothing, and a body that is wrong under the
606 // cited etag — bit-rot, a broken gateway, a cache serving
607 // the wrong object — passes If-Match either way. Until
608 // 2026-09-12 the manifest's CRC was compared only on the
609 // RESUME path, so a corrupt fresh fetch was written, cited
610 // in the baseline and read by the agent as the file.
611 // Adopted (uncited) bytes are not the manifest's to
612 // describe: they are checked against the backend's own
613 // attestation when it offers one, and hashed either way,
614 // because the baseline records the CRC of what was
615 // written and the next barrier's citation repair cites
616 // THAT — never a HEAD's, which Ozone does not return.
617 let expect_crc =
618 if cited { Some(entry.crc64_b64.clone()) } else { meta.crc64_b64.clone() };
619 let (st, got) = {
620 let target_w = target.clone();
621 let body_w = body.clone(); // Bytes: a refcount bump, not a copy
622 let mode_w = entry.mode;
623 let key_err = entry.key.clone();
624 let key_crc = entry.key.clone();
625 let etag_err = entry.etag.clone();
626 let rel_err = path.clone();
627 tokio::task::spawn_blocking(
628 move || -> LeanResult<(std::fs::Metadata, String)> {
629 let got = flint_store::crc64_to_b64(flint_store::crc64_nvme(&body_w));
630 if let Some(want) = expect_crc {
631 if got != want {
632 let who = if cited {
633 format!("manifest cites {rel_err} ({key_crc} at etag {etag_err}) with")
634 } else {
635 format!("the store attests {rel_err} ({key_crc} at etag {etag_err}) with")
636 };
637 return Err(LeanError::State(format!(
638 "{who} CRC-64 {want}, but the bytes fetched under that \
639 etag hash to {got} — the object is corrupt or the store \
640 returned the wrong bytes; refusing to materialise it \
641 (nothing written)"
642 )));
643 }
644 }
645 let st = write_file_atomic(&target_w, &body_w, Some(mode_w))?;
646 Ok((st, got))
647 },
648 )
649 .await
650 .map_err(|e| {
651 LeanError::State(format!(
652 "write task for {key_err} did not complete: {e}"
653 ))
654 })??
655 };
656 Ok(Fetched {
657 ranged: false,
658 path: path.clone(),
659 be: Some(BaselineEntry {
660 etag: meta.etag.clone(),
661 generation: entry.generation,
662 size: st.len(),
663 mtime_unix: mtime_of(&st),
664 mtime_nanos: Some(mtime_nanos_of(&st)),
665 crc64_b64: Some(got),
666 }),
667 skipped: false,
668 bytes: body.len() as u64,
669 refused: None,
670 })
671 }
672 }))
673 .buffer_unordered(per_driver)
674 .collect::<Vec<LeanResult<Fetched>>>()
675 .await
676 });
677 }
678 let mut out = Vec::new();
679 while let Some(r) = set.join_next().await {
680 match r {
681 Ok(v) => out.extend(v),
682 Err(e) => out.push(Err(LeanError::State(format!(
683 "fetch driver did not complete: {e}"
684 )))),
685 }
686 }
687 out
688 }
689
690 /// Materialize the workspace from the manifest. Idempotent across
691 /// crashes (resume skips present paths); refuses over budget
692 /// BEFORE the first byte.
693 pub async fn checkout(&mut self) -> LeanResult<CheckoutReport> {
694 self.checkout_scoped(None).await
695 }
696
697 /// Materialize only the citations an explicit scope admits.
698 ///
699 /// The safety argument is `classify` (`scan.rs`), which derives
700 /// deletions by iterating `baseline.entries.keys()`: a path this
701 /// checkout never materialized is never cited, so it can never be
702 /// classified absent and can never have its object DELETEd. That is
703 /// why no scope filter is needed in the barrier, the sync or the
704 /// delete sites — the admission filter here IS the whole argument,
705 /// and four copies of a rule are four places for it to drift.
706 ///
707 /// What a scoped workspace does NOT get is a frozen held set. A path
708 /// outside the scope that changes REMOTELY arrives through the inbox
709 /// and enters the baseline, and from then on it is owned like any
710 /// other — probe P1/P3 of 2026-09-11 measured both halves. There is
711 /// no verb to shed it again, and no verb to ask for a path the
712 /// remote never touched. Scoping a checkout is therefore a bet that
713 /// the admitted set is the set the agent needs for its whole life.
714 /// Name the right culprit when a fetch could not be served.
715 ///
716 /// Two of `materialize`'s refusals — the SOLE WRITER 412 and the
717 /// cited-object-is-gone 404 — read the same
718 /// evidence ("this object is not what the manifest said") and
719 /// accuse a stranger of writing the bucket. There is a second
720 /// explanation, and after 2026-09-11 it is the LIKELIER one:
721 /// `checkout` no longer holds the epoch, so the workspace's own
722 /// publisher may have published a new generation between the
723 /// manifest we loaded and the object we asked for. A reader is then
724 /// citing a manifest one generation stale, and every word of
725 /// "something other than its publisher wrote that object" is false.
726 ///
727 /// One GET of the pointer tells them apart, and it is paid only on
728 /// a path that is already returning an error. A pointer that has
729 /// not moved leaves the original refusal exactly as written — the
730 /// stranger accusation is still the right one, and softening it for
731 /// every caller to buy this would be the worse trade.
732 async fn say_who_moved_it(&self, read_seq: u64, e: LeanError) -> LeanError {
733 let now = match manifest::load_pointer(self.store.as_ref(), &self.cfg).await {
734 Ok(Some(p)) => p.pointer.seq,
735 // No pointer, or we cannot read one. Either way we have
736 // learned nothing, so we must not overwrite what we know.
737 Ok(None) | Err(_) => return e,
738 };
739 if now <= read_seq {
740 return e;
741 }
742 LeanError::State(format!(
743 "this checkout read manifest seq {read_seq} and the workspace is now at \
744 seq {now}: its publisher published while the checkout was running, so \
745 the citation it was still fetching is one generation stale. Re-run \
746 checkout. (The underlying refusal, which names a stranger and in this \
747 case means nothing of the kind: {e})"
748 ))
749 }
750
751 pub async fn checkout_scoped(
752 &mut self,
753 scope: Option<Vec<String>>,
754 ) -> LeanResult<CheckoutReport> {
755 // Validated BEFORE the live-tree row, because a malformed scope
756 // is a configuration error whether or not this tree is already
757 // up, and the one thing it must never do is quietly widen.
758 // `Scope::new` silently drops entries that are too long, past
759 // MAX_SCOPE_ENTRIES, or contain `.`/`..`; three malformed paths
760 // normalize to an EMPTY scope, and an empty scope reads
761 // everywhere as "no restriction" = the whole manifest. Same rule
762 // and same shape as `sync_scoped`.
763 let scope = match scope {
764 None => None,
765 Some(raw) => {
766 let s = super::sync::Scope::new(&raw);
767 if s.is_empty() {
768 return Err(LeanError::State(format!(
769 "checkout scope named {} entr{} and NONE survived validation — \
770 refusing, because an empty scope widens to the WHOLE MANIFEST",
771 raw.len(),
772 if raw.len() == 1 { "y" } else { "ies" }
773 )));
774 }
775 Some(s)
776 }
777 };
778 let requested: Option<Vec<String>> = scope.as_ref().map(|s| s.entries().to_vec());
779
780 let mut report = CheckoutReport::default();
781 report.scope = requested.clone();
782 if self.state.marker_present() {
783 // The live-tree row: never re-materialize. But a live tree
784 // holds what it holds, and `checkout` cannot change that —
785 // so a caller whose scope disagrees with the one on disk
786 // gets an error, never a success naming a set it did not
787 // get. The dangerous direction is the quiet one: a caller
788 // that asks for the whole tree and resumes a 3-file
789 // workspace believes it is holding 2001 paths.
790 let held = self.state.load_scope()?;
791 if held != requested {
792 return Err(LeanError::State(format!(
793 "this workspace is already checked out {}, and the request asks for \
794 {} — checkout cannot change the admitted set of a live tree, and \
795 resuming it would return success for a set you did not get",
796 describe_scope(held.as_deref()),
797 describe_scope(requested.as_deref()),
798 )));
799 }
800 report.resumed_live_tree = true;
801 return Ok(report);
802 }
803
804 let t_start = std::time::Instant::now();
805 let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
806 report.manifest_secs = t_start.elapsed().as_secs_f64();
807 let mut baseline = self.state.load_baseline()?;
808 let (m, metag) = match loaded {
809 Some(l) => (l.manifest, Some(l.etag)),
810 None => (Default::default(), None),
811 };
812
813 // ADMISSION FIRST, budgets second. The order is load-bearing:
814 // a budget is a promise about what THIS checkout will write, and
815 // a 3-file scoped checkout summed over the whole 2001-file
816 // manifest is refused for bytes it was never going to fetch.
817 let admission: Vec<(&String, &super::manifest::LeanEntry)> = match &scope {
818 None => m.entries.iter().collect(),
819 Some(s) => m.entries.iter().filter(|(p, _)| s.covers(p)).collect(),
820 };
821 report.out_of_scope = m.entries.len() - admission.len();
822
823 // Budgets: refuse before materializing anything.
824 let total_bytes: u64 = admission.iter().map(|(_, e)| e.size).sum();
825 if self.cfg.max_bytes > 0 && total_bytes > self.cfg.max_bytes {
826 return Err(LeanError::Budget(format!(
827 "checkout is {} bytes; budget {}",
828 total_bytes, self.cfg.max_bytes
829 )));
830 }
831 if self.cfg.max_files > 0 && admission.len() as u64 > self.cfg.max_files {
832 return Err(LeanError::Budget(format!(
833 "checkout is {} files; budget {}",
834 admission.len(),
835 self.cfg.max_files
836 )));
837 }
838
839 let mut present: BTreeSet<String> = BTreeSet::new();
840 let t_fetch = std::time::Instant::now();
841 // A mirror's publisher is the only party entitled to write it,
842 // so an object off its citation was moved by a stranger.
843 // Adopting it would copy bytes no manifest cites into this
844 // tree, silently — drill C4.
845 let results = self.materialize(admission, m.sole_writer).await;
846 report.fetch_secs = t_fetch.elapsed().as_secs_f64();
847 let t_commit = std::time::Instant::now();
848 for r in results {
849 let f = match r {
850 Ok(f) => f,
851 Err(e) => return Err(self.say_who_moved_it(m.seq, e).await),
852 };
853 if let Some(why) = f.refused {
854 self.state.append_conflict(&super::state::ConflictRecord {
855 path: f.path.clone(),
856 foreign_etag: String::new(),
857 preserved_key: None,
858 kind: format!("checkout-refused: {why}"),
859 at_unix: super::now_unix(),
860 })?;
861 report.refused += 1;
862 continue;
863 }
864 if f.ranged {
865 report.ranged += 1;
866 }
867 if f.skipped {
868 report.skipped_present += 1;
869 } else {
870 report.materialized += 1;
871 report.bytes += f.bytes;
872 }
873 present.insert(f.path.clone());
874 if let Some(be) = f.be {
875 baseline.entries.insert(f.path, be);
876 }
877 }
878
879 baseline.seq = m.seq;
880 baseline.manifest_etag = metag;
881 // THE WHOLE MANIFEST, scope or no scope. `manifest.rs` reads an
882 // entry absent from the merge base as CHANGED
883 // (`base.get(p).map(..).unwrap_or(true)`), so an `inst_base`
884 // narrowed to the admitted set makes every unadmitted citation
885 // read as foreign, queue into the inbox, and land in the tree at
886 // the next barrier — a scoped checkout that downloads everything
887 // one barrier later. The constraint is not a refinement: the
888 // fast path and the safe path are the same line.
889 baseline.inst_base = m.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
890 baseline.prev_scan = present;
891 // Every materialised file reaches stable storage BEFORE the
892 // baseline and the marker that vouch for it: after a power loss
893 // the tree is then at least as durable as its description, so
894 // the next scan cannot read a zero-length survivor as a local
895 // edit and publish it over the good version.
896 self.state.sync_tree()?;
897 self.state.save_baseline(&baseline)?;
898 // Before the marker, always — including the `None` case, which
899 // REMOVES any scope a crashed predecessor left behind. The
900 // marker is the agent-start gate; a scope that landed after it
901 // would leave a window in which an agent is cleared to run
902 // against a tree whose admitted set is not yet durable, and a
903 // crash there yields a workspace holding three files that
904 // claims, by the absence of any scope, to hold all of them.
905 self.state.save_scope(requested.as_deref())?;
906 // D11: the capability marker and the gauges exist BEFORE the
907 // agent-start gate opens, so the first thing the agent does can
908 // be to read them. `run` writes capabilities around checkout
909 // too; doing it here as well covers the standalone `checkout`
910 // subcommand, which otherwise leaves an agent with no marker to
911 // read and therefore no way to know the verbs exist.
912 let posture = self.sentinel_preflight()?;
913 self.write_capabilities(&posture)?;
914 self.write_gauges(None)?;
915 // The marker is written LAST: the agent-start gate.
916 self.state.write_marker()?;
917 report.commit_secs = t_commit.elapsed().as_secs_f64();
918 Ok(report)
919 }
920}
921
922// ── the narrow / widen verb (scoped-read design §4) ──────────────────
923
924#[derive(Debug, Default)]
925pub struct RescopeReport {
926 /// The scope this workspace now holds. `None` = the whole tree.
927 pub target: Option<Vec<String>>,
928 /// Citations removed from the held set — the NARROW half.
929 pub uncited: usize,
930 /// Files removed from the tree. Normally equal to `uncited`; lower
931 /// when a path was already gone.
932 pub unlinked: usize,
933 /// Newly admitted citations fetched — the WIDEN half.
934 pub materialized: usize,
935 pub bytes: u64,
936 /// Admitted and already held: the scope grew over paths the
937 /// workspace had. Zero work, and the number that tells a widen that
938 /// did nothing from a widen that had nothing to do.
939 pub already_held: usize,
940 /// Paths that should have left the held set but carry unpublished
941 /// local changes. KEPT, cited, on disk, with a conflict record —
942 /// a narrow may unwatch a file, never discard an edit.
943 pub kept_dirty: Vec<String>,
944 /// This call finished a rescope a crash had left half-applied.
945 pub replayed: bool,
946}
947
948impl Syncer {
949 /// Move the workspace to `target`: stop holding what it no longer
950 /// admits, and fetch what it newly does.
951 ///
952 /// ## Why this cannot be `rm` plus a scope edit
953 ///
954 /// `classify` reads exactly the two states a narrow passes through:
955 /// present-in-scan-absent-from-baseline is an **upload**, and
956 /// present-in-baseline-absent-from-scan-and-`prev_scan` is a
957 /// **delete**. So unlink-then-uncite crashes into publishing
958 /// deletions, and uncite-then-unlink crashes into re-uploading
959 /// identical bytes and re-citing everything just dropped. Neither
960 /// order is safe alone. The invariant, stated for the model:
961 /// **a narrow is an unwatch, never an absence.**
962 ///
963 /// What makes it safe is the INTENT, not the order: the target is
964 /// durable before the first mutation and cleared after the last,
965 /// `run_barrier` replays any intent it finds before it does
966 /// anything else, and the replay is idempotent. The order within
967 /// still matters for the window where the intent itself is lost —
968 /// uncite first, so the failure mode is a file that gets
969 /// re-uploaded rather than one that gets DELETED from the bucket.
970 ///
971 /// ## Why the verb takes a SET
972 ///
973 /// `manifest::load` fetches the pointer and every chunk regardless
974 /// of scope, so the floor is one whole-manifest load per call.
975 /// A per-path verb would pay that floor per path.
976 pub async fn rescope(&mut self, target: Option<Vec<String>>) -> LeanResult<RescopeReport> {
977 if let Some(raw) = &target {
978 // An all-rejected scope is REFUSED, never widened to the
979 // whole tree — `Scope::new` silently drops malformed
980 // entries, and `sync.rs` already shipped the bug where that
981 // turned a typo into maximum privilege.
982 if super::sync::Scope::new(raw).is_empty() {
983 return Err(LeanError::State(format!(
984 "every entry of the requested scope was rejected ({raw:?}); refusing to \
985 read that as the whole tree"
986 )));
987 }
988 }
989 if !self.state.marker_present() {
990 return Err(LeanError::State(
991 "rescope needs a checked-out workspace — run checkout first".into(),
992 ));
993 }
994
995 // The strict check belongs at the DOOR, before anything is
996 // written: a caller asking to drop a path it has unpublished
997 // edits to gets told, and the old scope stands untouched. The
998 // replay below cannot be this strict — it must converge — so it
999 // keeps such paths instead. Two postures, deliberately.
1000 let leaving = self.paths_leaving(&target)?;
1001 let dirty = self.dirty_among(&leaving)?;
1002 if !dirty.is_empty() {
1003 return Err(LeanError::State(format!(
1004 "these paths would leave the scope but have unpublished local changes: {:?} — \
1005 publish or discard them first",
1006 dirty
1007 )));
1008 }
1009
1010 let intent = super::state::ScopeIntent { target, drop: leaving };
1011 self.state.save_scope_intent(&intent)?;
1012 self.apply_scope_intent(&intent).await
1013 }
1014
1015 /// Finish a rescope a crash left half-applied. `None` when there is
1016 /// none in flight. Idempotent: running it twice is running it once.
1017 pub async fn replay_scope_intent(&mut self) -> LeanResult<Option<RescopeReport>> {
1018 let Some(intent) = self.state.load_scope_intent()? else { return Ok(None) };
1019 let mut report = self.apply_scope_intent(&intent).await?;
1020 report.replayed = true;
1021 Ok(Some(report))
1022 }
1023
1024 /// Which held paths the target scope would stop admitting.
1025 fn paths_leaving(&self, target: &Option<Vec<String>>) -> LeanResult<Vec<String>> {
1026 let baseline = self.state.load_baseline()?;
1027 let scope = target.as_ref().map(|r| super::sync::Scope::new(r));
1028 Ok(baseline
1029 .entries
1030 .keys()
1031 .filter(|p| scope.as_ref().map(|s| !s.covers(p)).unwrap_or(false))
1032 .cloned()
1033 .collect())
1034 }
1035
1036 /// Which of `paths` carry local changes the bucket has not seen.
1037 /// The same three states `sync` calls dirty, for the same reason.
1038 fn dirty_among(&self, paths: &[String]) -> LeanResult<Vec<String>> {
1039 if paths.is_empty() {
1040 return Ok(vec![]);
1041 }
1042 let baseline = self.state.load_baseline()?;
1043 let scanned = super::scan::scan(&self.cfg.root)?;
1044 let c = super::scan::classify(&scanned, &baseline);
1045 Ok(paths
1046 .iter()
1047 .filter(|p| {
1048 c.uploads.contains(*p) || c.deletes.contains(*p) || c.first_absence.contains(*p)
1049 })
1050 .cloned()
1051 .collect())
1052 }
1053
1054 async fn apply_scope_intent(
1055 &mut self,
1056 intent: &super::state::ScopeIntent,
1057 ) -> LeanResult<RescopeReport> {
1058 let target = intent.target.clone();
1059 let mut report = RescopeReport { target: target.clone(), ..Default::default() };
1060 let scope = target.as_ref().map(|r| super::sync::Scope::new(r));
1061 let covered = |p: &str| scope.as_ref().map(|s| s.covers(p)).unwrap_or(true);
1062
1063 // §4.4's floor: the whole manifest, scope or no scope.
1064 let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
1065 let (m, metag) = match loaded {
1066 Some(l) => (l.manifest, Some(l.etag)),
1067 None => (Default::default(), None),
1068 };
1069
1070 let mut baseline = self.state.load_baseline()?;
1071 // THE RECORDED set, never re-derived — see `ScopeIntent::drop`.
1072 // Re-deriving from the baseline loses the whole set the moment
1073 // the uncite lands; re-deriving from the tree cannot tell a
1074 // leftover from a file the agent created.
1075 let leaving: Vec<String> = intent.drop.clone();
1076 // Converging posture: a dirty path is KEPT rather than dropped,
1077 // so a replay can never be stuck refusing forever.
1078 //
1079 // A path already uncited by a crashed run reads as dirty here
1080 // (present in scan, absent from the baseline ⇒ `uploads`), and
1081 // keeping it would undo the very step that crashed. So dirt is
1082 // judged against what the path IS: still cited and modified, or
1083 // not cited at all and therefore already half-dropped.
1084 let still_cited: Vec<String> =
1085 leaving.iter().filter(|p| baseline.entries.contains_key(*p)).cloned().collect();
1086 let dirty = self.dirty_among(&still_cited)?;
1087 for p in &dirty {
1088 self.state.append_conflict(&super::state::ConflictRecord {
1089 path: p.clone(),
1090 foreign_etag: String::new(),
1091 preserved_key: None,
1092 kind: "rescope-kept-locally-dirty".into(),
1093 at_unix: super::now_unix(),
1094 })?;
1095 }
1096 report.kept_dirty = dirty.clone();
1097 let drop_set: Vec<String> =
1098 leaving.into_iter().filter(|p| !dirty.contains(p)).collect();
1099
1100 // UNCITE FIRST, and durably, before a single file leaves the
1101 // tree. Both halves must happen, and the intent guarantees
1102 // they will; this order decides only which way a lost intent
1103 // fails — toward a file that reads as a local add (re-uploaded,
1104 // recoverable) rather than one that reads as a local delete
1105 // (published as a DELETE, not recoverable).
1106 for p in &drop_set {
1107 baseline.entries.remove(p);
1108 baseline.prev_scan.remove(p);
1109 }
1110 report.uncited = drop_set.len();
1111 self.state.save_baseline(&baseline)?;
1112
1113 for p in &drop_set {
1114 let local = self.cfg.root.join(p);
1115 // The same containment the barrier demands: a citation is
1116 // not a licence to unlink whatever a path resolves to.
1117 if contained_path(&self.cfg.root, p).is_err() {
1118 continue;
1119 }
1120 match std::fs::remove_file(&local) {
1121 Ok(()) => report.unlinked += 1,
1122 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1123 Err(e) => return Err(LeanError::Io(e)),
1124 }
1125 }
1126
1127 // WIDEN: admitted citations this workspace does not hold.
1128 let add: Vec<(&String, &super::manifest::LeanEntry)> =
1129 m.entries.iter().filter(|(p, _)| covered(p) && !baseline.entries.contains_key(*p)).collect();
1130 report.already_held =
1131 m.entries.keys().filter(|p| covered(p) && baseline.entries.contains_key(*p)).count();
1132 if !add.is_empty() {
1133 let results = self.materialize(add, m.sole_writer).await;
1134 for r in results {
1135 let f = r?;
1136 if let Some(why) = f.refused {
1137 self.state.append_conflict(&super::state::ConflictRecord {
1138 path: f.path.clone(),
1139 foreign_etag: String::new(),
1140 preserved_key: None,
1141 kind: format!("rescope-refused: {why}"),
1142 at_unix: super::now_unix(),
1143 })?;
1144 continue;
1145 }
1146 if !f.skipped {
1147 report.materialized += 1;
1148 report.bytes += f.bytes;
1149 }
1150 baseline.prev_scan.insert(f.path.clone());
1151 if let Some(be) = f.be {
1152 baseline.entries.insert(f.path, be);
1153 }
1154 }
1155 }
1156
1157 baseline.seq = m.seq;
1158 baseline.manifest_etag = metag;
1159 // C2, unchanged and non-negotiable: the merge base is the WHOLE
1160 // manifest. Narrow it with the held set and every unadmitted
1161 // citation reads as foreign at the next merge, queues into the
1162 // inbox, and lands in the tree one barrier later — a narrow
1163 // that downloads everything it just dropped.
1164 baseline.inst_base = m.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
1165
1166 self.state.sync_tree()?;
1167 self.state.save_baseline(&baseline)?;
1168 self.state.save_scope(target.as_deref())?;
1169 // LAST. While this document exists the workspace is mid-rescope
1170 // and `run_barrier` will replay before doing anything else.
1171 self.state.clear_scope_intent()?;
1172 Ok(report)
1173 }
1174}