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}