Rindle docs and package mapSkip to main content

rindle_sqlite/
batch_delta.rs

1//! The in-memory **batch delta** (`designs/306-BATCH-OVERLAY-DESIGN.md` §4, impl plan §3):
2//! a per-`TableSource` fold of the current transaction's `SourceChange`s, standing in for
3//! the throwaway derivation *writes* the historical write-then-abort path performed
4//! (deleted with 306 S5). During a
5//! derivation, change *k+1* must read a base state that already includes changes *1..k*;
6//! this structure holds exactly that divergence from the pinned base snapshot, so the
7//! derivation connection can stay read-only.
8//!
9//! Three parts, each a [`rindle::btree::BTree`] (the engine's own COW B+tree, reused
10//! as-is so the delta inherits the already-fuzzed `compare_rows` ordering — plan §3.5):
11//!
12//! - **`touched`** — every pk the batch touched (present OR removed), keyed under the
13//!   pk sort. A base row whose pk is here is *suppressed* from the scan: its
14//!   authoritative content now lives in `primary`, or nowhere if the batch removed it.
15//! - **`primary`** — the current derived content of each touched-and-present pk.
16//! - **`secondary`** — the same rows under each index sort a fetch has asked for,
17//!   created lazily by [`BatchDelta::rows_for_fetch`] and maintained eagerly by
18//!   [`BatchDelta::apply`] for the rest of the transaction (plan §3.3).
19//!
20//! The delta is **shared, not forked**: [`TableSource::fork`](crate::TableSource::fork)
21//! clones the `Rc<BatchDelta>` because the delta models *storage*, which forks share
22//! (plan D1). Range reads vend the tree's **owning** [`BTreeCursor`] (an `Rc<BNode>`
23//! spine — the OQ-1 design), constructed under a momentary `RefCell` borrow, so no
24//! borrow is ever held across a vend (plan §3.4 as amended 2026-08-10: the original
25//! materialize-to-`Vec` rule assumed a borrowing cursor and cost O(|delta|) per fetch
26//! — O(N²) per batch under per-change ordered re-fetches). A later `apply` cannot
27//! invalidate an in-flight stream either way: it runs at write-after-drain (the same
28//! quiet point `write_change` occupied, with the in-flight change carried by the push
29//! overlay), and if a cursor ever were alive, `Rc::make_mut` path-copies around every
30//! node it pins, leaving the cursor its as-of-fetch snapshot.
31
32use std::cell::{Cell, RefCell};
33use std::cmp::Ordering;
34use std::collections::HashMap;
35use std::rc::Rc;
36
37use rindle::btree::{row_bound_of, BTree, BTreeCursor, RowBound};
38use rindle::change::{constraint_matches, Constraint, FetchRequest, SourceChange};
39use rindle::source_common::{compare_rows_rev, RowPredicate};
40use rindle::value::{compare_rows, ColId, OwnedRow as Row, RowRef, RowStream, Sort};
41use rindle::RindleError;
42use rindle::{build_scan_start, constrained_index_sort};
43
44/// D4's default derivation memory budget: 256 MiB of *accounted* delta (see
45/// [`DeltaBudget`]), shared by every table a transaction touches. High enough that no
46/// OLTP transaction or existing test fires it; a bulk load that does trips
47/// [`RindleError::DeltaOverflow`] — the load-shed signal (the host commits the data and
48/// re-hydrates the affected queries) — instead of an OOM. The spill (design §7 / plan
49/// S6) lifts it later.
50///
51/// Sized to land near the row cap it replaced (1M rows) for a *small* row: at ~100 byte
52/// rows with a couple of secondary sorts this trips around 1M rows. The point of the
53/// change is the other end — a 1 KiB row now sheds at ~230k rows instead of holding
54/// ~1.1 GiB, which the row cap permitted.
55pub const DEFAULT_MAX_DELTA_BYTES: usize = 256 * 1024 * 1024;
56
57/// Heap overhead of one `OwnedRow`'s `Arc<[u8]>` allocation: the `ArcInner` header's two
58/// `usize` refcounts, on top of the row's own `byte_len()`.
59const ARC_HEADER_BYTES: usize = 16;
60
61/// What one row costs in **one** B+tree, beyond its shared buffer: a 16-byte
62/// `OwnedRow` fat pointer in the leaf's `Vec<Row>`, doubled to cover `Vec` capacity
63/// slack (a leaf holds 32..=64 rows at a capacity rounded up by doubling), plus the
64/// internal spine (~1/`MAX_NODE_SIZE` of the entries per level) and each `Rc<BNode>`
65/// allocation header.
66///
67/// Measured at ~37 B/slot by `tests/delta_budget_alloc_probe.rs` (which fails the build
68/// if this drifts under what the heap actually does). Rounded up from there on purpose:
69/// over-charging sheds a little early, under-charging is the OOM this budget exists to
70/// prevent.
71const TREE_ENTRY_BYTES: usize = 48;
72
73/// Round an allocation up to the 16-byte granularity a general-purpose allocator
74/// hands out.
75const fn alloc_granular(bytes: usize) -> usize {
76    (bytes + 15) & !15
77}
78
79/// The heap an [`OwnedRow`](Row)'s buffer costs, charged once no matter how many of the
80/// delta's trees reference it — cloning a row between them is an `Arc` bump.
81fn row_bytes(r: &Row) -> usize {
82    alloc_granular(ARC_HEADER_BYTES + r.byte_len())
83}
84
85/// The **derivation memory budget** (design 306 D4) — what bounds a batch delta, in
86/// place of a row count.
87///
88/// A row count is not a memory bound: rows are variable-length `Arc<[u8]>`, so 1M rows
89/// is ~190 MiB of 100-byte rows or ~1.2 GiB of 1 KiB rows, and the cap that protects
90/// the first OOMs on the second. This tracks estimated **bytes**, charged as rows enter
91/// the delta's trees and released as they leave, so the ceiling means the same thing
92/// for every schema.
93///
94/// **One budget spans every table in the transaction.** `rindle-replica`'s `Engine` hands
95/// the same `Rc<DeltaBudget>` to each registered table's delta, so a write touching 20
96/// tables is bounded by the budget, not by 20× it. (Threads do not share: a cluster's
97/// worker engines each get their own, so a process ceiling is `workers × max_bytes` —
98/// tune per-worker, not per-process.)
99///
100/// **The number is an accounted upper bound, not a measurement.** It sums, per delta:
101/// `row_bytes` once per distinct row buffer the delta holds (in `touched`, the
102/// superset — `primary` and the secondaries share those same `Arc`s), plus
103/// `TREE_ENTRY_BYTES` per tree slot across `touched`, `primary`, and each secondary
104/// index. It does not model allocator fragmentation or the connection's own SQLite
105/// pager, and it deliberately rounds against itself.
106/// `tests/delta_budget_alloc_probe.rs` pins it against a live-bytes global allocator and
107/// fails the build if it ever drops below what the heap actually retains.
108pub struct DeltaBudget {
109    max_bytes: Cell<usize>,
110    used: Cell<usize>,
111}
112
113impl DeltaBudget {
114    /// A budget of `max_bytes`, nothing charged against it yet.
115    pub fn new(max_bytes: usize) -> DeltaBudget {
116        DeltaBudget {
117            max_bytes: Cell::new(max_bytes),
118            used: Cell::new(0),
119        }
120    }
121
122    /// Re-tune the ceiling. Takes effect at the next overflow check; safe on a live,
123    /// shared budget — the limit is only ever read, never part of any structure.
124    pub fn set_max_bytes(&self, max_bytes: usize) {
125        self.max_bytes.set(max_bytes);
126    }
127
128    /// The current ceiling.
129    pub fn max_bytes(&self) -> usize {
130        self.max_bytes.get()
131    }
132
133    /// Estimated bytes currently held across every delta sharing this budget.
134    pub fn used(&self) -> usize {
135        self.used.get()
136    }
137
138    fn charge(&self, bytes: usize) {
139        self.used.set(self.used.get().saturating_add(bytes));
140    }
141
142    fn release(&self, bytes: usize) {
143        self.used.set(self.used.get().saturating_sub(bytes));
144    }
145
146    fn exceeded(&self) -> bool {
147        self.used.get() > self.max_bytes.get()
148    }
149}
150
151impl Default for DeltaBudget {
152    fn default() -> Self {
153        DeltaBudget::new(DEFAULT_MAX_DELTA_BYTES)
154    }
155}
156
157/// What the delta knows about a primary key ([`BatchDelta::lookup_pk`]).
158#[derive(Clone, Debug)]
159pub enum DeltaLookup {
160    /// The batch never touched this pk — fall through to the base snapshot.
161    Untouched,
162    /// The batch removed this pk; the base row (if any) is stale. Authoritative: gone.
163    Absent,
164    /// The batch's current derived content for this pk.
165    Present(Row),
166}
167
168struct Inner {
169    /// Every pk the batch touched (present OR removed). Stores whole rows keyed under
170    /// `pk_sort` — the comparator reads only the pk columns, so any version of the row
171    /// is an equivalent key and no probe-row synthesis is needed (plan §3.1).
172    touched: BTree,
173    /// Current derived content of each touched-and-present pk. Ordered by `pk_sort`.
174    /// Invariant: `primary ⊆ touched` (under pk equality).
175    primary: BTree,
176    /// The same rows under each `index_sort` a fetch has asked for — keyed by the SAME
177    /// `constraint columns ++ connection sort` computation `MemorySource::fetch` uses
178    /// (plan §3.2), never by the bare connection sort. `pk_sort` itself is served by
179    /// `primary` directly and never lands here.
180    secondary: HashMap<Sort, BTree>,
181    pk_sort: Sort,
182    primary_key: Vec<ColId>,
183    /// `false` ⇒ every read path short-circuits (the hot fast path: outside a
184    /// derivation the delta must cost nothing).
185    active: bool,
186    /// The batch outgrew the [`DeltaBudget`] and the delta gave up: its trees were
187    /// dropped (the memory bound is the point) and every further [`BatchDelta::apply`]
188    /// re-raises [`RindleError::DeltaOverflow`] without accumulating. Reads while
189    /// overflowed see an empty delta — nothing may derive from it; the host's shed
190    /// path (teardown + re-hydrate) is the defined recovery. Reset by `begin`/`end`.
191    overflowed: bool,
192    /// The budget this delta charges against — shared with every other table in the
193    /// same derivation (design 306 D4).
194    budget: Rc<DeltaBudget>,
195    /// What THIS delta has charged to `budget`, so `begin`/`end`/overflow can release
196    /// exactly its own contribution and leave its peers' alone.
197    owed: usize,
198}
199
200/// The per-source batch delta. Interior-`RefCell` so one `Rc<BatchDelta>` can be shared
201/// between a `TableSource`, its forks, and the engine that drives the lifecycle
202/// (plan D1 — the same idiom as `cursors_open`/`fetch_error`).
203pub struct BatchDelta {
204    inner: RefCell<Inner>,
205}
206
207impl BatchDelta {
208    /// An **inactive** delta over `primary_key` with its own private
209    /// [`DEFAULT_MAX_DELTA_BYTES`] budget. Every embedded caller gets one of these
210    /// implicitly and never pays for it; a host driving several tables replaces it with
211    /// one shared budget via [`set_budget`](Self::set_budget).
212    pub fn new(primary_key: Vec<ColId>) -> BatchDelta {
213        Self::with_budget(primary_key, Rc::new(DeltaBudget::default()))
214    }
215
216    /// An inactive delta charging against `budget` (D4) — pass the same `Rc` to every
217    /// table in a derivation and the ceiling bounds the whole transaction.
218    pub fn with_budget(primary_key: Vec<ColId>, budget: Rc<DeltaBudget>) -> BatchDelta {
219        let pk_sort: Sort = primary_key.iter().map(|&c| (c, true)).collect();
220        BatchDelta {
221            inner: RefCell::new(Inner {
222                touched: BTree::new(),
223                primary: BTree::new(),
224                secondary: HashMap::new(),
225                pk_sort,
226                primary_key,
227                active: false,
228                overflowed: false,
229                budget,
230                owed: 0,
231            }),
232        }
233    }
234
235    /// An inactive delta with a private budget of `max_bytes` — the single-table
236    /// convenience over [`with_budget`](Self::with_budget).
237    pub fn with_max_bytes(primary_key: Vec<ColId>, max_bytes: usize) -> BatchDelta {
238        Self::with_budget(primary_key, Rc::new(DeltaBudget::new(max_bytes)))
239    }
240
241    /// Move this delta onto `budget` — how a host installs one shared ceiling across
242    /// every table it registers, on a delta the source already created (and may already
243    /// have forked: the budget lives behind the shared `RefCell`, so forks follow).
244    /// Anything currently charged moves with it.
245    pub fn set_budget(&self, budget: Rc<DeltaBudget>) {
246        let mut inner = self.inner.borrow_mut();
247        let owed = inner.owed;
248        inner.budget.release(owed);
249        budget.charge(owed);
250        inner.budget = budget;
251    }
252
253    /// The budget this delta charges against (shared — re-tune it with
254    /// [`DeltaBudget::set_max_bytes`], read it with [`DeltaBudget::used`]).
255    pub fn budget(&self) -> Rc<DeltaBudget> {
256        Rc::clone(&self.inner.borrow().budget)
257    }
258
259    /// Estimated bytes **this** delta is holding (its share of `budget().used()`).
260    pub fn used_bytes(&self) -> usize {
261        self.inner.borrow().owed
262    }
263
264    // -- lifecycle (plan §3.3) --
265
266    /// Start a derivation: clear all state and mark active. Replaces the write path's
267    /// `BEGIN CONCURRENT` throwaway transaction. Drops the `secondary` **map** entirely
268    /// (not just its rows) so `M` in the fold cost is bounded by the sorts actually
269    /// fetched during *this* transaction.
270    pub fn begin(&self) {
271        let mut inner = self.inner.borrow_mut();
272        inner.clear();
273        inner.active = true;
274        inner.overflowed = false;
275    }
276
277    /// End a derivation: clear and deactivate. Replaces `execute_batch("ROLLBACK")` and
278    /// is strictly more reliable — there is no path on which a failed or skipped
279    /// rollback leaves derivation state behind.
280    pub fn end(&self) {
281        let mut inner = self.inner.borrow_mut();
282        inner.clear();
283        inner.active = false;
284        inner.overflowed = false;
285    }
286
287    pub fn is_active(&self) -> bool {
288        self.inner.borrow().active
289    }
290
291    pub fn is_empty(&self) -> bool {
292        self.inner.borrow().touched.is_empty()
293    }
294
295    // -- the fold (plan §3.1) --
296
297    /// Fold one change into the delta — the stand-in for `TableSource::write_change`,
298    /// called from exactly where the write is called (after the fan-out drains), so the
299    /// I1/I2 push invariants hold by construction (design §3.1).
300    ///
301    /// Erroring on an inactive delta is deliberate (plan §3.3): after the write closure
302    /// becomes `delta.apply` unconditionally, a push outside an open snapshot must fail
303    /// loudly rather than silently diverge.
304    ///
305    /// A batch whose folded rows outgrow the shared [`DeltaBudget`] trips
306    /// [`RindleError::DeltaOverflow`] (design 306 D4): the delta drops its trees on the
307    /// spot — the budget is a memory bound, so overflowing must not keep holding the
308    /// rows — and every further `apply` re-raises the same error without accumulating.
309    /// The derivation for this batch is unrecoverable; the host sheds (commits the data,
310    /// tears down + re-hydrates the affected queries) rather than failing the write.
311    ///
312    /// The charge is **net**: re-touching rows already in the delta costs nothing beyond
313    /// the change in their content size, so a transaction that rewrites the same working
314    /// set a million times never sheds — only one that holds an unbounded amount of it
315    /// does.
316    ///
317    /// An `Add` of a pk already present is [`RindleError::ConsistencyViolation`] in
318    /// every build (see `Inner::add_row`) — the loud fault the write-then-abort
319    /// path's UNIQUE constraint used to raise, never a silent overwrite.
320    pub fn apply(&self, change: &SourceChange) -> Result<(), RindleError> {
321        let mut inner = self.inner.borrow_mut();
322        if !inner.active {
323            return Err(RindleError::Storage(
324                "batch delta: apply outside an active derivation (no open snapshot)".into(),
325            ));
326        }
327        if inner.overflowed {
328            return Err(inner.overflow_error());
329        }
330        match change {
331            SourceChange::Add(r) => {
332                inner.add_row(r)?;
333            }
334            SourceChange::Remove(r) => {
335                inner.touch(r);
336                inner.delete_stored(r);
337            }
338            SourceChange::Edit { row, old } => {
339                // `Engine::push` normalizes a pk-*changing* Edit to Remove(old) +
340                // Add(row) before the fan-out — but the delta is a public seam, so
341                // fold the same normalization here rather than trusting the caller:
342                // BOTH pks must end up touched, or the old base row stays visible in
343                // merged scans (it used to be a debug_assert with a silently-corrupt
344                // release fallthrough).
345                if compare_rows(&inner.pk_sort, old, row) != Ordering::Equal {
346                    inner.touch(old);
347                }
348                inner.delete_stored(old);
349                inner.add_row(row)?;
350            }
351        }
352        if inner.budget.exceeded() {
353            let err = inner.overflow_error();
354            // Drop the rows AND hand their bytes back: a shed that kept charging would
355            // poison the budget for the peers whose deltas are still within it.
356            inner.clear();
357            inner.overflowed = true;
358            return Err(err);
359        }
360        Ok(())
361    }
362
363    // -- reads (plan §3.4) --
364
365    /// What the delta says about `probe`'s primary key. `probe` is any row carrying the
366    /// pk columns — the pk-sort comparator reads nothing else.
367    pub fn lookup_pk(&self, probe: &Row) -> DeltaLookup {
368        let inner = self.inner.borrow();
369        if !inner.active || !inner.touched.has(probe, &inner.pk_sort) {
370            return DeltaLookup::Untouched;
371        }
372        match inner.primary.get(probe, &inner.pk_sort) {
373            Some(r) => DeltaLookup::Present(r.clone()),
374            None => DeltaLookup::Absent,
375        }
376    }
377
378    /// Whether a vended base row must be suppressed from the scan: its pk was touched,
379    /// so its authoritative content lives in the delta (or nowhere). One pk-sort B-tree
380    /// probe per vended base row — the merge's per-row cost (design §5).
381    pub fn suppresses(&self, row: &Row) -> bool {
382        let inner = self.inner.borrow();
383        inner.active && inner.touched.has(row, &inner.pk_sort)
384    }
385
386    /// The delta row matching every `(col, value)` equality of `key`, if any — the
387    /// delta-first half of a `get_row`/`lookup_unique` point read (plan §4.4). `key` may
388    /// be any unique key, not just the pk, so this scans `primary` linearly
389    /// ([`constraint_matches`] semantics, `values_equal`: null never matches) — bounded
390    /// by the delta, and these are build-time / consistency reads, not the hot path.
391    pub fn find_by_key(&self, key: &[(ColId, rindle::value::OwnedValue)]) -> Option<Row> {
392        let inner = self.inner.borrow();
393        if !inner.active || inner.primary.is_empty() {
394            return None;
395        }
396        let key: Constraint = key.to_vec();
397        let mut cur = inner.primary.values_from(None, true, &inner.pk_sort);
398        while let Some(r) = cur.next_row() {
399            if constraint_matches(r, &key) {
400                return Some(r.clone());
401            }
402        }
403        None
404    }
405
406    /// The delta side of one `fetch` (plan §4.2/§4.3): the rows this transaction's
407    /// delta contributes to a request, narrowed exactly as [`compute_overlays`] narrows
408    /// the single-change overlay — constraint, multi-constraint `any`, `predicate`
409    /// (§2.1: delta rows, like overlay rows, are never seen by SQL, so `predicate` —
410    /// which can be strictly stronger than `sql_condition` — is the rule).
411    ///
412    /// **Lazy** (plan §3.4 as amended): returns a [`DeltaRows`] pull stream over the
413    /// tree's owning cursor rather than a materialized `Vec`, so a fetch costs one
414    /// O(log |delta|) seek plus the rows the merge actually consumes — never
415    /// O(|delta|) per fetch (the O(N²)-per-batch trap under per-change ordered
416    /// re-fetches). The `RefCell` borrow lives only inside this call; the cursor owns
417    /// its `Rc<BNode>` spine (OQ-1), so nothing of the borrow escapes, and a later
418    /// `apply` path-copies around any node a live stream pins.
419    ///
420    /// Vended in **connection-sort order** (`sort`, honoring `req.reverse`): the seek
421    /// index is `constraint columns ++ sort` (§3.2), and restricted to the group an
422    /// equality constraint selects, that order *is* the connection sort — which is what
423    /// makes the output mergeable against SQL's `ORDER BY sort` stream.
424    ///
425    /// `start` is a seek optimization only; the exact `At`/`After` cut is re-applied
426    /// downstream by `generate_with_start`, exactly as the tiebreak path relies on.
427    ///
428    /// [`compute_overlays`]: rindle::source_common::compute_overlays
429    pub fn rows_for_fetch(
430        &self,
431        req: &FetchRequest,
432        sort: &Sort,
433        predicate: Option<&RowPredicate>,
434    ) -> DeltaRows {
435        let constraint = req.constraint.as_ref();
436        let mut inner = self.inner.borrow_mut();
437        if !inner.active || inner.touched.is_empty() {
438            return DeltaRows::empty();
439        }
440
441        // INDEX SORT: the shared `MemorySource::fetch` rule, computed from
442        // `req.constraint` only (never from `multi_constraints` — their fetches
443        // scan the bare-sort index and filter, because the merge order must match
444        // the base stream, plan §4.2).
445        let index_sort = constrained_index_sort(constraint, sort, &inner.primary_key);
446        inner.ensure_index(&index_sort);
447
448        // Seek: constraint values with reverse-aware sentinels for unconstrained
449        // columns; with no constraint, seek to the plain start row (the basis is
450        // applied downstream, not by the seek). Always inclusive.
451        let scan_start: Option<RowBound> = match constraint {
452            Some(c) => Some(build_scan_start(c, &index_sort, req.reverse)),
453            None => req
454                .start
455                .as_ref()
456                .map(|s| row_bound_of(&s.row, &index_sort)),
457        };
458
459        let tree = inner.tree_for(&index_sort);
460        let cur = if req.reverse {
461            tree.values_from_reversed(scan_start.as_ref(), true, &index_sort)
462        } else {
463            tree.values_from(scan_start.as_ref(), true, &index_sort)
464        };
465        DeltaRows {
466            cur: Some(cur),
467            constraint: req.constraint.clone(),
468            multi: req
469                .multi_constraints
470                .iter()
471                .filter(|mc| !mc.is_empty())
472                .cloned()
473                .collect(),
474            predicate: predicate.cloned(),
475        }
476        // `inner`'s borrow ends here; `cur` owns its spine.
477    }
478}
479
480/// The lazily-pulled delta side of one fetch ([`BatchDelta::rows_for_fetch`]): the
481/// owning [`BTreeCursor`] plus the request's narrowing, applied **per pull** —
482/// constraint group-break, multi-constraint `any`, then the predicate (arbitrary
483/// user-compiled code, safe to run here because no `RefCell` borrow is held; the
484/// same guarantee materializing used to buy, now free).
485pub struct DeltaRows {
486    /// `None` after exhaustion — and from birth for the inactive/empty
487    /// short-circuit, which must build no index and touch no tree.
488    cur: Option<BTreeCursor>,
489    /// The group-break: the seek index leads with these columns, so the first
490    /// non-matching row ends the stream (a `break`, not a filter).
491    constraint: Option<Constraint>,
492    /// The non-empty multiConstraints only (an empty entry constrains nothing):
493    /// keep a row iff it matches SOME entry of EVERY one.
494    multi: Vec<Vec<Constraint>>,
495    predicate: Option<RowPredicate>,
496}
497
498impl DeltaRows {
499    fn empty() -> DeltaRows {
500        DeltaRows {
501            cur: None,
502            constraint: None,
503            multi: Vec::new(),
504            predicate: None,
505        }
506    }
507}
508
509impl Iterator for DeltaRows {
510    type Item = Row;
511
512    /// Pull the next matching delta row, owned (an `Arc` bump per vended row).
513    fn next(&mut self) -> Option<Row> {
514        let cur = self.cur.as_mut()?;
515        while let Some(r) = cur.next_row() {
516            if let Some(c) = &self.constraint {
517                if !constraint_matches(r, c) {
518                    // Past the matching group — every later row fails too.
519                    break;
520                }
521            }
522            if !self
523                .multi
524                .iter()
525                .all(|mc| mc.iter().any(|c| constraint_matches(r, c)))
526            {
527                continue;
528            }
529            if let Some(p) = &self.predicate {
530                if !p(r) {
531                    continue;
532                }
533            }
534            return Some(r.clone());
535        }
536        self.cur = None;
537        None
538    }
539}
540
541impl Inner {
542    /// Charge `bytes` to the shared budget, remembering this delta's own share.
543    fn charge(&mut self, bytes: usize) {
544        self.owed = self.owed.saturating_add(bytes);
545        self.budget.charge(bytes);
546    }
547
548    /// Hand `bytes` back.
549    fn release(&mut self, bytes: usize) {
550        self.owed = self.owed.saturating_sub(bytes);
551        self.budget.release(bytes);
552    }
553
554    /// Recompute what this delta *should* owe, straight from the trees — the model in
555    /// [`DeltaBudget`]'s docs, expressed once more as an oracle. `charge`/`release` are
556    /// spread across `touch`/`add_row`/`delete_stored`/`ensure_index`, each of which has
557    /// to mirror the others exactly (a secondary index created between an `add_row` and
558    /// its `delete_stored` changes how many slots that row occupies); this is how that
559    /// symmetry gets machine-checked instead of argued. O(|delta|), debug builds only,
560    /// once per derivation — so every test in the suite validates the accounting.
561    #[cfg(debug_assertions)]
562    fn audit(&self) -> usize {
563        let mut bytes = 0;
564        let mut cur = self.touched.values_from(None, true, &self.pk_sort);
565        while let Some(r) = cur.next_row() {
566            bytes += row_bytes(r);
567        }
568        let slots = self.touched.len()
569            + self.primary.len()
570            + self.secondary.values().map(|t| t.len()).sum::<usize>();
571        bytes + slots * TREE_ENTRY_BYTES
572    }
573
574    /// Drop every tree and return this delta's whole charge to the budget. The one
575    /// place trees are emptied, so no path can drop rows while still holding their
576    /// bytes against the peers sharing the budget.
577    fn clear(&mut self) {
578        // A `#[cfg]` block, not a bare `debug_assert_eq!`: the macro still type-checks
579        // its arguments in release, so `audit` would have to be compiled there too.
580        // This way the O(|delta|) oracle does not exist in a release build at all.
581        #[cfg(debug_assertions)]
582        {
583            let audited = self.audit();
584            debug_assert_eq!(
585                self.owed, audited,
586                "batch delta: budget accounting drifted from what the trees hold"
587            );
588        }
589        self.touched = BTree::new();
590        self.primary = BTree::new();
591        self.secondary = HashMap::new();
592        let owed = self.owed;
593        self.release(owed);
594    }
595
596    /// The D4 shed signal, reporting the shared budget's state at the trip.
597    fn overflow_error(&self) -> RindleError {
598        RindleError::DeltaOverflow {
599            max_bytes: self.budget.max_bytes(),
600            used: self.budget.used(),
601        }
602    }
603
604    /// `touched.add` under the pk sort. Duplicate touches replace the slot (`BTree::add`
605    /// overwrites — any version of the row is an equivalent key).
606    ///
607    /// **This is where row content is charged to the budget, and the only place.**
608    /// `touched` is the superset — every pk the batch touched is here, and after an
609    /// `add_row` its slot holds the very `Arc` `primary` and the secondaries hold — so
610    /// charging here counts each buffer exactly once, and `primary`/secondary
611    /// membership costs only [`TREE_ENTRY_BYTES`]. It is also what makes a bulk DELETE
612    /// accounted at all: those rows live in `touched` and nowhere else.
613    fn touch(&mut self, r: &Row) {
614        let cost = row_bytes(r);
615        match self.touched.add_replacing(r.clone(), &self.pk_sort) {
616            // Replaced: this pk's buffer changed, so charge the difference only —
617            // net accounting is what keeps a re-touched working set from drifting up.
618            Some(prev) => {
619                let prev_cost = row_bytes(&prev);
620                self.charge(cost);
621                self.release(prev_cost);
622            }
623            None => {
624                self.charge(cost + TREE_ENTRY_BYTES);
625            }
626        }
627    }
628
629    /// Insert `r` into `primary` and every secondary index (a row clone is one `Arc`
630    /// bump). Also records the touch.
631    ///
632    /// An ADD of a pk whose content is already present is a malformed change stream —
633    /// a real error in every build, not a debug assert: the write-then-abort path it
634    /// replaced hit the table's UNIQUE pk constraint here (a loud derive fault →
635    /// teardown + re-hydrate), and silently overwriting instead would double-count
636    /// the row in the fan-out while `apply()` reports success. The delta is left
637    /// abandoned (primary overwritten, secondaries untouched) — the error unwinds the
638    /// derivation, and `end()` clears everything before anything reads it.
639    fn add_row(&mut self, r: &Row) -> Result<(), RindleError> {
640        self.touch(r);
641        let newly = self.primary.add(r.clone(), &self.pk_sort);
642        if !newly {
643            return Err(RindleError::ConsistencyViolation {
644                kind: "batch delta: ADD of a pk already present in the delta",
645            });
646        }
647        for (s, tree) in self.secondary.iter_mut() {
648            tree.add(r.clone(), s);
649        }
650        // `primary` + one slot per secondary. The row's *buffer* is already charged by
651        // `touch` and shared with all of them (a clone is an `Arc` bump), so these
652        // memberships cost only their tree slots.
653        let slots = 1 + self.secondary.len();
654        self.charge(slots * TREE_ENTRY_BYTES);
655        Ok(())
656    }
657
658    /// Delete the pk of `probe` from `primary` and every secondary — **by the currently
659    /// stored row, never by the incoming one** (plan §3.1, the one trap): secondary keys
660    /// include non-pk columns, and when a pk is touched twice in one batch the incoming
661    /// `old` is the *original base* row, not what the delta holds. No-op if the pk has
662    /// no stored content (the batch is removing/editing a pure base row).
663    fn delete_stored(&mut self, probe: &Row) {
664        let prev = self.primary.get(probe, &self.pk_sort).cloned();
665        if let Some(prev) = prev {
666            self.primary.delete(&prev, &self.pk_sort);
667            for (s, tree) in self.secondary.iter_mut() {
668                let removed = tree.delete(&prev, s);
669                debug_assert!(removed, "batch delta: secondary index missing a stored row");
670            }
671            // Mirror `add_row`: give back the slots, never the buffer — `touched` still
672            // holds this pk (that is what suppresses the base row) and still owns the
673            // content charge.
674            let slots = 1 + self.secondary.len();
675            self.release(slots * TREE_ENTRY_BYTES);
676        }
677    }
678
679    /// Build the index for `index_sort` from `primary` if it does not exist yet —
680    /// `MemorySource::ensure_index` almost verbatim: own each primary row (`Arc` bumps),
681    /// re-sort under the new comparator, bulk-load (`from_sorted`, O(|D| log |D|),
682    /// bounded by the delta and never by the table). The pk sort itself is served by
683    /// `primary` directly ([`Inner::tree_for`]) and is never duplicated here.
684    fn ensure_index(&mut self, index_sort: &Sort) {
685        if *index_sort == self.pk_sort || self.secondary.contains_key(index_sort) {
686            return;
687        }
688        let mut rows: Vec<Row> = {
689            let mut cur = self.primary.values_from(None, true, &self.pk_sort);
690            let mut v = Vec::with_capacity(self.primary.len());
691            while let Some(r) = cur.next_row() {
692                v.push(r.clone());
693            }
694            v
695        };
696        rows.sort_by(|a, b| compare_rows(index_sort, a, b));
697        // One new slot per primary row (the buffers are the same `Arc`s). Charged, but
698        // NOT checked here: `ensure_index` is on the read path, which has no way to
699        // report an overflow — the next `apply` sees the raised total and sheds.
700        self.charge(rows.len() * TREE_ENTRY_BYTES);
701        self.secondary.insert(
702            index_sort.clone(),
703            BTree::from_sorted(rows.into_iter(), index_sort),
704        );
705    }
706
707    /// The tree serving `index_sort`: `primary` for the pk sort, else the (already
708    /// ensured) secondary.
709    fn tree_for(&self, index_sort: &Sort) -> &BTree {
710        if *index_sort == self.pk_sort {
711            &self.primary
712        } else {
713            self.secondary
714                .get(index_sort)
715                .expect("ensure_index built it")
716        }
717    }
718}
719
720// ---------------------------------------------------------------------------
721// The merge (plan §4.2): base ⊎ delta, under the connection sort
722// ---------------------------------------------------------------------------
723
724/// Two-way sorted merge of the SQL leaf stream and the delta's rows for one fetch
725/// (design §5, plan §4.2). Sits *below* `generate_with_overlay_checked`, exactly where
726/// `TiebreakStream` sits, so every downstream consumer (the overlay splice, the start
727/// cut) is unchanged.
728///
729/// - **base side** — the SQL cursor, dropping any row whose pk the delta touched
730///   ([`BatchDelta::suppresses`] — its authoritative content is on the delta side, or
731///   nowhere).
732/// - **delta side** — [`BatchDelta::rows_for_fetch`], already narrowed (constraint /
733///   multi-constraint / predicate) and in connection-sort order.
734/// - **merged** under the **connection `sort`** — the comparator the overlay splice
735///   receives. There is no `index_sort` in `TableSource::fetch`; the delta's
736///   constraint-prefixed index is a *seek* structure whose group order coincides with
737///   the connection sort (plan §4.2).
738///
739/// Owning each base row here costs nothing net: `LeafRows` above owns every row it
740/// pulls anyway, and its `to_owned_row` on the vended `&OwnedRow` degrades to an `Arc`
741/// bump. Like `TiebreakStream`, this inherits `LeafRows`' error duty — a
742/// `try_to_owned_row` failure is parked in the shared fetch error sink.
743pub(crate) struct BatchMerge<S: RowStream> {
744    base: S,
745    delta: Rc<BatchDelta>,
746    delta_rows: DeltaRows,
747    base_peek: Option<Row>,
748    delta_peek: Option<Row>,
749    sort: Sort,
750    reverse: bool,
751    /// The row being lent out (the lending `RowStream` contract).
752    current: Option<Row>,
753    base_done: bool,
754    error_sink: Rc<RefCell<Option<RindleError>>>,
755}
756
757impl<S: RowStream> BatchMerge<S> {
758    pub(crate) fn new(
759        base: S,
760        delta: Rc<BatchDelta>,
761        req: &FetchRequest,
762        sort: &Sort,
763        predicate: Option<&RowPredicate>,
764        error_sink: Rc<RefCell<Option<RindleError>>>,
765    ) -> BatchMerge<S> {
766        let delta_rows = delta.rows_for_fetch(req, sort, predicate);
767        BatchMerge {
768            base,
769            delta,
770            delta_rows,
771            base_peek: None,
772            delta_peek: None,
773            sort: sort.clone(),
774            reverse: req.reverse,
775            current: None,
776            base_done: false,
777            error_sink,
778        }
779    }
780
781    /// Pull the next non-suppressed base row, owned (the shared [`pull_base_row`]).
782    fn pull_base(&mut self) -> Option<Row> {
783        pull_base_row(
784            &mut self.base,
785            &mut self.base_done,
786            &self.delta,
787            &self.error_sink,
788        )
789    }
790}
791
792/// Pull the next non-suppressed base row, owned — the base-side loop both merge
793/// shapes share. Parks a materialization error into the shared sink and reports
794/// end-of-stream (matching `LeafRows::next`).
795fn pull_base_row<S: RowStream>(
796    base: &mut S,
797    base_done: &mut bool,
798    delta: &BatchDelta,
799    error_sink: &RefCell<Option<RindleError>>,
800) -> Option<Row> {
801    if *base_done {
802        return None;
803    }
804    loop {
805        match base.next_row() {
806            None => {
807                *base_done = true;
808                return None;
809            }
810            Some(r) => match r.try_to_owned_row() {
811                Ok(row) => {
812                    if delta.suppresses(&row) {
813                        continue;
814                    }
815                    return Some(row);
816                }
817                Err(err) => {
818                    *error_sink.borrow_mut() = Some(err);
819                    *base_done = true;
820                    return None;
821                }
822            },
823        }
824    }
825}
826
827impl<S: RowStream> RowStream for BatchMerge<S> {
828    type Row<'a>
829        = &'a Row
830    where
831        Self: 'a;
832
833    fn next_row(&mut self) -> Option<&Row> {
834        if self.base_peek.is_none() {
835            self.base_peek = self.pull_base();
836        }
837        if self.delta_peek.is_none() {
838            self.delta_peek = self.delta_rows.next();
839        }
840        let take_delta = match (&self.base_peek, &self.delta_peek) {
841            (None, None) => return None,
842            (Some(_), None) => false,
843            (None, Some(_)) => true,
844            // A tie is impossible (sort includes the full pk, and a base row whose pk
845            // the delta holds was suppressed); delta-first on a tie is inert.
846            (Some(b), Some(d)) => {
847                compare_rows_rev(&self.sort, self.reverse, d, b) != Ordering::Greater
848            }
849        };
850        self.current = if take_delta {
851            self.delta_peek.take()
852        } else {
853            self.base_peek.take()
854        };
855        self.current.as_ref()
856    }
857}
858
859/// The unordered peer of [`BatchMerge`] for `unordered` connections: delta rows are
860/// vended first (order is not a contract here — mirroring the unordered overlay's
861/// eager head injection), then the base stream with touched pks suppressed.
862pub(crate) struct BatchMergeUnordered<S: RowStream> {
863    base: S,
864    delta: Rc<BatchDelta>,
865    delta_rows: DeltaRows,
866    current: Option<Row>,
867    base_done: bool,
868    error_sink: Rc<RefCell<Option<RindleError>>>,
869}
870
871impl<S: RowStream> BatchMergeUnordered<S> {
872    /// `req` must carry no `start` (nothing downstream of the unordered splice
873    /// re-applies a cut); the caller strips it.
874    pub(crate) fn new(
875        base: S,
876        delta: Rc<BatchDelta>,
877        req: &FetchRequest,
878        sort: &Sort,
879        predicate: Option<&RowPredicate>,
880        error_sink: Rc<RefCell<Option<RindleError>>>,
881    ) -> BatchMergeUnordered<S> {
882        debug_assert!(req.start.is_none(), "unordered merge with a start bound");
883        let delta_rows = delta.rows_for_fetch(req, sort, predicate);
884        BatchMergeUnordered {
885            base,
886            delta,
887            delta_rows,
888            current: None,
889            base_done: false,
890            error_sink,
891        }
892    }
893}
894
895impl<S: RowStream> RowStream for BatchMergeUnordered<S> {
896    type Row<'a>
897        = &'a Row
898    where
899        Self: 'a;
900
901    fn next_row(&mut self) -> Option<&Row> {
902        if let Some(d) = self.delta_rows.next() {
903            self.current = Some(d);
904            return self.current.as_ref();
905        }
906        self.current = pull_base_row(
907            &mut self.base,
908            &mut self.base_done,
909            &self.delta,
910            &self.error_sink,
911        );
912        self.current.as_ref()
913    }
914}
915
916#[cfg(test)]
917impl BatchDelta {
918    /// Test convenience: drain [`Self::rows_for_fetch`] (tests compare whole sets).
919    fn fetch_vec(
920        &self,
921        req: &FetchRequest,
922        sort: &Sort,
923        predicate: Option<&RowPredicate>,
924    ) -> Vec<Row> {
925        self.rows_for_fetch(req, sort, predicate).collect()
926    }
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932    use rindle::change::{Basis, Start};
933    use rindle::value::{owned_row, OwnedValue as V};
934    use std::rc::Rc;
935
936    /// Rows are `(id, grp, ord)` with pk = [0].
937    fn row(id: i64, grp: i64, ord: i64) -> Row {
938        owned_row(vec![V::Int(id), V::Int(grp), V::Int(ord)])
939    }
940
941    fn delta() -> BatchDelta {
942        let d = BatchDelta::new(vec![0]);
943        d.begin();
944        d
945    }
946
947    /// `(grp asc, ord asc, id asc)` — a connection sort including the pk.
948    fn grp_ord_sort() -> Sort {
949        vec![(1, true), (2, true), (0, true)]
950    }
951
952    fn ids(rows: &[Row]) -> Vec<i64> {
953        rows.iter()
954            .map(|r| match r.col(0) {
955                rindle::value::Value::Int(i) => i,
956                other => panic!("non-int id {other:?}"),
957            })
958            .collect()
959    }
960
961    /// Assert every tree's structural invariants — free to call, and the cheapest
962    /// guard on the §3.1 stale-secondary trap.
963    fn check(d: &BatchDelta) {
964        let inner = d.inner.borrow();
965        inner.touched.check_invariants(&inner.pk_sort).unwrap();
966        inner.primary.check_invariants(&inner.pk_sort).unwrap();
967        for (s, tree) in &inner.secondary {
968            tree.check_invariants(s).unwrap();
969            // Every secondary must hold exactly primary's rows.
970            assert_eq!(
971                tree.len(),
972                inner.primary.len(),
973                "secondary/primary size drift"
974            );
975        }
976        // primary ⊆ touched under pk equality.
977        let mut cur = inner.primary.values_from(None, true, &inner.pk_sort);
978        while let Some(r) = cur.next_row() {
979            assert!(
980                inner.touched.has(r, &inner.pk_sort),
981                "primary row not in touched"
982            );
983        }
984    }
985
986    fn apply(d: &BatchDelta, c: SourceChange) {
987        d.apply(&c).unwrap();
988        check(d);
989    }
990
991    #[test]
992    fn fold_add_remove_edit() {
993        let d = delta();
994        assert!(d.is_active());
995        assert!(d.is_empty());
996
997        apply(&d, SourceChange::Add(row(1, 10, 100)));
998        assert!(!d.is_empty());
999        assert!(matches!(
1000            d.lookup_pk(&row(1, 0, 0)),
1001            DeltaLookup::Present(_)
1002        ));
1003        assert!(d.suppresses(&row(1, 99, 99)));
1004        assert!(!d.suppresses(&row(2, 10, 100)));
1005
1006        // Remove a pure base row: pk becomes touched-and-absent.
1007        apply(&d, SourceChange::Remove(row(2, 10, 100)));
1008        assert!(matches!(d.lookup_pk(&row(2, 0, 0)), DeltaLookup::Absent));
1009        assert!(d.suppresses(&row(2, 10, 100)));
1010
1011        // Edit a pure base row: touched, present with the new content.
1012        apply(
1013            &d,
1014            SourceChange::Edit {
1015                row: row(3, 11, 300),
1016                old: row(3, 10, 100),
1017            },
1018        );
1019        match d.lookup_pk(&row(3, 0, 0)) {
1020            DeltaLookup::Present(r) => assert_eq!(ids(std::slice::from_ref(&r)), vec![3]),
1021            other => panic!("expected Present, got {other:?}"),
1022        }
1023
1024        assert!(matches!(d.lookup_pk(&row(4, 0, 0)), DeltaLookup::Untouched));
1025    }
1026
1027    /// The §3.1 trap, named: after edit-then-edit the secondary must hold exactly ONE
1028    /// version of the row — deleting by the incoming `old` (the original base row)
1029    /// instead of the stored version would leave the intermediate version behind and
1030    /// ordered fetches would emit the pk twice.
1031    #[test]
1032    fn edit_then_edit_leaves_no_stale_secondary() {
1033        let sort = grp_ord_sort();
1034        // Two variants of the second edit's `old`: the previous derived version (what
1035        // a live capture stream reports) and the ORIGINAL base row (a stale `old` with
1036        // the right pk — the exact §3.1 divergence). Deleting by the stored row makes
1037        // both correct; deleting by the incoming `old` breaks the second.
1038        let base = row(1, 10, 100);
1039        let v1 = row(1, 20, 200);
1040        let v2 = row(1, 30, 300);
1041        for stale_old in [v1.clone(), base.clone()] {
1042            let d = delta();
1043            // Materialize the secondary FIRST so the fold must maintain it eagerly.
1044            assert!(d.fetch_vec(&FetchRequest::all(), &sort, None).is_empty());
1045            apply(
1046                &d,
1047                SourceChange::Edit {
1048                    row: v1.clone(),
1049                    old: base.clone(),
1050                },
1051            );
1052            apply(
1053                &d,
1054                SourceChange::Edit {
1055                    row: v2.clone(),
1056                    old: stale_old,
1057                },
1058            );
1059
1060            let got = d.fetch_vec(&FetchRequest::all(), &sort, None);
1061            assert_eq!(got.len(), 1, "stale secondary entry: {}", got.len());
1062            assert_eq!(ids(&got), vec![1]);
1063            match d.lookup_pk(&row(1, 0, 0)) {
1064                DeltaLookup::Present(r) => {
1065                    assert!(matches!(r.col(1), rindle::value::Value::Int(30)))
1066                }
1067                other => panic!("expected Present, got {other:?}"),
1068            }
1069        }
1070    }
1071
1072    #[test]
1073    fn edit_then_delete_and_delete_then_insert() {
1074        let d = delta();
1075        let sort = grp_ord_sort();
1076        assert!(d.fetch_vec(&FetchRequest::all(), &sort, None).is_empty());
1077
1078        // edit-then-delete: the delete's `old` is the base version.
1079        let base = row(1, 10, 100);
1080        apply(
1081            &d,
1082            SourceChange::Edit {
1083                row: row(1, 20, 200),
1084                old: base.clone(),
1085            },
1086        );
1087        apply(&d, SourceChange::Remove(base));
1088        assert!(matches!(d.lookup_pk(&row(1, 0, 0)), DeltaLookup::Absent));
1089        assert!(d.fetch_vec(&FetchRequest::all(), &sort, None).is_empty());
1090
1091        // delete-then-insert (same pk, new content).
1092        apply(&d, SourceChange::Remove(row(2, 10, 100)));
1093        apply(&d, SourceChange::Add(row(2, 50, 500)));
1094        let got = d.fetch_vec(&FetchRequest::all(), &sort, None);
1095        assert_eq!(ids(&got), vec![2]);
1096        assert!(matches!(got[0].col(1), rindle::value::Value::Int(50)));
1097    }
1098
1099    /// A sort-key-changing edit must MOVE the row in the secondary, not duplicate it.
1100    #[test]
1101    fn sort_key_changing_edit_moves_in_secondary() {
1102        let d = delta();
1103        let sort = grp_ord_sort();
1104        apply(&d, SourceChange::Add(row(1, 10, 100)));
1105        apply(&d, SourceChange::Add(row(2, 20, 200)));
1106        // Build the index between changes, then move row 1 past row 2.
1107        assert_eq!(
1108            ids(&d.fetch_vec(&FetchRequest::all(), &sort, None)),
1109            vec![1, 2]
1110        );
1111        apply(
1112            &d,
1113            SourceChange::Edit {
1114                row: row(1, 30, 300),
1115                old: row(1, 10, 100),
1116            },
1117        );
1118        assert_eq!(
1119            ids(&d.fetch_vec(&FetchRequest::all(), &sort, None)),
1120            vec![2, 1]
1121        );
1122    }
1123
1124    /// A lazily-built index must agree with one maintained eagerly across the same fold.
1125    #[test]
1126    fn lazy_index_agrees_with_eager() {
1127        let changes = |d: &BatchDelta| {
1128            apply(d, SourceChange::Add(row(3, 30, 1)));
1129            apply(d, SourceChange::Add(row(1, 10, 3)));
1130            apply(
1131                d,
1132                SourceChange::Edit {
1133                    row: row(3, 5, 2),
1134                    old: row(3, 30, 1),
1135                },
1136            );
1137            apply(d, SourceChange::Remove(row(2, 20, 2)));
1138            apply(d, SourceChange::Add(row(4, 10, 0)));
1139        };
1140        let sort = grp_ord_sort();
1141
1142        // Eager: index exists before any change.
1143        let eager = delta();
1144        assert!(eager
1145            .fetch_vec(&FetchRequest::all(), &sort, None)
1146            .is_empty());
1147        changes(&eager);
1148
1149        // Lazy: index built only at the end.
1150        let lazy = delta();
1151        changes(&lazy);
1152
1153        let e = eager.fetch_vec(&FetchRequest::all(), &sort, None);
1154        let l = lazy.fetch_vec(&FetchRequest::all(), &sort, None);
1155        assert_eq!(ids(&e), ids(&l));
1156        // grp asc, ord asc: (5,2)=3, (10,0)=4, (10,3)=1.
1157        assert_eq!(ids(&e), vec![3, 4, 1]);
1158    }
1159
1160    #[test]
1161    fn rows_for_fetch_constraint_and_reverse() {
1162        let d = delta();
1163        let sort = grp_ord_sort();
1164        apply(&d, SourceChange::Add(row(1, 10, 3)));
1165        apply(&d, SourceChange::Add(row(2, 10, 1)));
1166        apply(&d, SourceChange::Add(row(3, 20, 2)));
1167
1168        // Constraint on grp: only the group, in connection-sort order.
1169        let req = FetchRequest::with_constraint(vec![(1, V::Int(10))]);
1170        assert_eq!(ids(&d.fetch_vec(&req, &sort, None)), vec![2, 1]);
1171
1172        // Reverse.
1173        let req_rev = FetchRequest {
1174            constraint: Some(vec![(1, V::Int(10))]),
1175            reverse: true,
1176            ..Default::default()
1177        };
1178        assert_eq!(ids(&d.fetch_vec(&req_rev, &sort, None)), vec![1, 2]);
1179
1180        // Non-matching constraint: empty.
1181        let req_none = FetchRequest::with_constraint(vec![(1, V::Int(99))]);
1182        assert!(d.fetch_vec(&req_none, &sort, None).is_empty());
1183
1184        // Fully-constrained single-column pk: the index skips the connection sort.
1185        let req_pk = FetchRequest::with_constraint(vec![(0, V::Int(2))]);
1186        assert_eq!(ids(&d.fetch_vec(&req_pk, &sort, None)), vec![2]);
1187    }
1188
1189    #[test]
1190    fn rows_for_fetch_multi_constraint_and_predicate() {
1191        let d = delta();
1192        let sort = grp_ord_sort();
1193        apply(&d, SourceChange::Add(row(1, 10, 1)));
1194        apply(&d, SourceChange::Add(row(2, 20, 2)));
1195        apply(&d, SourceChange::Add(row(3, 30, 3)));
1196
1197        // multiConstraint: grp IN (10, 30) — bare-sort index, linear filter.
1198        let req = FetchRequest {
1199            multi_constraints: vec![vec![vec![(1, V::Int(10))], vec![(1, V::Int(30))]]],
1200            ..Default::default()
1201        };
1202        assert_eq!(ids(&d.fetch_vec(&req, &sort, None)), vec![1, 3]);
1203
1204        // An empty multiConstraint entry constrains nothing.
1205        let req_empty = FetchRequest {
1206            multi_constraints: vec![vec![]],
1207            ..Default::default()
1208        };
1209        assert_eq!(ids(&d.fetch_vec(&req_empty, &sort, None)), vec![1, 2, 3]);
1210
1211        // Predicate narrows delta rows (§2.1 — the overlay rule).
1212        let p: RowPredicate = Rc::new(|r: &Row| !matches!(r.col(1), rindle::value::Value::Int(20)));
1213        assert_eq!(
1214            ids(&d.fetch_vec(&FetchRequest::all(), &sort, Some(&p))),
1215            vec![1, 3]
1216        );
1217    }
1218
1219    #[test]
1220    fn rows_for_fetch_start_seek_is_inclusive_and_relaxed() {
1221        let d = delta();
1222        let sort = grp_ord_sort();
1223        apply(&d, SourceChange::Add(row(1, 10, 1)));
1224        apply(&d, SourceChange::Add(row(2, 20, 2)));
1225        apply(&d, SourceChange::Add(row(3, 30, 3)));
1226
1227        // Start at row 2 (Basis::After): the seek is inclusive — row 2 is still
1228        // returned; the exact After cut is generate_with_start's job downstream.
1229        let req = FetchRequest {
1230            start: Some(Start {
1231                row: row(2, 20, 2),
1232                basis: Basis::After,
1233            }),
1234            ..Default::default()
1235        };
1236        assert_eq!(ids(&d.fetch_vec(&req, &sort, None)), vec![2, 3]);
1237    }
1238
1239    #[test]
1240    fn budget_overflow_sheds_typed_sticky_and_dropped() {
1241        // Start unbounded and let the delta tell us what two rows cost, so the test
1242        // pins the *mechanism* and not the current value of TREE_ENTRY_BYTES.
1243        let d = BatchDelta::with_max_bytes(vec![0], usize::MAX);
1244        d.begin();
1245        d.apply(&SourceChange::Add(row(1, 1, 1))).unwrap();
1246        d.apply(&SourceChange::Add(row(2, 2, 2))).unwrap();
1247        let two_rows = d.used_bytes();
1248        assert!(two_rows > 0, "a live delta charges the budget");
1249
1250        // Re-touching an already-counted pk is net-zero — the charge tracks what is
1251        // HELD, not how many changes flowed through.
1252        d.apply(&SourceChange::Edit {
1253            row: row(2, 9, 9),
1254            old: row(2, 2, 2),
1255        })
1256        .unwrap();
1257        assert_eq!(d.used_bytes(), two_rows, "a same-size re-touch is net-zero");
1258
1259        // Pinned at exactly what it is holding, a third distinct pk trips the typed D4
1260        // shed signal.
1261        d.budget().set_max_bytes(two_rows);
1262        let err = d.apply(&SourceChange::Add(row(3, 3, 3))).unwrap_err();
1263        assert!(matches!(err, RindleError::DeltaOverflow { max_bytes, used }
1264                     if max_bytes == two_rows && used > two_rows));
1265        // Overflow DROPS the accumulated rows AND their charge — a delta that shed but
1266        // kept owing would keep every peer sharing the budget shed forever.
1267        assert!(d.is_empty());
1268        assert_eq!(d.used_bytes(), 0);
1269        assert_eq!(d.budget().used(), 0);
1270        // …and is sticky: further applies re-raise without accumulating.
1271        let err = d.apply(&SourceChange::Add(row(4, 4, 4))).unwrap_err();
1272        assert!(matches!(err, RindleError::DeltaOverflow { .. }));
1273        assert!(d.is_empty());
1274        assert_eq!(d.budget().used(), 0);
1275        // The next derivation starts clean.
1276        d.end();
1277        d.budget().set_max_bytes(usize::MAX);
1278        d.begin();
1279        d.apply(&SourceChange::Add(row(5, 5, 5))).unwrap();
1280        assert!(!d.is_empty());
1281        // The ceiling is re-tunable on the shared handle, mid-derivation.
1282        d.budget().set_max_bytes(1);
1283        let err = d.apply(&SourceChange::Add(row(6, 6, 6))).unwrap_err();
1284        assert!(matches!(
1285            err,
1286            RindleError::DeltaOverflow { max_bytes: 1, .. }
1287        ));
1288    }
1289
1290    /// The budget is the TRANSACTION's, not each table's: deltas sharing one
1291    /// `DeltaBudget` charge one pool, and each hands back only its own share.
1292    #[test]
1293    fn a_shared_budget_bounds_every_table_together() {
1294        let budget = Rc::new(DeltaBudget::new(usize::MAX));
1295        let a = BatchDelta::with_budget(vec![0], Rc::clone(&budget));
1296        let b = BatchDelta::with_budget(vec![0], Rc::clone(&budget));
1297        a.begin();
1298        b.begin();
1299
1300        a.apply(&SourceChange::Add(row(1, 1, 1))).unwrap();
1301        let one_row = budget.used();
1302        b.apply(&SourceChange::Add(row(1, 1, 1))).unwrap();
1303        assert_eq!(
1304            budget.used(),
1305            2 * one_row,
1306            "both tables charge the same pool"
1307        );
1308        assert_eq!(a.used_bytes(), one_row, "…and each knows its own share");
1309        assert_eq!(b.used_bytes(), one_row);
1310
1311        // Pinned at the pool's current total, the NEXT row on either table sheds —
1312        // which is the point: 20 tables cannot hold 20x the ceiling.
1313        budget.set_max_bytes(budget.used());
1314        assert!(b.apply(&SourceChange::Add(row(2, 2, 2))).is_err());
1315        // b shed its own rows; a is untouched and still owes exactly its share.
1316        assert!(b.is_empty());
1317        assert!(!a.is_empty());
1318        assert_eq!(budget.used(), one_row);
1319        assert_eq!(a.used_bytes(), one_row);
1320
1321        // Ending a derivation returns that delta's share and nothing else.
1322        a.end();
1323        assert_eq!(budget.used(), 0);
1324    }
1325
1326    /// A wide row costs more of the budget than a narrow one — the whole reason this
1327    /// is bytes and not a row count.
1328    #[test]
1329    fn wide_rows_cost_more_than_narrow_ones() {
1330        let narrow = BatchDelta::with_max_bytes(vec![0], usize::MAX);
1331        narrow.begin();
1332        narrow.apply(&SourceChange::Add(row(1, 1, 1))).unwrap();
1333
1334        let wide = BatchDelta::with_max_bytes(vec![0], usize::MAX);
1335        wide.begin();
1336        wide.apply(&SourceChange::Add(owned_row(vec![
1337            V::Int(1),
1338            V::Int(1),
1339            V::Str("x".repeat(4096).into()),
1340        ])))
1341        .unwrap();
1342
1343        assert!(
1344            wide.used_bytes() > narrow.used_bytes() + 4000,
1345            "a 4 KiB row must cost ~4 KiB more than a 24-byte one: \
1346             wide={} narrow={}",
1347            wide.used_bytes(),
1348            narrow.used_bytes()
1349        );
1350    }
1351
1352    /// A duplicate ADD is a loud consistency error in every build — the parity
1353    /// replacement for the UNIQUE-constraint fault the write-then-abort path raised —
1354    /// never a silent overwrite that double-counts the row.
1355    #[test]
1356    fn duplicate_add_is_a_consistency_error() {
1357        let d = delta();
1358        apply(&d, SourceChange::Add(row(1, 10, 100)));
1359        let err = d.apply(&SourceChange::Add(row(1, 20, 200))).unwrap_err();
1360        assert!(matches!(err, RindleError::ConsistencyViolation { .. }));
1361        // delete-then-insert of the same pk stays legal (the pk has no stored content).
1362        apply(&d, SourceChange::Remove(row(2, 10, 100)));
1363        apply(&d, SourceChange::Add(row(2, 50, 500)));
1364    }
1365
1366    /// A pk-changing Edit folds as Remove(old) + Add(row) — the same normalization
1367    /// `Engine::push` applies upstream — so BOTH pks are touched: the old base row is
1368    /// suppressed from merged scans and the new content is present. (This used to be
1369    /// a debug_assert whose release fallthrough left the old base row visible.)
1370    #[test]
1371    fn pk_changing_edit_folds_as_remove_plus_add() {
1372        let d = delta();
1373        let sort = grp_ord_sort();
1374        // Materialize the secondary FIRST so the fold must maintain it eagerly.
1375        assert!(d.fetch_vec(&FetchRequest::all(), &sort, None).is_empty());
1376        apply(
1377            &d,
1378            SourceChange::Edit {
1379                row: row(2, 20, 200),
1380                old: row(1, 10, 100),
1381            },
1382        );
1383        // The OLD pk is touched-and-absent (its base row must be suppressed)…
1384        assert!(d.suppresses(&row(1, 10, 100)));
1385        assert!(matches!(d.lookup_pk(&row(1, 0, 0)), DeltaLookup::Absent));
1386        // …and the new pk carries the content.
1387        assert!(matches!(
1388            d.lookup_pk(&row(2, 0, 0)),
1389            DeltaLookup::Present(_)
1390        ));
1391        let got = d.fetch_vec(&FetchRequest::all(), &sort, None);
1392        assert_eq!(ids(&got), vec![2]);
1393    }
1394
1395    #[test]
1396    fn apply_outside_derivation_is_an_error() {
1397        let d = BatchDelta::new(vec![0]);
1398        assert!(!d.is_active());
1399        let err = d.apply(&SourceChange::Add(row(1, 1, 1))).unwrap_err();
1400        assert!(matches!(err, RindleError::Storage(_)));
1401    }
1402
1403    /// The inactive/empty short-circuits: every read answers without building state.
1404    #[test]
1405    fn inactive_and_empty_short_circuits() {
1406        let d = BatchDelta::new(vec![0]);
1407        let sort = grp_ord_sort();
1408        assert!(matches!(d.lookup_pk(&row(1, 0, 0)), DeltaLookup::Untouched));
1409        assert!(!d.suppresses(&row(1, 0, 0)));
1410        assert!(d.fetch_vec(&FetchRequest::all(), &sort, None).is_empty());
1411        assert!(d.find_by_key(&[(0, V::Int(1))]).is_none());
1412        // No secondary index was materialized by any of the above.
1413        assert!(d.inner.borrow().secondary.is_empty());
1414
1415        // begin → end clears state and deactivates.
1416        d.begin();
1417        d.apply(&SourceChange::Add(row(1, 1, 1))).unwrap();
1418        assert!(!d.is_empty());
1419        d.end();
1420        assert!(!d.is_active());
1421        assert!(d.is_empty());
1422        assert!(matches!(d.lookup_pk(&row(1, 0, 0)), DeltaLookup::Untouched));
1423    }
1424
1425    /// `begin` drops the secondary MAP (plan §3.3), so M is bounded by the sorts
1426    /// fetched during the current transaction, not all history.
1427    #[test]
1428    fn begin_drops_secondary_indexes() {
1429        let d = delta();
1430        let sort = grp_ord_sort();
1431        apply(&d, SourceChange::Add(row(1, 1, 1)));
1432        assert_eq!(
1433            ids(&d.fetch_vec(&FetchRequest::all(), &sort, None)),
1434            vec![1]
1435        );
1436        assert_eq!(d.inner.borrow().secondary.len(), 1);
1437        d.begin();
1438        assert!(d.inner.borrow().secondary.is_empty());
1439    }
1440
1441    #[test]
1442    fn find_by_key_prefers_delta_content() {
1443        let d = delta();
1444        apply(&d, SourceChange::Add(row(1, 10, 100)));
1445        // pk key.
1446        assert!(d.find_by_key(&[(0, V::Int(1))]).is_some());
1447        // non-pk (unique-ish) key over delta content.
1448        let hit = d.find_by_key(&[(1, V::Int(10)), (2, V::Int(100))]).unwrap();
1449        assert_eq!(ids(&[hit]), vec![1]);
1450        assert!(d.find_by_key(&[(1, V::Int(99))]).is_none());
1451    }
1452}