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}