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// ── the wire format ─────────────────────────────────────────────────
91
92use std::collections::BTreeMap;
93
94use serde::{Deserialize, Serialize};
95
96use super::manifest::{LeanEntry, Tombstone};
97use super::{LeanError, LeanResult};
98
99/// One entry of the pointer's chunk list.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct ChunkRef {
102    /// Content address; the object lives at `.flint/lean/chunks/<addr>`.
103    pub addr: String,
104    /// The chunk's FIRST key. Carried so a reader can resolve a path to
105    /// its chunk without fetching anything (design §5's partial reads),
106    /// and so `assemble` can check the list covers the key space in
107    /// order rather than assuming it.
108    pub first: String,
109    /// Entry count, so a truncated or substituted chunk is caught
110    /// against a number the pointer committed to.
111    pub n: usize,
112}
113
114/// A chunk object's body.
115///
116/// A struct rather than a bare map so the object is self-describing and
117/// so a future field does not change what the address hashes over.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ChunkBody {
120    pub entries: BTreeMap<String, LeanEntry>,
121}
122
123fn encode(body: &ChunkBody) -> LeanResult<Vec<u8>> {
124    serde_json::to_vec(body).map_err(|e| LeanError::State(format!("chunk encode: {e}")))
125}
126
127/// The document's tombstones (review 2026-09-18, H1e) in the chunked
128/// layout: one content-addressed object beside the chunks, named by the
129/// pointer, so an unchanged set re-addresses to the object that already
130/// exists and the chunk reaper keeps it as it keeps a referenced chunk.
131/// The single-object layout carries them inline in the document.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct TombstoneBody {
134    pub tombstones: BTreeMap<String, Tombstone>,
135}
136
137pub fn encode_tombstones(tombstones: &BTreeMap<String, Tombstone>) -> LeanResult<(String, Vec<u8>)> {
138    let body = serde_json::to_vec(&TombstoneBody { tombstones: tombstones.clone() })
139        .map_err(|e| LeanError::State(format!("tombstones encode: {e}")))?;
140    Ok((chunk_address(&body), body))
141}
142
143pub fn decode_tombstones(bytes: &[u8], addr: &str) -> LeanResult<BTreeMap<String, Tombstone>> {
144    let got = chunk_address(bytes);
145    if got != addr {
146        return Err(LeanError::State(format!("tombstones {addr} do not match their address (body hashes to {got})")));
147    }
148    let body: TombstoneBody =
149        serde_json::from_slice(bytes).map_err(|e| LeanError::State(format!("tombstones {addr} parse: {e}")))?;
150    Ok(body.tombstones)
151}
152
153/// Split entries into chunk bodies and their references.
154///
155/// `BTreeMap` iterates in sorted order and `LeanEntry` holds no floats,
156/// so identical entry sets encode to identical bytes and therefore to
157/// identical addresses. That determinism is not a nicety — it is the
158/// whole dedup mechanism: an untouched chunk must re-address to the
159/// object that already exists, or every publish uploads everything.
160pub fn split_with(
161    entries: &BTreeMap<String, LeanEntry>,
162    target: usize,
163    min: usize,
164    max: usize,
165) -> LeanResult<Vec<(ChunkRef, Vec<u8>)>> {
166    let keys: Vec<&str> = entries.keys().map(|s| s.as_str()).collect();
167    let cuts = chunk_ranges_with(&keys, target, min, max);
168    let mut out = Vec::with_capacity(cuts.len());
169    let mut start = 0usize;
170    for c in cuts {
171        let slice: BTreeMap<String, LeanEntry> = keys[start..c]
172            .iter()
173            .map(|k| ((*k).to_string(), entries[*k].clone()))
174            .collect();
175        let bytes = encode(&ChunkBody { entries: slice })?;
176        out.push((
177            ChunkRef {
178                addr: chunk_address(&bytes),
179                first: keys[start].to_string(),
180                n: c - start,
181            },
182            bytes,
183        ));
184        start = c;
185    }
186    Ok(out)
187}
188
189pub fn split(entries: &BTreeMap<String, LeanEntry>) -> LeanResult<Vec<(ChunkRef, Vec<u8>)>> {
190    split_with(entries, CHUNK_TARGET, CHUNK_MIN, CHUNK_MAX)
191}
192
193/// Rebuild the entry map from a chunk list and the fetched bodies.
194///
195/// Every invariant the format depends on is CHECKED here rather than
196/// assumed, because each one fails silently if it is not. A manifest
197/// used to be one object: it was either there or it was not. Split
198/// across N objects, a wrong list produces a well-formed manifest that
199/// is quietly missing entries — and a missing entry reads to every
200/// consumer as a file the agent deleted.
201///
202/// Checked: the list is in strictly increasing key order; each body
203/// addresses to the reference that named it; each body's length and
204/// first key match what the pointer committed to; no chunk spills past
205/// the next chunk's first key; and no key arrives twice.
206pub fn assemble(refs: &[ChunkRef], bodies: &[Vec<u8>]) -> LeanResult<BTreeMap<String, LeanEntry>> {
207    if refs.len() != bodies.len() {
208        return Err(LeanError::State(format!(
209            "chunk list names {} chunks but {} bodies were fetched",
210            refs.len(),
211            bodies.len()
212        )));
213    }
214    let mut out: BTreeMap<String, LeanEntry> = BTreeMap::new();
215    for (i, (r, b)) in refs.iter().zip(bodies).enumerate() {
216        if i > 0 && refs[i - 1].first >= r.first {
217            return Err(LeanError::State(format!(
218                "chunk list is not in strictly increasing key order: {:?} then {:?}",
219                refs[i - 1].first, r.first
220            )));
221        }
222        // The object was fetched BY address, so a mismatch means the
223        // store handed back something else — a stale read, a wrong key,
224        // a truncated body. Content addressing makes this checkable for
225        // one hash per chunk, and declining to check it would be
226        // choosing not to know.
227        let got = chunk_address(b);
228        if got != r.addr {
229            return Err(LeanError::State(format!(
230                "chunk {} does not match its address (body hashes to {got})",
231                r.addr
232            )));
233        }
234        let body: ChunkBody = serde_json::from_slice(b)
235            .map_err(|e| LeanError::State(format!("chunk {} parse: {e}", r.addr)))?;
236        if body.entries.len() != r.n {
237            return Err(LeanError::State(format!(
238                "chunk {} holds {} entries, the pointer says {}",
239                r.addr,
240                body.entries.len(),
241                r.n
242            )));
243        }
244        match body.entries.keys().next() {
245            Some(k) if *k == r.first => {}
246            Some(k) => {
247                return Err(LeanError::State(format!(
248                    "chunk {} starts at {k:?}, the pointer says {:?}",
249                    r.addr, r.first
250                )))
251            }
252            None => {
253                return Err(LeanError::State(format!(
254                    "chunk {} is empty; an empty chunk cannot be addressed by a first key",
255                    r.addr
256                )))
257            }
258        }
259        if let (Some(next), Some(last)) = (refs.get(i + 1), body.entries.keys().next_back()) {
260            if *last >= next.first {
261                return Err(LeanError::State(format!(
262                    "chunk {} runs to {last:?}, past the next chunk's first key {:?} — the \
263                     chunks overlap and one shadows the other",
264                    r.addr, next.first
265                )));
266            }
267        }
268        for (k, v) in body.entries {
269            if out.insert(k.clone(), v).is_some() {
270                return Err(LeanError::State(format!(
271                    "key {k:?} appears in more than one chunk"
272                )));
273            }
274        }
275    }
276    let expect: usize = refs.iter().map(|r| r.n).sum();
277    if out.len() != expect {
278        return Err(LeanError::State(format!(
279            "assembled {} entries, the chunk list accounts for {expect}",
280            out.len()
281        )));
282    }
283    Ok(out)
284}