Skip to main content

flint_lean/
chunk.rs

1//! Content-defined chunking of the manifest's key stream
2//! (`docs/plans/flint-lean-chunked-manifest-design.md` §3).
3//!
4//! The pointer layout made a TAKEOVER cheap; it did nothing for a
5//! publish, which still writes every entry of the project to change one
6//! file. Splitting the sorted key stream into immutable, content-
7//! addressed chunks makes a publish rewrite only what it touched.
8//!
9//! Everything here is pure and synchronous on purpose: the boundary
10//! rule is part of the on-the-wire format, and a format decision that
11//! can only be exercised through a store is one nobody tests properly.
12
13use flint_store::crc64_nvme;
14
15/// Entries per chunk, in expectation. ~1 MiB of compact JSON at the
16/// ~277 B/entry this manifest measures: big enough that per-object
17/// overhead is noise, small enough that one changed file rewrites ~1
18/// MiB rather than the whole project.
19pub const CHUNK_TARGET: usize = 4096;
20/// Floor. Hash boundaries are geometrically distributed, so without one
21/// a project accumulates many tiny objects.
22pub const CHUNK_MIN: usize = CHUNK_TARGET / 4;
23/// Ceiling, so one pathological run cannot recreate the single-object
24/// problem this exists to remove.
25pub const CHUNK_MAX: usize = CHUNK_TARGET * 4;
26
27/// Does a chunk end after this key?
28///
29/// A function of the KEY ALONE — never of the entry's contents, and
30/// never of the key's position. Both matter:
31///
32/// - Position would be fixed-count chunking, where inserting one key
33///   near the front shifts every later key by one slot, moves every
34///   boundary and rewrites every chunk. That restores O(entries) on
35///   exactly the operation being optimised (§3).
36/// - Contents would move a boundary whenever a file's size or etag
37///   changed, so editing a file in place would re-split the project
38///   instead of rewriting one chunk.
39fn cuts_after_with(key: &str, target: usize) -> bool {
40    crc64_nvme(key.as_bytes()).is_multiple_of(target as u64)
41}
42
43/// Split a SORTED key stream into content-defined runs, as end-exclusive
44/// indices. `[]` for no keys; the last run always ends at `keys.len()`.
45///
46/// `min`/`max` are where the pure-function property leaks: a suppressed
47/// or forced cut depends on where the previous boundary fell, so a
48/// change can cascade — but only to the next natural boundary that
49/// satisfies `min`, which is one chunk in expectation. The guarantee is
50/// "O(changed) in expectation", not worst case, and this is why.
51pub fn chunk_ranges_with(keys: &[&str], target: usize, min: usize, max: usize) -> Vec<usize> {
52    debug_assert!(target > 0 && min > 0 && max >= min, "degenerate chunk sizing");
53    let mut cuts = Vec::new();
54    let mut run = 0usize;
55    for (i, k) in keys.iter().enumerate() {
56        run += 1;
57        if (run >= min && cuts_after_with(k, target)) || run >= max {
58            cuts.push(i + 1);
59            run = 0;
60        }
61    }
62    // The tail is a chunk even when it never hit a boundary. Dropping it
63    // would silently lose every entry after the last cut.
64    if run > 0 {
65        cuts.push(keys.len());
66    }
67    cuts
68}
69
70/// The shipped sizing.
71pub fn chunk_ranges(keys: &[&str]) -> Vec<usize> {
72    chunk_ranges_with(keys, CHUNK_TARGET, CHUNK_MIN, CHUNK_MAX)
73}
74
75/// The content address of a chunk body: SHA-256, truncated to 128 bits.
76///
77/// NOT `crc64_nvme`, which the boundary rule above uses. The two hashes
78/// are answering different questions and carry different consequences:
79/// a boundary collision merely moves a cut and costs nothing, while an
80/// ADDRESS collision means two different chunks share an object key and
81/// one silently shadows the other — a data-loss class, and a silent one.
82/// 64 bits is not enough margin to take that risk for; 128 is past any
83/// practical concern.
84pub fn chunk_address(body: &[u8]) -> String {
85    use sha2::{Digest, Sha256};
86    let d = Sha256::digest(body);
87    d.iter().take(16).map(|b| format!("{b:02x}")).collect()
88}
89
90/// Is `addr` an address `chunk_address` could have minted: 32 lowercase
91/// hex digits? A pointer is read from the bucket, so an address in it is
92/// remote-supplied text. Anything that turns one into a LOCAL path (the
93/// chunk cache) must refuse every other shape, `..` and `/` above all.
94pub fn is_chunk_address(addr: &str) -> bool {
95    addr.len() == 32 && addr.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
96}
97
98// ── the wire format ─────────────────────────────────────────────────
99
100use std::collections::BTreeMap;
101
102use serde::{Deserialize, Serialize};
103
104use super::manifest::{LeanEntry, Tombstone};
105use super::{LeanError, LeanResult};
106
107/// One entry of the pointer's chunk list.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct ChunkRef {
110    /// Content address; the object lives at `.flint/lean/chunks/<addr>`.
111    pub addr: String,
112    /// The chunk's FIRST key. Carried so a reader can resolve a path to
113    /// its chunk without fetching anything (design §5's partial reads),
114    /// and so `assemble` can check the list covers the key space in
115    /// order rather than assuming it.
116    pub first: String,
117    /// Entry count, so a truncated or substituted chunk is caught
118    /// against a number the pointer committed to.
119    pub n: usize,
120}
121
122/// A chunk object's body.
123///
124/// A struct rather than a bare map so the object is self-describing and
125/// so a future field does not change what the address hashes over.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ChunkBody {
128    pub entries: BTreeMap<String, LeanEntry>,
129}
130
131fn encode(body: &ChunkBody) -> LeanResult<Vec<u8>> {
132    serde_json::to_vec(body).map_err(|e| LeanError::State(format!("chunk encode: {e}")))
133}
134
135/// The document's tombstones (review 2026-09-18, H1e) in the chunked
136/// layout: one content-addressed object beside the chunks, named by the
137/// pointer, so an unchanged set re-addresses to the object that already
138/// exists and the chunk reaper keeps it as it keeps a referenced chunk.
139/// The single-object layout carries them inline in the document.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct TombstoneBody {
142    pub tombstones: BTreeMap<String, Tombstone>,
143}
144
145pub fn encode_tombstones(tombstones: &BTreeMap<String, Tombstone>) -> LeanResult<(String, Vec<u8>)> {
146    let body = serde_json::to_vec(&TombstoneBody { tombstones: tombstones.clone() })
147        .map_err(|e| LeanError::State(format!("tombstones encode: {e}")))?;
148    Ok((chunk_address(&body), body))
149}
150
151pub fn decode_tombstones(bytes: &[u8], addr: &str) -> LeanResult<BTreeMap<String, Tombstone>> {
152    let got = chunk_address(bytes);
153    if got != addr {
154        return Err(LeanError::State(format!("tombstones {addr} do not match their address (body hashes to {got})")));
155    }
156    let body: TombstoneBody =
157        serde_json::from_slice(bytes).map_err(|e| LeanError::State(format!("tombstones {addr} parse: {e}")))?;
158    Ok(body.tombstones)
159}
160
161/// Split entries into chunk bodies and their references.
162///
163/// `BTreeMap` iterates in sorted order and `LeanEntry` holds no floats,
164/// so identical entry sets encode to identical bytes and therefore to
165/// identical addresses. That determinism is not a nicety — it is the
166/// whole dedup mechanism: an untouched chunk must re-address to the
167/// object that already exists, or every publish uploads everything.
168pub fn split_with(
169    entries: &BTreeMap<String, LeanEntry>,
170    target: usize,
171    min: usize,
172    max: usize,
173) -> LeanResult<Vec<(ChunkRef, Vec<u8>)>> {
174    let keys: Vec<&str> = entries.keys().map(|s| s.as_str()).collect();
175    let cuts = chunk_ranges_with(&keys, target, min, max);
176    let mut out = Vec::with_capacity(cuts.len());
177    let mut start = 0usize;
178    for c in cuts {
179        let slice: BTreeMap<String, LeanEntry> = keys[start..c]
180            .iter()
181            .map(|k| ((*k).to_string(), entries[*k].clone()))
182            .collect();
183        let bytes = encode(&ChunkBody { entries: slice })?;
184        out.push((
185            ChunkRef {
186                addr: chunk_address(&bytes),
187                first: keys[start].to_string(),
188                n: c - start,
189            },
190            bytes,
191        ));
192        start = c;
193    }
194    Ok(out)
195}
196
197pub fn split(entries: &BTreeMap<String, LeanEntry>) -> LeanResult<Vec<(ChunkRef, Vec<u8>)>> {
198    split_with(entries, CHUNK_TARGET, CHUNK_MIN, CHUNK_MAX)
199}
200
201/// The chunk list covers the key space in order: each chunk's first key is
202/// past the one before it.
203pub fn check_list_order(refs: &[ChunkRef]) -> LeanResult<()> {
204    for w in refs.windows(2) {
205        if w[0].first >= w[1].first {
206            return Err(LeanError::State(format!(
207                "chunk list is not in strictly increasing key order: {:?} then {:?}",
208                w[0].first, w[1].first
209            )));
210        }
211    }
212    Ok(())
213}
214
215/// Parse chunk `i` of `refs` and check it against what the list committed
216/// to: its entry count, its first key, and no key at or past the next
217/// chunk's first. The body's address is the caller's to have checked. A
218/// full load (`assemble`) and lever G's view (`docview`) both parse
219/// through here, so a chunk passes the same checks however it is read.
220pub fn parse_chunk(refs: &[ChunkRef], i: usize, b: &[u8]) -> LeanResult<BTreeMap<String, LeanEntry>> {
221    let r = &refs[i];
222    let body: ChunkBody =
223        serde_json::from_slice(b).map_err(|e| LeanError::State(format!("chunk {} parse: {e}", r.addr)))?;
224    if body.entries.len() != r.n {
225        return Err(LeanError::State(format!(
226            "chunk {} holds {} entries, the pointer says {}",
227            r.addr,
228            body.entries.len(),
229            r.n
230        )));
231    }
232    match body.entries.keys().next() {
233        Some(k) if *k == r.first => {}
234        Some(k) => {
235            return Err(LeanError::State(format!("chunk {} starts at {k:?}, the pointer says {:?}", r.addr, r.first)))
236        }
237        None => {
238            return Err(LeanError::State(format!(
239                "chunk {} is empty; an empty chunk cannot be addressed by a first key",
240                r.addr
241            )))
242        }
243    }
244    if let (Some(next), Some(last)) = (refs.get(i + 1), body.entries.keys().next_back()) {
245        if *last >= next.first {
246            return Err(LeanError::State(format!(
247                "chunk {} runs to {last:?}, past the next chunk's first key {:?} — the \
248                 chunks overlap and one shadows the other",
249                r.addr, next.first
250            )));
251        }
252    }
253    Ok(body.entries)
254}
255
256/// One step of a re-chunk's key stream.
257enum Step {
258    /// A key of the changed document, its entry, and whether it is an old
259    /// chunk's LAST key (the old chunking cut after it).
260    Key(String, LeanEntry, bool),
261    /// A deleted key, or a delete of a key the document lacks.
262    Skip,
263    End,
264}
265
266/// Re-chunk a document after `changes`, reusing every chunk of `old` that
267/// neither the changes nor their cascade reach (latency plan §12, claim 1).
268///
269/// `old` is the chunk list `split_with` made of the document, and
270/// `entries_of(i)` hands back chunk `i`'s entries; it is asked only for the
271/// chunks the re-chunk passes through. `changes` maps a path to its new
272/// entry, `None` deleting it (a delete of a path the document lacks changes
273/// nothing). Each ref comes back with the body this call encoded, or `None`
274/// where it is `old`'s ref, reused.
275///
276/// The chunker's only state is the run since the last cut, 0 at every cut.
277/// So the work starts at the chunk holding the next change, whose start is a
278/// cut in both chunkings, and stops at the first new cut that is also an old
279/// one with no change before the next old chunk: from there the key stream,
280/// and so its chunking, is the old one. The result is `split_with` of the
281/// changed entries, ref for ref and byte for byte.
282pub fn rechunk_with<F>(
283    old: &[ChunkRef],
284    mut entries_of: F,
285    changes: &BTreeMap<String, Option<LeanEntry>>,
286    target: usize,
287    min: usize,
288    max: usize,
289) -> LeanResult<Vec<(ChunkRef, Option<Vec<u8>>)>>
290where
291    F: FnMut(usize) -> LeanResult<std::sync::Arc<BTreeMap<String, LeanEntry>>>,
292{
293    debug_assert!(target > 0 && min > 0 && max >= min, "degenerate chunk sizing");
294    let changes: Vec<(&String, &Option<LeanEntry>)> = changes.iter().collect();
295    // The chunk a path falls in: the last whose first key is not after it.
296    let home = |p: &str| old.partition_point(|r| r.first.as_str() <= p).saturating_sub(1);
297    let mut load = |m: usize| -> LeanResult<Vec<(String, LeanEntry)>> {
298        Ok(entries_of(m)?.iter().map(|(k, e)| (k.clone(), e.clone())).collect())
299    };
300    let mut out: Vec<(ChunkRef, Option<Vec<u8>>)> = Vec::with_capacity(old.len() + 1);
301    let mut c = 0usize; // the next change
302    let mut i = 0usize; // the next old chunk not yet reused or re-chunked
303    while c < changes.len() {
304        let start = home(changes[c].0);
305        debug_assert!(old.is_empty() || start >= i, "a change before the old chunk the last region synced to");
306        let start = start.max(i);
307        out.extend(old[i..start.min(old.len())].iter().map(|r| (r.clone(), None)));
308        let mut m = start;
309        let mut cur = if m < old.len() { load(m)? } else { vec![] };
310        let mut pos = 0usize;
311        let mut run: BTreeMap<String, LeanEntry> = BTreeMap::new();
312        let mut first: Option<String> = None;
313        loop {
314            // The changes below the next old chunk's first key are this one's.
315            let bound = old.get(m + 1).map(|r| r.first.as_str());
316            let change = changes.get(c).copied().filter(|(k, _)| bound.is_none_or(|b| k.as_str() < b));
317            if pos >= cur.len() && change.is_none() && m + 1 < old.len() {
318                m += 1;
319                cur = load(m)?;
320                pos = 0;
321                continue;
322            }
323            let step = match (cur.get(pos), change) {
324                (None, None) => Step::End,
325                (Some((ok, _)), Some((ck, cv))) if ck.as_str() < ok.as_str() => {
326                    c += 1;
327                    cv.clone().map_or(Step::Skip, |e| Step::Key(ck.clone(), e, false))
328                }
329                (Some((ok, _)), Some((ck, cv))) if ck.as_str() == ok.as_str() => {
330                    c += 1;
331                    pos += 1;
332                    cv.clone().map_or(Step::Skip, |e| Step::Key(ck.clone(), e, pos == cur.len()))
333                }
334                (Some((ok, oe)), _) => {
335                    pos += 1;
336                    Step::Key(ok.clone(), oe.clone(), pos == cur.len())
337                }
338                (None, Some((ck, cv))) => {
339                    c += 1;
340                    cv.clone().map_or(Step::Skip, |e| Step::Key(ck.clone(), e, false))
341                }
342            };
343            let (k, e, old_last) = match step {
344                Step::Key(k, e, last) => (k, e, last),
345                Step::Skip => continue,
346                Step::End => {
347                    // The tail is a chunk even when it never hit a boundary.
348                    if let Some(f) = first.take() {
349                        out.push(encode_chunk(f, std::mem::take(&mut run))?);
350                    }
351                    i = old.len();
352                    break;
353                }
354            };
355            first.get_or_insert_with(|| k.clone());
356            run.insert(k.clone(), e);
357            if (run.len() >= min && cuts_after_with(&k, target)) || run.len() >= max {
358                let f = first.take().expect("a run holds its first key");
359                out.push(encode_chunk(f, std::mem::take(&mut run))?);
360                // In sync: the old chunking cut here too, and no change falls
361                // before the next old chunk.
362                let synced = old_last
363                    && match (changes.get(c), old.get(m + 1)) {
364                        (None, _) => true,
365                        (Some((ck, _)), Some(next)) => ck.as_str() >= next.first.as_str(),
366                        (Some(_), None) => false,
367                    };
368                if synced {
369                    i = m + 1;
370                    break;
371                }
372            }
373        }
374    }
375    out.extend(old[i.min(old.len())..].iter().map(|r| (r.clone(), None)));
376    Ok(out)
377}
378
379fn encode_chunk(first: String, entries: BTreeMap<String, LeanEntry>) -> LeanResult<(ChunkRef, Option<Vec<u8>>)> {
380    let n = entries.len();
381    let bytes = encode(&ChunkBody { entries })?;
382    Ok((ChunkRef { addr: chunk_address(&bytes), first, n }, Some(bytes)))
383}
384
385/// A body that does not hash to the address it was fetched by: the store
386/// handed back something else (a stale read, a wrong key, a truncated
387/// body), or a local copy was damaged.
388pub fn check_address_of(body: &[u8], addr: &str) -> LeanResult<()> {
389    let got = chunk_address(body);
390    if got != addr {
391        return Err(LeanError::State(format!(
392            "chunk {addr} does not match its address (body hashes to {got})"
393        )));
394    }
395    Ok(())
396}
397
398/// Rebuild the entry map from a chunk list and the fetched bodies.
399///
400/// Every invariant the format depends on is CHECKED here rather than
401/// assumed, because each one fails silently if it is not. A manifest
402/// used to be one object: it was either there or it was not. Split
403/// across N objects, a wrong list produces a well-formed manifest that
404/// is quietly missing entries — and a missing entry reads to every
405/// consumer as a file the agent deleted.
406///
407/// Checked: the list is in strictly increasing key order; each body
408/// addresses to the reference that named it; each body's length and
409/// first key match what the pointer committed to; no chunk spills past
410/// the next chunk's first key; and no key arrives twice.
411pub fn assemble(refs: &[ChunkRef], bodies: &[Vec<u8>]) -> LeanResult<BTreeMap<String, LeanEntry>> {
412    assemble_checked(refs, bodies, true)
413}
414
415/// [`assemble`] over bodies whose addresses the caller has ALREADY checked
416/// one by one (`manifest::load_from` checks each as it arrives, so it can
417/// tell a stale cache entry from a bad fetch): every other check runs, and
418/// no body is hashed twice. A caller that has not checked must use
419/// `assemble`.
420pub fn assemble_verified<B: AsRef<[u8]>>(refs: &[ChunkRef], bodies: &[B]) -> LeanResult<BTreeMap<String, LeanEntry>> {
421    assemble_checked(refs, bodies, false)
422}
423
424fn assemble_checked<B: AsRef<[u8]>>(
425    refs: &[ChunkRef],
426    bodies: &[B],
427    check_address: bool,
428) -> LeanResult<BTreeMap<String, LeanEntry>> {
429    if refs.len() != bodies.len() {
430        return Err(LeanError::State(format!(
431            "chunk list names {} chunks but {} bodies were fetched",
432            refs.len(),
433            bodies.len()
434        )));
435    }
436    check_list_order(refs)?;
437    let mut out: BTreeMap<String, LeanEntry> = BTreeMap::new();
438    for (i, b) in bodies.iter().enumerate() {
439        // The object was fetched BY address, so a mismatch means the
440        // store handed back something else — a stale read, a wrong key,
441        // a truncated body. Content addressing makes this checkable for
442        // one hash per chunk, and declining to check it would be
443        // choosing not to know.
444        let b = b.as_ref();
445        if check_address {
446            check_address_of(b, &refs[i].addr)?;
447        }
448        let body = ChunkBody { entries: parse_chunk(refs, i, b)? };
449        for (k, v) in body.entries {
450            if out.insert(k.clone(), v).is_some() {
451                return Err(LeanError::State(format!(
452                    "key {k:?} appears in more than one chunk"
453                )));
454            }
455        }
456    }
457    let expect: usize = refs.iter().map(|r| r.n).sum();
458    if out.len() != expect {
459        return Err(LeanError::State(format!(
460            "assembled {} entries, the chunk list accounts for {expect}",
461            out.len()
462        )));
463    }
464    Ok(out)
465}