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;
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/// Split entries into chunk bodies and their references.
128///
129/// `BTreeMap` iterates in sorted order and `LeanEntry` holds no floats,
130/// so identical entry sets encode to identical bytes and therefore to
131/// identical addresses. That determinism is not a nicety — it is the
132/// whole dedup mechanism: an untouched chunk must re-address to the
133/// object that already exists, or every publish uploads everything.
134pub fn split_with(
135    entries: &BTreeMap<String, LeanEntry>,
136    target: usize,
137    min: usize,
138    max: usize,
139) -> LeanResult<Vec<(ChunkRef, Vec<u8>)>> {
140    let keys: Vec<&str> = entries.keys().map(|s| s.as_str()).collect();
141    let cuts = chunk_ranges_with(&keys, target, min, max);
142    let mut out = Vec::with_capacity(cuts.len());
143    let mut start = 0usize;
144    for c in cuts {
145        let slice: BTreeMap<String, LeanEntry> = keys[start..c]
146            .iter()
147            .map(|k| ((*k).to_string(), entries[*k].clone()))
148            .collect();
149        let bytes = encode(&ChunkBody { entries: slice })?;
150        out.push((
151            ChunkRef {
152                addr: chunk_address(&bytes),
153                first: keys[start].to_string(),
154                n: c - start,
155            },
156            bytes,
157        ));
158        start = c;
159    }
160    Ok(out)
161}
162
163pub fn split(entries: &BTreeMap<String, LeanEntry>) -> LeanResult<Vec<(ChunkRef, Vec<u8>)>> {
164    split_with(entries, CHUNK_TARGET, CHUNK_MIN, CHUNK_MAX)
165}
166
167/// Rebuild the entry map from a chunk list and the fetched bodies.
168///
169/// Every invariant the format depends on is CHECKED here rather than
170/// assumed, because each one fails silently if it is not. A manifest
171/// used to be one object: it was either there or it was not. Split
172/// across N objects, a wrong list produces a well-formed manifest that
173/// is quietly missing entries — and a missing entry reads to every
174/// consumer as a file the agent deleted.
175///
176/// Checked: the list is in strictly increasing key order; each body
177/// addresses to the reference that named it; each body's length and
178/// first key match what the pointer committed to; no chunk spills past
179/// the next chunk's first key; and no key arrives twice.
180pub fn assemble(refs: &[ChunkRef], bodies: &[Vec<u8>]) -> LeanResult<BTreeMap<String, LeanEntry>> {
181    if refs.len() != bodies.len() {
182        return Err(LeanError::State(format!(
183            "chunk list names {} chunks but {} bodies were fetched",
184            refs.len(),
185            bodies.len()
186        )));
187    }
188    let mut out: BTreeMap<String, LeanEntry> = BTreeMap::new();
189    for (i, (r, b)) in refs.iter().zip(bodies).enumerate() {
190        if i > 0 && refs[i - 1].first >= r.first {
191            return Err(LeanError::State(format!(
192                "chunk list is not in strictly increasing key order: {:?} then {:?}",
193                refs[i - 1].first, r.first
194            )));
195        }
196        // The object was fetched BY address, so a mismatch means the
197        // store handed back something else — a stale read, a wrong key,
198        // a truncated body. Content addressing makes this checkable for
199        // one hash per chunk, and declining to check it would be
200        // choosing not to know.
201        let got = chunk_address(b);
202        if got != r.addr {
203            return Err(LeanError::State(format!(
204                "chunk {} does not match its address (body hashes to {got})",
205                r.addr
206            )));
207        }
208        let body: ChunkBody = serde_json::from_slice(b)
209            .map_err(|e| LeanError::State(format!("chunk {} parse: {e}", r.addr)))?;
210        if body.entries.len() != r.n {
211            return Err(LeanError::State(format!(
212                "chunk {} holds {} entries, the pointer says {}",
213                r.addr,
214                body.entries.len(),
215                r.n
216            )));
217        }
218        match body.entries.keys().next() {
219            Some(k) if *k == r.first => {}
220            Some(k) => {
221                return Err(LeanError::State(format!(
222                    "chunk {} starts at {k:?}, the pointer says {:?}",
223                    r.addr, r.first
224                )))
225            }
226            None => {
227                return Err(LeanError::State(format!(
228                    "chunk {} is empty; an empty chunk cannot be addressed by a first key",
229                    r.addr
230                )))
231            }
232        }
233        if let (Some(next), Some(last)) = (refs.get(i + 1), body.entries.keys().next_back()) {
234            if *last >= next.first {
235                return Err(LeanError::State(format!(
236                    "chunk {} runs to {last:?}, past the next chunk's first key {:?} — the \
237                     chunks overlap and one shadows the other",
238                    r.addr, next.first
239                )));
240            }
241        }
242        for (k, v) in body.entries {
243            if out.insert(k.clone(), v).is_some() {
244                return Err(LeanError::State(format!(
245                    "key {k:?} appears in more than one chunk"
246                )));
247            }
248        }
249    }
250    let expect: usize = refs.iter().map(|r| r.n).sum();
251    if out.len() != expect {
252        return Err(LeanError::State(format!(
253            "assembled {} entries, the chunk list accounts for {expect}",
254            out.len()
255        )));
256    }
257    Ok(out)
258}