Skip to main content

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