flint_lean/manifest.rs
1//! The lean subtree manifest: one JSON document, CAS-written, the
2//! atomic commit point every reader trusts (checkout, sync, DR).
3//!
4//! Deliberately NOT `tier::manifest`: the hub's writer rebuilds the
5//! whole document from its local walk and cannot merge (its Entry has
6//! no author identity), which is the amputation engine the lean review
7//! reproduced three ways. This writer merges three-way:
8//! base = the manifest view at OUR last install, mine = this barrier's
9//! walk, theirs = bucket-current. Foreign entries are preserved and
10//! surfaced for the next consume; a local delete loses to a foreign
11//! modify (delete/modify resolves conservative — the formal model's
12//! GC-vs-merge counterexample is exactly this case).
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use serde::{Deserialize, Serialize};
17
18use flint_store::{
19 crc64_nvme, GenerationStamps, ObjectMeta, ObjectStore, PutCondition, StoreError,
20};
21
22use super::{LeanConfig, LeanResult};
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct LeanEntry {
26 /// Object key (under `<prefix>/files/`).
27 pub key: String,
28 pub etag: String,
29 /// Full-object CRC-64/NVME of the cited bytes, in wire (base64)
30 /// form — computed by the flint client that MOVED the bytes (the
31 /// publisher over what it uploaded, a consume or checkout over what
32 /// it wrote), never copied from a backend header. It is the one
33 /// integrity check that holds on every backend: S3 attests a
34 /// checksum on the wire, Ozone attests none, and the manifest's
35 /// value describes the bytes either way. Every reader verifies a
36 /// fresh fetch against it before the file becomes visible.
37 pub crc64_b64: String,
38 pub size: u64,
39 pub mode: u32,
40 pub mtime_unix: i64,
41 pub generation: u64,
42 /// The publishing writer's lease epoch (0 = a gateway/HITL write).
43 pub epoch: u64,
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct LeanManifest {
48 pub seq: u64,
49 /// path -> entry. Paths are workspace-relative, '/'-separated.
50 pub entries: BTreeMap<String, LeanEntry>,
51 /// This workspace is published by exactly ONE writer, so an object
52 /// that has moved off its citation was moved by something that is
53 /// not the publisher.
54 ///
55 /// It exists because a reader's default arm on a 412 is an
56 /// explicit S3-wins adopt — right for a lean workspace, where an
57 /// object past its citation means a human wrote newer bytes that
58 /// ought to win. On a MIRROR that reading is exactly inverted: the
59 /// publisher is the only party entitled to write, so "past the
60 /// citation" means a stranger did, and adopting copies bytes no
61 /// manifest cites into a reader's tree with no error and no
62 /// conflict record. Composition drill C4 measured that against
63 /// forge's legible export.
64 ///
65 /// Set by the installing pass from config, never inherited.
66 #[serde(default)]
67 pub sole_writer: bool,
68 /// Which citation source installed this manifest (§2.4.1). Also
69 /// stamped on the object's metadata so it is readable by HEAD.
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub boundary_source: Option<String>,
72}
73
74impl LeanManifest {
75 /// COMPACT, not pretty. This document is O(entries) and it moves on
76 /// every path that matters: the pre-marker checkout GET, every
77 /// barrier merge GET and CAS PUT, and every gateway read verb. The
78 /// indentation was ~22% of those bytes and of the parse that
79 /// follows. `mc cat | jq` — the operator's and every rig's only
80 /// debugging surface for the CAS commit point — is unaffected.
81 pub fn to_bytes(&self) -> Vec<u8> {
82 serde_json::to_vec(self).expect("manifest serializes")
83 }
84 pub fn parse(bytes: &[u8]) -> Result<LeanManifest, String> {
85 serde_json::from_slice(bytes).map_err(|e| e.to_string())
86 }
87}
88
89/// The bucket-current manifest + its document ETag (the CAS token).
90/// The pointer document at `<prefix>/.flint/lean/current` — the ONLY
91/// mutable metadata object under the pointer layout (design of record:
92/// `docs/plans/flint-lean-manifest-pointer-design.md`).
93///
94/// Everything a reader needs before it decides whether to fetch entries
95/// at all lives here, so the barrier's idle tick reads a few hundred
96/// bytes instead of a document that runs to 264 MiB at 1M entries.
97#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
98pub struct Pointer {
99 /// The manifest generation this pointer publishes. Bumped by a
100 /// citation AND by a takeover rotation, which is why it is not the
101 /// same number as `entries_seq`.
102 pub seq: u64,
103 /// The single immutable object holding every entry of this
104 /// generation — the step-one layout. `None` on a CHUNKED pointer.
105 ///
106 /// Optional here and REQUIRED in the step-one binary's schema, and
107 /// that asymmetry is the migration: a step-one reader handed a
108 /// chunked pointer fails to parse it and refuses, rather than
109 /// decoding a pointer it half-understands. `manifest::load` maps a
110 /// missing object to `Ok(None)`, `None` means FIRST WRITE, and a
111 /// barrier answers that with `If-None-Match: *` — so every layout
112 /// change has to make an old reader refuse rather than conclude the
113 /// project is empty. **Do not give this field a serde default in a
114 /// released binary; that is what makes the refusal work.**
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub entries_key: Option<String>,
117 /// The `seq` the entries object was written under. A rotation
118 /// leaves this ALONE while bumping `seq`, which is exactly what
119 /// lets a follower skip the entries GET after a takeover.
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub entries_seq: Option<u64>,
122 /// The chunk list — the step-two layout. `Some(vec![])` is a
123 /// legitimately EMPTY project and is distinguishable from absence,
124 /// which is why this is an Option around a Vec rather than a bare
125 /// Vec with a default.
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub chunks: Option<Vec<super::chunk::ChunkRef>>,
128 /// Carried here as well as on the manifest because the pointer is
129 /// the authority for the fields it carries, and a reader that
130 /// reconstructs a manifest from it must not lose the flag that
131 /// decides whether it may adopt.
132 #[serde(default)]
133 pub sole_writer: bool,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub boundary_source: Option<String>,
136 /// The epoch that installed this pointer. Diagnostic; the fence is
137 /// the CAS, not this field.
138 pub epoch: u64,
139}
140
141/// Where a pointer's entries actually live.
142#[derive(Debug)]
143pub enum Entries<'a> {
144 /// One generation object (step one).
145 Single { key: &'a str, seq: u64 },
146 /// A chunk list (step two). Empty = an empty project.
147 Chunked(&'a [super::chunk::ChunkRef]),
148}
149
150impl Pointer {
151 /// Resolve the layout, refusing anything ambiguous.
152 ///
153 /// BOTH forms present, or NEITHER, is malformed and must fail —
154 /// never pick one. A pointer carrying both would let two readers
155 /// that broke the tie differently disagree about the contents of
156 /// the same seq, which is the one thing a single visible object was
157 /// supposed to make impossible.
158 pub fn entries(&self) -> LeanResult<Entries<'_>> {
159 match (&self.entries_key, &self.chunks) {
160 (Some(k), None) => {
161 Ok(Entries::Single { key: k, seq: self.entries_seq.unwrap_or(self.seq) })
162 }
163 (None, Some(c)) => Ok(Entries::Chunked(c)),
164 (Some(_), Some(_)) => Err(super::LeanError::State(format!(
165 "manifest pointer at seq {} names BOTH a generation object and a chunk list; \
166 refusing to guess which describes this manifest",
167 self.seq
168 ))),
169 (None, None) => Err(super::LeanError::State(format!(
170 "manifest pointer at seq {} names no entries at all — neither a generation \
171 object nor a chunk list; an empty project is an empty chunk LIST, not a \
172 missing one",
173 self.seq
174 ))),
175 }
176 }
177}
178
179/// What a writer must present to CAS. Carries the LAYOUT as well as the
180/// tag, because a workspace mid-migration has a legacy manifest and no
181/// pointer yet, and the two cases take different preconditions: an
182/// If-Match on the object the writer read, versus an If-None-Match on a
183/// pointer that must not already exist.
184#[derive(Debug, Clone, PartialEq)]
185pub struct ManifestHandle {
186 pub etag: String,
187 /// True when the document was read from the LEGACY single-object
188 /// key, i.e. this write is the migration.
189 pub legacy: bool,
190 /// The chunk list this write is superseding, empty if the workspace
191 /// is not chunked yet. Carried on the handle rather than re-read at
192 /// publish time because the caller has just loaded it, and because
193 /// a publish that had to GET the pointer again to find out what it
194 /// already knew would pay a request per barrier for nothing.
195 ///
196 /// This is what makes a publish O(changed): a chunk whose address
197 /// is already here is not sent.
198 pub prev_chunks: Vec<super::chunk::ChunkRef>,
199}
200
201/// What the legacy key is overwritten with once a workspace has moved to
202/// the pointer layout. It must NOT parse as a manifest: `LeanManifest`
203/// gives `seq` and `entries` no serde default, so an OLD `flint-sync`
204/// reading this gets `LeanError::State` and refuses.
205///
206/// Deleting the legacy key instead would be a data-loss shape, not a
207/// tidy-up: `manifest::load` maps a missing object to `Ok(None)`, `None`
208/// means FIRST WRITE, and a barrier answers that with
209/// `IfNoneMatchAny` — so an old syncer would conclude the project is
210/// empty and re-seed over it.
211pub const REFUSAL_DOC: &str = concat!(
212 "{\"moved\":\".flint/lean/current\",",
213 "\"note\":\"this workspace uses the manifest pointer layout; upgrade flint-sync\"}"
214);
215
216pub struct LoadedManifest {
217 pub manifest: LeanManifest,
218 /// The CAS handle: the POINTER's etag under the pointer layout, the
219 /// legacy object's etag before migration.
220 pub etag: String,
221 /// The object's own Last-Modified. The GET already carries it; a
222 /// caller that wants the coherence stamp must not pay a second
223 /// request for what this one threw away (review: U31).
224 pub last_modified_unix: Option<u64>,
225 /// `None` ⇒ this workspace is still on the legacy single-object
226 /// layout and the next write migrates it.
227 pub pointer: Option<Pointer>,
228}
229
230impl LoadedManifest {
231 /// The handle a writer presents to CAS over this read.
232 pub fn handle(&self) -> ManifestHandle {
233 ManifestHandle {
234 etag: self.etag.clone(),
235 legacy: self.pointer.is_none(),
236 prev_chunks: match self.pointer.as_ref().map(|p| p.entries()) {
237 Some(Ok(Entries::Chunked(c))) => c.to_vec(),
238 _ => Vec::new(),
239 },
240 }
241 }
242}
243
244/// Read the pointer alone. The barrier's idle tick wants exactly this
245/// and nothing else: a few hundred bytes that answer "did anything
246/// move, and were the ENTRIES part of it".
247pub struct LoadedPointer {
248 pub pointer: Pointer,
249 pub etag: String,
250 /// The pointer object's own Last-Modified — the citation's clock,
251 /// which /status reports and which the GET already carries.
252 pub last_modified_unix: Option<u64>,
253}
254
255pub async fn load_pointer(
256 store: &dyn ObjectStore,
257 cfg: &LeanConfig,
258) -> LeanResult<Option<LoadedPointer>> {
259 match store.get_whole(&cfg.current_key(), None).await {
260 Ok((meta, bytes)) => {
261 let pointer: Pointer = serde_json::from_slice(&bytes)
262 .map_err(|e| super::LeanError::State(format!("manifest pointer parse: {e}")))?;
263 Ok(Some(LoadedPointer { pointer, etag: meta.etag, last_modified_unix: meta.last_modified_unix }))
264 }
265 Err(StoreError::NotFound(_)) => Ok(None),
266 Err(e) => Err(e.into()),
267 }
268}
269
270/// The whole document: pointer, then the generation it names. Falls
271/// back to the legacy single-object key for a workspace that has not
272/// been migrated — which every existing workspace is, exactly once.
273/// How many times `load` will restart onto a newer generation before
274/// giving up. A restart happens only when a chunk we needed was swept
275/// AND the pointer moved under us, so it is bounded by publish rate,
276/// not by anything we control — three is generous for a workspace whose
277/// floor is measured in seconds.
278const LOAD_ATTEMPTS: u32 = 3;
279
280pub async fn load(
281 store: &dyn ObjectStore,
282 cfg: &LeanConfig,
283) -> LeanResult<Option<LoadedManifest>> {
284 for attempt in 0..LOAD_ATTEMPTS {
285 if let Some(LoadedPointer { pointer: p, etag, last_modified_unix: pointer_lm }) =
286 load_pointer(store, cfg).await?
287 {
288 // A pointer naming an object that is not there is not an empty
289 // workspace — it is a broken one, and answering `None` would
290 // invite a re-seed over a live project. That rule holds one
291 // level deeper for chunks: a chunk list with a hole is a
292 // manifest missing entries, and a missing entry reads to every
293 // consumer as a file the agent deleted.
294 let (mut manifest, meta_lm) = match p.entries()? {
295 Entries::Single { key, .. } => {
296 let (meta, bytes) = match store.get_whole(key, None).await {
297 Ok(v) => v,
298 Err(StoreError::NotFound(_)) => {
299 return Err(super::LeanError::State(format!(
300 "manifest pointer at seq {} names {key}, which does not exist — \
301 refusing to treat a broken pointer as an empty workspace",
302 p.seq
303 )))
304 }
305 Err(e) => return Err(e.into()),
306 };
307 let m = LeanManifest::parse(&bytes)
308 .map_err(|e| super::LeanError::State(format!("manifest parse: {e}")))?;
309 (m, meta.last_modified_unix)
310 }
311 Entries::Chunked(refs) => {
312 // Sequential on purpose for now: a chunk fetch is
313 // ordinary and the interesting property is that a hole
314 // FAILS rather than silently shortening the manifest.
315 let mut bodies = Vec::with_capacity(refs.len());
316 let mut raced = false;
317 for r in refs {
318 let key = cfg.chunk_key(&r.addr);
319 match store.get_whole(&key, None).await {
320 Ok((_, b)) => bodies.push(b.to_vec()),
321 Err(StoreError::NotFound(_)) => {
322 // A chunk we need is gone. Which of two very
323 // different things happened is decided by
324 // the POINTER, not by the chunk:
325 //
326 // - the pointer MOVED ⇒ we raced a sweep
327 // that collected a generation we were
328 // still reading. The current generation is
329 // a complete snapshot, so start over
330 // against it: coherent, just newer. This
331 // is what makes a reader safe for a
332 // DURATION rather than for `Retain`
333 // publishes (chunked design §8.2,
334 // machine-checked by
335 // `LeanChunkGCSlowReader`).
336 // - the pointer is UNCHANGED ⇒ the live
337 // manifest genuinely has a hole, and there
338 // is nothing newer to restart onto. Fail,
339 // and never as `Ok(None)`, which every
340 // caller reads as FIRST WRITE.
341 let moved = match load_pointer(store, cfg).await? {
342 Some(now) => now.etag != etag,
343 None => true,
344 };
345 if moved && attempt + 1 < LOAD_ATTEMPTS {
346 raced = true;
347 break;
348 }
349 return Err(super::LeanError::State(format!(
350 "manifest pointer at seq {} names chunk {} (first key {:?}), \
351 which does not exist — refusing to serve a manifest with a \
352 hole in it as though those entries had been deleted",
353 p.seq, r.addr, r.first
354 )));
355 }
356 Err(e) => return Err(e.into()),
357 }
358 }
359 if raced {
360 continue;
361 }
362 let entries = super::chunk::assemble(refs, &bodies)?;
363 (
364 LeanManifest {
365 seq: p.seq,
366 entries,
367 sole_writer: p.sole_writer,
368 boundary_source: p.boundary_source.clone(),
369 },
370 None,
371 )
372 }
373 };
374 // The pointer is the authority for the fields it carries: a
375 // rotation moves `seq` without rewriting the entries object, so
376 // the generation's own copy is stale by construction.
377 manifest.seq = p.seq;
378 manifest.sole_writer = p.sole_writer;
379 manifest.boundary_source = p.boundary_source.clone();
380 return Ok(Some(LoadedManifest {
381 manifest,
382 etag,
383 // The CITATION's clock is when the pointer moved, not when
384 // the entries object happened to be written: a rotation
385 // republishes the same entries under a new generation.
386 last_modified_unix: pointer_lm.or(meta_lm),
387 pointer: Some(p),
388 }));
389 }
390 // No pointer: the pre-migration layout, which has no chunks and so
391 // nothing to race a sweep over. Answer it and leave the loop.
392 return match store.get_whole(&cfg.manifest_key(), None).await {
393 Ok((meta, bytes)) => {
394 let manifest = LeanManifest::parse(&bytes)
395 .map_err(|e| super::LeanError::State(format!("manifest parse: {e}")))?;
396 Ok(Some(LoadedManifest {
397 manifest,
398 etag: meta.etag,
399 last_modified_unix: meta.last_modified_unix,
400 pointer: None,
401 }))
402 }
403 Err(StoreError::NotFound(_)) => Ok(None),
404 Err(e) => Err(e.into()),
405 };
406 }
407 // Every attempt raced a sweep. Refusing is right: the alternative is
408 // to serve a manifest we know is short, and short reads as deleted.
409 Err(super::LeanError::State(format!(
410 "manifest load restarted {LOAD_ATTEMPTS} times without settling — a sweep is \
411 collecting generations faster than this reader can finish one"
412 )))
413}
414
415/// CAS-write the manifest document. `expected`: None ⇒ first write
416/// (If-None-Match:*); Some(etag) ⇒ If-Match. The caller owns 412
417/// handling (merge-retry or fence — never blind re-seed).
418pub async fn cas_write(
419 store: &dyn ObjectStore,
420 cfg: &LeanConfig,
421 m: &LeanManifest,
422 expected: Option<&ManifestHandle>,
423 epoch: u64,
424 flush_uuid: &str,
425) -> LeanResult<ObjectMeta> {
426 cas_write_stamped(store, cfg, m, expected, epoch, flush_uuid, None).await
427}
428
429/// `cas_write` with an explicit boundary-source stamp on the manifest
430/// OBJECT (§2.4.1): downstream consumers distinguish a declared-coherent
431/// citation from a forced possibly-torn one from the bucket alone, for
432/// the price of a HEAD rather than a GET of a manifest that can run to
433/// hundreds of MiB.
434pub async fn cas_write_stamped(
435 store: &dyn ObjectStore,
436 cfg: &LeanConfig,
437 m: &LeanManifest,
438 expected: Option<&ManifestHandle>,
439 epoch: u64,
440 flush_uuid: &str,
441 boundary_source: Option<&str>,
442) -> LeanResult<ObjectMeta> {
443 // The manifest's own document must agree with its object stamp:
444 // a reader that GETs and a reader that HEADs must never disagree
445 // about how coherent the citation claims to be.
446 // The layout switch. Every publisher routes through here — the
447 // barrier, the takeover rotation and the gateway's HITL CAS — so the
448 // decision lives at one site rather than three, and §6's "chunk
449 // server-side on receipt" falls out rather than needing its own
450 // path.
451 if cfg.chunked {
452 return cas_write_chunked(
453 store,
454 cfg,
455 m,
456 expected,
457 expected.map(|h| h.prev_chunks.as_slice()).unwrap_or(&[]),
458 PublishStamps { epoch, flush_uuid, boundary_source },
459 )
460 .await;
461 }
462 let mut m = m.clone();
463 if boundary_source.is_some() {
464 m.boundary_source = boundary_source.map(|s| s.to_string());
465 }
466 let m = &m;
467 let bytes = m.to_bytes();
468 let crc = crc64_nvme(&bytes);
469 let stamps = GenerationStamps {
470 generation: m.seq,
471 epoch,
472 flush_uuid: flush_uuid.to_string(),
473 // Stamp what the DOCUMENT carries, not merely what this call
474 // passed. An explicit source has already been written into the
475 // document above, so the two agree there either way; the case
476 // this closes is a `None` caller over a document that already
477 // names its clock — the gateway's HITL CAS relays a document it
478 // did not author. Stamping `None` on those left the object
479 // saying nothing while the document said `sentinel`, which is
480 // exactly the GET/HEAD divergence the contract above forbids.
481 boundary_source: m.boundary_source.clone(),
482 posix: None,
483 };
484
485 // 1. The ENTRIES, to a fresh immutable key. `IfNoneMatchAny` is the
486 // whole safety story for this object: two writers that raced to
487 // the same seq cannot both land, and the loser is told so here
488 // rather than discovering it after it has already published.
489 let entries_key = cfg.generation_key(m.seq, flush_uuid);
490 match store
491 .put_whole(&entries_key, bytes.into(), &PutCondition::IfNoneMatchAny, &stamps, crc)
492 .await
493 {
494 Ok(_) => {}
495 // The key carries this writer's flush uuid, so this is not a
496 // race with another writer — it is a retry of OUR OWN write
497 // whose pointer CAS we never learned the outcome of. The object
498 // is byte-identical by construction; fall through to the CAS,
499 // which is where the race is actually decided.
500 Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {}
501 Err(e) => return Err(e.into()),
502 }
503
504 // 2. The POINTER, which is what makes the publish visible and what
505 // decides the race. Everything above this line is invisible to
506 // every reader; a crash here leaves an orphan generation object
507 // that nobody reads and the sweep collects.
508 let pointer = Pointer {
509 seq: m.seq,
510 entries_key: Some(entries_key.clone()),
511 entries_seq: Some(m.seq),
512 // The step-one layout. `cas_write_chunked` is what produces a
513 // chunk list; this path stays byte-for-byte what it was.
514 chunks: None,
515 sole_writer: m.sole_writer,
516 boundary_source: m.boundary_source.clone(),
517 epoch,
518 };
519 put_pointer(store, cfg, &pointer, expected, &stamps).await
520}
521
522/// Who is publishing, and under what coherence claim. Grouped because
523/// they travel together through every publish path and are meaningless
524/// apart — and because a positional list of three scalars is how an
525/// epoch ends up where a flush uuid was meant to go.
526pub struct PublishStamps<'a> {
527 pub epoch: u64,
528 pub flush_uuid: &'a str,
529 pub boundary_source: Option<&'a str>,
530}
531
532/// Publish a manifest as a CHUNK LIST (chunked design §2).
533///
534/// The ordering is the atomicity story and is not negotiable: every
535/// chunk is durable BEFORE the pointer CAS, and the pointer is the only
536/// thing that ever becomes visible. A crash between the two leaves
537/// orphan chunks nobody references and no reader can observe — never a
538/// half-written manifest.
539///
540/// `prev` is the chunk list this publish is replacing. Chunks whose
541/// address is already in it are skipped ENTIRELY — not uploaded and
542/// re-conditioned, but never sent. That is where O(changed) actually
543/// comes from: without it, a one-file publish still ships every chunk's
544/// bytes over the wire and only the store's 412 saves the storage.
545pub async fn cas_write_chunked(
546 store: &dyn ObjectStore,
547 cfg: &LeanConfig,
548 m: &LeanManifest,
549 expected: Option<&ManifestHandle>,
550 prev: &[super::chunk::ChunkRef],
551 pub_stamps: PublishStamps<'_>,
552) -> LeanResult<ObjectMeta> {
553 let PublishStamps { epoch, flush_uuid, boundary_source } = pub_stamps;
554 let mut m = m.clone();
555 if boundary_source.is_some() {
556 m.boundary_source = boundary_source.map(|s| s.to_string());
557 }
558 let m = &m;
559 let split =
560 super::chunk::split_with(&m.entries, cfg.chunk_target, cfg.chunk_min, cfg.chunk_max)?;
561 let have: std::collections::HashSet<&str> = prev.iter().map(|r| r.addr.as_str()).collect();
562
563 let stamps = GenerationStamps {
564 generation: m.seq,
565 epoch,
566 flush_uuid: flush_uuid.to_string(),
567 boundary_source: m.boundary_source.clone(),
568 posix: None,
569 };
570
571 for (r, body) in &split {
572 if have.contains(r.addr.as_str()) {
573 continue;
574 }
575 // UNCONDITIONAL, and that is a correctness requirement rather
576 // than laziness — `LeanChunkGCAdoptSkips` in the formal model
577 // fails without it.
578 //
579 // This chunk is not in `prev`, so nothing references it yet. It
580 // may nonetheless already EXIST: a crashed publish of ours left
581 // it, and content addressing means our retry produces the same
582 // key. Adopting it by reference alone would make an old object
583 // live without making it LOOK live, and the orphan sweep reads
584 // age — so the sweep would collect it as the orphan it still
585 // appears to be, between this call and our pointer CAS.
586 // Rewriting refreshes the age the sweep reads. The bytes are
587 // identical either way, so the write is idempotent; the cost
588 // falls only on adopted orphans, which are rare by
589 // construction, and chunks already in `prev` are skipped above.
590 let key = cfg.chunk_key(&r.addr);
591 let crc = crc64_nvme(body);
592 store.put_whole(&key, body.clone().into(), &PutCondition::Unconditional, &stamps, crc).await?;
593 }
594
595 let pointer = Pointer {
596 seq: m.seq,
597 entries_key: None,
598 entries_seq: None,
599 chunks: Some(split.into_iter().map(|(r, _)| r).collect()),
600 sole_writer: m.sole_writer,
601 boundary_source: m.boundary_source.clone(),
602 epoch,
603 };
604 put_pointer(store, cfg, &pointer, expected, &stamps).await
605}
606
607/// CAS the pointer. Split out because a takeover rotation writes ONLY
608/// this — no entries object, no multi-MB anything.
609async fn put_pointer(
610 store: &dyn ObjectStore,
611 cfg: &LeanConfig,
612 pointer: &Pointer,
613 expected: Option<&ManifestHandle>,
614 stamps: &GenerationStamps,
615) -> LeanResult<ObjectMeta> {
616 let body = serde_json::to_vec(pointer)
617 .map_err(|e| super::LeanError::State(format!("pointer encode: {e}")))?;
618 let crc = crc64_nvme(&body);
619 // A LEGACY handle is a migration: the writer read the old single
620 // object and the pointer does not exist yet, so the precondition is
621 // "no pointer", not "the pointer I read". A concurrent migration
622 // therefore loses here and retries against the layout that won,
623 // instead of both of them believing they installed it.
624 let cond = match expected {
625 Some(h) if !h.legacy => PutCondition::IfMatch(h.etag.clone()),
626 _ => PutCondition::IfNoneMatchAny,
627 };
628 let meta = store.put_whole(&cfg.current_key(), body.into(), &cond, stamps, crc).await?;
629 if let Some(h) = expected.filter(|h| h.legacy) {
630 // The workspace has moved. Poison the legacy key rather than
631 // delete it — see REFUSAL_DOC. Conditional on the very object
632 // this writer read, so a racing writer's view is never
633 // clobbered, and best effort: the refusal doc is decorative to
634 // every new reader, and the ONLY reader it speaks to is an old
635 // binary that must refuse rather than re-seed.
636 let bytes = REFUSAL_DOC.as_bytes().to_vec();
637 let crc = crc64_nvme(&bytes);
638 if let Err(e) = store
639 .put_whole(
640 &cfg.manifest_key(),
641 bytes.into(),
642 &PutCondition::IfMatch(h.etag.clone()),
643 stamps,
644 crc,
645 )
646 .await
647 {
648 eprintln!("flint-sync: could not poison the legacy manifest key after migration: {e}");
649 }
650 }
651 Ok(meta)
652}
653
654/// Bump the generation without touching the entries.
655///
656/// This is what a takeover does, and under the single-object layout it
657/// was a multi-MB GET + PUT per claim that also moved the ETag every
658/// other syncer's no-change early exit compares against — so one claim
659/// cost every follower a full fetch and parse of a document in which
660/// nothing had changed. Here it is one small CAS: `seq` moves, so an
661/// outstanding pointer handle goes stale exactly as before, and
662/// `entries_seq` does NOT, so a follower can see that the entries are
663/// the ones it already has.
664pub async fn rotate_for_takeover(
665 store: &dyn ObjectStore,
666 cfg: &LeanConfig,
667 epoch: u64,
668) -> LeanResult<Option<(LeanManifest, String)>> {
669 for _ in 0..3 {
670 // The legacy layout has no pointer to rotate: migrate by
671 // rewriting the document once, then rotations are cheap forever.
672 let Some(LoadedPointer { pointer: p, etag, .. }) = load_pointer(store, cfg).await? else {
673 let Some(loaded) = load(store, cfg).await? else {
674 return Ok(None);
675 };
676 let mut rotated = loaded.manifest.clone();
677 rotated.seq += 1;
678 let h = loaded.handle();
679 match cas_write(store, cfg, &rotated, Some(&h), epoch, "takeover-rotation").await {
680 Ok(meta) => return Ok(Some((rotated, meta.etag))),
681 Err(super::LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
682 Err(e) => return Err(e),
683 }
684 };
685 let next = Pointer { seq: p.seq + 1, epoch, ..p.clone() };
686 let stamps = GenerationStamps {
687 generation: next.seq,
688 epoch,
689 flush_uuid: "takeover-rotation".to_string(),
690 boundary_source: next.boundary_source.clone(),
691 posix: None,
692 };
693 let h = ManifestHandle { etag, legacy: false, prev_chunks: Vec::new() };
694 match put_pointer(store, cfg, &next, Some(&h), &stamps).await {
695 Ok(meta) => {
696 // The document the caller gets back is the standing one
697 // with the rotated seq — the entries were not read and
698 // did not need to be.
699 let mut m = LeanManifest::default();
700 m.seq = next.seq;
701 m.sole_writer = next.sole_writer;
702 m.boundary_source = next.boundary_source.clone();
703 return Ok(Some((m, meta.etag)));
704 }
705 Err(super::LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
706 Err(e) => return Err(e),
707 }
708 }
709 Err(super::LeanError::State(
710 "takeover rotation lost 3 CAS races — a live writer is still publishing".into(),
711 ))
712}
713
714/// How many superseded generations to keep behind the live one.
715///
716/// Not zero, and not one. A reader GETs the pointer and then GETs the
717/// object it names, and those are two requests with a gap between them:
718/// reaping the generation a reader has just resolved would turn a
719/// perfectly ordinary read into a `NotFound`. A handful is a few hundred
720/// KiB at any realistic entry count and buys that gap several publishes.
721pub const KEEP_GENERATIONS: usize = 5;
722
723/// How long a generation NEWER than the live one is left alone before it
724/// is treated as an orphan.
725///
726/// An object above the live generation is one of two things: a publish
727/// that is in flight right now — its entries written, its pointer CAS
728/// not yet landed — or the wreckage of one that died in that window. The
729/// two are indistinguishable from a listing, and deleting the first
730/// breaks a live publish, so age is the only honest discriminator. Well
731/// past any plausible manifest PUT, including a 264 MiB one over a slow
732/// link.
733pub const ORPHAN_GRACE_SECS: u64 = 3600;
734
735/// Delete superseded generation objects, and orphans left by a publish
736/// that died between its entries PUT and its pointer CAS.
737///
738/// Immutable metadata that is never collected is a leak, and this one
739/// grows by a whole manifest per publish.
740///
741/// The two cases are NOT symmetric, which the first cut of this got
742/// wrong. BELOW the live generation, everything is superseded by
743/// definition and the only question is how much slack to leave a reader
744/// mid-resolve — that is `KEEP_GENERATIONS`. ABOVE it, an object may be
745/// a publish still in flight, and a keep-window is exactly the wrong
746/// tool: an orphan at a high seq sorts to the front and the window
747/// protects it forever while costing a real generation its slot. Age
748/// decides that case instead.
749///
750/// Best effort and off the critical path: a failure here costs storage,
751/// never correctness, so it warns rather than failing a publish.
752pub async fn sweep_generations(store: &dyn ObjectStore, cfg: &LeanConfig) -> LeanResult<usize> {
753 let Some(lp) = load_pointer(store, cfg).await? else {
754 return Ok(0);
755 };
756 // A chunked pointer names no generation object at all, so there is
757 // nothing here to keep a window of. Chunks are shared across
758 // generations and are collected by a different rule entirely
759 // (chunked design §8.1) — sweeping them by "not named by the live
760 // pointer" would delete history the window exists to preserve.
761 let Some(live) = lp.pointer.entries_key else {
762 // The workspace has MOVED to the chunk layout, so no pointer
763 // names a generation object any more and every one of them is
764 // superseded. Collect them past the grace rather than leaking
765 // the whole pre-migration history forever — a reader still
766 // resolving one revalidates and restarts (§8.2), and the grace
767 // covers the window in which it might not have noticed yet.
768 let prefix = format!("{}/{}/manifests/", cfg.prefix, super::LEAN_DIR);
769 let now = super::now_unix();
770 let mut removed = 0;
771 for o in store.list(&prefix).await? {
772 let age = o.last_modified_unix.map(|t| now.saturating_sub(t));
773 if !age.is_some_and(|a| a >= cfg.orphan_grace_secs) {
774 continue;
775 }
776 match store.delete(&o.key).await {
777 Ok(()) => removed += 1,
778 Err(e) => eprintln!("flint-sync: could not reap migrated generation {}: {e}", o.key),
779 }
780 }
781 return Ok(removed);
782 };
783 let prefix = format!("{}/{}/manifests/", cfg.prefix, super::LEAN_DIR);
784 let mut listed = store.list(&prefix).await?;
785 // The key is `<seq:020>-<uuid>`, so lexical order IS generation
786 // order and a plain sort is enough — the zero padding is doing this
787 // job, which is why it is there.
788 listed.sort_by(|a, b| a.key.cmp(&b.key));
789 let now = super::now_unix();
790 let live_ix = listed.iter().position(|o| o.key == live);
791
792 let mut doomed: Vec<String> = Vec::new();
793 for (i, o) in listed.iter().enumerate() {
794 if o.key == live {
795 continue;
796 }
797 match live_ix {
798 // Newer than the live pointer: in flight, or wreckage.
799 Some(ix) if i > ix => {
800 let age = o.last_modified_unix.map(|t| now.saturating_sub(t));
801 // No timestamp ⇒ leave it. A store that cannot date its
802 // objects gets a leak, not a deleted live publish.
803 if age.is_some_and(|a| a > ORPHAN_GRACE_SECS) {
804 doomed.push(o.key.clone());
805 }
806 }
807 // Older than the live pointer: superseded. Keep a window.
808 Some(ix) => {
809 if ix.saturating_sub(i) > KEEP_GENERATIONS {
810 doomed.push(o.key.clone());
811 }
812 }
813 // The pointer names an object that is not in the listing.
814 // `load` already refuses that workspace; sweep nothing.
815 None => return Ok(0),
816 }
817 }
818 let mut removed = 0;
819 for k in doomed {
820 match store.delete(&k).await {
821 Ok(()) => removed += 1,
822 Err(e) => eprintln!("flint-sync: could not reap superseded generation {k}: {e}"),
823 }
824 }
825 Ok(removed)
826}
827
828/// Collect chunk objects that no live pointer references.
829///
830/// Built to `lean/formal/LeanChunkGC.tla`, which refuted the ordering
831/// rule the design originally prescribed. Four things carry the safety
832/// here and each has a mutation config that breaks it alone:
833///
834/// 1. **The reference set must not predate a CAS the delete follows.**
835/// S3 cannot make the read and the delete atomic, so this fences
836/// instead: the pointer's ETag is read before the listing and again
837/// after, and the pass ABORTS if it moved. A publish landed, and we
838/// no longer know whether it references a candidate.
839/// 2. **A grace.** A chunk written and not yet referenced would
840/// otherwise be collected out from under its own publish.
841/// 3. **The grace outlives the longest publish** — a timing
842/// assumption, which is why `orphan_grace_secs` is config and why
843/// this comment names it rather than leaving it implied.
844/// 4. **Adoption rewrites what it adopts** (`cas_write_chunked`), so
845/// any chunk a concurrent publish could reference is FRESH and the
846/// grace covers it. Without that rule an adopted orphan is old, and
847/// the age this reads is a lie about whether it is in use.
848///
849/// Deliberately no history window, unlike `sweep_generations`. That
850/// window existed partly so a reader mid-resolve could finish; readers
851/// now revalidate against the pointer and restart onto the current
852/// generation (§8.2), which removes the reason. Adding one back means
853/// retaining pointer SNAPSHOTS so their chunk sets are enumerable
854/// (§9) — chunks are shared, so "what the live pointer does not name"
855/// is the only reference set available without them.
856pub async fn sweep_chunks(store: &dyn ObjectStore, cfg: &LeanConfig) -> LeanResult<usize> {
857 let Some(before) = load_pointer(store, cfg).await? else {
858 return Ok(0);
859 };
860 let refs: std::collections::HashSet<String> = match before.pointer.entries()? {
861 Entries::Chunked(c) => c.iter().map(|r| r.addr.clone()).collect(),
862 // The single-object layout has no chunks; `sweep_generations`
863 // owns it, and running both over one workspace would have each
864 // reasoning about objects the other manages.
865 Entries::Single { .. } => return Ok(0),
866 };
867
868 let prefix = format!("{}/{}/chunks/", cfg.prefix, super::LEAN_DIR);
869 let listed = store.list(&prefix).await?;
870
871 // THE FENCE (rule 1). Nothing published across the reference read
872 // and the listing, so the candidates and the references describe
873 // the same moment. A publish that lands after this still cannot
874 // lose a chunk: everything it references is either already in
875 // `refs` or was just written or rewritten, and rule 4 guarantees
876 // the latter are fresh.
877 let after = load_pointer(store, cfg).await?;
878 if after.map(|p| p.etag) != Some(before.etag) {
879 return Ok(0);
880 }
881
882 let now = super::now_unix();
883 let mut removed = 0;
884 for o in listed {
885 let Some(addr) = o.key.rsplit('/').next() else { continue };
886 if refs.contains(addr) {
887 continue;
888 }
889 // A candidate the LISTING already shows inside the grace cannot be
890 // past it on a HEAD taken now: a rewrite only makes an object
891 // younger, so the listing's age bounds the HEAD's from above.
892 // Skipping it keeps a chunk longer — a leak, never a deleted live
893 // chunk — and every deletion still rests on the HEAD below. Without
894 // this every commit HEADed every chunk superseded within the grace,
895 // one at a time inside the fence: 700 ms of a 1 s hold after five
896 // minutes of six writers (contention drill), growing for the hour.
897 if let Some(listed) = o.last_modified_unix {
898 if now.saturating_sub(listed) < cfg.orphan_grace_secs {
899 continue;
900 }
901 }
902 // The age must come from a HEAD taken NOW, never from the
903 // listing above. That listing predates the fence, and rule 4
904 // (adoption REWRITES what it adopts) works by refreshing a
905 // chunk's age — so judging by the listing's timestamp makes
906 // exactly the refresh that rule exists to produce invisible,
907 // and the reaper deletes a chunk an in-flight publish is about
908 // to name. The model computed this against the store's state at
909 // DELETE time; reading it from a pre-fence snapshot was an
910 // implementation that did not match the rule it was built to.
911 //
912 // One HEAD per CANDIDATE past the grace by the listing, not per
913 // chunk: a busy workspace supersedes chunks on every publish.
914 //
915 // Gone already ⇒ nothing to do. Any other HEAD failure, or no
916 // timestamp ⇒ leave it: a store that cannot date its objects
917 // gets a leak, not a deleted live chunk, the same rule
918 // `sweep_generations` follows and for the same reason.
919 let fresh = match store.head(&o.key).await {
920 Ok(m) => m.last_modified_unix,
921 Err(StoreError::NotFound(_)) => continue,
922 Err(e) => {
923 eprintln!("flint-sync: could not date orphan chunk {} ({e}); leaving it", o.key);
924 continue;
925 }
926 };
927 // `>=`, not `>`: the config value is a DURATION the object must
928 // have survived, so `orphan_grace_secs = 0` has to mean "no
929 // grace". With `>` it would silently mean "collect nothing
930 // written this second", which is most of them — a knob whose
931 // zero disables the thing it configures is a trap.
932 let age = fresh.map(|t| now.saturating_sub(t));
933 if !age.is_some_and(|a| a >= cfg.orphan_grace_secs) {
934 continue;
935 }
936 match store.delete(&o.key).await {
937 Ok(()) => removed += 1,
938 Err(e) => eprintln!("flint-sync: could not reap orphan chunk {}: {e}", o.key),
939 }
940 }
941 Ok(removed)
942}
943
944/// The three-way merge (the model's `inst`). Starts from THEIRS so
945/// foreign entries survive by construction; applies my upserts; applies
946/// my deletes only where theirs is unchanged since my merge base.
947///
948/// **`mine_upserts` and `mine_deletes` must be DISJOINT**, and that is
949/// the caller's job, not this function's: which one wins depends on
950/// which the tree saw LAST, and neither set carries that. The fused
951/// barrier gets it free (both are computed from one scan). Resolving an
952/// overlap here —
953/// in either direction — cites a deleted file half the time and
954/// amputates a live one the other half. TLC found both halves.
955/// Returns the merged document plus the foreign entries a consume must
956/// integrate next (present in theirs, changed vs base, not mine).
957pub fn merge(
958 base: &BTreeMap<String, String>,
959 theirs: &LeanManifest,
960 mine_upserts: &BTreeMap<String, LeanEntry>,
961 mine_deletes: &BTreeSet<String>,
962 parked: &BTreeSet<String>,
963) -> (LeanManifest, Vec<(String, LeanEntry)>) {
964 let mut merged = theirs.clone();
965 merged.seq = theirs.seq + 1;
966 // Same discipline, and for a sharper reason: inheriting it would
967 // let a workspace that once published as a mirror keep refusing
968 // adopts forever, and NOT inheriting it would let a mirror silently
969 // stop being one the first time anything else wrote a manifest.
970 // The installing pass sets it from its own config.
971 merged.sole_writer = false;
972 merged.boundary_source = None;
973
974 let mut foreign: Vec<(String, LeanEntry)> = vec![];
975 for (p, e) in &theirs.entries {
976 let changed = base.get(p).map(|b| b != &e.etag).unwrap_or(true);
977 if changed && !mine_upserts.contains_key(p) && !parked.contains(p) {
978 foreign.push((p.clone(), e.clone()));
979 }
980 }
981
982 for (p, e) in mine_upserts {
983 merged.entries.insert(p.clone(), e.clone());
984 }
985 for p in mine_deletes {
986 if parked.contains(p) {
987 continue;
988 }
989
990 let theirs_unchanged = match (theirs.entries.get(p), base.get(p)) {
991 (Some(e), Some(b)) => &e.etag == b,
992 (None, None) => true,
993 // Present in theirs but not in base (foreign add), or
994 // vanished from theirs (someone else already deleted):
995 // either way our delete does not apply.
996 _ => false,
997 };
998 if theirs_unchanged {
999 merged.entries.remove(p);
1000 }
1001 }
1002 (merged, foreign)
1003}
1004
1005