Skip to content

Commit d48f129

Browse files
authored
Fix history independence with Dolt-style streaming chunker (#185)
* Enforce history independence via canonical rebuild after mutations ProllyTree mutations (insert/insert_batch/delete/delete_batch) now call canonicalize(), which rebuilds the tree from its leaf-level (key, value) pairs so the resulting shape and root hash depend only on the final set and the TreeConfig, never on operation history. The rebuild chunks the sorted sequence with the existing rolling-hash chunker, emits leaves, then iteratively chunks each level's pivots until a single root remains. Add a history-independence test suite at three layers: * ProllyNode unit tests in src/node.rs - the primitive layer that bypasses canonicalize still has order-dependent balance behavior; these tests are #[ignore]d as the acceptance suite for a future incremental rebalance. * ProllyTree unit tests in src/tree.rs - 4 always-on tests covering root hash equality across orders, plus update and delete paths. * tests/history_independence.rs - 6-test integration matrix covering (config x N x key-pattern x order x op-mix) at the public API. Trade-off: canonicalize is O(N) per mutation, so an N-key insert is O(N^2) overall. Acceptable as a correctness-first stop-gap; the incremental fast path can return once the node-level tests validate it. * Extend history-independence tests to versioned and namespaced stores The canonicalize fix in ProllyTree::insert/delete is inherited by the git-backed store layers because both ultimately call through to the ProllyTree mutation API. Add tests at those layers to verify: - GitVersionedKvStore: insert/update/delete sequences in different orders produce identical committed tree root hashes (3 tests in src/git/versioned_store/tests.rs). - GitNamespacedKvStore: per-namespace root hashes are order- independent, including under cross-namespace interleaved writes with multiple intermediate commits (3 tests in src/git/versioned_store/namespaced_tests.rs). All 6 new tests pass under the default, git-only, and minimal feature flag combinations. * Bench: document canonicalize stop-gap perf cost (3x-26x slower N=100-10k) * Verify Tree::merge_trees / apply_merge_results inherit canonicalize The merge path was already using ProllyTree::insert / delete to apply adds, modifications, and removes to a destination tree, so it inherits the canonicalize step for free. Add three tests that pin this: * apply_merge_results_is_canonical - merging two divergent trees produces a result whose root hash matches a fresh canonical build of the same final key/value set. * apply_merge_results_independent_of_result_order - shuffling the merge results before apply produces the same root hash. * merge_trees_ignore_conflicts_is_canonical - the higher-level convenience wrapper also produces a canonical result. All three pass without further code changes. * Batch commit-time mutations through one canonicalize per commit VersionedKvStore::commit and NamespacedKvStore::commit_impl both drained their staging area by calling ProllyTree::insert / delete for each item - and since each ProllyTree mutation triggers a full canonicalize (O(tree size)), a commit with K items cost O(K * tree_size). Add ProllyTree::apply_changes(impl IntoIterator<Item = (Vec<u8>, Option<Vec<u8>>)>) which materializes the current state into a BTreeMap, applies all changes, then rebuilds the tree canonically once. Wire the two commit paths through it; namespaced commit collects the externalize threshold transformation into the batch before applying. Result: commit cost drops from O(K * N) to O(N + K log K), a meaningful win for any commit that stages more than a handful of changes. * Step 3 follow-up: document the incremental-rebalance redesign needed Attempted a focused fix to lift the canonicalize stop-gap (a `removed` flag on ProllyNode set when a delete empties a node, with parent dropping it). The change compiled but broke node::tests::test_delete because the existing `merged` / `split` two-bit signaling protocol isn't expressive enough to share state with the new flag. Reverted. Documented why a small fix isn't tractable and what the correct redesign looks like: a richer BalanceOutcome signal carrying "remove range [l..r] and insert these new children", with the rebalance loading both left and right neighbors and recursing up to the root. The canonicalize stop-gap remains in place. The #[ignore]d node-level tests in src/node.rs are the acceptance suite for the redesign work. * docs: Dolt streaming-chunker analysis and ProllyTree port plan * Replace canonicalize backend with streaming chunker (Dolt port, Phase 1) Add src/streaming_chunker.rs - a Rust port of Dolt's streaming chunker pipeline (chunker.go + node_splitter.go + node_builder.go). The chunker streams sorted (key, value) pairs through a Splitter that resets at every chunk boundary; each chunk's split decision depends only on the items inside that chunk, so the same sorted input always produces the same root hash by construction. Phase 1 scope: no cursor support yet. The chunker is wired in as the backend for ProllyTree::canonicalize and apply_changes, replacing ProllyNode::build_canonical_from_pairs. Per-mutation cost is still O(N) until Phase 2 adds NodeCursor + advance_to for fast-forward over unchanged subtrees. Tests: 5 new unit tests in src/streaming_chunker.rs verify empty inputs, single-item, sub-min sequences, order-independence, and parity with build_canonical_from_pairs at N=1024. Existing suite of 154 lib tests + integration matrix all pass. Perf: cargo bench --bench tree -- insert_single_10000 drops from 30.1s (canonicalize stop-gap) to 13.3s - 2.3x speedup. Still 11.7x slower than the pre-fix baseline (1.14s), which is what Phase 2 closes. * Cursor-driven apply_mutations replaces the legacy insert/delete path Add NodeCursor (Rust port of Dolt's tree.cursor): a stateful linked list of cursors, one per tree level, with `at_key`, `at_start`, `advance`, etc. Add `apply_mutations(root, sorted_mutations, config, storage)` which walks the old tree with the cursor and streams unchanged + mutated items through a fresh chunker, producing a canonical new tree. ProllyTree::{insert, insert_batch, delete, delete_batch} now route through `apply_changes -> apply_mutations`. The old in-place `self.root.insert/delete` calls (which couldn't maintain history independence) are gone from the public mutation path. Tests: 5 new apply_mutations cases (empty tree, no-op pass-through, insert middle, deletes, updates) all pass; 159 lib tests + integration matrix + all other suites green. Perf: bench at N=10k went from 30.1s (canonicalize stop-gap) -> 13.3s (Phase 1 streaming) -> 16.6s (Phase 2 cursor). The Phase 2 number is slightly worse than Phase 1 because the cursor adds overhead without yet enabling the fast-forward skip. Adding the cursor fast-forward (matching Dolt's chunker.advanceTo) is the next step and is what brings the per-op cost back to O(log N + edits). * docs: update history-independence + perf docs after Phases 1+2 Refresh docs/bench-canonicalize-impact.md and docs/dolt-streaming-chunker.md with the bench numbers and status after Phase 1 (streaming chunker) and Phase 2 (cursor-driven apply_mutations). State after these phases: * Public API at every consumed layer (ProllyTree, GitVersionedKvStore, GitNamespacedKvStore) has history-independence by construction - no more post-hoc canonicalize rebuild. * Bench at N=10k: 16.6s (14.5x slower than pre-fix baseline of 1.14s) * The cursor fast-forward (chunker.advanceTo) is the next step and is what closes the perf gap to ~2x of baseline. * tree: drop dead canonicalize/collect_pairs - replaced by streaming chunker * streaming_chunker: Phase 3 fast paths (pure-append + alignment fast-forward) Add two complementary fast paths to apply_mutations that recover most of the per-op cost lost in Phase 2 (cursor-driven walk): 1. **Pure-append** (`try_pure_append`): triggered when every mutation is an insert past the tree's max key (common in append-only and monotonic-key workloads). Walks the tree's leaves once to collect (firstKey, leaf_hash), feeds all but the last leaf directly to the level-1 chunker via `Chunker::append_subtree_at_parent_level`, then re-processes only the last leaf's items + new keys through level-0. Skips reading the items of N-1 leaves. 2. **Alignment-aware fast-forward** (`fast_forward_to_end`): after each chunker boundary emit, the chunker exposes its `last_emit_hash`. When the cursor was at the end of an old leaf and the emitted hash matches that leaf's hash, the chunker has re-synced with the old tree. If no more mutations are pending, we copy the remaining old leaves directly to the level-1 chunker without reading their items. Both paths preserve canonicality: they feed the same sequence of (firstKey, hash) entries to the level-1 chunker that a full canonical rebuild would, just without re-walking unchanged leaf items. Tests: the 6 history-independence tests at the ProllyTree API layer + the integration matrix + the 10 streaming-chunker unit tests all pass. Bench: insert_single at N=10k drops from 16.6s (Phase 2) to 11.2s (Phase 3), a 32% speedup. vs the canonicalize stop-gap (30.1s) the total speedup is 63%. The remaining gap to the pre-fix baseline (1.14s) is the inherent O(N²) of one-mutation-per-call and the cost of writing new tree nodes to storage on every commit. * docs: scrub stale ProllyTree::canonicalize references The canonicalize method was removed in 2c019bf when the streaming chunker took over. Update all the comment/doc references that still mentioned it across the codebase: src/streaming_chunker.rs, src/node.rs test-module header, src/tree.rs merge tests, src/git/versioned_store/{tests,namespaced_tests,namespaced,core}.rs, and tests/history_independence.rs. Also annotate ProllyNode::build_canonical_from_pairs as the alternate canonical builder kept as an independent oracle in the matches_node_build_canonical_from_pairs unit test - it is no longer in the production path, the streaming chunker handles every mutation. * Phase 4 cleanup: remove obsolete node-level ignored tests and stale roadmap Two cleanups now that the streaming-chunker port is the only mutation path and the public-API + integration tests cover history independence end-to-end: 1. Remove the 6 #[ignore]d tests in src/node.rs and their dedicated `build_node` helper. These exercised the legacy in-place `Balanced::balance` path and documented bugs (chunk-boundary drift under non-default configs, empty trailing leaves after delete, internal-node structure divergence) that no longer matter for the public API. Coverage of history independence now lives in: * src/streaming_chunker.rs (10 unit tests) * src/tree.rs::history_independence_tests (4 tests) * src/tree.rs::merge_canonicality_tests (3 tests) * src/git/versioned_store/tests.rs::history_independence_tests (3) * src/git/versioned_store/namespaced_tests.rs (3 tests) * tests/history_independence.rs (6 integration tests) The narrow `test_history_independence` and the lifted-to-u64 `test_history_independence_default_config_traversal` remain as ProllyNode-level smoke checks against the legacy path. Updated the module header to reflect the new state. 2. Remove docs/incremental-rebalance-roadmap.md. It described the earlier "rewrite the in-place balance" plan that was abandoned in favor of the Dolt streaming-chunker port. Fully superseded by docs/dolt-streaming-chunker.md, which now documents Phase 4 as done and lists the remaining optional follow-ups (recursive fast-forward, multi-mutation batch advance, legacy balance removal). Suite: 159 lib tests pass, 0 ignored, 0 failed. * streaming_chunker: don't emit boundary on internal-level chunks with <2 entries The Python CI hit a segfault (release) / u8 overflow panic (debug) in basic_usage.py at the file-backed `insert` step. Root cause: Python's `TreeConfig.__init__` has `pattern=0` as its default, and the example also sets `min_chunk_size=1`. With pattern=0 the splitter's `(hash & pattern) == pattern` check is true on every item, so a boundary fires on every append. The chunker's `handle_boundary` then recursively created a new parent chunker one level up, whose splitter saw the just-emitted `(firstKey, hash)` entry, fired a boundary, and cascaded - climbing the `u8` level counter until it overflowed. This is Dolt's "constraint (3): internal nodes must contain at least 2 key-value pairs" rule from `chunker.go::append`, which we didn't port. Adding it: at level > 0, defer boundary emission until the in-progress chunk has at least 2 entries. Leaf-level chunks (level 0) are still allowed to be single-entry so we can store a 1-key dataset. The old in-place `Balanced::balance` didn't have this bug because it emitted upward only when the chunker reported >1 chunks (its `chunks.len() <= 1` early-return). The streaming chunker emits at every boundary, so we need the explicit constraint here. Regression test in `streaming_chunker::tests` reproduces the degenerate config and asserts the level stays bounded. Verified by re-running basic_usage.py against a debug + release build, both succeed end-to-end. Rust suite: 160 lib tests pass, 0 failed. * remove unused design doc * clean up comments * add new document * fix diagram * add more tests * address copilot's comments
1 parent 02e1cfa commit d48f129

13 files changed

Lines changed: 3345 additions & 40 deletions

File tree

docs/theory/index.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ If you've used a B-tree, a Merkle tree, or Git, each of the pieces below will al
1313

1414
1. **[Prolly Trees](prolly_tree.md)** — the core data structure. What a prolly tree is, why it exists, and how it compares to classical B-trees and Merkle trees.
1515
2. **[Probabilistic Balancing](rolling_hash.md)** — how node boundaries are chosen by a rolling-hash predicate, and why this gives you history-independent shape with O(log n) depth.
16-
3. **[Merkle Properties & Proofs](merkle.md)** — how inclusion proofs are constructed, what the root hash tells you, and why two trees with the same root *are* the same tree.
17-
4. **[Versioning & Merge](versioning.md)** — how the versioned KV store layers commits, branches, and three-way merges on top of the tree, and what conflict resolvers you can plug in.
16+
3. **[Mutations & Streaming Chunker](mutation.md)** — what actually happens between `tree.insert(...)` and a new root hash. The shared pipeline behind `insert`, `delete`, `insert_batch`, and `delete_batch`, including the cursor walk and the two fast paths.
17+
4. **[Merkle Properties & Proofs](merkle.md)** — how inclusion proofs are constructed, what the root hash tells you, and why two trees with the same root *are* the same tree.
18+
5. **[Versioning & Merge](versioning.md)** — how the versioned KV store layers commits, branches, and three-way merges on top of the tree, and what conflict resolvers you can plug in.
1819

1920
## Why this matters in practice
2021

docs/theory/mutation.md

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
# Mutations & the Streaming Chunker
2+
3+
This page explains what happens between the moment a caller invokes
4+
`tree.insert(...)` and the moment a new canonical root hash lands on
5+
storage. The same pipeline handles `insert`, `delete`, `insert_batch`,
6+
and `delete_batch` — there is no special-case path for any of them.
7+
8+
The property the pipeline buys you: **the resulting root hash depends
9+
only on the final `(key, value)` set and the `TreeConfig`, not on the
10+
order in which the mutations arrived.** This is history independence
11+
expressed as a property of the algorithm rather than something patched
12+
on afterwards. See [Probabilistic Balancing](rolling_hash.md) for the
13+
boundary rule that makes this possible; this page is about how the
14+
mutation engine uses that rule.
15+
16+
## The funnel: every mutation routes through one path
17+
18+
```mermaid
19+
flowchart LR
20+
I["tree.insert(k, v)"]
21+
D["tree.delete(k)"]
22+
IB["tree.insert_batch(...)"]
23+
DB["tree.delete_batch(...)"]
24+
AC["apply_changes(I)"]
25+
BT[("BTreeMap&lt;Vec&lt;u8&gt;, Option&lt;Vec&lt;u8&gt;&gt;&gt;<br/><i>sorted, dedup'd</i>")]
26+
AM["streaming_chunker::apply_mutations"]
27+
R["new root replaces self.root"]
28+
29+
I --> AC
30+
D --> AC
31+
IB --> AC
32+
DB --> AC
33+
AC --> BT
34+
BT --> AM
35+
AM --> R
36+
```
37+
38+
The wrappers are intentionally thin:
39+
40+
```rust
41+
fn insert(&mut self, key: Vec<u8>, value: Vec<u8>) {
42+
self.apply_changes(std::iter::once((key, Some(value))));
43+
self.persist_root();
44+
}
45+
46+
fn delete(&mut self, key: &[u8]) -> bool {
47+
if self.find(key).is_none() { return false; }
48+
self.apply_changes(std::iter::once((key.to_vec(), None)));
49+
self.persist_root();
50+
true
51+
}
52+
```
53+
54+
`apply_changes` itself does just three things:
55+
56+
1. Collect the batch into a `BTreeMap` — gives sorted iteration and
57+
last-write-wins deduplication for free.
58+
2. Probe `find()` once per delete to compute the
59+
"missing-deletes" return value.
60+
3. Call `streaming_chunker::apply_mutations(self.root.clone(), map, &self.config, &mut self.storage)`
61+
and install the returned root.
62+
63+
The interesting work all happens inside `apply_mutations`.
64+
65+
## The streaming chunker
66+
67+
`apply_mutations` walks the old tree with a **`NodeCursor`** and feeds
68+
items to a **`Chunker`** which produces a new canonical tree. The
69+
chunker has three cooperating parts:
70+
71+
```mermaid
72+
flowchart LR
73+
subgraph C0["Chunker (level 0)"]
74+
direction TB
75+
S0["Splitter<br/>rolling-hash window<br/>resets at every boundary"]
76+
B0["NodeBuilder<br/>in-progress chunk<br/>(keys, values)"]
77+
S0 -->|"boundary?<br/>(hash &amp; pattern) == pattern"| B0
78+
end
79+
subgraph C1["Chunker (level 1) — created lazily"]
80+
direction TB
81+
S1["Splitter"]
82+
B1["NodeBuilder"]
83+
S1 --> B1
84+
end
85+
subgraph Cn["Chunker (level 2, 3, …)<br/>recursively, until 1 chunk left"]
86+
end
87+
KV["(key, value)<br/>stream"]
88+
Out["canonical root<br/>(possibly multi-level)"]
89+
90+
KV --> C0
91+
C0 -->|"on emit:<br/>(firstKey, leaf_hash)"| C1
92+
C1 -->|"on emit:<br/>(firstKey, internal_hash)"| Cn
93+
Cn --> Out
94+
```
95+
96+
- **`Splitter`** sees every appended item and maintains a rolling hash
97+
over the last `min_chunk_size` items. When `hash & pattern == pattern`
98+
the splitter declares a *boundary* and is reset. Because state is
99+
reset at every boundary, the next chunk's decision depends only on
100+
items inside that chunk — never on prior history. (Details:
101+
[Probabilistic Balancing](rolling_hash.md).)
102+
- **`NodeBuilder`** holds the keys and values for the chunk currently
103+
being assembled.
104+
- **`Chunker`** is the streaming driver: takes items in sorted order,
105+
feeds them through the splitter and builder, and when the splitter
106+
fires it seals the builder into a `ProllyNode`, writes it to storage,
107+
and forwards `(firstKey, hash)` to a parent chunker one level up. The
108+
parent is created lazily on first need.
109+
110+
## Walking through `apply_mutations`
111+
112+
Below, `root` is the current tree, `muts` is the sorted mutation map.
113+
114+
```mermaid
115+
flowchart TD
116+
start(["apply_mutations(root, muts)"])
117+
empty{"root is empty leaf<br/>and no mutations?"}
118+
pure{"try_pure_append:<br/>all mutations are inserts<br/>past tree.max_key?"}
119+
cursor["cursor walk"]
120+
fast["pure-append fast path"]
121+
ff{"during walk:<br/>chunker emit hash<br/>== old leaf hash?<br/>(and no more mutations)"}
122+
ffend["fast_forward_to_end:<br/>promote remaining<br/>old leaves directly to<br/>level-1 chunker"]
123+
done["chunker.done()"]
124+
out(["new canonical root"])
125+
126+
start --> empty
127+
empty -->|yes| done
128+
empty -->|no| pure
129+
pure -->|yes| fast
130+
pure -->|no| cursor
131+
cursor --> ff
132+
ff -->|yes| ffend
133+
ff -->|"no, continue"| cursor
134+
fast --> done
135+
ffend --> done
136+
done --> out
137+
```
138+
139+
The cursor walk is the general case; the two fast paths are
140+
optimisations.
141+
142+
### The cursor walk (general case)
143+
144+
A `NodeCursor` is a linked list of cursors, one per tree level, each
145+
pointing at a `(node, idx)` pair. `at_start` descends to the leftmost
146+
leaf; `advance` moves the leaf-level idx forward, recursively bumping
147+
parents when a leaf is exhausted and re-descending into the next leaf.
148+
149+
For each cursor position the inner loop merges in any pending
150+
mutations:
151+
152+
| cmp(mutation.key, cur.key) | action |
153+
|---|---|
154+
| `Less` | The mutation is an insert before `cur`. Feed `(mk, mv)` to the chunker (deletes here are no-ops). Don't advance the cursor. |
155+
| `Equal` | The mutation targets the current key. If `Some(v)`, feed `(cur.key, v)` to the chunker (overwrite). If `None`, drop the item (delete). Consume the mutation. |
156+
| `Greater` | The mutation applies to a later key. Pass the current cursor key/value through unchanged. |
157+
158+
After processing the cursor position, advance. Repeat until the cursor
159+
is exhausted, then drain any mutations that fell past the end of the
160+
tree.
161+
162+
### Pure-append fast path
163+
164+
Triggered when *every* mutation is an insert with `key > tree.max_key`
165+
— common in append-only and monotonic-key workloads (time-series,
166+
log structures). When it fires, the tree's existing leaves are
167+
guaranteed unchanged except for the last one.
168+
169+
```mermaid
170+
flowchart TD
171+
leaves["iter_leaves(root):<br/>collect (firstKey, leaf_hash, leaf)<br/>for every leaf"]
172+
pop["pop last_leaf"]
173+
promote["for each remaining leaf:<br/>chunker.append_subtree_at_parent_level(<br/>&nbsp;&nbsp;firstKey, leaf_hash)<br/><i>(feeds the level-1 chunker directly)</i>"]
174+
rest["for each (k, v) in last_leaf.items:<br/>chunker.add_pair(k, v)"]
175+
new["for each (k, v) in mutations:<br/>chunker.add_pair(k, v)"]
176+
done["chunker.done()"]
177+
178+
leaves --> pop
179+
pop --> promote
180+
promote --> rest
181+
rest --> new
182+
new --> done
183+
```
184+
185+
The cost is `O(|last_leaf| + |new_keys|)` — the earlier leaves never
186+
have their items read or re-hashed. The last leaf has to be
187+
re-streamed because the splitter may have decided a different boundary
188+
at its right edge once the new keys merge in.
189+
190+
### Alignment-aware fast-forward
191+
192+
Triggered during the cursor walk when there are no mutations left and
193+
the chunker just emitted a chunk whose hash equals the leaf the cursor
194+
was about to leave. At that moment the chunker is back "in sync" with
195+
the old tree: subsequent unchanged old leaves can be promoted directly
196+
to the level-1 chunker without their items being re-fed through the
197+
splitter.
198+
199+
```mermaid
200+
sequenceDiagram
201+
participant C as Cursor
202+
participant CK as Chunker_L0
203+
participant P as Chunker_L1
204+
205+
Note over C,CK: cursor at end of an old leaf, no muts pending
206+
C->>CK: feed last item
207+
CK->>CK: splitter fires boundary
208+
CK->>P: emit firstKey + new_leaf_hash
209+
Note over CK: take_last_emit_hash == old_leaf_hash<br/>+ is_at_boundary ⇒ in sync
210+
loop for each remaining old leaf
211+
CK->>P: append_subtree_at_parent_level
212+
end
213+
Note over CK,P: items inside remaining leaves are never re-read
214+
```
215+
216+
The hash comparison is the alignment check. Computing the leaf hash
217+
requires a `SHA-256` over the leaf's bytes; this is done lazily —
218+
only when the cursor is about to cross a leaf boundary *and* the
219+
mutation queue is empty — so it doesn't cost anything on the hot path.
220+
221+
## What each operation looks like end-to-end
222+
223+
### `insert(key, value)`
224+
225+
```mermaid
226+
flowchart LR
227+
A["BTreeMap { k → Some(v) }"]
228+
B["root.is_leaf &amp;&amp; root.keys.is_empty()<br/><i>(empty tree?)</i>"]
229+
C["try_pure_append<br/><i>(k &gt; tree.max_key?)</i>"]
230+
D1["empty: stream the one item<br/>through a fresh chunker"]
231+
D2["pure-append:<br/>promote leaves, re-stream last,<br/>add new key"]
232+
D3["cursor walk:<br/>insert at the right position"]
233+
E["new root"]
234+
235+
A --> B
236+
B -->|yes| D1
237+
B -->|no| C
238+
C -->|yes| D2
239+
C -->|no| D3
240+
D1 --> E
241+
D2 --> E
242+
D3 --> E
243+
```
244+
245+
### `delete(key)`
246+
247+
```mermaid
248+
flowchart LR
249+
A["tree.find(key)?"]
250+
B["return false<br/>(key not present)"]
251+
C["BTreeMap { k → None }"]
252+
D["try_pure_append<br/><i>(disqualifies on any delete)</i>"]
253+
E["cursor walk"]
254+
F["new root"]
255+
256+
A -->|None| B
257+
A -->|Some| C
258+
C --> D
259+
D -->|fail| E
260+
E --> F
261+
```
262+
263+
A delete is just an entry whose value is `None`. When the cursor meets
264+
a matching key the item is simply not fed to the chunker — the new
265+
tree never sees it. There's no special "compact empty leaves"
266+
post-pass because empty leaves can't form: the chunker emits a leaf
267+
only when its builder has items.
268+
269+
### `insert_batch(keys, values)`
270+
271+
Identical to `insert` except the BTreeMap holds many entries. If all
272+
of them are past `tree.max_key` the pure-append fast path applies and
273+
amortises the per-item cost across the batch.
274+
275+
### `delete_batch(keys)`
276+
277+
Identical to `delete` for a batch: BTreeMap entries are all
278+
`(k, None)`, pure-append disqualifies, the cursor walk drops every
279+
matched item in a single pass.
280+
281+
## Why this is history-independent
282+
283+
The cursor walk reads from the old tree in **sorted key order**, and
284+
the merge with the mutation map preserves that order. Whatever
285+
mutation history produced the old tree, the chunker only ever sees a
286+
sorted stream of the *final* `(key, value)` set. The splitter resets
287+
at every boundary, so each chunk's decision depends only on its own
288+
contents.
289+
290+
Two consequences:
291+
292+
1. **The same final key set produces the same chunks**, no matter what
293+
order they were inserted in.
294+
2. **The same chunks produce the same internal nodes**, recursively,
295+
all the way to the root.
296+
297+
So the root hash is a function of the data alone — replicas that
298+
independently arrive at the same `(key, value)` set converge to
299+
exactly the same root, with no coordination.
300+
301+
## See also
302+
303+
- [Probabilistic Balancing](rolling_hash.md) for the rolling-hash
304+
predicate and the chunk-size distribution.
305+
- [Merkle Properties & Proofs](merkle.md) for what the root hash
306+
actually proves and how subtree sharing lets you sync replicas in
307+
time proportional to the *change*, not the store size.
308+
- [Versioning & Merge](versioning.md) for how this mutation pipeline
309+
composes with commits, branches, and three-way merge.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ nav:
9595
- Overview: theory/index.md
9696
- Prolly Trees: theory/prolly_tree.md
9797
- Probabilistic Balancing: theory/rolling_hash.md
98+
- Mutations & Streaming Chunker: theory/mutation.md
9899
- Merkle Properties & Proofs: theory/merkle.md
99100
- Versioning & Merge: theory/versioning.md
100101
- CLI:

src/git/versioned_store/core.rs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -278,17 +278,10 @@ where
278278

279279
/// Commit staged changes
280280
pub fn commit(&mut self, message: &str) -> Result<gix::ObjectId, GitKvError> {
281-
// Apply staged changes to the tree
282-
for (key, value) in self.staging_area.drain() {
283-
match value {
284-
Some(v) => {
285-
self.tree.insert(key, v);
286-
}
287-
None => {
288-
self.tree.delete(&key);
289-
}
290-
}
291-
}
281+
// Apply staged changes in a single batch so we run the streaming
282+
// canonical chunker once per commit instead of once per staged item.
283+
let changes: Vec<(Vec<u8>, Option<Vec<u8>>)> = self.staging_area.drain().collect();
284+
self.tree.apply_changes(changes);
292285

293286
// Persist the tree state (including updating root hash and saving config)
294287
self.tree.persist_root();

0 commit comments

Comments
 (0)