rindle/graph.rs
1//! Primitive #1: the operator graph as an **arena + `NodeId`**, NOT
2//! `Rc<RefCell<dyn Operator>>`.
3//!
4//! Why this is the make-or-break decision (verified against `join.ts`):
5//! `#pushChildChange` sets in-progress state then re-enters `parent.fetch()`
6//! *while a push is in flight*, and self-joins put the same source in the graph
7//! twice. The "obvious" `Rc<RefCell<dyn Operator>>` translation would hit a
8//! runtime `BorrowMutError` on exactly that pattern. The arena fixes it: the
9//! whole pipeline is one `Vec<Operator>` addressed by `NodeId`, every
10//! fetch/push takes `&self` (a *shared* borrow of the graph), and per-operator
11//! mutable state lives behind `Cell`/`RefCell` scoped to a single statement —
12//! never held across a vend. Reentrant fetch-during-push is then just another
13//! shared borrow. No cycles-of-owners, no borrow conflict.
14//!
15//! The leaf [`MemorySource`] (the connection/read/COW machinery) lives in
16//! `memory_source.rs`; this module hosts the operator graph that wires it up
17//! (`SourceConn`, `Join`, `View`, `Collector`) and drives the eager push fan-out
18//! ([`Graph::source_push`](crate::graph::Graph::source_push)), which is inherently graph-level (it must reach
19//! downstream operators by `NodeId`).
20
21use std::cell::{Cell, RefCell};
22use std::rc::Rc;
23
24use crate::canon::CanonKey;
25use crate::change::{
26 build_join_constraint, materialize_change_edit_old_row_only,
27 materialize_change_preserving_node, rebuild_change, Change, ChangeType, Constraint,
28 FetchRequest, JoinOverlay, Node, NodeStream, OutEdge, Port, Relationship, RowFlow,
29 SourceChange,
30};
31use crate::error::RindleError;
32use crate::family::{BindingSet, FamilyPipeline};
33use crate::memory_source::MemorySource;
34use crate::op::join_util::{canonical_key_of_constraint, Frontier, ParentKeySet, Probe};
35use crate::op::{is_join_match, row_equals_for_compound_key};
36use crate::predicate::CompiledPredicate;
37use crate::source_common::{Connection, ConnectionFilters};
38use crate::storage::{Storage, StorageFactory};
39use crate::{metric_change_kind, metric_changes_inc, metric_inc, metric_timer};
40// `Row` aliases the canonical owned row; the operator graph is all owned values
41// (it buffers, overlays, and materializes rows), so no `Value<'a>` appears here.
42use crate::value::{
43 compare_rows, compare_values, same_pk, ColId, OwnedRow as Row, OwnedValue, RelId, Schema, Sort,
44 SourceSchema,
45};
46
47/// How many fan-out loop iterations pass between wall-clock reads at a push-deadline
48/// checkpoint ([`Graph::push_deadline_exceeded`](crate::graph::Graph::push_deadline_exceeded), FOLLOWER-LAG-SHED §6.6): amortizes the
49/// ~tens-of-ns clock read to nothing while still bailing within O(1024 · per-iteration cost)
50/// of the deadline.
51const DEADLINE_CHECK_EVERY: u32 = 1024;
52
53/// A handle to an operator slot in the [`Graph`] arena. `idx` is the slot index;
54/// `gen` is the slot's **generation**, bumped every time the slot is freed (a
55/// pipeline teardown — [`Graph::destroy_pipeline`](crate::graph::Graph::destroy_pipeline)). The generation is what makes
56/// slot REUSE safe: a stale handle to a torn-down pipeline carries the old `gen`, so
57/// it fails the generation check in `Graph::node` instead of silently aliasing the
58/// new tenant of a recycled slot. Distinct from physical removal (which would shift
59/// indices and is impossible here) — the slot keeps its index across reuse.
60#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
61pub struct NodeId {
62 pub idx: u32,
63 pub gen: u32,
64}
65
66impl NodeId {
67 /// Construct a raw handle from a slot index and generation. The graph vends
68 /// `NodeId`s from `add`; this is for the few call sites that wire a known slot
69 /// directly (the builder/testkit and integration tests that reference a
70 /// freshly-added node at generation 0).
71 pub fn new(idx: u32, gen: u32) -> NodeId {
72 NodeId { idx, gen }
73 }
74 fn ix(self) -> usize {
75 self.idx as usize
76 }
77}
78
79/// Index into the graph's **parallel storage arena** (foundations §6.4). A
80/// stateful operator (`Take`/`Cap`/`Exists`, spec `07`) is handed one of these at
81/// build time and reads/writes its scratch state via `Graph::storage`. The
82/// operator never owns its store: the indirection is what lets the **client** keep
83/// it in RAM ([`MemoryStorage`](crate::storage::MemoryStorage)) while the
84/// **server** later spills it to SQLite (`10` §1.1) without touching operator code.
85/// Distinct newtype from [`NodeId`] so the two index spaces can't be confused.
86/// Generational for the same reason (storage slots are freed + reused on teardown).
87#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
88pub struct StorageId {
89 pub idx: u32,
90 pub gen: u32,
91}
92
93impl StorageId {
94 fn ix(self) -> usize {
95 self.idx as usize
96 }
97}
98
99// ---------------------------------------------------------------------------
100// The Source trait — the connection/read seam both backends implement
101// ---------------------------------------------------------------------------
102
103/// A connection handle into a source's connection table. Generational `{idx, gen}`
104/// (like [`NodeId`]/[`StorageId`]) so a source can RECYCLE a torn-down connection's
105/// slot — the [`ConnTable`](crate::source_common::ConnTable) bumps the generation on
106/// free and checks it on every access, so a stale handle to a recycled slot fail-fasts.
107/// Distinct from [`NodeId`]. Same role as `05`'s `ConnId`.
108#[derive(Clone, Copy, PartialEq, Eq, Debug)]
109pub struct ConnId {
110 pub idx: u32,
111 pub gen: u32,
112}
113
114impl ConnId {
115 pub fn new(idx: u32, gen: u32) -> ConnId {
116 ConnId { idx, gen }
117 }
118}
119
120/// The connection + read contract a leaf backend owns locally (`04` §4.7 /
121/// `05` §4.8). [`MemorySource`] implements it; the SQLite `TableSource` implements
122/// the *same* trait, so `connect`/`fetch`/`schema`/`destroy` — and everything
123/// downstream of the `NodeStream` they vend — are backend-identical.
124///
125/// The *eager push* fan-out is inherently graph-level (it drives downstream
126/// operators by `NodeId`), so it lives in [`Graph::source_push`](crate::graph::Graph::source_push) + the source's
127/// [`MemorySource::push`]; the trait scopes to what a source answers from `&self`.
128pub trait Source {
129 /// Register a new connection (one downstream output). `sort = None` ⇒
130 /// unordered. Self-joins call this twice. Builds the [`Connection`] from the
131 /// (07-compiled) filter spec + split-edit keys, and asserts the ordering
132 /// includes the PK when ordered. Mirrors `connect` (`memory-source.ts:162`).
133 fn connect(
134 &self,
135 sort: Option<Sort>,
136 filters: Option<ConnectionFilters>,
137 split_edit_keys: Vec<ColId>,
138 ) -> ConnId;
139
140 /// Lazy pull for `conn`: a stream of **rows** in (reverse-aware) sort order,
141 /// overlay-spliced, start-gated, constraint-trimmed, filtered. A source emits
142 /// rows; the `SourceConn` operator wraps them in leaf nodes (`Graph::fetch`).
143 fn fetch<'g>(&'g self, conn: ConnId, req: &FetchRequest) -> RowFlow<'g>;
144
145 /// The effective sort for a connection. This can differ from the table schema's
146 /// default/primary sort, and ordered downstream operators must use this.
147 fn conn_sort(&self, conn: ConnId) -> Sort;
148
149 /// The schema of rows this source vends.
150 fn schema(&self) -> &Schema;
151
152 /// Drop a connection's downstream edge so it stops receiving pushes. Does NOT
153 /// delete the backing indexes (§3.10).
154 fn destroy(&self, conn: ConnId);
155
156 /// Open-cursor count — `0` ⇒ no cursor is mid-iteration and the connection is
157 /// free for a write.
158 fn cursors_open(&self) -> i64;
159
160 /// Eager fallible push: fan `change` to every connection (overlay live,
161 /// epoch-gated), clear the overlay, then write. `push_one` is the graph's
162 /// downstream driver. When `strict`, a malformed change returns a typed
163 /// [`RindleError`] instead of a `debug_assert`. (A backend may keep a faster
164 /// infallible `push` as an inherent method; this is the object-safe seam the
165 /// graph drives.)
166 fn try_push(
167 &self,
168 change: SourceChange,
169 push_one: &dyn Fn(&Connection, SourceChange),
170 strict: bool,
171 ) -> Result<(), RindleError>;
172
173 /// Take any error a cursor parked during the last drained fetch (`05` §4.5);
174 /// `None` if it drained cleanly. Infallible backends always return `None`.
175 fn take_error(&self) -> Option<RindleError>;
176
177 /// Wire a connection's downstream output edge (mirrors `input.setOutput`).
178 fn set_conn_output(&self, conn: ConnId, edge: OutEdge);
179
180 /// Add a **dynamic** push-index guard value to `conn` (design 310 §4.1 — a family
181 /// root's binding set growing; see `ConnTable::add_guard_value`). Required, not
182 /// defaulted: a decorator that forgot to forward it would leave the family root
183 /// unindexed and silently drop its deltas.
184 fn add_guard_value(&self, conn: ConnId, value: OwnedValue);
185
186 /// Remove one dynamic guard value added with [`add_guard_value`](Self::add_guard_value).
187 fn remove_guard_value(&self, conn: ConnId, value: &OwnedValue);
188}
189
190/// The source leaf stored in the arena.
191///
192/// The in-memory backend keeps its own concrete [`Memory`](SourceLeaf::Memory) variant
193/// so the client/wasm hot path stays statically dispatched. Any other backend — e.g. the
194/// SQLite `TableSource` from the `rindle-sqlite` crate — is erased behind the object-safe
195/// [`Source`] trait via [`Dyn`](SourceLeaf::Dyn). The vtable hop is per connect/fetch,
196/// never per row (`fetch` already returns a boxed [`RowFlow`]).
197///
198// The `Memory` variant is deliberately unboxed (the static client/wasm hot path);
199// `Dyn` is a fat pointer. The size asymmetry is intentional and harmless — there is
200// one `SourceLeaf` per source node (a handful per graph), so boxing `Memory` would
201// only add a pointless heap indirection on the hot path.
202#[allow(clippy::large_enum_variant)]
203pub(crate) enum SourceLeaf {
204 Memory(MemorySource),
205 Dyn(Box<dyn Source>),
206}
207
208impl Source for SourceLeaf {
209 fn connect(
210 &self,
211 sort: Option<Sort>,
212 filters: Option<ConnectionFilters>,
213 split_edit_keys: Vec<ColId>,
214 ) -> ConnId {
215 match self {
216 SourceLeaf::Memory(s) => s.connect(sort, filters, split_edit_keys),
217 SourceLeaf::Dyn(s) => s.connect(sort, filters, split_edit_keys),
218 }
219 }
220
221 fn fetch<'g>(&'g self, conn: ConnId, req: &FetchRequest) -> RowFlow<'g> {
222 match self {
223 SourceLeaf::Memory(s) => s.fetch(conn, req),
224 SourceLeaf::Dyn(s) => s.fetch(conn, req),
225 }
226 }
227
228 fn conn_sort(&self, conn: ConnId) -> Sort {
229 match self {
230 SourceLeaf::Memory(s) => s.conn_sort(conn),
231 SourceLeaf::Dyn(s) => s.conn_sort(conn),
232 }
233 }
234
235 fn schema(&self) -> &Schema {
236 match self {
237 SourceLeaf::Memory(s) => s.schema(),
238 SourceLeaf::Dyn(s) => s.schema(),
239 }
240 }
241
242 fn destroy(&self, conn: ConnId) {
243 match self {
244 SourceLeaf::Memory(s) => s.destroy(conn),
245 SourceLeaf::Dyn(s) => s.destroy(conn),
246 }
247 }
248
249 fn cursors_open(&self) -> i64 {
250 match self {
251 SourceLeaf::Memory(s) => s.cursors_open(),
252 SourceLeaf::Dyn(s) => s.cursors_open(),
253 }
254 }
255
256 fn try_push(
257 &self,
258 change: SourceChange,
259 push_one: &dyn Fn(&Connection, SourceChange),
260 strict: bool,
261 ) -> Result<(), RindleError> {
262 match self {
263 // Non-strict memory keeps the infallible hot-path `push` verbatim; strict
264 // routes through the fallible sibling so a bad change returns a typed error.
265 SourceLeaf::Memory(s) => {
266 if strict {
267 s.try_push(change, push_one, true)
268 } else {
269 s.push(change, push_one);
270 Ok(())
271 }
272 }
273 SourceLeaf::Dyn(s) => s.try_push(change, push_one, strict),
274 }
275 }
276
277 fn take_error(&self) -> Option<RindleError> {
278 match self {
279 SourceLeaf::Memory(_) => None,
280 SourceLeaf::Dyn(s) => s.take_error(),
281 }
282 }
283
284 fn set_conn_output(&self, conn: ConnId, edge: OutEdge) {
285 match self {
286 SourceLeaf::Memory(s) => s.set_conn_output(conn, edge),
287 SourceLeaf::Dyn(s) => s.set_conn_output(conn, edge),
288 }
289 }
290
291 fn add_guard_value(&self, conn: ConnId, value: OwnedValue) {
292 match self {
293 SourceLeaf::Memory(s) => Source::add_guard_value(s, conn, value),
294 SourceLeaf::Dyn(s) => s.add_guard_value(conn, value),
295 }
296 }
297
298 fn remove_guard_value(&self, conn: ConnId, value: &OwnedValue) {
299 match self {
300 SourceLeaf::Memory(s) => Source::remove_guard_value(s, conn, value),
301 SourceLeaf::Dyn(s) => s.remove_guard_value(conn, value),
302 }
303 }
304}
305
306// ---------------------------------------------------------------------------
307// CountingSource — a diagnostic decorator (the `analyze query` emit counter)
308// ---------------------------------------------------------------------------
309
310/// A shared per-source counter of rows a source emitted into the pipeline. One cell
311/// per wrapped source node (so the count is already keyed per `NodeId`); a
312/// child-driven join that re-fetches the same leaf under many constraints accumulates
313/// across those fetches into the same cell. `Rc<Cell<..>>` (not atomic) — a `Graph`
314/// is `!Send`, single-threaded by construction.
315pub type EmitCounter = Rc<Cell<u64>>;
316
317/// A [`Source`] decorator that counts the rows crossing the connection boundary — the
318/// **one new measurement** the `analyze query` diagnostic needs
319/// (`designs-implemented/ANALYZE-QUERY-DESIGN.md` §3.4). It wraps any object-safe [`Source`] and
320/// delegates every method to `inner` unchanged, except [`fetch`](Source::fetch), whose
321/// returned `RowFlow` is wrapped in a lazy `.inspect` that bumps `emitted` once per row
322/// **as the row is pulled**. Because it counts `inner.fetch()`'s *final* stream — after
323/// the backend's overlay/start/constraint/filter chain and across both the ordered and
324/// unordered fetch paths — it sees exactly what the pipeline sees, on either backend
325/// (SQLite or memory), and a downstream `Take` that abandons the stream early counts
326/// only what it consumed (the correct "emitted into the pipeline" semantics).
327///
328/// This is installed **only** on the throwaway pipeline `analyze_query` builds; the
329/// live `Graph::fetch` and every live source stay byte-for-byte unchanged — there is no
330/// branch or wrapper on any steady-state fetch or push. The only per-analyze cost is
331/// one vtable hop plus one `Cell` increment per emitted row.
332pub struct CountingSource {
333 inner: Box<dyn Source>,
334 emitted: EmitCounter,
335}
336
337impl CountingSource {
338 /// Wrap `inner`; rows it emits are tallied into `emitted` (a cell the caller keeps a
339 /// clone of, to read the total back after hydration).
340 pub fn new(inner: Box<dyn Source>, emitted: EmitCounter) -> CountingSource {
341 CountingSource { inner, emitted }
342 }
343}
344
345impl Source for CountingSource {
346 fn connect(
347 &self,
348 sort: Option<Sort>,
349 filters: Option<ConnectionFilters>,
350 split_edit_keys: Vec<ColId>,
351 ) -> ConnId {
352 self.inner.connect(sort, filters, split_edit_keys)
353 }
354
355 fn fetch<'g>(&'g self, conn: ConnId, req: &FetchRequest) -> RowFlow<'g> {
356 let n = self.emitted.clone();
357 Box::new(
358 self.inner
359 .fetch(conn, req)
360 .inspect(move |_| n.set(n.get() + 1)),
361 )
362 }
363
364 fn conn_sort(&self, conn: ConnId) -> Sort {
365 self.inner.conn_sort(conn)
366 }
367
368 fn schema(&self) -> &Schema {
369 self.inner.schema()
370 }
371
372 fn destroy(&self, conn: ConnId) {
373 self.inner.destroy(conn)
374 }
375
376 fn cursors_open(&self) -> i64 {
377 self.inner.cursors_open()
378 }
379
380 fn try_push(
381 &self,
382 change: SourceChange,
383 push_one: &dyn Fn(&Connection, SourceChange),
384 strict: bool,
385 ) -> Result<(), RindleError> {
386 self.inner.try_push(change, push_one, strict)
387 }
388
389 fn take_error(&self) -> Option<RindleError> {
390 self.inner.take_error()
391 }
392
393 fn set_conn_output(&self, conn: ConnId, edge: OutEdge) {
394 self.inner.set_conn_output(conn, edge)
395 }
396
397 fn add_guard_value(&self, conn: ConnId, value: OwnedValue) {
398 self.inner.add_guard_value(conn, value)
399 }
400
401 fn remove_guard_value(&self, conn: ConnId, value: &OwnedValue) {
402 self.inner.remove_guard_value(conn, value)
403 }
404}
405
406// ---------------------------------------------------------------------------
407// Operators
408// ---------------------------------------------------------------------------
409
410/// Hierarchical join (parent rows get a lazily-streamed `rel_name` relationship
411/// of matching child rows). Mirrors `join.ts`.
412pub(crate) struct Join {
413 pub parent: NodeId,
414 pub child: NodeId,
415 pub parent_key: Vec<ColId>,
416 pub child_key: Vec<ColId>,
417 /// The relationship slot this join attaches (resolved from its name against the
418 /// parent schema at build time — `08`). Index-addressed on the hot path
419 /// (foundations §3.4); the name is recoverable via the parent
420 /// `Schema::relationships[rel_slot]`.
421 pub rel_slot: RelId,
422 /// The downstream edge as a **port-carrying** [`OutEdge`] (like a
423 /// [`SourceConn`]'s output), so a join can feed another join: `Port::Single`
424 /// to a terminal sink, `Port::JoinParent` when this join is the *parent* of the
425 /// next (sibling relationships stacked on one row), or `Port::JoinChild` when
426 /// this join is a *nested child* feeding the parent join's child port. Both
427 /// `join_push` and `push_child_change` forward on `out.port`.
428 pub output: Cell<Option<OutEdge>>,
429 /// The in-flight child change being fanned out by `Graph::push_child_change`
430 /// — the LIVE port of Zero's `Join.#inprogressChildChange` (`join.ts:61`). Read
431 /// by the child-relationship thunk on EVERY fetch (including the reentrant
432 /// refetch a downstream `Take`/`Exists` triggers mid-push), so a not-yet-
433 /// processed parent's relationship reflects PRE-change (stale) membership — the
434 /// cascade the depth-2 EXISTS+top-N+push shapes need. Owned [`JoinOverlay`]
435 /// (a row-level `SourceChange`, no `'g`) so it lives in the arena. `None` outside
436 /// a child-push. See `Graph::join_overlay_for`.
437 pub inprogress_overlay: RefCell<Option<JoinOverlay>>,
438 /// The row of the parent currently being processed in the child-push fan-out —
439 /// Zero's `#inprogressChildChangePosition` (`join.ts:62`). The overlay applies
440 /// only to parents sorting strictly AFTER this (`compareRows(parent, pos) > 0`):
441 /// parents at-or-before it have already had the change delivered. `None` until
442 /// the first parent of a fan-out is reached.
443 pub inprogress_position: RefCell<Option<Row>>,
444 /// The join membership pre-check's counted parent-key set (design 311): the parent
445 /// correlation keys the parent input holds within `[-∞, frontier]` of its sort order.
446 /// Probed at the top of [`Graph::push_child_change`](crate::graph::Graph::push_child_change) —
447 /// a miss ends the push in one hash probe instead of the reentrant parent fetch.
448 /// Populated by **observing** `join_fetch` (never a build fetch), maintained on the
449 /// `JoinParent` arms of `join_push`, and gated to `Disabled` (never approximate) on
450 /// overflow or an ineligible chain. `Unbuilt` while the graph knob is off.
451 pub precheck: RefCell<ParentKeySet>,
452 /// The cached root-chain eligibility walk (§2.4), computed on the first observation.
453 pub precheck_eligible: Cell<Option<bool>>,
454}
455
456/// A connection (one of a source's outputs). Self-joins create two of these over
457/// the same source. Holds the full generational [`ConnId`] (not a bare index) so a
458/// teardown disconnects the exact connection slot and a recycled slot can never be
459/// mistaken for it.
460pub(crate) struct SourceConn {
461 pub source: NodeId,
462 pub conn: ConnId,
463 /// Set when this connection is the **root of a parameterized query family**
464 /// (design 310 §4.1): the membership test the fetch path applies to committed rows
465 /// (the leaf's `sql_condition` carries only the residual predicate — membership is
466 /// never lowered to SQL, §4.4) and the marker behind the constrained-fetch check.
467 /// Boxed so the common connection stays two words.
468 pub family: Option<Box<FamilyRootConn>>,
469 /// A mirror of the source-side output edge (`Connection::output`), kept here so the
470 /// graph can read a connection's downstream without a `Source` trait getter — the
471 /// family unbind path injects its synthetic removes at a spine node's out edge, and
472 /// that node may be the root connection itself.
473 pub output: Cell<Option<OutEdge>>,
474}
475
476/// The family-root half of a [`SourceConn`]: the binding-set handle and the partition
477/// key (parameter columns), shared with the connection's predicate.
478pub(crate) struct FamilyRootConn {
479 pub bindings: Rc<BindingSet>,
480 pub param_cols: Vec<ColId>,
481}
482
483/// A change recorded by a `Collector` — the spike's analogue of `Catch`'s
484/// `pushes`. Lets a test assert the exact change sequence a source fans out.
485/// Equality compares rows cell-by-cell via [`compare_values`] (null == null) —
486/// `OwnedValue` deliberately has no derived `PartialEq` (it would invite the
487/// wrong comparator), so we spell the row equality out.
488#[derive(Clone, Debug)]
489pub enum CollectedChange {
490 Add(Row),
491 Remove(Row),
492 Edit { row: Row, old: Row },
493}
494
495/// Wrap a source `SourceChange` (rows) into a node-bearing downstream `Change`
496/// (`makeAddChange`/`makeRemoveChange`/`makeEditChange`). Leaf nodes (no rels) —
497/// this IS the row→node conversion, localized to the `SourceConn` write boundary.
498/// Takes the change by value: the rows move into the nodes, no clone.
499/// `pub(crate)`: the out-of-file [`FlippedJoin`](crate::op::FlippedJoin) builds the
500/// nested `child` node of a forwarded `Change::Child` the same way.
501pub(crate) fn source_change_to_node<'g>(change: SourceChange) -> Change<'g> {
502 match change {
503 SourceChange::Add(r) => Change::Add(Node::leaf(r)),
504 SourceChange::Remove(r) => Change::Remove(Node::leaf(r)),
505 SourceChange::Edit { row, old } => Change::Edit {
506 node: Node::leaf(row),
507 old: Node::leaf(old),
508 },
509 }
510}
511
512/// Cell-by-cell row equality for test assertions (`compare_values`, null == null).
513fn full_row_eq(a: &Row, b: &Row) -> bool {
514 a.len() == b.len()
515 && (0..a.len()).all(|i| compare_values(a.col(i), b.col(i)) == std::cmp::Ordering::Equal)
516}
517
518impl PartialEq for CollectedChange {
519 fn eq(&self, other: &Self) -> bool {
520 use CollectedChange::*;
521 match (self, other) {
522 (Add(a), Add(b)) | (Remove(a), Remove(b)) => full_row_eq(a, b),
523 (Edit { row: r1, old: o1 }, Edit { row: r2, old: o2 }) => {
524 full_row_eq(r1, r2) && full_row_eq(o1, o2)
525 }
526 _ => false,
527 }
528 }
529}
530
531/// A leaf sink that records every change pushed to it (and, optionally, the rows
532/// of a reentrant fetch of its input taken *during* the push — to observe the
533/// epoch-gated overlay, as in the JS `fetch during push` tests). Stands in for
534/// `Catch` (`catch.ts`).
535pub(crate) struct Collector {
536 pub input: NodeId,
537 changes: RefCell<Vec<CollectedChange>>,
538 fetch_on_push: Cell<bool>,
539 fetched: RefCell<Vec<Vec<Row>>>,
540 /// When set (via [`Graph::add_change_sink`](crate::graph::Graph::add_change_sink)), every push also records the
541 /// fully-materialized [`CaughtChange`](crate::changes::CaughtChange) tree (nested
542 /// relationships drained) into `caught` — the production change-stream sink the
543 /// `rindle-replica` wrapper consumes. Off for a plain testkit `Collector`, so its
544 /// flat `changes` recording is byte-for-byte unchanged.
545 capture_caught: Cell<bool>,
546 caught: RefCell<Vec<crate::changes::CaughtChange>>,
547}
548
549// ---------------------------------------------------------------------------
550// The multi-port chassis: the Filter sub-graph (`filter-operators.ts`) + the OR
551// fan (`fan-out.ts`/`fan-in.ts`). This is the skeleton **five** operators share
552// — `FilterStart`, `Filter`, `FanOut`, `FanIn`, `FilterEnd` (and, later, `Exists`
553// and `FilterProbe`) — proven once here so spec `07`'s Filter/Exists and spec
554// `06`'s fan land on a known-good frame.
555//
556// Two structural patterns are at stake, neither of which the single-output `Join`
557// exercised:
558//
559// 1. **The `begin_filter`/`filter`/`end_filter` lifecycle** (a `FilterOperator`
560// *gates* — `filter(node) -> bool` — it does not vend its own rows; the one
561// row scan happens in `FilterStart::fetch`). The lifecycle brackets that scan
562// so a stateful link (Exists's per-loop cache) can cache for the loop and
563// clear after — and `end_filter` MUST run on early stream drop / early
564// return, which is the RAII (`Drop`) contract (Primitive #2; `07` §6.4). It
565// also runs on panic ONLY in unwinding builds (`release-server`, tests);
566// under the shipping `panic = "abort"` client profile a panic aborts and
567// `Drop` is skipped. See WS02. Dispatched
568// by `NodeId` through the arena (`07` §4.3 "not a `dyn FilterChain` chain"),
569// exactly like `fetch`/`push`.
570//
571// 2. **Multi-port wiring**: `FanOut` has one input and **N outputs** (branches);
572// `FanIn` has **N inputs** and one output. The OR push fans one change to
573// every branch, then collapses the branches' forwarded changes back to
574// exactly one (`push_accumulated_changes` — the dedup that stops an OR from
575// double-counting, `06` §3.5).
576//
577// **Why the accumulation lives on the stack, not in a `FanIn` field.** The JS
578// `FanIn` buffers branch pushes in a `#accumulatedPushes: Change[]` *instance
579// field*. A `Change<'g>` borrows the graph (its relationship thunks are
580// `+ 'g`), so it cannot live in a `'static` `Graph` field without a
581// self-reference. Instead [`Graph::chain_push`](crate::graph::Graph::chain_push) threads the accumulation through
582// the call stack (lifetime `'g`) and `FanIn` is just the branch terminator that
583// hands its change up to the owning `FanOut`. This mirrors Primitive #5's move
584// from JS's live `#inprogressChildChange` field to a by-value capture: the Rust
585// port makes the accumulation lifetime explicit instead of relying on GC.
586
587/// `FilterStart` (`filter-operators.ts:61-104`): adapts a normal `Input` → the
588/// filter chain. Its `fetch` is a NORMAL operator fetch that brackets the input
589/// scan with `begin_filter`/`end_filter` and yields each node iff the chain's
590/// `filter(node)` is true. `chain_head` is the single `FilterOutput` edge — used
591/// for both `filter` (fetch) and `push`; do not split it (`07` §4.3).
592pub(crate) struct FilterStart {
593 pub input: NodeId,
594 pub chain_head: Cell<Option<NodeId>>,
595}
596
597/// `FilterEnd` (`filter-operators.ts:106-146`): adapts the chain → a normal
598/// `Input`. Its `filter()` is always `true` (the chain terminator); its `fetch`
599/// delegates to `start.fetch`; its `push` forwards to the normal downstream
600/// `output`. `start` is the matched [`FilterStart`].
601pub(crate) struct FilterEnd {
602 pub start: NodeId,
603 /// The post-chain downstream **edge** — port-bearing (the `Skip`/`Take`/`Cap`
604 /// template), so a `FilterEnd` can feed a relationship join's `JoinParent` port
605 /// (a `related`/sibling join over a `where` sub-graph) as well as a plain `Single`
606 /// sink. Wired `Single` via [`Graph::set_output`](crate::graph::Graph::set_output), port-bearing via
607 /// [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
608 pub output: Cell<Option<OutEdge>>,
609}
610
611/// `FanOut` (`fan-out.ts`): one input, **N outputs** (the OR branches). On
612/// `filter` it is OR (true on the first branch that passes, short-circuit). On
613/// `push` it replays the change to every branch then collapses what they forward
614/// (via the paired `FanIn`'s continuation). A `FilterOperator`, so it has no
615/// `fetch` of its own.
616pub(crate) struct FanOut {
617 pub input: NodeId,
618 /// The branch heads, wired late (two-phase) — interior-mutable.
619 outputs: RefCell<Vec<NodeId>>,
620 /// The paired [`FanIn`] (set with the branches via [`Graph::set_fan`](crate::graph::Graph::set_fan)).
621 fan_in: Cell<Option<NodeId>>,
622}
623
624/// `FanIn` (`fan-in.ts`): **N inputs** (the branch tails all point here), one
625/// output. In the JS it buffers branch pushes; here it is the branch *terminator*
626/// (its `chain_push` hands the change up to the owning `FanOut`, which owns the
627/// accumulation — see the chassis note above). `output` is the post-fan
628/// continuation (the next `FilterOutput`, e.g. `FilterEnd`).
629pub(crate) struct FanIn {
630 pub fan_out: NodeId,
631 output: Cell<Option<NodeId>>,
632}
633
634/// A `Filter` link (`filter.ts`): a stateless predicate gate. `filter(node)` =
635/// `pred(row) && downstream.filter(node)`; `push` is the predicate-gated
636/// `filter_push` with the Edit split (`07` §3.4). The single `output` edge serves
637/// both paths.
638pub(crate) struct Filter {
639 pub input: NodeId,
640 pub pred: CompiledPredicate,
641 output: Cell<Option<NodeId>>,
642}
643
644/// Proof instrumentation: a `FilterChain` link that records its `begin_filter` /
645/// `filter` / `end_filter` calls and passes every node/change through unchanged.
646/// It stands in for a *stateful* filter (the role `Exists` will fill — `Exists`
647/// caches on `begin`, clears on `end`) so the chassis can prove the lifecycle
648/// fires in order AND that `end_filter` runs even when the fetch stream is
649/// abandoned early (the `Drop` guard, `07` §6.4). Not a real query operator.
650pub(crate) struct FilterProbe {
651 pub input: NodeId,
652 output: Cell<Option<NodeId>>,
653 begin_count: Cell<usize>,
654 filter_count: Cell<usize>,
655 end_count: Cell<usize>,
656}
657
658pub(crate) enum Operator {
659 /// A **freed** slot (a torn-down pipeline's operator, [`Graph::destroy_pipeline`](crate::graph::Graph::destroy_pipeline)).
660 /// The slot keeps its arena index (so no other id shifts) but its generation has
661 /// been bumped, so any live access goes through the generation check in
662 /// `Graph::node` and fail-fasts before reaching here; the dispatch panic arms are
663 /// a belt-and-suspenders net. Replacing the old `Operator` with this variant is what
664 /// drops the torn-down operator's heavy state (View tree, Collector buffers,
665 /// compiled predicates, …).
666 Tombstone,
667 Source(SourceLeaf),
668 SourceConn(SourceConn),
669 Join(Join),
670 /// The production materialization sink (`09`): the `Arc`-shared, reference-stable
671 /// `ArrayView` + immutable tree differ. Logic lives in `crate::view`.
672 View(crate::view::View),
673 Collector(Collector),
674 FilterStart(FilterStart),
675 FilterEnd(FilterEnd),
676 FanOut(FanOut),
677 FanIn(FanIn),
678 Filter(Filter),
679 FilterProbe(FilterProbe),
680 // --- spec 06/07 operators, implemented out-of-file (the fan-out seam,
681 // `crate::op`). Each variant's logic lives in its own `op/<name>.rs`; only the
682 // thin wiring (this variant + delegating match arms + an `add_<name>` builder)
683 // is here, so isolated operator agents don't collide in graph.rs. ---
684 Skip(crate::op::Skip),
685 Take(crate::op::Take),
686 Cap(crate::op::Cap),
687 /// A `where`-EXISTS gate (a `FilterChain` link; logic in `op/exists.rs`).
688 Exists(crate::op::Exists),
689 /// The flipped (child-driven) inner join (logic in `op/flipped_join.rs`).
690 FlippedJoin(crate::op::FlippedJoin),
691 /// The node-level OR fan over the source (`op/union.rs`): `UnionFanOut`
692 /// broadcasts a source change to the OR branches; `UnionFanIn` k-way-merges their
693 /// fetches (with PK dedup) and collapses their pushes back to one change.
694 UnionFanOut(crate::op::UnionFanOut),
695 UnionFanIn(crate::op::UnionFanIn),
696 /// A global, invertible aggregate (`op/reduce.rs`, `REDUCE-DESIGN.md`): collapses
697 /// its input into one synthetic aggregate row (v1: `count(*)`).
698 Reduce(crate::op::Reduce),
699 // --- spec 11 test harness sinks/taps, logic out-of-file in `crate::testkit`
700 // (same seam shape as `crate::op`). `Catch` is the output oracle (generalizes
701 // `Collector` to a full nested tree + Child); `Snitch` is the message-log tap. ---
702 #[cfg(any(test, feature = "testkit"))]
703 Catch(crate::testkit::Catch),
704 #[cfg(any(test, feature = "testkit"))]
705 Snitch(crate::testkit::Snitch),
706}
707
708// ---------------------------------------------------------------------------
709// Graph
710// ---------------------------------------------------------------------------
711
712/// The exact arena ids one `build_pipeline` (+ its sink) created — captured by the
713/// graph's recording mode ([`Graph::begin_recording`](crate::graph::Graph::begin_recording)/[`Graph::take_recording`](crate::graph::Graph::take_recording)) so
714/// the pipeline can be torn down precisely with [`Graph::destroy_pipeline`](crate::graph::Graph::destroy_pipeline).
715///
716/// It is a *recorded id list*, not a contiguous range: with slot reuse a query's
717/// nodes can land on scattered recycled slots. The `(source, ConnId)` pairs to
718/// disconnect are recovered from the recorded `SourceConn` nodes, so they need not
719/// be stored separately. Shared `Source` nodes are pre-registered (outside any build),
720/// so they never appear here and are never torn down.
721#[derive(Clone, Debug, Default)]
722pub struct PipelineManifest {
723 nodes: Vec<NodeId>,
724 storage: Vec<StorageId>,
725}
726
727impl PipelineManifest {
728 /// The node ids this pipeline owns (test/inspection access).
729 pub fn nodes(&self) -> &[NodeId] {
730 &self.nodes
731 }
732 /// The storage ids this pipeline owns.
733 pub fn storage(&self) -> &[StorageId] {
734 &self.storage
735 }
736}
737
738/// The two bounds of the join membership pre-check (design 311 §2.5), host-settable via
739/// [`Graph::set_join_precheck_bounds`](crate::graph::Graph::set_join_precheck_bounds).
740///
741/// Every approximation the pre-check makes errs toward *fetching*: a join whose parent
742/// key set would cross `per_join` distinct keys, or whose keys would push the graph's
743/// total past `per_graph`, drops its set and falls back to today's fetch for the rest of
744/// its life. So the bounds cap **memory**, never correctness — O(registered regions), not
745/// O(data) (§6).
746#[derive(Clone, Copy, Debug, PartialEq, Eq)]
747pub struct JoinPrecheckBounds {
748 /// Distinct parent keys one join may track; `None` turns the pre-check **off**
749 /// graph-wide (no observation, no maintenance, no probe — today's path byte for byte).
750 pub per_join: Option<usize>,
751 /// Total distinct keys tracked across every join in the graph. Many joins each near
752 /// their own bound cannot compound past this.
753 pub per_graph: usize,
754}
755
756impl JoinPrecheckBounds {
757 /// The per-join bound the design suggests for the daemon (~400 KB worst case per join).
758 pub const DEFAULT_PER_JOIN: usize = 4096;
759 /// The per-graph budget the design suggests for the daemon (~6.5 MB worst case).
760 pub const DEFAULT_PER_GRAPH: usize = 65_536;
761 /// Pre-check off — today's path byte for byte (no observation, no maintenance, no
762 /// probe). The S1 default of every `Graph` until S2 flipped it (design 311 §9); the
763 /// daemon's `joinPrecheckPerJoin: 0` and the test/testkit/debug-build
764 /// `RINDLE_JOIN_PRECHECK=off` both land here.
765 pub const OFF: JoinPrecheckBounds = JoinPrecheckBounds {
766 per_join: None,
767 per_graph: Self::DEFAULT_PER_GRAPH,
768 };
769 /// Pre-check on at the design's suggested bounds (4096 / 65 536).
770 pub const ON: JoinPrecheckBounds = JoinPrecheckBounds {
771 per_join: Some(Self::DEFAULT_PER_JOIN),
772 per_graph: Self::DEFAULT_PER_GRAPH,
773 };
774}
775
776impl Default for JoinPrecheckBounds {
777 /// [`JoinPrecheckBounds::ON`] — design 311 §9 S2 (2026-09-02): every `Graph` starts
778 /// with the pre-check on at the daemon bounds, so every operator, parity, oracle and
779 /// fuzz lane exercises it without naming it. [`OFF`](Self::OFF) is one
780 /// [`Graph::set_join_precheck_bounds`] call away (the daemon's `joinPrecheckPerJoin: 0`).
781 fn default() -> Self {
782 JoinPrecheckBounds::ON
783 }
784}
785
786/// Per-graph counters for the join membership pre-check (design 311 §8) — the
787/// always-on, per-`Graph` twin of the build-gated process-global `EngineMetrics`
788/// counters (`join_probe_hit` / `join_probe_miss` / `join_precheck_disabled_*`). Plain
789/// `Cell` adds on a `!Send` graph, so they cost nothing measurable.
790#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
791pub struct JoinPrecheckStats {
792 /// Probes that found the key in the region (the fetch ran, as today).
793 pub probe_hits: u64,
794 /// Probes that missed — parent fetches skipped.
795 pub probe_misses: u64,
796 /// Joins disabled because the eligibility walk found a non-root chain (§2.4).
797 pub disabled_ineligible: u64,
798 /// Joins disabled because a bound was crossed while observing a fetch (§2.5).
799 pub disabled_observation: u64,
800 /// Joins disabled because a bound was crossed (or a count went inconsistent) on a
801 /// parent-side push (§2.5).
802 pub disabled_maintenance: u64,
803}
804
805/// A read-only snapshot of one join's parent-key set (`op::join_util::ParentKeySet`) for
806/// inspection and tests ([`Graph::join_precheck_state`](crate::graph::Graph::join_precheck_state)).
807#[derive(Clone, Debug, PartialEq, Eq)]
808pub enum JoinPrecheckState {
809 /// Nothing observed yet, or the pre-check is off.
810 Unbuilt,
811 /// A live region: how many distinct keys it holds, and whether it covers the whole
812 /// parent input (`complete`) or ends at a finite frontier.
813 Active {
814 distinct_keys: usize,
815 complete: bool,
816 },
817 /// Gate tripped or chain ineligible; the join fetches for the rest of its life.
818 Disabled,
819}
820
821#[cfg(any(test, feature = "testkit"))]
822thread_local! {
823 /// Test-only default for every `Graph` built on this thread after the call
824 /// (`set_join_precheck_bounds_for_test`) — the join pre-check analogue of the
825 /// `FlippedJoin` chunk-size seam, so the testkit runners and the fuzz lanes can force
826 /// the pre-check on at a chosen bound without a graph handle.
827 static JOIN_PRECHECK_OVERRIDE: Cell<Option<JoinPrecheckBounds>> = const { Cell::new(None) };
828}
829
830/// Set the [`JoinPrecheckBounds`] every `Graph` constructed on the current thread starts
831/// with (`None` restores the documented default, off). Build-time only: an existing graph
832/// keeps its bounds — use [`Graph::set_join_precheck_bounds`](crate::graph::Graph::set_join_precheck_bounds)
833/// for that. The seam the testkit parity lanes and `rindle-fuzz` use to sweep the bound
834/// (design 311 §7).
835#[cfg(any(test, feature = "testkit"))]
836pub fn set_join_precheck_bounds_for_test(bounds: Option<JoinPrecheckBounds>) {
837 JOIN_PRECHECK_OVERRIDE.set(bounds);
838}
839
840/// Process-wide default from the environment (test, testkit, and **debug** builds only —
841/// never a release artifact): with `RINDLE_JOIN_PRECHECK=<per_join>[,<per_graph>]` set,
842/// every `Graph` on every thread starts with the pre-check at those bounds unless a
843/// thread-local override is active; `RINDLE_JOIN_PRECHECK=off` forces it OFF. Since S2
844/// the unset default is already [`JoinPrecheckBounds::ON`], so this is how an ENTIRE
845/// existing suite — every operator, parity, and fuzz lane — runs at a *stressed* bound
846/// (`RINDLE_JOIN_PRECHECK=1 cargo test --workspace --features testkit`: overflow on nearly
847/// every join, the §2.6 debug soundness net armed on every miss) or on the pre-S2 path
848/// (`RINDLE_JOIN_PRECHECK=off`), without a single test naming the feature. (Debug builds
849/// are included so an integration test binary, which links the non-`cfg(test)` library,
850/// honours it too.)
851#[cfg(any(test, feature = "testkit", debug_assertions))]
852fn join_precheck_env_default() -> JoinPrecheckBounds {
853 static ENV: std::sync::OnceLock<JoinPrecheckBounds> = std::sync::OnceLock::new();
854 *ENV.get_or_init(|| {
855 let Ok(raw) = std::env::var("RINDLE_JOIN_PRECHECK") else {
856 return JoinPrecheckBounds::default();
857 };
858 if raw.trim().eq_ignore_ascii_case("off") {
859 return JoinPrecheckBounds::OFF;
860 }
861 let mut parts = raw.split(',').map(|p| p.trim().parse::<usize>());
862 let per_join = parts.next().and_then(Result::ok);
863 let per_graph = parts
864 .next()
865 .and_then(Result::ok)
866 .unwrap_or(JoinPrecheckBounds::DEFAULT_PER_GRAPH);
867 JoinPrecheckBounds {
868 per_join,
869 per_graph,
870 }
871 })
872}
873
874/// The graph's pre-check knob cell, whose `Default` is the documented default — or, in
875/// test/testkit builds, the thread-local override — so `Graph` keeps `#[derive(Default)]`.
876struct PrecheckKnob(Cell<JoinPrecheckBounds>);
877
878impl Default for PrecheckKnob {
879 fn default() -> Self {
880 #[cfg(any(test, feature = "testkit"))]
881 let bounds = JOIN_PRECHECK_OVERRIDE
882 .get()
883 .unwrap_or_else(join_precheck_env_default);
884 #[cfg(all(not(any(test, feature = "testkit")), debug_assertions))]
885 let bounds = join_precheck_env_default();
886 #[cfg(not(any(test, feature = "testkit", debug_assertions)))]
887 let bounds = JoinPrecheckBounds::default();
888 PrecheckKnob(Cell::new(bounds))
889 }
890}
891
892#[derive(Default)]
893pub struct Graph {
894 nodes: Vec<Operator>,
895 /// Per-slot **generation**, parallel to `nodes`. Bumped when a slot is freed
896 /// ([`Graph::destroy_pipeline`](crate::graph::Graph::destroy_pipeline)); [`Graph::node`](crate::graph::Graph::node) checks it on every access so a
897 /// stale [`NodeId`] to a recycled slot fail-fasts instead of aliasing the new
898 /// tenant.
899 node_gens: Vec<u32>,
900 /// Freed node-slot indices available for reuse (a LIFO free-list). `add` pops one
901 /// before growing `nodes`, so a long-lived graph with query churn recycles slots
902 /// rather than growing without bound.
903 free_nodes: Vec<u32>,
904 /// Parallel arena of per-operator scratch state (foundations §6.4), addressed
905 /// by [`StorageId`]. Held here — not inside the operators — so a stateful
906 /// operator reads its state through a *shared* `&Graph` borrow (like fetch/
907 /// push), and so the backend (memory vs spilled-SQLite) is a graph-level
908 /// choice. Empty for a graph with no stateful operators.
909 storage: Vec<Box<dyn Storage>>,
910 /// Per-storage-slot generation (the `node_gens` analogue for [`StorageId`]).
911 storage_gens: Vec<u32>,
912 /// Freed storage-slot indices available for reuse (the `free_nodes` analogue).
913 free_storage: Vec<u32>,
914 /// When `Some`, every [`Graph::add`](crate::graph::Graph::add)/[`Graph::alloc_storage`](crate::graph::Graph::alloc_storage) appends the id it
915 /// minted here — capturing the exact node/storage ids one `build_pipeline` created
916 /// into a [`PipelineManifest`], so the pipeline can later be torn down precisely.
917 /// Bracketed by [`Graph::begin_recording`](crate::graph::Graph::begin_recording)/[`Graph::take_recording`](crate::graph::Graph::take_recording); `None`
918 /// (off) on the hot path.
919 recording: Option<PipelineManifest>,
920 storage_factory: StorageFactory,
921 /// Opt-in strict change-consistency validation (WS02.2). When `true`, a malformed
922 /// change (ADD of an existing row, REMOVE/EDIT of an absent row) returns a
923 /// [`RindleError::ConsistencyViolation`] from [`Graph::try_source_push`](crate::graph::Graph::try_source_push) — runnable
924 /// in **release**, unlike the default `debug_assert!`. Off by default to preserve
925 /// hot-path parity; a server that doesn't fully trust its change producer turns
926 /// it on. `Cell` so it is settable on a built `&Graph`.
927 validate_changes: Cell<bool>,
928 /// Graph-level runtime-error sink (WS02.2 / the WS02.3 prelude). A failure raised
929 /// deep in the push fan-out — where the call stack cannot thread a `Result` back
930 /// up — parks here (e.g. a strict join-key consistency violation); the existing
931 /// [`take_runtime_error`](Self::take_runtime_error) drains it at the mutation
932 /// boundary (`try_source_push`/`try_hydrate` already call it). `Rc<RefCell<…>>` so
933 /// a future `OpStorage` (WS02.3) can hold a clone of the same sink.
934 runtime_error: Rc<RefCell<Option<RindleError>>>,
935 /// Host-armed wall-clock deadline for the CURRENT push (FOLLOWER-LAG-SHED §6.6 —
936 /// the runaway-push bail). The unbounded fan-out loops (join child fan-out, take
937 /// backfill/refill) test it every [`DEADLINE_CHECK_EVERY`] iterations and park a
938 /// [`RindleError::PushDeadlineExceeded`] on expiry — converting today's
939 /// stuck-worker detach-and-leak into a clean, prompt, single-push fault. `None`
940 /// (never armed) on the wasm client, whose checkpoints therefore never call
941 /// `Instant::now()` (which traps on wasm32-unknown-unknown) — no feature gate
942 /// needed, this crate stays std-only. `Cell` so the host arms it per push on
943 /// a built `&Graph` (mirrors `validate_changes`).
944 push_deadline: Cell<Option<std::time::Instant>>,
945 /// Shared amortization counter for the armed deadline (§6.6). The fan-out loops (join child
946 /// fan-out, take backfill/refill) increment THIS on every iteration and consult the clock only
947 /// every [`DEADLINE_CHECK_EVERY`]-th tick — so the deadline bounds the WHOLE armed derive (a
948 /// batch's many changes, each with its own small fan-out loop), not just one loop in isolation.
949 /// Reset to 0 by [`set_push_deadline`](Self::set_push_deadline) each time the host (re)arms.
950 /// A prior revision counted in a fan-out-loop-LOCAL `u32` reset per call, so the checkpoint
951 /// never accumulated across the many small changes of a bulk write — the clock was never
952 /// consulted and the deadline silently never fired for that shape. `Cell` (single-thread
953 /// engine) so it is bumped through a shared `&Graph` borrow deep in the reentrant fan-out.
954 push_deadline_ticks: Cell<u32>,
955 /// The join membership pre-check's bounds (design 311 §2.5 / §8). `per_join == None`
956 /// (the default) is OFF: no join observes, maintains, or probes. Settable on a built
957 /// `&Graph` like `validate_changes`; a change resets every join's set to `Unbuilt`.
958 join_precheck: PrecheckKnob,
959 /// Distinct parent keys currently tracked across every join (the `per_graph` budget's
960 /// live total). Charged on a set's first sight of a key, released at zero count, on
961 /// `Disabled`, on a knob reset, and on pipeline teardown.
962 join_precheck_tracked: Cell<usize>,
963 /// Per-graph pre-check counters ([`JoinPrecheckStats`]).
964 join_precheck_stats: Cell<JoinPrecheckStats>,
965}
966
967impl Graph {
968 pub fn new() -> Graph {
969 Graph::default()
970 }
971
972 pub fn with_storage_factory(storage_factory: StorageFactory) -> Graph {
973 Graph {
974 storage_factory,
975 ..Graph::default()
976 }
977 }
978
979 /// Enable/disable strict change-consistency validation (WS02.2). Off by default.
980 /// When on, [`try_source_push`](Self::try_source_push) returns a
981 /// [`RindleError::ConsistencyViolation`] (instead of a release-stripped
982 /// `debug_assert!`) on a malformed change stream.
983 pub fn set_validate_changes(&self, on: bool) {
984 self.validate_changes.set(on);
985 }
986
987 /// Whether strict change validation is currently enabled.
988 pub fn validate_changes(&self) -> bool {
989 self.validate_changes.get()
990 }
991
992 /// Arm (or clear) the wall-clock deadline for the CURRENT push (FOLLOWER-LAG-SHED §6.6).
993 /// A host that bounds per-push derive time arms `now + P` around each push and clears it
994 /// after; on expiry the fan-out checkpoints park a
995 /// [`RindleError::PushDeadlineExceeded`] and stop iterating — the push surfaces `Err`
996 /// from [`try_source_push`](Self::try_source_push) with torn operator state, which the
997 /// host discards (the same abort≙epoch-rehydrate contract a panic uses). Never arm this
998 /// on wasm: the expiry check calls `Instant::now()`, which traps there.
999 pub fn set_push_deadline(&self, deadline: Option<std::time::Instant>) {
1000 self.push_deadline.set(deadline);
1001 // Reset the amortization counter so the checkpoint interval is measured from THIS arm and
1002 // ticks accumulate across the whole armed derive — not per fan-out loop (see the field doc).
1003 self.push_deadline_ticks.set(0);
1004 }
1005
1006 /// Amortized deadline checkpoint for the unbounded fan-out loops (§6.6): counts every call in
1007 /// the graph-level [`push_deadline_ticks`](Self::push_deadline_ticks) — SHARED across the whole
1008 /// armed window, so a batch of many small-fan-out changes accumulates toward the checkpoint
1009 /// instead of resetting per loop — and consults the clock only every [`DEADLINE_CHECK_EVERY`]-th
1010 /// call, and only when a deadline is armed, so the wasm client (which never arms one) never
1011 /// reaches `Instant::now()`.
1012 #[inline]
1013 pub(crate) fn push_deadline_exceeded(&self) -> bool {
1014 let ticks = self.push_deadline_ticks.get().wrapping_add(1);
1015 self.push_deadline_ticks.set(ticks);
1016 if !ticks.is_multiple_of(DEADLINE_CHECK_EVERY) {
1017 return false;
1018 }
1019 match self.push_deadline.get() {
1020 None => false,
1021 Some(deadline) => std::time::Instant::now() >= deadline,
1022 }
1023 }
1024
1025 fn add(&mut self, op: Operator) -> NodeId {
1026 // Reuse a freed slot if one is available (its generation was already bumped at
1027 // free time, so the returned id is distinct from the slot's prior tenant);
1028 // otherwise grow the arena with a fresh generation-0 slot.
1029 let id = if let Some(idx) = self.free_nodes.pop() {
1030 let i = idx as usize;
1031 self.nodes[i] = op;
1032 NodeId::new(idx, self.node_gens[i])
1033 } else {
1034 let idx = self.nodes.len() as u32;
1035 self.nodes.push(op);
1036 self.node_gens.push(0);
1037 NodeId::new(idx, 0)
1038 };
1039 if let Some(rec) = self.recording.as_mut() {
1040 rec.nodes.push(id);
1041 }
1042 id
1043 }
1044
1045 /// Begin capturing a [`PipelineManifest`]: subsequent `Graph::add` /
1046 /// [`Graph::alloc_storage`](crate::graph::Graph::alloc_storage) record the ids they mint. Bracket one `build_pipeline`
1047 /// (+ its sink) with this and [`Graph::take_recording`](crate::graph::Graph::take_recording) to get the exact id set to
1048 /// hand [`Graph::destroy_pipeline`](crate::graph::Graph::destroy_pipeline). # Panics (debug) if recording is already on.
1049 pub fn begin_recording(&mut self) {
1050 debug_assert!(self.recording.is_none(), "recording already in progress");
1051 self.recording = Some(PipelineManifest::default());
1052 }
1053
1054 /// Stop recording and return the captured [`PipelineManifest`] (empty if recording
1055 /// was not active).
1056 pub fn take_recording(&mut self) -> PipelineManifest {
1057 self.recording.take().unwrap_or_default()
1058 }
1059
1060 /// Allocate a fresh scratch-state slot for a stateful operator and return its
1061 /// [`StorageId`] (the builder, `08` §5, calls this and embeds the id in the
1062 /// `Take`/`Cap`/`Exists` struct). On the client/test path a fresh
1063 /// [`MemoryStorage`](crate::storage::MemoryStorage) *is* the namespace (`10`
1064 /// §3.3 / §4.5): each slot is a disjoint keyspace because it is a distinct
1065 /// store. On the SQLite server path, a graph constructed with
1066 /// `StorageFactory::sqlite` vends an `op_id`-namespaced `OpStorage` instead
1067 /// (`10` §4.4).
1068 pub fn alloc_storage(&mut self) -> StorageId {
1069 // Reuse a freed storage slot if one is available — its store was `clear`ed at
1070 // free time, so it is already an empty namespace ready for the new operator;
1071 // otherwise grow the arena. Thread the graph's runtime-error sink into a
1072 // freshly-created store so a SQLite operator-storage failure parks a `RindleError`
1073 // (drained by `take_runtime_error`) instead of `.expect`-aborting (WS02.3).
1074 let id = if let Some(idx) = self.free_storage.pop() {
1075 let i = idx as usize;
1076 StorageId {
1077 idx,
1078 gen: self.storage_gens[i],
1079 }
1080 } else {
1081 let idx = self.storage.len() as u32;
1082 self.storage.push(
1083 self.storage_factory
1084 .create_storage_with_sink(self.runtime_error.clone()),
1085 );
1086 self.storage_gens.push(0);
1087 StorageId { idx, gen: 0 }
1088 };
1089 if let Some(rec) = self.recording.as_mut() {
1090 rec.storage.push(id);
1091 }
1092 id
1093 }
1094
1095 /// Borrow a stateful operator's scratch store (the read side of
1096 /// [`Graph::alloc_storage`](crate::graph::Graph::alloc_storage)). `&dyn Storage` so operator code is
1097 /// backend-agnostic; the store's own methods take `&self` (interior
1098 /// mutability), so this composes with the reentrant shared-borrow push.
1099 // `allow(dead_code)`: consumed by the stateful operators (`Take`/`Cap`/
1100 // `Exists`, spec `07`) once they land; exercised today by the arena test.
1101 #[allow(dead_code)]
1102 pub(crate) fn storage(&self, id: StorageId) -> &dyn Storage {
1103 let i = id.ix();
1104 assert!(
1105 self.storage_gens[i] == id.gen,
1106 "stale StorageId {id:?} (slot generation is {})",
1107 self.storage_gens[i]
1108 );
1109 &*self.storage[i]
1110 }
1111
1112 /// The generation-checked operator lookup — the single read path into the node
1113 /// arena. Every dispatch/accessor goes through here, so a stale [`NodeId`] (an old
1114 /// generation for a slot a torn-down pipeline freed and a newer one recycled) is a
1115 /// loud fail-fast, never a silent alias. Checked in release too (the stale-handle
1116 /// safety net is the whole point of the generation).
1117 #[inline]
1118 fn node(&self, id: NodeId) -> &Operator {
1119 let i = id.ix();
1120 assert!(
1121 self.node_gens[i] == id.gen,
1122 "stale NodeId {id:?} (slot generation is {})",
1123 self.node_gens[i]
1124 );
1125 &self.nodes[i]
1126 }
1127
1128 /// Snapshot **all** operator scratch storage, one entry per [`StorageId`] in
1129 /// allocation order, each a full ascending `scan("")` of `(key, value)` pairs.
1130 /// The test-only sink-independence probe (spec `11` §3.1.1): two graphs built
1131 /// from the *same* `Ast` allocate storage in the same order, so snapshot index
1132 /// `i` denotes the same operator's store in both — letting
1133 /// `run_push_test_ast_view` assert
1134 /// `Catch`-sink storage equals `View`-sink storage (operator state is identical
1135 /// regardless of sink). `StorageValue` has no `PartialEq`, so the comparison is
1136 /// spelled out at the testkit boundary.
1137 pub fn storage_snapshot(&self) -> Vec<Vec<(Box<str>, crate::storage::StorageValue)>> {
1138 self.storage.iter().map(|s| s.scan("").collect()).collect()
1139 }
1140
1141 /// The number of `(key, value)` entries in the operator scratch storage **one
1142 /// pipeline** owns — the leak probe (`take_partition_leak.rs`'s assertion, and the
1143 /// family unbind's "no zombie slot" check of design 310 impl plan D5), keyed by the
1144 /// [`PipelineManifest`] captured when the pipeline was built rather than by the
1145 /// whole arena, so a graph hosting several pipelines can be probed one at a time.
1146 pub fn storage_entries_of(&self, manifest: &PipelineManifest) -> usize {
1147 manifest
1148 .storage()
1149 .iter()
1150 .map(|&sid| self.storage(sid).scan("").count())
1151 .sum()
1152 }
1153
1154 /// Every `(slot, key, value)` of one pipeline's operator scratch storage — the
1155 /// diagnostic twin of [`storage_entries_of`](Self::storage_entries_of), for the
1156 /// failure message when a leak probe finds entries it did not expect. `slot` indexes
1157 /// the manifest's storage list (a stable, build-order name for the operator's store).
1158 pub fn storage_dump_of(&self, manifest: &PipelineManifest) -> Vec<(usize, String, String)> {
1159 manifest
1160 .storage()
1161 .iter()
1162 .enumerate()
1163 .flat_map(|(i, &sid)| {
1164 self.storage(sid)
1165 .scan("")
1166 .map(move |(k, v)| (i, k.to_string(), format!("{v:?}")))
1167 .collect::<Vec<_>>()
1168 })
1169 .collect()
1170 }
1171
1172 /// Total node slots in the arena, **including tombstoned (freed) slots**. A
1173 /// teardown does not shrink this; a subsequent build reuses freed slots, so this
1174 /// staying flat across a destroy+rebuild is the observable proof of slot reuse.
1175 pub fn node_count(&self) -> usize {
1176 self.nodes.len()
1177 }
1178
1179 /// Total storage slots in the arena, including freed (cleared) ones (the
1180 /// [`node_count`](Self::node_count) analogue for [`StorageId`]).
1181 pub fn storage_count(&self) -> usize {
1182 self.storage.len()
1183 }
1184
1185 /// Tear down one pipeline, identified by the [`PipelineManifest`] captured when it
1186 /// was built (recording mode). Two passes:
1187 ///
1188 /// 1. **Disconnect** every `SourceConn` the pipeline owns via
1189 /// [`Source::destroy`], which nulls that connection's output edge. Because the
1190 /// push fan-out only drives connections whose output is `Some`
1191 /// ([`Graph::try_source_push`](crate::graph::Graph::try_source_push)), this orphans the whole subgraph from every
1192 /// future source push. The shared [`Source`] itself — pre-registered, outside
1193 /// the manifest — is untouched: its indexes and connection slots persist (§3.10).
1194 /// 2. **Reclaim** each storage slot (`clear` its contents) and each node slot
1195 /// (replace the operator with `Operator::Tombstone`, dropping its heavy state:
1196 /// View tree, Collector buffers, compiled predicates, …). Both bump the slot's
1197 /// generation and push it onto the free-list, so the slot is recycled by the
1198 /// next `add`/`alloc_storage` while every *other* pipeline's ids stay valid.
1199 ///
1200 /// Idempotency / safety: each slot's current generation is asserted to match the
1201 /// manifest before freeing, so a double teardown (or a corrupted manifest) is a
1202 /// loud fail-fast rather than silent corruption. Runs under `&mut self`, so it can
1203 /// only be called between transactions, never mid-push.
1204 ///
1205 /// **Known limitation (deferred):** the shared source's per-connection slot is
1206 /// *nulled* (step 1) but not freed — `ConnId`s are not yet generational, so a
1207 /// source's `conns` Vec grows by one small record per teardown. Under sustained
1208 /// build/destroy churn against a long-lived source this is a slow, bounded-per-query
1209 /// leak (no correctness impact: the fan-out skips nulled connections). Reclaiming it
1210 /// needs a generational `ConnId` + per-source free-list — a planned fast-follow.
1211 pub fn destroy_pipeline(&mut self, manifest: &PipelineManifest) {
1212 // Pass 1: collect the (source, connection) pairs first — pass 2 tombstones the
1213 // SourceConn nodes, which would lose this information.
1214 let conns: Vec<(NodeId, ConnId)> = manifest
1215 .nodes
1216 .iter()
1217 .filter_map(|&nid| match self.node(nid) {
1218 Operator::SourceConn(sc) => Some((sc.source, sc.conn)),
1219 _ => None,
1220 })
1221 .collect();
1222 for (source, conn) in conns {
1223 self.source(source).destroy(conn);
1224 }
1225
1226 // Pass 2a: clear + free each storage slot. The generation guard is a release
1227 // `assert!` (not `debug_assert!`): freeing a slot pushes it onto the free-list,
1228 // so a stale/double-freed id here would silently recycle a live store into two
1229 // owners. This runs once per slot at teardown (off any per-row path), so the
1230 // check is free; it must hold in release to match the docstring's guarantee.
1231 for &sid in &manifest.storage {
1232 let i = sid.ix();
1233 assert!(
1234 self.storage_gens[i] == sid.gen,
1235 "destroy_pipeline: stale StorageId {sid:?} in manifest (double free?)"
1236 );
1237 self.storage[i].clear();
1238 self.storage_gens[i] = self.storage_gens[i].wrapping_add(1);
1239 self.free_storage.push(i as u32);
1240 }
1241
1242 // Pass 2b: tombstone + free each node slot (same release-checked guard).
1243 for &nid in &manifest.nodes {
1244 let i = nid.ix();
1245 assert!(
1246 self.node_gens[i] == nid.gen,
1247 "destroy_pipeline: stale NodeId {nid:?} in manifest (double free?)"
1248 );
1249 // A torn-down join gives its tracked parent keys back to the graph-wide
1250 // pre-check budget (design 311 §2.5) before its set is dropped with the operator.
1251 if let Operator::Join(j) = &self.nodes[i] {
1252 j.precheck.borrow_mut().reset(&self.join_precheck_tracked);
1253 }
1254 self.nodes[i] = Operator::Tombstone;
1255 self.node_gens[i] = self.node_gens[i].wrapping_add(1);
1256 self.free_nodes.push(i as u32);
1257 }
1258 }
1259
1260 // --- construction helpers (mirror builder.ts wiring: two-phase, by id) ---
1261
1262 /// Add an in-memory source. # Panics if a row's width does not match the schema;
1263 /// prefer [`try_add_source`](Self::try_add_source) in production (WS02.6).
1264 pub fn add_source(&mut self, schema: SourceSchema, initial: Vec<Row>) -> NodeId {
1265 self.add(Operator::Source(SourceLeaf::Memory(MemorySource::new(
1266 schema.into_schema(),
1267 initial,
1268 ))))
1269 }
1270
1271 /// Fallible [`add_source`](Self::add_source): validates ingest row widths and
1272 /// returns a [`RindleError::SchemaViolation`] instead of panicking (WS02.4).
1273 pub fn try_add_source(
1274 &mut self,
1275 schema: SourceSchema,
1276 initial: Vec<Row>,
1277 ) -> Result<NodeId, RindleError> {
1278 let source = MemorySource::try_new(schema.into_schema(), initial)?;
1279 Ok(self.add(Operator::Source(SourceLeaf::Memory(source))))
1280 }
1281
1282 pub fn add_memory_source(&mut self, source: MemorySource) -> NodeId {
1283 self.add(Operator::Source(SourceLeaf::Memory(source)))
1284 }
1285
1286 /// Add an external [`Source`] erased behind a trait object — e.g. the SQLite
1287 /// `TableSource` from the `rindle-sqlite` crate. The in-memory backend uses the
1288 /// concrete [`add_source`](Self::add_source)/[`add_memory_source`](Self::add_memory_source).
1289 pub fn add_dyn_source(&mut self, source: Box<dyn Source>) -> NodeId {
1290 self.add(Operator::Source(SourceLeaf::Dyn(source)))
1291 }
1292
1293 /// Remove an in-memory source added by [`add_source`](Self::add_source) /
1294 /// [`try_add_source`](Self::try_add_source): tombstone its node and free the slot (bump
1295 /// generation + free-list) — the single-node analogue of [`destroy_pipeline`](Self::destroy_pipeline).
1296 /// The inverse of `add_source`, for a synthetic table whose last reading query is gone
1297 /// (`AGGREGATE-SYNC-DESIGN.md` §4). Errors with a [`RindleError`] if `source` is not an
1298 /// in-memory source node, or if it still has a live connection — every pipeline reading it
1299 /// must be torn down first (`destroy_pipeline`), which the JS backend guarantees by
1300 /// refcounting readers.
1301 pub fn remove_source(&mut self, source: NodeId) -> Result<(), RindleError> {
1302 let live = match self.node(source) {
1303 Operator::Source(SourceLeaf::Memory(ms)) => ms.live_conn_count(),
1304 Operator::Source(SourceLeaf::Dyn(_)) => {
1305 return Err(RindleError::schema_violation(
1306 "remove_source: external (dyn) sources are not removable".to_string(),
1307 ))
1308 }
1309 _ => {
1310 return Err(RindleError::schema_violation(format!(
1311 "remove_source: {source:?} is not a source node"
1312 )))
1313 }
1314 };
1315 if live != 0 {
1316 return Err(RindleError::schema_violation(format!(
1317 "remove_source: {source:?} still has {live} live connection(s) — destroy readers first"
1318 )));
1319 }
1320 let i = source.ix();
1321 assert!(
1322 self.node_gens[i] == source.gen,
1323 "remove_source: stale NodeId {source:?} (slot generation is {})",
1324 self.node_gens[i]
1325 );
1326 self.nodes[i] = Operator::Tombstone;
1327 self.node_gens[i] = self.node_gens[i].wrapping_add(1);
1328 self.free_nodes.push(i as u32);
1329 Ok(())
1330 }
1331
1332 /// Create a new connection (output) on a source and return its `SourceConn`
1333 /// node id. Self-joins call this twice on the same source.
1334 pub fn connect(
1335 &mut self,
1336 source: NodeId,
1337 sort: Option<Sort>,
1338 filters: Option<ConnectionFilters>,
1339 split_edit_keys: Vec<ColId>,
1340 ) -> NodeId {
1341 let conn = self.source(source).connect(sort, filters, split_edit_keys);
1342 self.add(Operator::SourceConn(SourceConn {
1343 source,
1344 conn,
1345 family: None,
1346 output: Cell::new(None),
1347 }))
1348 }
1349
1350 /// Mark `conn` (a `SourceConn`) as the root connection of a parameterized query
1351 /// family (design 310 §4.1): the fetch path then filters committed rows by
1352 /// membership in `bindings` on `param_cols` and checks that every fetch is
1353 /// constrained (§4.4). Called by the family builder right after `connect`.
1354 pub(crate) fn set_family_root(
1355 &mut self,
1356 conn: NodeId,
1357 bindings: Rc<BindingSet>,
1358 param_cols: Vec<ColId>,
1359 ) {
1360 let i = conn.ix();
1361 assert!(
1362 self.node_gens[i] == conn.gen,
1363 "stale NodeId {conn:?} (slot generation is {})",
1364 self.node_gens[i]
1365 );
1366 match &mut self.nodes[i] {
1367 Operator::SourceConn(sc) => {
1368 sc.family = Some(Box::new(FamilyRootConn {
1369 bindings,
1370 param_cols,
1371 }))
1372 }
1373 _ => panic!("set_family_root: {conn:?} is not a SourceConn"),
1374 }
1375 }
1376
1377 /// Wire a hierarchical join with an **already-resolved** [`RelId`] slot. The builder
1378 /// (`08`) resolves the relationship name against its *query-local* slot tree —
1379 /// computed from the query AST, not the shared source schema's declared
1380 /// relationships (which a query can't pre-declare for synthesized EXISTS-gate
1381 /// aliases like `comments_0`) — and passes the slot directly, so the join is not
1382 /// re-resolved against `input_schema(parent)`.
1383 pub fn add_join_slot(
1384 &mut self,
1385 parent: NodeId,
1386 child: NodeId,
1387 parent_key: Vec<ColId>,
1388 child_key: Vec<ColId>,
1389 rel_slot: RelId,
1390 ) -> NodeId {
1391 self.add(Operator::Join(Join {
1392 parent,
1393 child,
1394 parent_key,
1395 child_key,
1396 rel_slot,
1397 output: Cell::new(None),
1398 inprogress_overlay: RefCell::new(None),
1399 inprogress_position: RefCell::new(None),
1400 precheck: RefCell::new(ParentKeySet::Unbuilt),
1401 precheck_eligible: Cell::new(None),
1402 }))
1403 }
1404
1405 /// The flipped, child-driven inner join with an **already-resolved** [`RelId`]
1406 /// slot — the flipped analogue of [`Graph::add_join_slot`](crate::graph::Graph::add_join_slot), for the builder's
1407 /// query-local slot resolution. The flipped join outputs parent rows with the
1408 /// child relationship attached; wire its parent/child inputs with
1409 /// [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge) (`JoinParent`/`JoinChild`).
1410 pub fn add_flipped_join_slot(
1411 &mut self,
1412 parent: NodeId,
1413 child: NodeId,
1414 parent_key: Vec<ColId>,
1415 child_key: Vec<ColId>,
1416 rel_slot: RelId,
1417 ) -> NodeId {
1418 self.add(Operator::FlippedJoin(crate::op::FlippedJoin::new(
1419 parent, child, parent_key, child_key, rel_slot,
1420 )))
1421 }
1422
1423 /// [`Graph::add_flipped_join_slot`](crate::graph::Graph::add_flipped_join_slot) with an explicit IN-batch chunk size — the
1424 /// test seam analogous to the JS `setMultiConstraintChunkSizeForTest`
1425 /// (`flipped-join.ts:57`). A small size forces the chunked fetch path (per-window
1426 /// fetch + node-level k-way merge) so it can be diffed against the unchunked path.
1427 pub fn add_flipped_join_slot_with_chunk_size(
1428 &mut self,
1429 parent: NodeId,
1430 child: NodeId,
1431 parent_key: Vec<ColId>,
1432 child_key: Vec<ColId>,
1433 rel_slot: RelId,
1434 chunk_size: usize,
1435 ) -> NodeId {
1436 self.add(Operator::FlippedJoin(
1437 crate::op::FlippedJoin::new(parent, child, parent_key, child_key, rel_slot)
1438 .with_chunk_size(chunk_size),
1439 ))
1440 }
1441
1442 /// Add a production [`View`](crate::view::View) over `input` with the default
1443 /// `ResultType::Complete` (the server/test default). For explicit `with_ids` or a
1444 /// pending result type use [`Graph::add_view_with`](crate::graph::Graph::add_view_with). The view shape is carried by
1445 /// `schema` (a relationship is in-view iff its `RelDef` has a child schema).
1446 pub fn add_view(&mut self, input: NodeId, schema: Schema) -> NodeId {
1447 self.add(Operator::View(crate::view::View::new(
1448 input,
1449 schema,
1450 false,
1451 crate::view::ResultType::Complete,
1452 )))
1453 }
1454
1455 /// Add a production [`View`](crate::view::View) with explicit `with_ids` and initial
1456 /// [`ResultType`](crate::view::ResultType). The view shape is carried by `schema`.
1457 pub fn add_view_with(
1458 &mut self,
1459 input: NodeId,
1460 schema: Schema,
1461 with_ids: bool,
1462 result_type: crate::view::ResultType,
1463 ) -> NodeId {
1464 self.add(Operator::View(crate::view::View::new(
1465 input,
1466 schema,
1467 with_ids,
1468 result_type,
1469 )))
1470 }
1471
1472 /// Add an out-of-file operator (the fan-out seam). The builder takes a
1473 /// fully-constructed [`Skip`](crate::op::Skip), so growing `Skip`'s fields
1474 /// never touches this method. Wire its downstream with [`Graph::set_output`](crate::graph::Graph::set_output).
1475 /// (`Take`/`Cap`/`Exists`/`FlippedJoin`/`Union*` get an identical `add_*`.)
1476 pub fn add_skip(&mut self, skip: crate::op::Skip) -> NodeId {
1477 self.add(Operator::Skip(skip))
1478 }
1479
1480 /// Add a [`Take`](crate::op::Take) (the `LIMIT` operator). Like
1481 /// [`Graph::add_skip`](crate::graph::Graph::add_skip), takes a fully-constructed value (the builder hands it a
1482 /// [`StorageId`] from [`Graph::alloc_storage`](crate::graph::Graph::alloc_storage)); wire its downstream with
1483 /// [`Graph::set_output`](crate::graph::Graph::set_output) (terminal sink) or [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge) (feeding a
1484 /// relationship join's parent port).
1485 pub fn add_take(&mut self, take: crate::op::Take) -> NodeId {
1486 self.add(Operator::Take(take))
1487 }
1488
1489 /// Add a [`Cap`](crate::op::Cap) (the unordered EXISTS-child limiter). Same
1490 /// shape as [`Graph::add_take`](crate::graph::Graph::add_take): a fully-constructed value carrying its
1491 /// [`StorageId`]; wire its downstream with [`Graph::set_output`](crate::graph::Graph::set_output) /
1492 /// [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
1493 pub fn add_cap(&mut self, cap: crate::op::Cap) -> NodeId {
1494 self.add(Operator::Cap(cap))
1495 }
1496
1497 /// Add a [`Reduce`](crate::op::Reduce) (an invertible aggregate, `REDUCE-DESIGN.md`).
1498 /// Same shape as [`Graph::add_take`](crate::graph::Graph::add_take): a fully-constructed value carrying its
1499 /// [`StorageId`]. Unlike `Take`, `Reduce` reshapes its row (output is a synthetic
1500 /// aggregate row), so it carries its own output schema. Wire its downstream with
1501 /// [`Graph::set_output`](crate::graph::Graph::set_output).
1502 pub fn add_reduce(&mut self, reduce: crate::op::Reduce) -> NodeId {
1503 self.add(Operator::Reduce(reduce))
1504 }
1505
1506 /// Add an [`Exists`](crate::op::Exists) gate (a `FilterChain` link). Wire its
1507 /// upstream `FilterStart`'s chain head to it ([`Graph::set_chain_head`](crate::graph::Graph::set_chain_head)) and its
1508 /// downstream `FilterOutput` with [`Graph::set_output`](crate::graph::Graph::set_output).
1509 pub fn add_exists(&mut self, exists: crate::op::Exists) -> NodeId {
1510 self.add(Operator::Exists(exists))
1511 }
1512
1513 pub fn add_collector(&mut self, input: NodeId) -> NodeId {
1514 self.add(Operator::Collector(Collector {
1515 input,
1516 changes: RefCell::new(Vec::new()),
1517 fetch_on_push: Cell::new(false),
1518 fetched: RefCell::new(Vec::new()),
1519 capture_caught: Cell::new(false),
1520 caught: RefCell::new(Vec::new()),
1521 }))
1522 }
1523
1524 /// Add a **change-stream sink**: a terminal sink that records the
1525 /// fully-materialized [`CaughtChange`](crate::changes::CaughtChange) tree of every
1526 /// change pushed to it (nested relationships drained eagerly). Wire it with
1527 /// [`set_sink_edge`](Self::set_sink_edge); drain per-transaction events with
1528 /// [`take_sink_changes`](Self::take_sink_changes) and get the initial hydration set
1529 /// with [`try_hydrate_change_sink`](Self::try_hydrate_change_sink). Implemented as a
1530 /// `Collector` with caught-capture enabled, so no new operator variant (and no
1531 /// change to the dispatch/fetch/schema match arms) is required.
1532 pub fn add_change_sink(&mut self, input: NodeId) -> NodeId {
1533 self.add(Operator::Collector(Collector {
1534 input,
1535 changes: RefCell::new(Vec::new()),
1536 fetch_on_push: Cell::new(false),
1537 fetched: RefCell::new(Vec::new()),
1538 capture_caught: Cell::new(true),
1539 caught: RefCell::new(Vec::new()),
1540 }))
1541 }
1542
1543 // --- spec 11 testkit sinks/taps (the seam, `crate::testkit`) ---
1544
1545 /// The output oracle [`Catch`](crate::testkit::Catch). Sink it on the operator
1546 /// under test, then wire the push edge with [`Graph::set_sink_edge`](crate::graph::Graph::set_sink_edge) (the
1547 /// runner does both). `fetch_on_push` records a re-fetch alongside each push.
1548 #[cfg(any(test, feature = "testkit"))]
1549 pub fn add_catch(&mut self, input: NodeId, fetch_on_push: bool) -> NodeId {
1550 self.add(Operator::Catch(crate::testkit::Catch::new(
1551 input,
1552 fetch_on_push,
1553 )))
1554 }
1555
1556 /// The message-log tap [`Snitch`](crate::testkit::Snitch) over `input`. Wire
1557 /// its single downstream with [`Graph::set_output`](crate::graph::Graph::set_output); read the log with
1558 /// [`Graph::snitch_log`](crate::graph::Graph::snitch_log).
1559 #[cfg(any(test, feature = "testkit"))]
1560 pub fn add_snitch(&mut self, input: NodeId, name: impl Into<String>) -> NodeId {
1561 self.add(Operator::Snitch(crate::testkit::Snitch::new(input, name)))
1562 }
1563
1564 // --- the multi-port chassis: filter sub-graph + OR fan ---
1565
1566 /// A `FilterStart` over `input` (the upstream normal `Input`). Wire its single
1567 /// chain edge with [`Graph::set_chain_head`](crate::graph::Graph::set_chain_head).
1568 pub fn add_filter_start(&mut self, input: NodeId) -> NodeId {
1569 self.add(Operator::FilterStart(FilterStart {
1570 input,
1571 chain_head: Cell::new(None),
1572 }))
1573 }
1574
1575 /// A `FilterEnd` paired with `start`. Wire its downstream with
1576 /// [`Graph::set_output`](crate::graph::Graph::set_output).
1577 pub fn add_filter_end(&mut self, start: NodeId) -> NodeId {
1578 self.add(Operator::FilterEnd(FilterEnd {
1579 start,
1580 output: Cell::new(None),
1581 }))
1582 }
1583
1584 /// A `FanOut` over `input`. Wire its branches + paired `FanIn` with
1585 /// [`Graph::set_fan`](crate::graph::Graph::set_fan).
1586 pub fn add_fan_out(&mut self, input: NodeId) -> NodeId {
1587 self.add(Operator::FanOut(FanOut {
1588 input,
1589 outputs: RefCell::new(Vec::new()),
1590 fan_in: Cell::new(None),
1591 }))
1592 }
1593
1594 /// A `FanIn` paired with `fan_out`. Wire its post-fan continuation with
1595 /// [`Graph::set_output`](crate::graph::Graph::set_output).
1596 pub fn add_fan_in(&mut self, fan_out: NodeId) -> NodeId {
1597 self.add(Operator::FanIn(FanIn {
1598 fan_out,
1599 output: Cell::new(None),
1600 }))
1601 }
1602
1603 /// A `UnionFanOut` over `input` (the node-level OR fan-out). Wire its branch
1604 /// broadcast edges + paired `UnionFanIn` with [`Graph::set_union_fan`](crate::graph::Graph::set_union_fan).
1605 pub fn add_union_fan_out(&mut self, input: NodeId) -> NodeId {
1606 self.add(Operator::UnionFanOut(crate::op::UnionFanOut::new(input)))
1607 }
1608
1609 /// A `UnionFanIn` paired with `fan_out` over the branch tails `inputs`, carrying
1610 /// the merged branch `schema` (it owns the output schema; the `sort` must be
1611 /// defined) and the per-branch **pushable constraints** (parallel to `inputs`) the
1612 /// fan-in merges into each branch fetch. Wire its post-fan continuation with
1613 /// [`Graph::set_output`](crate::graph::Graph::set_output).
1614 pub fn add_union_fan_in(
1615 &mut self,
1616 fan_out: NodeId,
1617 inputs: Vec<NodeId>,
1618 branch_constraints: Vec<crate::change::Constraint>,
1619 schema: Schema,
1620 ) -> NodeId {
1621 self.add(Operator::UnionFanIn(crate::op::UnionFanIn::new(
1622 fan_out,
1623 inputs,
1624 branch_constraints,
1625 schema,
1626 )))
1627 }
1628
1629 /// A `Filter` link over `input` with predicate `pred`. Wire its single
1630 /// `FilterOutput` with [`Graph::set_output`](crate::graph::Graph::set_output).
1631 pub fn add_filter(&mut self, input: NodeId, pred: CompiledPredicate) -> NodeId {
1632 self.add(Operator::Filter(Filter {
1633 input,
1634 pred,
1635 output: Cell::new(None),
1636 }))
1637 }
1638
1639 /// A `FilterProbe` link over `input` (proof instrumentation; see the struct).
1640 pub fn add_filter_probe(&mut self, input: NodeId) -> NodeId {
1641 self.add(Operator::FilterProbe(FilterProbe {
1642 input,
1643 output: Cell::new(None),
1644 begin_count: Cell::new(0),
1645 filter_count: Cell::new(0),
1646 end_count: Cell::new(0),
1647 }))
1648 }
1649
1650 /// Wire a `FilterStart`'s single chain edge (`#output`, the chain head).
1651 pub fn set_chain_head(&self, filter_start: NodeId, head: NodeId) {
1652 self.filter_start_op(filter_start)
1653 .chain_head
1654 .set(Some(head));
1655 }
1656
1657 /// Wire a `FanOut`'s branch outputs and its paired `FanIn`.
1658 pub fn set_fan(&self, fan_out: NodeId, branches: Vec<NodeId>, fan_in: NodeId) {
1659 let fo = self.fan_out_op(fan_out);
1660 *fo.outputs.borrow_mut() = branches;
1661 fo.fan_in.set(Some(fan_in));
1662 }
1663
1664 /// Wire a `UnionFanOut`'s branch broadcast edges (each branch head + the port to
1665 /// push it on — `JoinParent` for a flipped branch, `Single` for a filter branch)
1666 /// and its paired `UnionFanIn`.
1667 pub fn set_union_fan(&self, fan_out: NodeId, branches: Vec<OutEdge>, fan_in: NodeId) {
1668 self.union_fan_out_op(fan_out).set_fan(branches, fan_in);
1669 }
1670
1671 /// Wire a source connection's output edge (mirrors `input.setOutput`).
1672 pub fn set_conn_output(&self, conn: NodeId, edge: OutEdge) {
1673 let sc = self.source_conn(conn);
1674 sc.output.set(Some(edge));
1675 self.source(sc.source).set_conn_output(sc.conn, edge);
1676 }
1677
1678 /// The port-bearing downstream edge of a spine node, if wired: the read side of
1679 /// [`set_out_edge`](Self::set_out_edge) / [`set_conn_output`](Self::set_conn_output)
1680 /// for the operators that carry an `OutEdge`. `None` for the multi-output chassis
1681 /// links and the sinks.
1682 pub(crate) fn out_edge(&self, node: NodeId) -> Option<OutEdge> {
1683 match self.node(node) {
1684 Operator::SourceConn(sc) => sc.output.get(),
1685 Operator::Join(j) => j.output.get(),
1686 Operator::FlippedJoin(fj) => fj.output.get(),
1687 Operator::Skip(s) => s.output.get(),
1688 Operator::Take(t) => t.output.get(),
1689 Operator::Cap(c) => c.output.get(),
1690 Operator::Reduce(r) => r.output.get(),
1691 Operator::FilterEnd(fe) => fe.output.get(),
1692 Operator::UnionFanIn(u) => u.output_edge(),
1693 _ => None,
1694 }
1695 }
1696
1697 /// Wire a single-output operator's downstream. Covers the join plus every
1698 /// single-`FilterOutput` chassis link (`FilterEnd`, `FanIn`, `Filter`,
1699 /// `FilterProbe`). `FilterStart` uses [`Graph::set_chain_head`](crate::graph::Graph::set_chain_head) and `FanOut`
1700 /// uses [`Graph::set_fan`](crate::graph::Graph::set_fan) — those are not single-output.
1701 pub fn set_output(&self, op: NodeId, downstream: NodeId) {
1702 match self.node(op) {
1703 // A join's single downstream lands on the sink's sole input port. A
1704 // join feeding *another join* (sibling/nested relationships) needs an
1705 // explicit port and is wired by the builder via [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
1706 Operator::Join(j) => j.output.set(Some(OutEdge {
1707 node: downstream,
1708 port: Port::Single,
1709 })),
1710 // Like a Join, a FlippedJoin's terminal-sink downstream lands on the
1711 // sink's sole input port; feeding another join's port uses `set_out_edge`.
1712 Operator::FlippedJoin(fj) => fj.output.set(Some(OutEdge {
1713 node: downstream,
1714 port: Port::Single,
1715 })),
1716 Operator::FilterEnd(fe) => fe.output.set(Some(OutEdge {
1717 node: downstream,
1718 port: Port::Single,
1719 })),
1720 Operator::FanIn(fi) => fi.output.set(Some(downstream)),
1721 // UnionFanIn has a single post-fan output (UnionFanOut is wired via
1722 // `set_union_fan`, not here).
1723 Operator::UnionFanIn(u) => u.set_output(OutEdge {
1724 node: downstream,
1725 port: Port::Single,
1726 }),
1727 Operator::Filter(f) => f.output.set(Some(downstream)),
1728 Operator::FilterProbe(p) => p.output.set(Some(downstream)),
1729 Operator::Exists(e) => e.output.set(Some(downstream)),
1730 // Like a Join, a Skip's downstream on a terminal sink lands on the
1731 // sink's sole input port; a Skip feeding a relationship join's parent
1732 // port is wired by the builder via [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
1733 Operator::Skip(s) => s.output.set(Some(OutEdge {
1734 node: downstream,
1735 port: Port::Single,
1736 })),
1737 // Like a Skip, a Take's terminal-sink downstream lands on the sink's
1738 // sole input port; feeding a relationship join's parent port uses
1739 // [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
1740 Operator::Take(t) => t.output.set(Some(OutEdge {
1741 node: downstream,
1742 port: Port::Single,
1743 })),
1744 Operator::Cap(c) => c.output.set(Some(OutEdge {
1745 node: downstream,
1746 port: Port::Single,
1747 })),
1748 // Reduce has a single downstream landing on the sink's sole input port
1749 // (its synthetic aggregate row); it is not a relationship parent.
1750 Operator::Reduce(r) => r.output.set(Some(OutEdge {
1751 node: downstream,
1752 port: Port::Single,
1753 })),
1754 #[cfg(any(test, feature = "testkit"))]
1755 Operator::Snitch(s) => s.set_output(downstream),
1756 #[cfg(any(test, feature = "testkit"))]
1757 Operator::Catch(_) => panic!(
1758 "set_output: Catch is a terminal sink (no downstream); attach it with \
1759 add_catch + set_sink_edge (or route its upstream to it)"
1760 ),
1761 _ => panic!("set_output: not a single-output operator"),
1762 }
1763 }
1764
1765 /// Wire the final edge from a built pipeline's last operator into a terminal
1766 /// sink (a view, change sink, or testkit `Catch`) or a forwarding testkit `Snitch`.
1767 /// Routes a
1768 /// `SourceConn` through [`Graph::set_conn_output`](crate::graph::Graph::set_conn_output) (a bare `source → sink`
1769 /// pipeline) and any single-output operator — including a transparent tap —
1770 /// through [`Graph::set_output`](crate::graph::Graph::set_output). Used by the testkit runners after the build
1771 /// closure returns the op feeding the sink.
1772 pub fn set_sink_edge(&self, upstream: NodeId, sink: NodeId) {
1773 match self.node(upstream) {
1774 Operator::SourceConn(_) => self.set_conn_output(
1775 upstream,
1776 OutEdge {
1777 node: sink,
1778 port: Port::Single,
1779 },
1780 ),
1781 _ => self.set_output(upstream, sink),
1782 }
1783 }
1784
1785 /// Wire an upstream operator's output edge with an **explicit port** — the
1786 /// port-aware generalization of [`Graph::set_output`](crate::graph::Graph::set_output) (which always wires
1787 /// `Port::Single`). The builder (`08`) uses this to chain joins: a
1788 /// `SourceConn` routes through the source's [`Graph::set_conn_output`](crate::graph::Graph::set_conn_output); a
1789 /// `Join` sets its [`OutEdge`] cell directly. The port says how the
1790 /// *downstream* receives — `JoinParent` when `upstream` is the next join's
1791 /// parent (sibling relationships), `JoinChild` when it is a nested child top.
1792 pub fn set_out_edge(&self, upstream: NodeId, edge: OutEdge) {
1793 match self.node(upstream) {
1794 Operator::SourceConn(_) => self.set_conn_output(upstream, edge),
1795 Operator::Join(j) => j.output.set(Some(edge)),
1796 Operator::FlippedJoin(fj) => fj.output.set(Some(edge)),
1797 Operator::Skip(s) => s.output.set(Some(edge)),
1798 Operator::Take(t) => t.output.set(Some(edge)),
1799 Operator::Cap(c) => c.output.set(Some(edge)),
1800 // A grouped `Reduce` is the top of a relationship-aggregate child subtree:
1801 // it feeds a parent join's `JoinChild` port, which lifts each per-group
1802 // `Add`/`Edit`/`Remove` into a `Change::Child` on the parent (the §9 Tier-1
1803 // singular-relationship attachment, `REDUCE-DESIGN.md`). It already forwards
1804 // on `out.port`, so it carries a port-bearing edge like any join child.
1805 Operator::Reduce(r) => r.output.set(Some(edge)),
1806 // The filter-chain / union-fan tails now carry a port-bearing edge, so a
1807 // relationship join (`related`/sibling) can sit over a `where` sub-graph.
1808 Operator::FilterEnd(fe) => fe.output.set(Some(edge)),
1809 Operator::UnionFanIn(u) => u.set_output(edge),
1810 _ => panic!(
1811 "set_out_edge: not a source-conn, join, flipped-join, skip, take, cap, \
1812 reduce, filter-end, or union-fan-in ({upstream:?})"
1813 ),
1814 }
1815 }
1816
1817 /// True iff `node` can carry a port-bearing [`OutEdge`] via [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
1818 /// Covers the port-aware ops (`SourceConn`/`Join`/`FlippedJoin`/`Skip`/`Take`/`Cap`/
1819 /// `Reduce`) **and** the filter-chain / union-fan tails `FilterEnd`/`UnionFanIn`, which
1820 /// now carry a port-bearing `OutEdge` (Gap B) — so a `related`/sibling relationship join
1821 /// can sit over a `where` sub-graph (an OR, a nested AND-OR, or a flipped EXISTS).
1822 /// `Reduce` is here as the top of a relationship-aggregate child subtree (§9). `FanIn`
1823 /// is excluded: it returns up the filter-chain stack, it is not a downstream-pushing tail.
1824 pub fn output_port_capable(&self, node: NodeId) -> bool {
1825 matches!(
1826 self.node(node),
1827 Operator::SourceConn(_)
1828 | Operator::Join(_)
1829 | Operator::FlippedJoin(_)
1830 | Operator::Skip(_)
1831 | Operator::Take(_)
1832 | Operator::Cap(_)
1833 | Operator::Reduce(_)
1834 | Operator::FilterEnd(_)
1835 | Operator::UnionFanIn(_)
1836 )
1837 }
1838
1839 /// Wire a `Port::Single` forward edge from `upstream` to `downstream`, tolerant of a
1840 /// non-port-aware tail. A `SourceConn` routes through [`Graph::set_conn_output`](crate::graph::Graph::set_conn_output);
1841 /// every other single-output operator — port-aware ops (via their `Single` fallback)
1842 /// AND the filter-chain / union-fan tails `FilterEnd`/`FanIn`/`UnionFanIn` — routes
1843 /// through [`Graph::set_output`](crate::graph::Graph::set_output). Unlike [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge), this accepts a
1844 /// `FilterEnd`/`UnionFanIn` tail, so a `Take`/`Cap` (a root or EXISTS-child `limit`)
1845 /// can sit above a `where`-subgraph / union-fan end without panicking.
1846 pub fn wire_single(&self, upstream: NodeId, downstream: NodeId) {
1847 match self.node(upstream) {
1848 Operator::SourceConn(_) => self.set_conn_output(
1849 upstream,
1850 OutEdge {
1851 node: downstream,
1852 port: Port::Single,
1853 },
1854 ),
1855 _ => self.set_output(upstream, downstream),
1856 }
1857 }
1858
1859 // --- typed accessors ---
1860 //
1861 // PANIC-CLASS: internal — every `_ => panic!("expected X at {id:?}")` arm below
1862 // fires only if the *builder* hands an accessor a NodeId of the wrong operator
1863 // kind. That is an engine wiring bug, never reachable from data/push/AST, so it
1864 // is a correct fail-fast panic (foundations §10; WS02.1). Do NOT convert to
1865 // `RindleError` — these are unconditional catch-alls, not conditional invariants.
1866
1867 fn source(&self, id: NodeId) -> &SourceLeaf {
1868 match self.node(id) {
1869 Operator::Source(s) => s,
1870 _ => panic!("expected Source at {id:?}"),
1871 }
1872 }
1873 /// The in-memory source behind `id`, or `None` if the node is a `SourceLeaf::Dyn`
1874 /// backend (or not a source). The optimistic fork/rebase loop reaches through this
1875 /// to fork the live primary tree (`OPTIMISTIC-WRITES-DESIGN.md` §1) — memory
1876 /// sources only, by design: the loop is the wasm client's, not a SQL backend's.
1877 pub fn memory_source(&self, id: NodeId) -> Option<&MemorySource> {
1878 match self.node(id) {
1879 Operator::Source(SourceLeaf::Memory(s)) => Some(s),
1880 _ => None,
1881 }
1882 }
1883 fn source_conn(&self, id: NodeId) -> &SourceConn {
1884 match self.node(id) {
1885 Operator::SourceConn(s) => s,
1886 _ => panic!("expected SourceConn at {id:?}"),
1887 }
1888 }
1889 fn join(&self, id: NodeId) -> &Join {
1890 match self.node(id) {
1891 Operator::Join(j) => j,
1892 _ => panic!("expected Join at {id:?}"),
1893 }
1894 }
1895 fn view(&self, id: NodeId) -> &crate::view::View {
1896 match self.node(id) {
1897 Operator::View(v) => v,
1898 _ => panic!("expected View at {id:?}"),
1899 }
1900 }
1901 fn collector(&self, id: NodeId) -> &Collector {
1902 match self.node(id) {
1903 Operator::Collector(c) => c,
1904 _ => panic!("expected Collector at {id:?}"),
1905 }
1906 }
1907 #[cfg(any(test, feature = "testkit"))]
1908 fn catch_op(&self, id: NodeId) -> &crate::testkit::Catch {
1909 match self.node(id) {
1910 Operator::Catch(c) => c,
1911 _ => panic!("expected Catch at {id:?}"),
1912 }
1913 }
1914 #[cfg(any(test, feature = "testkit"))]
1915 fn snitch_op(&self, id: NodeId) -> &crate::testkit::Snitch {
1916 match self.node(id) {
1917 Operator::Snitch(s) => s,
1918 _ => panic!("expected Snitch at {id:?}"),
1919 }
1920 }
1921 fn filter_start_op(&self, id: NodeId) -> &FilterStart {
1922 match self.node(id) {
1923 Operator::FilterStart(s) => s,
1924 _ => panic!("expected FilterStart at {id:?}"),
1925 }
1926 }
1927 fn fan_out_op(&self, id: NodeId) -> &FanOut {
1928 match self.node(id) {
1929 Operator::FanOut(f) => f,
1930 _ => panic!("expected FanOut at {id:?}"),
1931 }
1932 }
1933 fn fan_in_op(&self, id: NodeId) -> &FanIn {
1934 match self.node(id) {
1935 Operator::FanIn(f) => f,
1936 _ => panic!("expected FanIn at {id:?}"),
1937 }
1938 }
1939 fn union_fan_out_op(&self, id: NodeId) -> &crate::op::UnionFanOut {
1940 match self.node(id) {
1941 Operator::UnionFanOut(u) => u,
1942 _ => panic!("expected UnionFanOut at {id:?}"),
1943 }
1944 }
1945 /// `pub(crate)`: [`UnionFanOut::push`](crate::op::UnionFanOut) drives its paired
1946 /// fan-in's accumulate/drain through this (the fan-out seam).
1947 pub(crate) fn union_fan_in_op(&self, id: NodeId) -> &crate::op::UnionFanIn {
1948 match self.node(id) {
1949 Operator::UnionFanIn(u) => u,
1950 _ => panic!("expected UnionFanIn at {id:?}"),
1951 }
1952 }
1953 fn filter_op(&self, id: NodeId) -> &Filter {
1954 match self.node(id) {
1955 Operator::Filter(f) => f,
1956 _ => panic!("expected Filter at {id:?}"),
1957 }
1958 }
1959 fn filter_probe_op(&self, id: NodeId) -> &FilterProbe {
1960 match self.node(id) {
1961 Operator::FilterProbe(p) => p,
1962 _ => panic!("expected FilterProbe at {id:?}"),
1963 }
1964 }
1965
1966 /// Schema of an input node's output rows (for resolving child PK/sort inside
1967 /// relationship thunks). Recurses through joins to the parent source.
1968 ///
1969 /// `pub(crate)`: operators in [`crate::op`] resolve their input's schema
1970 /// through this (the fan-out seam, `op/mod.rs`).
1971 pub(crate) fn input_schema(&self, id: NodeId) -> &Schema {
1972 match self.node(id) {
1973 Operator::SourceConn(sc) => self.source(sc.source).schema(),
1974 Operator::Join(j) => self.input_schema(j.parent),
1975 // FlippedJoin outputs parent rows (with the child relationship attached).
1976 Operator::FlippedJoin(fj) => self.input_schema(fj.parent),
1977 // UnionFanOut passes its input through; UnionFanIn owns the merged schema.
1978 Operator::UnionFanOut(u) => self.input_schema(u.input),
1979 Operator::UnionFanIn(u) => u.schema(),
1980 Operator::Source(s) => s.schema(),
1981 // `View::schema` is `Arc<Schema>`; hand back the inner `&Schema`.
1982 Operator::View(v) => v.schema.as_ref(),
1983 Operator::Collector(c) => self.input_schema(c.input),
1984 // The filter sub-graph never reshapes rows (`07` §3.1), so every link's
1985 // output schema is its input's. Recurse to the upstream source side.
1986 Operator::FilterStart(s) => self.input_schema(s.input),
1987 Operator::FilterEnd(e) => self.input_schema(e.start),
1988 Operator::FanOut(f) => self.input_schema(f.input),
1989 Operator::FanIn(f) => self.input_schema(self.fan_out_op(f.fan_out).input),
1990 Operator::Filter(f) => self.input_schema(f.input),
1991 Operator::FilterProbe(p) => self.input_schema(p.input),
1992 Operator::Exists(e) => self.input_schema(e.input),
1993 // Skip/Take/Cap/Exists never reshape rows → output schema = input's.
1994 Operator::Skip(s) => self.input_schema(s.input),
1995 Operator::Take(t) => self.input_schema(t.input),
1996 Operator::Cap(c) => self.input_schema(c.input),
1997 // Reduce DOES reshape: its output is the synthetic aggregate row, so it
1998 // carries (and returns) its own schema rather than delegating upstream.
1999 Operator::Reduce(r) => &r.schema,
2000 // Testkit sinks/taps never reshape rows → output schema = input's.
2001 #[cfg(any(test, feature = "testkit"))]
2002 Operator::Catch(c) => self.input_schema(c.input),
2003 #[cfg(any(test, feature = "testkit"))]
2004 Operator::Snitch(s) => self.input_schema(s.input),
2005 Operator::Tombstone => panic!("input_schema on a torn-down node {id:?}"),
2006 }
2007 }
2008
2009 /// Effective output ordering for `id`. This is distinct from
2010 /// [`Self::input_schema`]: a source connection can be ordered by a per-query sort
2011 /// that is not the table schema's primary sort.
2012 pub(crate) fn input_sort(&self, id: NodeId) -> Sort {
2013 match self.node(id) {
2014 Operator::SourceConn(sc) => self.source(sc.source).conn_sort(sc.conn),
2015 Operator::Join(j) => self.input_sort(j.parent),
2016 Operator::FlippedJoin(fj) => self.input_sort(fj.parent),
2017 Operator::UnionFanOut(u) => self.input_sort(u.input),
2018 Operator::UnionFanIn(u) => u.schema().sort.clone(),
2019 Operator::Source(s) => s.schema().sort.clone(),
2020 Operator::View(v) => v.schema.sort.clone(),
2021 Operator::Collector(c) => self.input_sort(c.input),
2022 Operator::FilterStart(s) => self.input_sort(s.input),
2023 Operator::FilterEnd(e) => self.input_sort(e.start),
2024 Operator::FanOut(f) => self.input_sort(f.input),
2025 Operator::FanIn(f) => self.input_sort(self.fan_out_op(f.fan_out).input),
2026 Operator::Filter(f) => self.input_sort(f.input),
2027 Operator::FilterProbe(p) => self.input_sort(p.input),
2028 Operator::Exists(e) => self.input_sort(e.input),
2029 Operator::Skip(s) => self.input_sort(s.input),
2030 Operator::Take(t) => t.sort.clone(),
2031 Operator::Cap(c) => self.input_sort(c.input),
2032 // Reduce reshapes to its synthetic row: its order is its own (empty) sort
2033 // (the global aggregate is a single row), not the input's.
2034 Operator::Reduce(r) => r.schema.sort.clone(),
2035 #[cfg(any(test, feature = "testkit"))]
2036 Operator::Catch(c) => self.input_sort(c.input),
2037 #[cfg(any(test, feature = "testkit"))]
2038 Operator::Snitch(s) => self.input_sort(s.input),
2039 Operator::Tombstone => panic!("input_sort on a torn-down node {id:?}"),
2040 }
2041 }
2042
2043 pub fn cursors_open(&self, source: NodeId) -> i64 {
2044 self.source(source).cursors_open()
2045 }
2046
2047 // -------------------------------------------------------------------
2048 // FETCH (pull). Every fetch is a *shared* `&'g self` borrow -> reentrancy
2049 // composes for free.
2050 // -------------------------------------------------------------------
2051
2052 pub fn fetch<'g>(&'g self, id: NodeId, req: &FetchRequest) -> NodeStream<'g> {
2053 match self.node(id) {
2054 // The connection boundary: a source emits rows; the SourceConn wraps
2055 // each in a leaf node. Downstream joins then attach relationships.
2056 Operator::SourceConn(sc) => {
2057 let rows = self.source(sc.source).fetch(sc.conn, req);
2058 match sc.family.as_deref() {
2059 None => Box::new(rows.map(Node::leaf)),
2060 // A family root (design 310 §4.1/§4.4): membership is never lowered
2061 // to SQL, so the committed rows a leaf vends are filtered HERE — the
2062 // memory leaf already applied the connection predicate (which
2063 // includes membership) and re-checks for nothing; the SQLite leaf
2064 // applied only the residual `sql_condition` and needs this. Every
2065 // family-root fetch is constrained by construction; an unconstrained
2066 // one would be a whole-table scan filtered in-engine — correct, but
2067 // exactly the cost the design forbids, so it is asserted.
2068 Some(f) => {
2069 if req.constraint.is_none() && !req.has_multi() {
2070 // Never reached by construction (asserted); answered exactly
2071 // in release: the bound values of the first parameter column
2072 // as an IN-batch — O(bindings) seeks / a native `IN` — never
2073 // a scan, and never the empty static guard's "no rows".
2074 self.check_family_root_fetch(req);
2075 drop(rows);
2076 return self.fetch_family_root_unconstrained(sc, f, req);
2077 }
2078 Box::new(
2079 rows.filter(move |r| f.bindings.contains_row(r, &f.param_cols))
2080 .map(Node::leaf),
2081 )
2082 }
2083 }
2084 }
2085 Operator::Join(_) => self.join_fetch(id, req),
2086 // Out-of-file operators delegate to their own module (the fan-out seam).
2087 Operator::FlippedJoin(fj) => fj.fetch(self, req),
2088 // The union fan IS fetchable (unlike the filter fan): UnionFanOut
2089 // delegates to its input; UnionFanIn k-way-merges the branch fetches.
2090 Operator::UnionFanOut(u) => u.fetch(self, req),
2091 Operator::UnionFanIn(u) => u.fetch(self, req),
2092 Operator::Skip(s) => s.fetch(self, req),
2093 Operator::Take(t) => t.fetch(self, req),
2094 Operator::Cap(c) => c.fetch(self, req),
2095 Operator::Reduce(r) => r.fetch(self, req),
2096 // Testkit tap: Snitch logs the fetch and vends the upstream stream
2097 // wrapped to log a fetchCount on Drop (`crate::testkit`).
2098 #[cfg(any(test, feature = "testkit"))]
2099 Operator::Snitch(s) => s.fetch(self, req),
2100 // The filter sub-graph's two fetch boundaries: `FilterStart` runs the
2101 // bracketed scan; `FilterEnd` delegates to it (`filter-operators.ts:118`).
2102 Operator::FilterStart(_) => self.filter_start_fetch(id, req),
2103 Operator::FilterEnd(e) => self.fetch(e.start, req),
2104 Operator::Source(_) => panic!("fetch a SourceConn, not the Source directly"),
2105 Operator::View(_) => panic!("Views are sinks, not fetched"),
2106 Operator::Collector(_) => panic!("Collectors are sinks, not fetched"),
2107 #[cfg(any(test, feature = "testkit"))]
2108 Operator::Catch(_) => panic!("Catch is a sink, not fetched; use catch_fetch ({id:?})"),
2109 // FilterOperators gate (`filter(node)`), they do not vend — the scan is
2110 // owned by `FilterStart::fetch` (`07` §3.2).
2111 Operator::FanOut(_)
2112 | Operator::FanIn(_)
2113 | Operator::Filter(_)
2114 | Operator::FilterProbe(_)
2115 | Operator::Exists(_) => {
2116 panic!("filter-chain links have no fetch; fetch the FilterStart/End ({id:?})")
2117 }
2118 Operator::Tombstone => panic!("fetch on a torn-down node {id:?}"),
2119 }
2120 }
2121
2122 /// Eagerly drain `fetch` and surface any runtime error a backend parked during
2123 /// the scan. The lazy [`Graph::fetch`](crate::graph::Graph::fetch) API remains infallible for iterator
2124 /// composition; production callers that need error propagation should use this
2125 /// owned-drain boundary.
2126 pub fn try_fetch_all<'g>(
2127 &'g self,
2128 id: NodeId,
2129 req: &FetchRequest,
2130 ) -> Result<Vec<Node<'g>>, RindleError> {
2131 let out: Vec<Node<'g>> = self.fetch(id, req).collect();
2132 self.take_runtime_error()?;
2133 Ok(out)
2134 }
2135
2136 fn join_fetch<'g>(&'g self, jid: NodeId, req: &FetchRequest) -> NodeStream<'g> {
2137 let j = self.join(jid);
2138 // Design 311 §2.4: an unconstrained forward enumeration of the parent input is
2139 // the pre-check's ONLY source of membership — decide before the parent fetch
2140 // whether this request may build or advance the join's region.
2141 let observe = self.join_precheck_observation(jid, req);
2142 let parent_stream = self.fetch(j.parent, req);
2143 let parent_stream: NodeStream<'g> = if observe {
2144 Box::new(ObservedParents {
2145 g: self,
2146 jid,
2147 inner: parent_stream,
2148 done: false,
2149 })
2150 } else {
2151 parent_stream
2152 };
2153 Box::new(parent_stream.map(move |pnode| {
2154 // No overlay during a plain fetch.
2155 self.process_parent_node(jid, pnode)
2156 }))
2157 }
2158
2159 /// Attach this join's child relationship to a parent node as a thunk that reads
2160 /// the join's **LIVE** in-flight child overlay on every evaluation (`join.ts:252`
2161 /// `#processParentNode`), **preserving the parent node's existing relationships**
2162 /// (`join.ts:298` `{...parentNodeRelations, [relName]: childStream}`). Unlike the
2163 /// by-value [`Graph::attach_child_rel`](crate::graph::Graph::attach_child_rel) (kept for [`FlippedJoin`]), the thunk here
2164 /// re-reads [`Join::inprogress_overlay`]/[`Join::inprogress_position`] each time it
2165 /// is drained — so a reentrant refetch a downstream `Take`/`Exists` triggers
2166 /// mid-push sees PRE-change membership for not-yet-processed parents (the depth-2
2167 /// EXISTS+top-N+push cascade). Everything is captured BY VALUE except `self` (the
2168 /// shared `&'g Graph`), through which the live fields are re-read.
2169 fn process_parent_node<'g>(&'g self, jid: NodeId, mut parent_node: Node<'g>) -> Node<'g> {
2170 let j = self.join(jid);
2171 let child_id = j.child;
2172 let parent_key = j.parent_key.clone();
2173 let child_key = j.child_key.clone();
2174 let rel_slot = j.rel_slot;
2175 let child_schema = self.input_schema(child_id);
2176 let child_pk = child_schema.primary_key.clone();
2177 let child_sort: Sort = child_schema.sort.clone();
2178 let pr = parent_node.row.clone();
2179
2180 let thunk: Box<dyn Fn() -> NodeStream<'g> + 'g> = Box::new(move || {
2181 let base: NodeStream<'g> = match build_join_constraint(&pr, &parent_key, &child_key) {
2182 Some(c) => self.fetch(child_id, &FetchRequest::with_constraint(c)),
2183 None => Box::new(std::iter::empty()),
2184 };
2185 // Live, gated read of the in-flight child overlay (lazy `#inprogress…`).
2186 match self.join_overlay_for(jid, &pr) {
2187 Some(ov) => splice_join_overlay_pre(base, &ov, &child_pk, &child_sort),
2188 None => base,
2189 }
2190 });
2191
2192 parent_node.rels.push(Relationship {
2193 slot: rel_slot,
2194 thunk,
2195 });
2196 parent_node
2197 }
2198
2199 /// Walk a relationship join's child subtree from `node`, deleting partitioned
2200 /// limiter (`Take`/`Cap`) slots that match `constraint` (the parent→child
2201 /// correlation key of the parent that just left a bounded parent view). Recurses
2202 /// through the row-preserving passthrough operators between the join and its
2203 /// limiter(s); stops at the source connection and at a nested `Join` (a grandchild
2204 /// relationship is keyed by a *different* correlation, so this constraint does not
2205 /// identify its partitions — a deeper-nesting residual, not a correctness issue).
2206 fn evict_child_partitions(&self, node: NodeId, constraint: &Constraint) {
2207 match self.node(node) {
2208 Operator::Take(t) => {
2209 t.evict_partition(self, constraint);
2210 // A Skip/Filter could sit between the Take and the source; recurse so a
2211 // limited-with-offset child still reaches its limiter chain.
2212 self.evict_child_partitions(t.input, constraint);
2213 }
2214 Operator::Cap(c) => {
2215 c.evict_partition(self, constraint);
2216 self.evict_child_partitions(c.input, constraint);
2217 }
2218 Operator::Skip(s) => self.evict_child_partitions(s.input, constraint),
2219 Operator::FilterStart(s) => self.evict_child_partitions(s.input, constraint),
2220 Operator::FilterEnd(e) => self.evict_child_partitions(e.start, constraint),
2221 // A relationship aggregate's lazy grouped `Reduce` keeps one slot per parent
2222 // it ever folded and deletes none of them itself (the death arm is `eager`-
2223 // only, by design). The walk does NOT continue below it: the aggregate's
2224 // child is uncapped by construction, and the constraint is in the reduce's
2225 // OUTPUT coordinates, which do not address anything underneath.
2226 Operator::Reduce(r) => r.evict_partition(self, constraint),
2227 // Anything else (SourceConn, a nested Join, a union reshape) is not a
2228 // partitioned child limiter keyed by THIS constraint — stop the walk.
2229 _ => {}
2230 }
2231 }
2232
2233 /// Walk **upstream** from a limiter's input, evicting each relationship join's
2234 /// child partition for `row` — the mirror of
2235 /// [`evict_child_partitions`](Self::evict_child_partitions), which walks *down* a
2236 /// join's child subtree when a parent leaves a bounded PARENT view.
2237 ///
2238 /// The two exist because the two lowerings put the join on opposite sides of the
2239 /// limiter. A `related` join sits AFTER the root `Take`
2240 /// (`build_pipeline_internal`: "limit → Take … before `related`"), so a parent
2241 /// falling out of the top-N arrives at that join as a `JoinParent` `Remove` and the
2242 /// downstream walk reclaims its child state. A **spine EXISTS/aggregate join** sits
2243 /// BEFORE it (`csq_conditions` loop → `applyWhere` gates → `Take`), so a parent
2244 /// leaving the window is never a `Remove` at the join: nothing tore its child
2245 /// partition down, and the `Cap`/`Reduce` under the gate kept one slot per distinct
2246 /// parent that ever passed through — operator state proportional to the parent
2247 /// table, not to the (bounded) view. See `follow-ups/04-cap-partition-leak.md`.
2248 ///
2249 /// **Safe only for a row outside a FULL window** — the sole caller,
2250 /// [`Take::evict_upstream_if_full`](crate::op::Take), carries that guard. Such a row
2251 /// cannot re-enter by a push: it sorts at-or-after the
2252 /// bound, and a child change cannot move a parent's sort position. It can only
2253 /// return through a **refill fetch**, and every fetch re-hydrates what it evicts —
2254 /// `Cap`/`Take` on `get_state == None` run `initial_fetch`, and `Reduce::fetch`
2255 /// re-folds. A parent whose gate could still admit it (an unfilled window) keeps its
2256 /// state: that slot is the subscription the `Exists` gate flips on, so evicting it
2257 /// would silently drop the child `Add` that should have pulled the parent in.
2258 ///
2259 /// Per join, evicts only when the correlation is the parent's **primary key**, so the
2260 /// partition belongs to exactly this one parent — the same condition the downstream
2261 /// walk's caller applies. Recurses through the row-preserving operators between the
2262 /// limiter and the joins (`FilterEnd`/`FilterStart` bracket the gate chain;
2263 /// `Skip` carries a `start`), and stops at anything else — a source connection, a
2264 /// union reshape, a fan-in — so an unrecognized shape keeps its state rather than
2265 /// guessing.
2266 pub(crate) fn evict_upstream_child_partitions(&self, node: NodeId, row: &Row) {
2267 match self.node(node) {
2268 Operator::Join(j) => {
2269 if self.input_schema(j.parent).primary_key == j.parent_key {
2270 if let Some(c) = build_join_constraint(row, &j.parent_key, &j.child_key) {
2271 self.evict_child_partitions(j.child, &c);
2272 }
2273 }
2274 self.evict_upstream_child_partitions(j.parent, row);
2275 }
2276 Operator::FilterEnd(e) => self.evict_upstream_child_partitions(e.start, row),
2277 Operator::FilterStart(s) => self.evict_upstream_child_partitions(s.input, row),
2278 Operator::Skip(s) => self.evict_upstream_child_partitions(s.input, row),
2279 _ => {}
2280 }
2281 }
2282
2283 /// The gated read of a [`Join`]'s LIVE in-flight child overlay for `parent_row`
2284 /// (Zero `join.ts:264-277`). Returns the overlay iff (a) the in-flight child joins
2285 /// to this parent (`isJoinMatch`) and (b) the parent sorts STRICTLY AFTER the
2286 /// parent currently being fanned out (`compareRows(parent, position) > 0`) — i.e.
2287 /// the change has not yet been delivered for this parent, so its relationship must
2288 /// still reflect the PRE-change child. `None` outside a child-push, for an
2289 /// unrelated parent, or for an at-or-before parent.
2290 fn join_overlay_for(&self, jid: NodeId, parent_row: &Row) -> Option<JoinOverlay> {
2291 let j = self.join(jid);
2292 let overlay = j.inprogress_overlay.borrow().clone()?;
2293 let position = j.inprogress_position.borrow().clone()?;
2294 if !is_join_match(
2295 parent_row,
2296 &j.parent_key,
2297 overlay.change.row(),
2298 &j.child_key,
2299 ) {
2300 return None;
2301 }
2302 // The fan-out delivers parents in the parent's EFFECTIVE output order (the
2303 // per-query sort, e.g. `orderBy`), NOT the table's primary-key sort — so the
2304 // "already delivered?" position gate must compare in that same order
2305 // (`input_sort`, not `input_schema(...).sort`). Zero uses the join schema's
2306 // `compareRows`, which for a memory connection is the resolved query order.
2307 let parent_sort = self.input_sort(j.parent);
2308 if compare_rows(&parent_sort, parent_row, &position) == std::cmp::Ordering::Greater {
2309 Some(overlay)
2310 } else {
2311 None
2312 }
2313 }
2314
2315 /// The field-based core of [`Graph::process_parent_node`](crate::graph::Graph::process_parent_node): attach a join child
2316 /// relationship (`parent_key → child_key` fetch, optionally spliced with an
2317 /// in-progress child `overlay` in the given [`OverlayPolarity`]) to
2318 /// `parent_node`, **appending** so the parent's existing relationships survive.
2319 /// Captures everything the thunk needs BY VALUE (Primitive #5), so the thunk
2320 /// owns its inputs and never reads a live operator field. `pub(crate)` so the
2321 /// out-of-file [`FlippedJoin`](crate::op::FlippedJoin) — which shares `Join`'s
2322 /// relationship + overlay model — builds the same thunk.
2323 ///
2324 /// **Drain discipline (the frozen-overlay hazard):** `overlay` (and its
2325 /// polarity) is frozen BY VALUE at yield time, derived from the fan-out state
2326 /// — in-flight change + position — at the moment the parent was yielded.
2327 /// `Join`'s own thunks ([`Graph::process_parent_node`]) instead re-read the
2328 /// live gate (`join_overlay_for`) at drain time and go inert once the guard
2329 /// clears. A frozen overlay has no such fallback: it is correct only while
2330 /// the fan-out state it was derived from still holds, so the thunk must be
2331 /// drained (or dropped) before the fan-out advances its position past this
2332 /// parent or clears the in-flight overlay. Every current consumer drains
2333 /// synchronously within the push frame; a consumer that holds the `Node`
2334 /// across a delivery boundary would re-materialize a stale PRE-change
2335 /// relationship after the change was already delivered.
2336 pub(crate) fn attach_child_rel<'g>(
2337 &'g self,
2338 child_id: NodeId,
2339 parent_key: &[ColId],
2340 child_key: &[ColId],
2341 rel_slot: RelId,
2342 mut parent_node: Node<'g>,
2343 overlay: Option<(JoinOverlay, OverlayPolarity)>,
2344 ) -> Node<'g> {
2345 let parent_key = parent_key.to_vec();
2346 let child_key = child_key.to_vec();
2347 let child_schema = self.input_schema(child_id);
2348 let child_pk = child_schema.primary_key.clone();
2349 let child_sort: Sort = child_schema.sort.clone();
2350 let pr = parent_node.row.clone();
2351
2352 let thunk: Box<dyn Fn() -> NodeStream<'g> + 'g> = Box::new(move || {
2353 let base: NodeStream<'g> = match build_join_constraint(&pr, &parent_key, &child_key) {
2354 Some(c) => self.fetch(child_id, &FetchRequest::with_constraint(c)),
2355 None => Box::new(std::iter::empty()),
2356 };
2357 match &overlay {
2358 None => base,
2359 Some((ov, OverlayPolarity::Post)) => splice_join_overlay(
2360 base,
2361 ov,
2362 &pr,
2363 &parent_key,
2364 &child_key,
2365 &child_pk,
2366 &child_sort,
2367 ),
2368 Some((ov, OverlayPolarity::Pre)) => {
2369 splice_join_overlay_pre(base, ov, &child_pk, &child_sort)
2370 }
2371 }
2372 });
2373
2374 // Append (don't replace) so the parent's pre-existing relationships survive.
2375 parent_node.rels.push(Relationship {
2376 slot: rel_slot,
2377 thunk,
2378 });
2379 parent_node
2380 }
2381
2382 // -------------------------------------------------------------------
2383 // The Filter sub-graph: bracketed fetch + the begin/filter/end lifecycle.
2384 // -------------------------------------------------------------------
2385
2386 /// `FilterStart::fetch` (`filter-operators.ts:86-103`). The single place the
2387 /// row scan happens for a `where`: pull each node from the upstream `Input`,
2388 /// gate it through the chain (`chain_filter`), yield iff it passes. The scan
2389 /// is bracketed by `begin_filter` (before) / `end_filter` (after) so a stateful
2390 /// link can cache for the loop. `end_filter` runs via an [`EndFilterGuard`]
2391 /// MOVED INTO the returned stream's closure — so dropping the stream early (a
2392 /// downstream `Take` break, `first()`) runs it. Unwinding also runs it, but
2393 /// only in unwinding builds; under the shipping `panic = "abort"` client
2394 /// profile a panic aborts the process and `Drop` does not run. This is the
2395 /// JS `try/finally` rendered as RAII (Primitive #2; `07` §6.4). See WS02.
2396 fn filter_start_fetch<'g>(&'g self, id: NodeId, req: &FetchRequest) -> NodeStream<'g> {
2397 // PANIC-CLASS: build-time — every `.expect("… not wired")` in this file
2398 // (FilterStart chain head, Filter/FilterProbe/FilterEnd/FanOut/join output)
2399 // asserts an edge the *builder* must have set before any fetch/push runs. An
2400 // unwired edge is an engine wiring bug, never data-reachable (WS02.1). Grep
2401 // the keep-bucket with `rg '"[^"]* not wired"' src/`.
2402 let chain = self
2403 .filter_start_op(id)
2404 .chain_head
2405 .get()
2406 .expect("FilterStart chain head not wired");
2407 let input = self.filter_start_op(id).input;
2408
2409 // `begin_filter` runs before the guard exists — matching the JS, where
2410 // `beginFilter()` sits *outside* the `try/finally` (a begin-time throw
2411 // skips `endFilter` there too). The guard is built *before* the upstream
2412 // fetch and moved into the stream, so every panic on the actual scan path
2413 // (the data-driven one) is covered; `begin_filter` itself is infallible on
2414 // a correctly-wired graph (its only panic is the mis-wired-node arm).
2415 self.begin_filter(chain);
2416 let guard = EndFilterGuard { g: self, chain };
2417 let upstream = self.fetch(input, req);
2418
2419 Box::new(upstream.filter_map(move |node| {
2420 // `guard` is owned by this closure → its Drop (end_filter) fires
2421 // exactly when the stream is dropped, however that happens.
2422 let _hold = &guard;
2423 if self.chain_filter(chain, &node) {
2424 Some(node)
2425 } else {
2426 None
2427 }
2428 }))
2429 }
2430
2431 /// Begin a fetch loop for the chain rooted at `link` (`beginFilter()`,
2432 /// `filter-operators.ts:37`). Walks the same topology as [`chain_filter`]:
2433 /// every stateful link gets one `begin` per loop. No-op for stateless links.
2434 ///
2435 /// A `Filter`/`FilterProbe` link is never a chain terminator, so its `output`
2436 /// must be wired (`.expect`) — same strict contract as the push path
2437 /// ([`Graph::chain_push`](crate::graph::Graph::chain_push)). Only a `FanOut`'s post-fan continuation is
2438 /// legitimately optional.
2439 pub(crate) fn begin_filter(&self, link: NodeId) {
2440 match self.node(link) {
2441 Operator::Filter(f) => {
2442 self.begin_filter(f.output.get().expect("Filter output not wired"))
2443 }
2444 Operator::FilterProbe(p) => {
2445 p.begin_count.set(p.begin_count.get() + 1);
2446 self.begin_filter(p.output.get().expect("FilterProbe output not wired"));
2447 }
2448 Operator::FanOut(fo) => {
2449 let branches = fo.outputs.borrow().clone();
2450 let cont = self.fan_continuation(link);
2451 for b in branches {
2452 self.begin_filter(b);
2453 }
2454 if let Some(c) = cont {
2455 self.begin_filter(c);
2456 }
2457 }
2458 // Branch terminator / chain terminator: the continuation past a FanIn
2459 // is begun by the owning FanOut; FilterEnd ends the chain.
2460 Operator::Exists(e) => e.begin(self),
2461 Operator::FanIn(_) | Operator::FilterEnd(_) => {}
2462 _ => panic!("begin_filter: not a filter-chain link ({link:?})"),
2463 }
2464 }
2465
2466 /// `end_filter` (`endFilter()`, `filter-operators.ts:37`) — the mirror of
2467 /// [`Graph::begin_filter`](crate::graph::Graph::begin_filter). Runs from the [`EndFilterGuard`]'s `Drop`.
2468 pub(crate) fn end_filter(&self, link: NodeId) {
2469 match self.node(link) {
2470 Operator::Filter(f) => {
2471 self.end_filter(f.output.get().expect("Filter output not wired"))
2472 }
2473 Operator::FilterProbe(p) => {
2474 p.end_count.set(p.end_count.get() + 1);
2475 self.end_filter(p.output.get().expect("FilterProbe output not wired"));
2476 }
2477 Operator::FanOut(fo) => {
2478 let branches = fo.outputs.borrow().clone();
2479 let cont = self.fan_continuation(link);
2480 for b in branches {
2481 self.end_filter(b);
2482 }
2483 if let Some(c) = cont {
2484 self.end_filter(c);
2485 }
2486 }
2487 Operator::Exists(e) => e.end(self),
2488 Operator::FanIn(_) | Operator::FilterEnd(_) => {}
2489 _ => panic!("end_filter: not a filter-chain link ({link:?})"),
2490 }
2491 }
2492
2493 /// Walk the filter chain for `node`, returning whether it passes ALL links
2494 /// (`FilterOutput::filter`, `filter-operators.ts:37`). Each link is `pred &&
2495 /// downstream`; `FanOut` is OR-over-branches AND the post-fan continuation;
2496 /// `FanIn`/`FilterEnd` terminate the walk with `true`.
2497 ///
2498 /// `Filter`/`FilterProbe` outputs are `.expect`-wired (a link is never a
2499 /// terminator — same as the push path); only a `FanOut`'s continuation is
2500 /// optional. Never holds a `RefCell` borrow across a recursive call (the
2501 /// cardinal rule, `01` §6): `FanOut`'s branch list is cloned to a local first.
2502 pub(crate) fn chain_filter(&self, link: NodeId, node: &Node) -> bool {
2503 match self.node(link) {
2504 Operator::Filter(f) => {
2505 f.pred.eval(&node.row)
2506 && self.chain_filter(f.output.get().expect("Filter output not wired"), node)
2507 }
2508 Operator::FilterProbe(p) => {
2509 p.filter_count.set(p.filter_count.get() + 1);
2510 self.chain_filter(p.output.get().expect("FilterProbe output not wired"), node)
2511 }
2512 Operator::FanOut(fo) => {
2513 let branches = fo.outputs.borrow().clone();
2514 // OR: short-circuit on the first branch that passes.
2515 let any = branches.iter().any(|&b| self.chain_filter(b, node));
2516 let cont = self.fan_continuation(link);
2517 any && cont.is_none_or(|c| self.chain_filter(c, node))
2518 }
2519 // Branch terminator (a branch's `filter` ends at its FanIn) and chain
2520 // terminator both return true.
2521 Operator::Exists(e) => e.filter(self, node),
2522 Operator::FanIn(_) | Operator::FilterEnd(_) => true,
2523 _ => panic!("chain_filter: not a filter-chain link ({link:?})"),
2524 }
2525 }
2526
2527 /// The post-fan continuation of a `FanOut`: its paired `FanIn`'s downstream
2528 /// output (the link *after* the fan). `None` until wired.
2529 fn fan_continuation(&self, fan_out: NodeId) -> Option<NodeId> {
2530 let fin = self
2531 .fan_out_op(fan_out)
2532 .fan_in
2533 .get()
2534 .expect("FanOut fan_in not wired");
2535 self.fan_in_op(fin).output.get()
2536 }
2537
2538 // -------------------------------------------------------------------
2539 // PUSH (eager). A whole push completes before the next (load-bearing).
2540 // `&self` shared borrow throughout -> reentrant fetch is legal.
2541 // -------------------------------------------------------------------
2542
2543 /// `pub(crate)`: operators in [`crate::op`] drive their downstream edge
2544 /// through this (the fan-out seam). Single-output operators pass
2545 /// [`Port::Single`].
2546 pub(crate) fn push<'g>(&'g self, id: NodeId, change: Change<'g>, port: Port) {
2547 match self.node(id) {
2548 Operator::Join(_) => self.join_push(id, change, port),
2549 Operator::FlippedJoin(fj) => fj.push(self, change, port),
2550 // The union fan (reached on `Port::Single`): UnionFanOut broadcasts to
2551 // the branches; UnionFanIn accumulates/forwards.
2552 Operator::UnionFanOut(u) => u.push(self, change),
2553 Operator::UnionFanIn(u) => u.push(self, change),
2554 Operator::Skip(s) => s.push(self, change),
2555 Operator::Take(t) => t.push(self, change),
2556 Operator::Cap(c) => c.push(self, change),
2557 Operator::Reduce(r) => r.push(self, change),
2558 Operator::View(_) => self.view_push(id, change),
2559 Operator::Collector(_) => self.collector_push(id, change),
2560 // Testkit: Snitch logs + forwards on the arriving port (transparent);
2561 // Catch records the expanded change tree (the sink terminus).
2562 #[cfg(any(test, feature = "testkit"))]
2563 Operator::Snitch(s) => s.push(self, change, port),
2564 #[cfg(any(test, feature = "testkit"))]
2565 Operator::Catch(c) => {
2566 // Catch is a terminus: it only ever sits on a single-output edge
2567 // (a non-Single port means it was mis-wired before a join port).
2568 // Guard symmetrically with the fetch-path panic above.
2569 assert!(
2570 matches!(port, Port::Single),
2571 "Catch is a sink; expected Port::Single, got {port:?} ({id:?})"
2572 );
2573 c.record(self, &change)
2574 }
2575 // The filter sub-graph's push entry: push into the chain head. The
2576 // chain transforms/gates and side-effects the real downstream push at
2577 // its `FilterEnd`; nothing should bubble back up here.
2578 Operator::FilterStart(s) => {
2579 let head = s
2580 .chain_head
2581 .get()
2582 .expect("FilterStart chain head not wired");
2583 let leftover = self.chain_push(head, change);
2584 debug_assert!(
2585 leftover.is_empty(),
2586 "filter sub-graph push did not terminate at a FilterEnd"
2587 );
2588 }
2589 _ => panic!("cannot push to {id:?}"),
2590 }
2591 }
2592
2593 /// Push a change *through* a filter-chain link, returning the change(s) it
2594 /// hands back to its caller. The filter sub-graph is a **pure transform
2595 /// region**: links gate/split rows and never touch sink state (the
2596 /// side-effecting sink lives downstream of `FilterEnd`). So a link RETURNS its
2597 /// output rather than pushing it onward — which is what lets the OR fan
2598 /// accumulate branch results **on the call stack** (lifetime `'g`) instead of
2599 /// in a self-referential `FanIn` field (see the chassis note on the operators).
2600 ///
2601 /// Returns a non-empty vec ONLY at a `FanIn` (a branch terminator handing its
2602 /// change up to the owning `FanOut`). `FilterEnd` side-effects the real
2603 /// downstream push and returns empty; `Filter`/`FilterProbe`/`FanOut` recurse.
2604 pub(crate) fn chain_push<'g>(&'g self, link: NodeId, change: Change<'g>) -> Vec<Change<'g>> {
2605 match self.node(link) {
2606 Operator::Filter(_) => self.filter_chain_push(link, change),
2607 Operator::FilterProbe(p) => {
2608 // Pass-through gate (proof instrumentation has no predicate).
2609 let out = p.output.get().expect("FilterProbe output not wired");
2610 self.chain_push(out, change)
2611 }
2612 Operator::FanOut(_) => self.fan_out_push(link, change),
2613 Operator::Exists(e) => e.push_chain(self, change),
2614 // Branch terminator: hand the change up to the owning FanOut, which
2615 // owns the accumulation + collapse.
2616 Operator::FanIn(_) => vec![change],
2617 // Chain terminator: exit the sub-graph — the real downstream push.
2618 Operator::FilterEnd(e) => {
2619 let out = e.output.get().expect("FilterEnd output not wired");
2620 self.push(out.node, change, out.port);
2621 Vec::new()
2622 }
2623 _ => panic!("chain_push: not a filter-chain link ({link:?})"),
2624 }
2625 }
2626
2627 /// `Filter` link push: `filterPush` + the Edit split (`07` §3.4,
2628 /// `maybe-split-and-push-edit-change.ts`). Add/Remove/Child pass through iff
2629 /// the predicate holds; an Edit splits on `(pred(old), pred(new))`.
2630 fn filter_chain_push<'g>(&'g self, link: NodeId, change: Change<'g>) -> Vec<Change<'g>> {
2631 let out = self
2632 .filter_op(link)
2633 .output
2634 .get()
2635 .expect("Filter output not wired");
2636 match change {
2637 Change::Add(n) | Change::Remove(n) if !self.filter_op(link).pred.eval(&n.row) => {
2638 let _ = n; // predicate failed → drop
2639 Vec::new()
2640 }
2641 Change::Add(n) => self.chain_push(out, Change::Add(n)),
2642 Change::Remove(n) => self.chain_push(out, Change::Remove(n)),
2643 Change::Edit { node, old } => {
2644 let pred = &self.filter_op(link).pred;
2645 match (pred.eval(&old.row), pred.eval(&node.row)) {
2646 (true, true) => self.chain_push(out, Change::Edit { node, old }),
2647 (true, false) => self.chain_push(out, Change::Remove(old)),
2648 (false, true) => self.chain_push(out, Change::Add(node)),
2649 (false, false) => Vec::new(),
2650 }
2651 }
2652 Change::Child { node, rel, child } => {
2653 // `filterPush` (`filter-push.ts:26`): a Child passes through unchanged
2654 // iff the predicate holds on the change's node row; else it is dropped.
2655 // (The nested sub-change rides along — a leaf Filter gates the parent
2656 // row, it never reshapes the child.)
2657 if self.filter_op(link).pred.eval(&node.row) {
2658 self.chain_push(out, Change::Child { node, rel, child })
2659 } else {
2660 Vec::new()
2661 }
2662 }
2663 }
2664 }
2665
2666 /// `FanOut` push (`fan-out.ts:74`): replay the change to every branch, then
2667 /// collapse what the branches forwarded into exactly one change per type
2668 /// (`push_accumulated_changes`, `06` §3.5) and continue down the post-fan
2669 /// chain. The collapse is the dedup that stops an OR from double-counting a
2670 /// row that several branches keep.
2671 fn fan_out_push<'g>(&'g self, link: NodeId, change: Change<'g>) -> Vec<Change<'g>> {
2672 // Clone the branch list to a local — never hold the RefCell borrow across
2673 // the reentrant branch pushes (the cardinal rule, `01` §6).
2674 let branches = self.fan_out_op(link).outputs.borrow().clone();
2675 let fan_out_type = change.change_type();
2676
2677 // Materialize the change once, rebuild a fresh copy per branch (the owned-change
2678 // model — a `Change<'g>` is neither `Clone` nor `'static`). The broadcast
2679 // **preserves relationships for every change type**: an `Exists` branch counts
2680 // its gated relationship off the change's node — on an Add/Remove/Edit (a
2681 // parent membership-test) as well as a Child (a relationship-size flip) — so
2682 // every branch's copy must carry it. A leaf-`Filter` branch reads only
2683 // `node.row` and ignores the rest; a leaf node (a pure-predicate fan with no
2684 // join above) materializes back to a leaf, so this stays cheap there.
2685 let owned = materialize_change_preserving_node(change);
2686 let mut accumulated: Vec<Change<'g>> = Vec::new();
2687 for b in &branches {
2688 accumulated.extend(self.chain_push(*b, rebuild_change(owned.clone())));
2689 }
2690
2691 let collapsed = collapse_accumulated(accumulated, fan_out_type);
2692
2693 let cont = self.fan_continuation(link);
2694 let mut leftover = Vec::new();
2695 for c in collapsed {
2696 match cont {
2697 Some(next) => leftover.extend(self.chain_push(next, c)),
2698 None => leftover.push(c),
2699 }
2700 }
2701 leftover
2702 }
2703
2704 fn join_push<'g>(&'g self, jid: NodeId, change: Change<'g>, port: Port) {
2705 let j = self.join(jid);
2706 let out = j.output.get().expect("join output not wired");
2707 let parent_key = j.parent_key.clone();
2708 let child_key = j.child_key.clone();
2709 let parent = j.parent;
2710 let child = j.child;
2711 match port {
2712 // Parent side (`join.ts#pushParent`): re-process the parent node (which
2713 // re-attaches this join's child relationship) and forward, preserving
2714 // change type.
2715 Port::JoinParent => match change {
2716 Change::Add(node) => {
2717 // Design 311 §2.3: membership maintenance at the entry of the arm.
2718 self.join_precheck_parent_add(jid, &node.row);
2719 let pn = self.process_parent_node(jid, node);
2720 self.push(out.node, Change::Add(pn), out.port);
2721 }
2722 Change::Remove(node) => {
2723 self.join_precheck_parent_remove(jid, &node.row);
2724 // Keep the parent row to evict its child partition AFTER the
2725 // downstream has consumed the removed subtree (which drains the
2726 // child thunk against the still-present partition).
2727 let key_row = node.row.clone();
2728 let pn = self.process_parent_node(jid, node);
2729 self.push(out.node, Change::Remove(pn), out.port);
2730 // The parent left a (bounded) parent view. Evict the child
2731 // limiter partition it hydrated so partitioned child Takes/Caps do
2732 // not leak one slot per distinct parent that ever passed through the
2733 // view — but ONLY when the parent correlation key is the parent's
2734 // primary key, so the evicted partition belongs to exactly this one
2735 // parent (a non-unique parent key could share a child partition with
2736 // a still-live sibling parent). See `Take::evict_partition`.
2737 if self.input_schema(parent).primary_key == parent_key {
2738 if let Some(c) = build_join_constraint(&key_row, &parent_key, &child_key) {
2739 self.evict_child_partitions(child, &c);
2740 }
2741 }
2742 }
2743 Change::Edit { node, old } => {
2744 // A parent edit MUST NOT change the join key (`join.ts:167`);
2745 // otherwise the relationship membership would change and this
2746 // would not be a simple edit. Data-reachable (a malformed change
2747 // stream): strict ⇒ park a `ConsistencyViolation` and skip the
2748 // push (the boundary `take_runtime_error` re-raises it); non-strict
2749 // ⇒ `debug_assert!` (WS02.2).
2750 if !row_equals_for_compound_key(&old.row, &node.row, &parent_key) {
2751 if self.validate_changes.get() {
2752 self.park_runtime_error(RindleError::ConsistencyViolation {
2753 kind: "parent edit changed the join key",
2754 });
2755 return;
2756 }
2757 debug_assert!(
2758 false,
2759 "parent edit must not change the join relationship key"
2760 );
2761 }
2762 // A key never changes (enforced above under strict validation); what
2763 // an Edit CAN change is a sort column — the row's position relative to
2764 // the region (§2.3). The non-strict key-changing case is covered too.
2765 self.join_precheck_parent_edit(jid, &old.row, &node.row);
2766 let pn = self.process_parent_node(jid, node);
2767 let po = self.process_parent_node(jid, old);
2768 self.push(out.node, Change::Edit { node: pn, old: po }, out.port);
2769 }
2770 Change::Child { node, rel, child } => {
2771 // Passthrough (`join.ts:154`): a relationship *above* this join
2772 // changed; re-process the parent (re-attaching our rel) and
2773 // forward the original child change untouched.
2774 let pn = self.process_parent_node(jid, node);
2775 self.push(
2776 out.node,
2777 Change::Child {
2778 node: pn,
2779 rel,
2780 child,
2781 },
2782 out.port,
2783 );
2784 }
2785 },
2786 // Child side (`join.ts#pushChild`): re-enter the parent fetch with the
2787 // child's key and emit a Child change per matching parent. The full child
2788 // change is carried up — Add/Remove/Edit carry the (re-derivable) child
2789 // node, and a **nested `Change::Child`** (the *deepest* push, e.g. a
2790 // reaction under `issue{comments{reactions}}`) carries the inner change
2791 // verbatim so the View/Catch recurse into it. [`push_child_change`]
2792 // materializes the change once and rebuilds a fresh copy per parent.
2793 Port::JoinChild => {
2794 if let Change::Edit { node, old } = &change {
2795 // Same join-key-immutability contract as the parent-edit check
2796 // above (WS02.2): strict ⇒ park a `ConsistencyViolation` + skip;
2797 // non-strict ⇒ `debug_assert!`.
2798 if !row_equals_for_compound_key(&old.row, &node.row, &child_key) {
2799 if self.validate_changes.get() {
2800 self.park_runtime_error(RindleError::ConsistencyViolation {
2801 kind: "child edit changed the join key",
2802 });
2803 return;
2804 }
2805 debug_assert!(
2806 false,
2807 "child edit must not change the join relationship key"
2808 );
2809 }
2810 }
2811 self.push_child_change(jid, change);
2812 }
2813 // PANIC-CLASS: internal — a Join is only ever dispatched on JoinParent /
2814 // JoinChild; a Single port here is an engine dispatch/wiring bug, not data.
2815 Port::Single => panic!("join received Single port"),
2816 }
2817 }
2818
2819 /// The reentrant crux (`join.ts#pushChildChange`): build a parent constraint
2820 /// from the changed child's row, re-enter `parent.fetch` *mid-push*, and for each
2821 /// matching parent emit a `Change::Child` carrying the child change.
2822 ///
2823 /// `child_change` is the **full** downstream change on this join's child input —
2824 /// `Add`/`Remove`/`Edit` of a child row, or a nested `Change::Child` (the
2825 /// *deepest* push: a relationship under this child changed). A child can join to
2826 /// many parents, so the change is materialized **once** (its thunks drained now,
2827 /// under the active source overlay) and a fresh `Change<'g>` is rebuilt per
2828 /// parent. The row-level overlay (`Add`/`Remove`/`Edit` only) is published on the
2829 /// join as a LIVE field ([`Join::inprogress_overlay`]) so a reentrant refetch
2830 /// during the downstream push sees PRE-change membership for not-yet-processed
2831 /// parents (Zero `join.ts#pushChildChange`); a nested `Child` carries no overlay
2832 /// (see [`JoinOverlay::for_child_change`]).
2833 fn push_child_change<'g>(&'g self, jid: NodeId, child_change: Change<'g>) {
2834 let j = self.join(jid);
2835 let out = j.output.get().expect("join output not wired");
2836 let rel_slot = j.rel_slot;
2837 let parent = j.parent;
2838 // The key row is the changed-child's row (an Edit keeps the same join key,
2839 // asserted on entry; a nested Child carries the unchanged child row), so the
2840 // constraint and the per-parent overlay agree.
2841 let key_row = child_change.primary_row().clone();
2842 let constraint = match build_join_constraint(&key_row, &j.child_key, &j.parent_key) {
2843 Some(c) => c,
2844 None => return, // null child key cannot join
2845 };
2846 // The join membership pre-check (design 311 §2.2): one hash probe against the
2847 // counted parent-key region. A miss means every parent this child could join to
2848 // is either absent (complete region) or sorts beyond the frontier, where nothing
2849 // downstream can observe it (§3.2) — so the whole fan-out is skipped. A hit, an
2850 // unbuilt or a disabled set all run today's path untouched.
2851 if self.join_precheck_probe(jid, &constraint) == Probe::Miss {
2852 self.debug_assert_join_precheck_miss_sound(jid, &constraint);
2853 return;
2854 }
2855 let overlay = JoinOverlay::for_child_change(&child_change);
2856 // An Edit's `old` subtree is consumed by no downstream (View reduces Edit→rows;
2857 // Exists gates only the new node), and the join-key-immutability contract makes
2858 // it identical to the already-materialized one anyway — so materialize it
2859 // row-only, skipping the reentrant leaf refetch of the whole old child subtree.
2860 let owned = materialize_change_edit_old_row_only(child_change);
2861
2862 // Publish the in-flight child overlay LIVE; the guard clears it on every exit
2863 // (the JS `try/finally`, `join.ts:222-249`).
2864 *self.join(jid).inprogress_overlay.borrow_mut() = overlay;
2865 let _guard = InprogressGuard { g: self, jid };
2866
2867 // Collect the reentrant parent fetch (with relationships preserved) so we
2868 // don't hold its `&self` stream borrow across the downstream push.
2869 let parents: Vec<Node> = self
2870 .fetch(parent, &FetchRequest::with_constraint(constraint))
2871 .collect();
2872
2873 // The unbounded join fan-out (RUNAWAY-PUSH-FINDINGS §3): one child change pushes
2874 // one `Change::Child` per matching parent, O(N) with no downstream-limit awareness
2875 // — THE loop a runaway push spins in, so it carries a deadline checkpoint (§6.6). The
2876 // tick counter is graph-level (shared across the whole armed batch), so many small
2877 // changes accumulate toward the checkpoint too — not just one huge single-change fan-out.
2878 for pnode in parents {
2879 if self.push_deadline_exceeded() {
2880 self.park_runtime_error(RindleError::PushDeadlineExceeded {
2881 site: "join child fan-out",
2882 });
2883 break; // torn is fine — the host discards the engine (abort≙epoch-rehydrate)
2884 }
2885 // Advance the in-flight position to the parent now being delivered; the
2886 // overlay applies only to parents sorting strictly after it.
2887 *self.join(jid).inprogress_position.borrow_mut() = Some(pnode.row.clone());
2888 let pn = self.process_parent_node(jid, pnode);
2889 let child = Box::new(rebuild_change(owned.clone()));
2890 self.push(
2891 out.node,
2892 Change::Child {
2893 node: pn,
2894 rel: rel_slot,
2895 child,
2896 },
2897 out.port,
2898 );
2899 }
2900 }
2901
2902 // -------------------------------------------------------------------
2903 // The join membership pre-check (design 311): knobs, observation,
2904 // maintenance, probe. The set itself is `op::join_util::ParentKeySet`.
2905 // -------------------------------------------------------------------
2906
2907 /// Set the join membership pre-check's bounds (design 311 §2.5 / §8): the per-join
2908 /// distinct-key bound (`None` = off, the default) and the per-graph key budget.
2909 /// Host-settable on a built `&Graph` like [`set_validate_changes`](Self::set_validate_changes).
2910 ///
2911 /// A change **resets every join's set to `Unbuilt`** (and the tracked total to 0): a
2912 /// set built under a looser bound could be over the new one, and a set left `Active`
2913 /// while the feature is off would silently go stale (maintenance stops with it) and
2914 /// lie once it is turned back on. Rebuilding happens by observation on the next
2915 /// unconstrained enumeration — never by a fetch of its own — so flipping the knob on
2916 /// a live graph is safe at any time.
2917 pub fn set_join_precheck_bounds(&self, per_join: Option<usize>, per_graph: usize) {
2918 let bounds = JoinPrecheckBounds {
2919 per_join,
2920 per_graph,
2921 };
2922 if self.join_precheck.0.get() == bounds {
2923 return;
2924 }
2925 self.join_precheck.0.set(bounds);
2926 for op in &self.nodes {
2927 if let Operator::Join(j) = op {
2928 j.precheck.borrow_mut().reset(&self.join_precheck_tracked);
2929 }
2930 }
2931 debug_assert_eq!(
2932 self.join_precheck_tracked.get(),
2933 0,
2934 "every tracked key belongs to some live join's set"
2935 );
2936 }
2937
2938 /// The current pre-check bounds.
2939 pub fn join_precheck_bounds(&self) -> JoinPrecheckBounds {
2940 self.join_precheck.0.get()
2941 }
2942
2943 /// The per-graph pre-check counters (probe hits/misses, disables by reason).
2944 pub fn join_precheck_stats(&self) -> JoinPrecheckStats {
2945 self.join_precheck_stats.get()
2946 }
2947
2948 /// Distinct parent keys tracked across every join right now (the live charge
2949 /// against the `per_graph` budget).
2950 pub fn join_precheck_tracked_keys(&self) -> usize {
2951 self.join_precheck_tracked.get()
2952 }
2953
2954 /// Inspect one join's set. # Panics if `join` is not a live `Join` node.
2955 pub fn join_precheck_state(&self, join: NodeId) -> JoinPrecheckState {
2956 match &*self.join(join).precheck.borrow() {
2957 ParentKeySet::Unbuilt => JoinPrecheckState::Unbuilt,
2958 set @ ParentKeySet::Active { frontier, .. } => JoinPrecheckState::Active {
2959 distinct_keys: set.distinct_keys(),
2960 complete: matches!(frontier, Frontier::Complete),
2961 },
2962 ParentKeySet::Disabled => JoinPrecheckState::Disabled,
2963 }
2964 }
2965
2966 /// The pre-check state of every live `Join` among `nodes` (a pipeline manifest's node
2967 /// list), in the order given; non-join and stale ids are skipped. The per-query
2968 /// inspection hook a host with several pipelines on one graph uses (the replica
2969 /// worker's `join_precheck_report`). Inspection-only: linear in the graph's joins per
2970 /// manifest node.
2971 pub fn join_precheck_states_of(&self, nodes: &[NodeId]) -> Vec<JoinPrecheckState> {
2972 let all = self.join_precheck_states();
2973 nodes
2974 .iter()
2975 .filter_map(|id| all.iter().find(|(j, _)| j == id).map(|(_, s)| s.clone()))
2976 .collect()
2977 }
2978
2979 /// Every live `Join` node with its pre-check state, in arena order — the inspection
2980 /// hook for a pipeline built by `build_pipeline` (whose join ids are not handed back).
2981 pub fn join_precheck_states(&self) -> Vec<(NodeId, JoinPrecheckState)> {
2982 self.nodes
2983 .iter()
2984 .enumerate()
2985 .filter(|(_, op)| matches!(op, Operator::Join(_)))
2986 .map(|(i, _)| {
2987 let id = NodeId::new(i as u32, self.node_gens[i]);
2988 (id, self.join_precheck_state(id))
2989 })
2990 .collect()
2991 }
2992
2993 fn join_precheck_stat(&self, f: impl FnOnce(&mut JoinPrecheckStats)) {
2994 let mut st = self.join_precheck_stats.get();
2995 f(&mut st);
2996 self.join_precheck_stats.set(st);
2997 }
2998
2999 /// The **eligibility walk** (design 311 §2.4), computed once per join and cached: the
3000 /// join must sit on the **root chain** — its parent side reaches the source through
3001 /// row-preserving nodes only, and its output reaches the sink through the same
3002 /// passthroughs plus at most one unpartitioned `Take`. Anything else (a partitioned
3003 /// limiter, a union fan, a reduce, a nested join whose enumeration is per outer
3004 /// parent) means the parent enumeration this join sees is never a sorted prefix of
3005 /// anything, so the region argument of §3.2 does not apply — the join is `Disabled`.
3006 fn join_precheck_eligible(&self, jid: NodeId) -> bool {
3007 let j = self.join(jid);
3008 if let Some(e) = j.precheck_eligible.get() {
3009 return e;
3010 }
3011 let eligible = self.join_precheck_on_root_chain(jid);
3012 j.precheck_eligible.set(Some(eligible));
3013 if !eligible {
3014 j.precheck.borrow_mut().disable(&self.join_precheck_tracked);
3015 self.join_precheck_stat(|s| s.disabled_ineligible += 1);
3016 metric_inc!(join_precheck_disabled_ineligible);
3017 }
3018 eligible
3019 }
3020
3021 fn join_precheck_on_root_chain(&self, jid: NodeId) -> bool {
3022 let j = self.join(jid);
3023 // Upstream: from the parent input toward the source.
3024 let mut cur = j.parent;
3025 loop {
3026 match self.node(cur) {
3027 Operator::SourceConn(_) => break,
3028 Operator::Skip(s) => cur = s.input,
3029 Operator::FilterStart(s) => cur = s.input,
3030 Operator::FilterEnd(e) => cur = e.start,
3031 // A limiter above the join is fine while it is a single global window:
3032 // the join then sees exactly that window, exhaustively. A partitioned one
3033 // enumerates per outer parent — never a sorted prefix.
3034 Operator::Take(t) if t.partition_key.is_none() => cur = t.input,
3035 Operator::Cap(c) if c.partition_key.is_none() => cur = c.input,
3036 // A sibling relationship join feeding our parent port is row-preserving
3037 // on the parent stream (its own child pushes arrive here as `Child`).
3038 Operator::Join(pj) => cur = pj.parent,
3039 #[cfg(any(test, feature = "testkit"))]
3040 Operator::Snitch(s) => cur = s.input,
3041 _ => return false,
3042 }
3043 }
3044 // Downstream: from the output edge to the sink.
3045 let mut edge = j.output.get();
3046 let mut limiters = 0u8;
3047 loop {
3048 let Some(e) = edge else {
3049 return false; // unwired — never happens on a built pipeline
3050 };
3051 match e.port {
3052 // This join is a nested child top: its parent enumeration is constrained
3053 // per outer parent (§4, "nested joins").
3054 Port::JoinChild => return false,
3055 Port::JoinParent => {
3056 edge = match self.node(e.node) {
3057 Operator::Join(nj) => nj.output.get(),
3058 _ => return false,
3059 };
3060 }
3061 Port::Single => match self.node(e.node) {
3062 Operator::View(_) | Operator::Collector(_) => return true,
3063 #[cfg(any(test, feature = "testkit"))]
3064 Operator::Catch(_) => return true,
3065 Operator::Skip(s) => edge = s.output.get(),
3066 Operator::Take(t) if t.partition_key.is_none() && limiters == 0 => {
3067 limiters += 1;
3068 edge = t.output.get();
3069 }
3070 // The `where` sub-graph: every chain link is a stateless gate (an
3071 // `Exists` forwards or converts a `Child`, never persists it), so walk
3072 // through to the `FilterEnd`'s downstream edge.
3073 Operator::FilterStart(fs) => edge = self.filter_chain_tail(fs),
3074 #[cfg(any(test, feature = "testkit"))]
3075 Operator::Snitch(s) => {
3076 edge = s.output().map(|node| OutEdge {
3077 node,
3078 port: Port::Single,
3079 })
3080 }
3081 _ => return false,
3082 },
3083 }
3084 }
3085 }
3086
3087 /// Follow a `FilterStart`'s chain to its `FilterEnd` and return that end's downstream
3088 /// edge (`None` if the chain does not terminate in a wired `FilterEnd`).
3089 fn filter_chain_tail(&self, fs: &FilterStart) -> Option<OutEdge> {
3090 let mut link = fs.chain_head.get()?;
3091 loop {
3092 link = match self.node(link) {
3093 Operator::Filter(f) => f.output.get()?,
3094 Operator::FilterProbe(p) => p.output.get()?,
3095 Operator::Exists(e) => e.output.get()?,
3096 Operator::FanOut(_) => self.fan_continuation(link)?,
3097 Operator::FanIn(f) => f.output.get()?,
3098 Operator::FilterEnd(fe) => return fe.output.get(),
3099 _ => return None,
3100 };
3101 }
3102 }
3103
3104 /// Should this fetch through join `jid` be **observed** (design 311 §2.4)? Only an
3105 /// unconstrained, forward request can build or advance a region:
3106 ///
3107 /// - `Unbuilt`: start a fresh region iff the request has no `start` (its enumeration
3108 /// begins at −∞, so the region it leaves behind is `[-∞, last yielded]`). A request
3109 /// that carries a `start` cannot be told apart from a downstream `Take`'s refill
3110 /// scan, and starting a region from one would leave the window below that start
3111 /// uncovered — so it never starts one (see the design's §12 implementation note).
3112 /// - `Active` at a finite frontier: advance iff the request is forward and starts at
3113 /// or before the frontier (or has no start) — rows are then yielded contiguously
3114 /// from the start, so nothing between the old and new frontier is skipped.
3115 /// - `Complete` or `Disabled`: nothing to learn.
3116 fn join_precheck_observation(&self, jid: NodeId, req: &FetchRequest) -> bool {
3117 if self.join_precheck.0.get().per_join.is_none() {
3118 return false;
3119 }
3120 if req.constraint.is_some() || !req.multi_constraints.is_empty() || req.reverse {
3121 return false;
3122 }
3123 let j = self.join(jid);
3124 // Read the state under a short borrow; the eligibility walk below touches other
3125 // nodes (never this cell), but the cardinal rule is no borrow across a walk.
3126 let decision = match &*j.precheck.borrow() {
3127 ParentKeySet::Unbuilt => None,
3128 ParentKeySet::Disabled => Some(false),
3129 ParentKeySet::Active {
3130 frontier: Frontier::Complete,
3131 ..
3132 } => Some(false),
3133 ParentKeySet::Active {
3134 frontier: Frontier::At(f),
3135 sort,
3136 ..
3137 } => Some(
3138 req.start
3139 .as_ref()
3140 .is_none_or(|s| compare_rows(sort, &s.row, f) != std::cmp::Ordering::Greater),
3141 ),
3142 };
3143 match decision {
3144 Some(d) => d,
3145 None => req.start.is_none() && self.join_precheck_eligible(jid),
3146 }
3147 }
3148
3149 /// One parent row yielded by an observed enumeration (§2.4): the transition is
3150 /// [`ParentKeySet::observe_row`] (walked exhaustively in `join_util.rs`'s tests); this arm
3151 /// resolves the parent sort for an `Unbuilt` set — before borrowing the set, since
3152 /// that walks the operator chain — and counts a trip.
3153 fn join_precheck_observe_row(&self, jid: NodeId, row: &Row) {
3154 let bounds = self.join_precheck.0.get();
3155 let Some(per_join) = bounds.per_join else {
3156 return;
3157 };
3158 let j = self.join(jid);
3159 let sort = matches!(&*j.precheck.borrow(), ParentKeySet::Unbuilt)
3160 .then(|| self.input_sort(j.parent));
3161 let tripped = j
3162 .precheck
3163 .borrow_mut()
3164 .observe_row(
3165 row,
3166 &j.parent_key,
3167 sort,
3168 per_join,
3169 bounds.per_graph,
3170 &self.join_precheck_tracked,
3171 )
3172 .is_err();
3173 if tripped {
3174 self.join_precheck_stat(|s| s.disabled_observation += 1);
3175 metric_inc!(join_precheck_disabled_observation);
3176 }
3177 }
3178
3179 /// An observed enumeration ran the parent input to exhaustion: the region is the
3180 /// whole input ([`ParentKeySet::observe_exhausted`]).
3181 fn join_precheck_observe_exhausted(&self, jid: NodeId) {
3182 if self.join_precheck.0.get().per_join.is_none() {
3183 return;
3184 }
3185 let j = self.join(jid);
3186 let sort = matches!(&*j.precheck.borrow(), ParentKeySet::Unbuilt)
3187 .then(|| self.input_sort(j.parent));
3188 j.precheck.borrow_mut().observe_exhausted(sort);
3189 }
3190
3191 /// Maintenance (§2.3), `Add` on the parent port ([`ParentKeySet::parent_add`]).
3192 fn join_precheck_parent_add(&self, jid: NodeId, row: &Row) {
3193 let bounds = self.join_precheck.0.get();
3194 let Some(per_join) = bounds.per_join else {
3195 return;
3196 };
3197 let j = self.join(jid);
3198 let tripped = j
3199 .precheck
3200 .borrow_mut()
3201 .parent_add(
3202 row,
3203 &j.parent_key,
3204 per_join,
3205 bounds.per_graph,
3206 &self.join_precheck_tracked,
3207 )
3208 .is_err();
3209 if tripped {
3210 self.join_precheck_stat(|s| s.disabled_maintenance += 1);
3211 metric_inc!(join_precheck_disabled_maintenance);
3212 }
3213 }
3214
3215 /// Maintenance (§2.3), `Remove` on the parent port ([`ParentKeySet::parent_remove`]).
3216 fn join_precheck_parent_remove(&self, jid: NodeId, row: &Row) {
3217 if self.join_precheck.0.get().per_join.is_none() {
3218 return;
3219 }
3220 let j = self.join(jid);
3221 let tripped = j
3222 .precheck
3223 .borrow_mut()
3224 .parent_remove(row, &j.parent_key, &self.join_precheck_tracked)
3225 .is_err();
3226 if tripped {
3227 self.join_precheck_stat(|s| s.disabled_maintenance += 1);
3228 metric_inc!(join_precheck_disabled_maintenance);
3229 }
3230 }
3231
3232 /// Maintenance (§2.3), `Edit` on the parent port ([`ParentKeySet::parent_edit`]).
3233 fn join_precheck_parent_edit(&self, jid: NodeId, old: &Row, new: &Row) {
3234 let bounds = self.join_precheck.0.get();
3235 let Some(per_join) = bounds.per_join else {
3236 return;
3237 };
3238 let j = self.join(jid);
3239 let tripped = j
3240 .precheck
3241 .borrow_mut()
3242 .parent_edit(
3243 old,
3244 new,
3245 &j.parent_key,
3246 per_join,
3247 bounds.per_graph,
3248 &self.join_precheck_tracked,
3249 )
3250 .is_err();
3251 if tripped {
3252 self.join_precheck_stat(|s| s.disabled_maintenance += 1);
3253 metric_inc!(join_precheck_disabled_maintenance);
3254 }
3255 }
3256
3257 /// The probe (§2.2): is the constraint's parent key inside the region?
3258 fn join_precheck_probe(&self, jid: NodeId, constraint: &Constraint) -> Probe {
3259 if self.join_precheck.0.get().per_join.is_none() {
3260 return Probe::Unknown;
3261 }
3262 let j = self.join(jid);
3263 let set = j.precheck.borrow();
3264 let probe = match &*set {
3265 ParentKeySet::Active { .. } => set.probe(&canonical_key_of_constraint(constraint)),
3266 // A join that is probed before it was ever observed unconstrained is
3267 // classified now (cached), so a nested join — whose enumeration is always
3268 // constrained and so is never observed — still lands in `Disabled` and the
3269 // `ineligible` counter, rather than lingering as `Unbuilt`.
3270 ParentKeySet::Unbuilt => {
3271 drop(set);
3272 self.join_precheck_eligible(jid);
3273 return Probe::Unknown;
3274 }
3275 ParentKeySet::Disabled => return Probe::Unknown,
3276 };
3277 drop(set);
3278 match probe {
3279 Probe::Hit => {
3280 self.join_precheck_stat(|s| s.probe_hits += 1);
3281 metric_inc!(join_probe_hit);
3282 }
3283 Probe::Miss => {
3284 self.join_precheck_stat(|s| s.probe_misses += 1);
3285 metric_inc!(join_probe_miss);
3286 }
3287 Probe::Unknown => {}
3288 }
3289 probe
3290 }
3291
3292 /// The debug soundness net (§2.6), in the style of 205's `debug_assert_index_sound`:
3293 /// on a probe **miss**, run the fetch the probe skipped and assert every parent it
3294 /// returns sorts **outside** the region — `[-∞, frontier]` — i.e. that skipping it
3295 /// dropped no delta. Compiled out of release.
3296 fn debug_assert_join_precheck_miss_sound(&self, jid: NodeId, constraint: &Constraint) {
3297 #[cfg(debug_assertions)]
3298 {
3299 let j = self.join(jid);
3300 let (frontier, sort) = match &*j.precheck.borrow() {
3301 ParentKeySet::Active { frontier, sort, .. } => (frontier.clone(), sort.clone()),
3302 _ => return,
3303 };
3304 let parents: Vec<Row> = self
3305 .fetch(j.parent, &FetchRequest::with_constraint(constraint.clone()))
3306 .map(|n| n.row)
3307 .collect();
3308 for p in &parents {
3309 match &frontier {
3310 Frontier::Complete => debug_assert!(
3311 false,
3312 "join pre-check missed on a complete region but a matching parent exists — a dropped delta"
3313 ),
3314 Frontier::At(f) => debug_assert!(
3315 compare_rows(&sort, p, f) == std::cmp::Ordering::Greater,
3316 "join pre-check missed but a matching parent sorts inside the region — a dropped delta"
3317 ),
3318 }
3319 }
3320 }
3321 #[cfg(not(debug_assertions))]
3322 {
3323 let _ = (jid, constraint);
3324 }
3325 }
3326
3327 // -------------------------------------------------------------------
3328 // Source push: fan the change out to every connection (overlay active),
3329 // then clear the overlay, then commit the write. The orchestration
3330 // (edit-split, existence asserts, write-after-drain) lives in
3331 // `source_common`; the graph only supplies the downstream driver.
3332 // -------------------------------------------------------------------
3333
3334 /// Infallible push (tests / prototyping).
3335 ///
3336 /// # Panics
3337 /// On any `RindleError` (SQLite I/O, a strict consistency violation, …). Prefer
3338 /// [`try_source_push`](Self::try_source_push) in production so the error is
3339 /// handled, not aborted (WS02.6 fault model).
3340 pub fn source_push(&self, src_id: NodeId, change: SourceChange) {
3341 self.try_source_push(src_id, change)
3342 .expect("source push failed")
3343 }
3344
3345 pub fn try_source_push(&self, src_id: NodeId, change: SourceChange) -> Result<(), RindleError> {
3346 // NOT timed here. The apply path pushes a batch's rows one at a time, so a
3347 // per-call timer was a per-ROW clock pair (~15ns each) whose samples all landed in
3348 // the histogram's first bucket anyway. Latency is measured per BATCH by the caller
3349 // that knows its own batch boundary — see `rindle::metrics::ApplyBatch`. The
3350 // per-change counter below stays: it is one relaxed add with no clock read, and it
3351 // is what keeps mean per-row cost recoverable from the batch histogram.
3352 let s = self.source(src_id);
3353 let strict = self.validate_changes.get();
3354 // Classify before `change` is moved into the push (compiled out when the
3355 // `metrics` feature is off); count `rindle.changes.processed` only after the
3356 // push succeeds below — a rejected change was not processed.
3357 let _change_kind = metric_change_kind!(&change);
3358 // The connection boundary on the write path: the source fans out a
3359 // `SourceChange` (rows); the `SourceConn` wraps it into a node-bearing
3360 // downstream `Change` (leaf nodes — joins attach relationships later).
3361 s.try_push(
3362 change,
3363 &|conn: &Connection, sc: SourceChange| {
3364 if let Some(edge) = conn.output.get() {
3365 self.push(edge.node, source_change_to_node(sc), edge.port);
3366 }
3367 },
3368 strict,
3369 )?;
3370 metric_changes_inc!(_change_kind);
3371 self.take_runtime_error()
3372 }
3373
3374 /// Server fault boundary (WS02.5): run one mutation under `catch_unwind` so a
3375 /// *residual* — unconverted, genuinely-unexpected — panic during this push does
3376 /// not abort the whole process (which, serving many connections, would take them
3377 /// all down). A caught panic becomes a `RindleError::Storage("internal panic: …")`.
3378 ///
3379 /// Effective only under `panic = "unwind"` (the `release-server` profile, D2);
3380 /// under `panic = "abort"` (the default/wasm client) a panic aborts regardless
3381 /// and this just forwards the result. It is the *last-resort net* **after** the
3382 /// WS02.1–02.4 conversions of the expected data-reachable panics — not a
3383 /// substitute for them.
3384 ///
3385 /// **Recovery contract: fail the connection, do not retry in place.** A panic
3386 /// mid-mutation can leave operator/view state logically torn (a `RefCell` is
3387 /// released on unwind — *not* poisoned, so the graph is still *usable*, just
3388 /// possibly inconsistent). The owning connection MUST discard and re-hydrate the
3389 /// view from source before serving further reads (WS09.6), never reuse it.
3390 pub fn source_push_isolated(
3391 &self,
3392 src_id: NodeId,
3393 change: SourceChange,
3394 ) -> Result<(), RindleError> {
3395 use std::panic::{catch_unwind, AssertUnwindSafe};
3396 match catch_unwind(AssertUnwindSafe(|| self.try_source_push(src_id, change))) {
3397 Ok(result) => result,
3398 Err(payload) => {
3399 // The containment fired: count it (208.5) so a rising `mutation_panics_total`
3400 // flags a bad mutation loose in the graph. No-op with `metrics` off.
3401 metric_inc!(mutation_panics);
3402 let msg = if let Some(s) = payload.downcast_ref::<&str>() {
3403 (*s).to_string()
3404 } else if let Some(s) = payload.downcast_ref::<String>() {
3405 s.clone()
3406 } else {
3407 "unknown panic".to_string()
3408 };
3409 Err(RindleError::Storage(format!(
3410 "internal panic during push: {msg}"
3411 )))
3412 }
3413 }
3414 }
3415
3416 // -------------------------------------------------------------------
3417 // View: hydrate / push / flush / listeners (the `array-view.ts` halves;
3418 // the pure folding lives in `crate::view::apply_change`).
3419 // -------------------------------------------------------------------
3420
3421 /// Build the initial view by draining the input pipeline (`#hydrate`,
3422 /// `array-view.ts:140`). The fetch runs here (the `Graph` borrow stays with the
3423 /// caller); the [`View`](crate::view::View) folds each node `InPlace` and flushes
3424 /// once. No `RefCell` borrow is held across the fetch (the root is taken out
3425 /// inside `hydrate_from`, after this iterator is constructed).
3426 /// Infallible hydrate (tests / prototyping).
3427 ///
3428 /// # Panics
3429 /// On any `RindleError` from the initial fetch. Prefer
3430 /// [`try_hydrate`](Self::try_hydrate) in production (WS02.6 fault model).
3431 pub fn hydrate(&self, view_id: NodeId) {
3432 self.try_hydrate(view_id).expect("hydrate failed")
3433 }
3434
3435 pub fn try_hydrate(&self, view_id: NodeId) -> Result<(), RindleError> {
3436 // Subscription cold-start latency (208.2): the one-time hydrate cost.
3437 let _timer = metric_timer!(hydrate);
3438 let input = self.view(view_id).input;
3439 let nodes: Vec<Node> = self.fetch(input, &FetchRequest::all()).collect();
3440 self.take_runtime_error()?;
3441 self.view(view_id).hydrate_from(nodes.into_iter());
3442 self.take_runtime_error()
3443 }
3444
3445 /// Fold a downstream `Change` into the view (the pipeline's terminal `Output`).
3446 fn view_push<'g>(&'g self, view_id: NodeId, change: Change<'g>) {
3447 self.view(view_id).push_change(change);
3448 }
3449
3450 /// Fire the view's listeners with the current snapshot and close the
3451 /// transaction (`flush`, `array-view.ts:173`).
3452 pub fn flush_view(&self, view_id: NodeId) {
3453 self.view(view_id).flush();
3454 }
3455
3456 pub fn take_runtime_error(&self) -> Result<(), RindleError> {
3457 // The graph-level sink first (parked deep in the fan-out), then per-source
3458 // parked errors (the SQLite leaf fetch path).
3459 if let Some(err) = self.runtime_error.borrow_mut().take() {
3460 return Err(err);
3461 }
3462 for op in &self.nodes {
3463 match op {
3464 Operator::Source(source) => {
3465 if let Some(err) = source.take_error() {
3466 return Err(err);
3467 }
3468 }
3469 // The §5.3 integer-sum overflow check (design 226) is state-based:
3470 // it raises while any stored group's all-integer set total is
3471 // outside i64 (SQLite's result contract — a fresh `SELECT sum(…)`
3472 // there errors every time too), and clears the moment the state
3473 // shrinks back into range. A delta-order transient corrected
3474 // before this boundary is consulted therefore never errors.
3475 Operator::Reduce(r) if r.overflow_count() > 0 => {
3476 return Err(RindleError::UnsupportedValue(
3477 "integer sum overflow: an aggregate's set total is outside \
3478 the i64 range (design 226 §5.3)"
3479 .into(),
3480 ));
3481 }
3482 _ => {}
3483 }
3484 }
3485 Ok(())
3486 }
3487
3488 /// Park a runtime error on the graph-level sink (set-if-empty, so the first
3489 /// failure in a push wins). Drained by [`take_runtime_error`](Self::take_runtime_error)
3490 /// at the mutation boundary. Used where a failure arises deep in the push
3491 /// fan-out and cannot be returned up the stack (WS02.2 join-key check; WS02.3).
3492 pub(crate) fn park_runtime_error(&self, err: RindleError) {
3493 let mut slot = self.runtime_error.borrow_mut();
3494 if slot.is_none() {
3495 *slot = Some(err);
3496 }
3497 }
3498
3499 /// Register a flush listener on the view; fires once immediately
3500 /// (`addListener`, `array-view.ts:115`). Returns its index.
3501 pub fn view_add_listener(&self, view_id: NodeId, l: crate::view::Listener) -> usize {
3502 self.view(view_id).add_listener(l)
3503 }
3504
3505 /// The view's current [`ResultType`](crate::view::ResultType).
3506 pub fn view_result_type(&self, view_id: NodeId) -> crate::view::ResultType {
3507 self.view(view_id).result_type()
3508 }
3509
3510 /// Resolve a pending query's result type (the async `queryComplete` path,
3511 /// `09` §3.8); fires listeners out of band.
3512 pub fn set_view_result_type(&self, view_id: NodeId, rt: crate::view::ResultType) {
3513 self.view(view_id).set_result_type(rt);
3514 }
3515
3516 // -------------------------------------------------------------------
3517 // Collector: record pushes (+ optional fetch-during-push)
3518 // -------------------------------------------------------------------
3519
3520 fn collector_push(&self, id: NodeId, change: Change) {
3521 // Change-stream sink capture (opt-in via `add_change_sink`): record the full
3522 // nested `CaughtChange` tree. Done first, because the flat path below early-
3523 // returns on `Change::Child` — but a change sink must see Child events too.
3524 if self.collector(id).capture_caught.get() {
3525 let caught = crate::changes::expand_change(&change);
3526 self.collector(id).caught.borrow_mut().push(caught);
3527 // A production change-sink (the `rindle-replica` seam) is drained ONLY through
3528 // `take_sink_changes` (the `caught` buffer); it never reads the flat testkit
3529 // `changes`/`fetched` recordings. Accumulating them here would grow `changes` by
3530 // one entry on every push, FOREVER — a per-commit leak invisible to
3531 // `storage_snapshot` (it scans operator storage, not collector buffers) that only
3532 // frees on teardown. So a change-sink stops here. (`capture_caught` is set only by
3533 // `add_change_sink`; a plain testkit `Collector` keeps the flat recording below.)
3534 return;
3535 }
3536 let (collected, input, fetch_on_push) = {
3537 let c = self.collector(id);
3538 let collected = match &change {
3539 Change::Add(n) => CollectedChange::Add(n.row.clone()),
3540 Change::Remove(n) => CollectedChange::Remove(n.row.clone()),
3541 Change::Edit { node, old } => CollectedChange::Edit {
3542 row: node.row.clone(),
3543 old: old.row.clone(),
3544 },
3545 Change::Child { .. } => return, // not exercised by source-level tests
3546 };
3547 c.changes.borrow_mut().push(collected.clone());
3548 (collected, c.input, c.fetch_on_push.get())
3549 };
3550 let _ = collected;
3551 if fetch_on_push {
3552 // Reentrant fetch of the source connection mid-push: observes the
3553 // epoch-gated overlay (this connection's epoch was bumped before its
3554 // own push, so it DOES see the in-flight change).
3555 let rows: Vec<Row> = self
3556 .fetch(input, &FetchRequest::all())
3557 .map(|n| n.row)
3558 .collect();
3559 self.collector(id).fetched.borrow_mut().push(rows);
3560 }
3561 }
3562
3563 /// Enable reentrant fetch-during-push capture on a collector.
3564 pub fn set_collector_fetch_on_push(&self, id: NodeId, on: bool) {
3565 self.collector(id).fetch_on_push.set(on);
3566 }
3567
3568 /// The changes a collector has received, in order.
3569 pub fn collector_changes(&self, id: NodeId) -> Vec<CollectedChange> {
3570 self.collector(id).changes.borrow().clone()
3571 }
3572
3573 /// The row-sets captured by reentrant fetch-during-push (one per change).
3574 pub fn collector_fetched(&self, id: NodeId) -> Vec<Vec<Row>> {
3575 self.collector(id).fetched.borrow().clone()
3576 }
3577
3578 // --- production change-stream sink (the `rindle-replica` seam) ---
3579
3580 /// Drain (take) the change events a change-sink (see [`add_change_sink`](Self::add_change_sink))
3581 /// has accumulated since the last call, in arrival order. The buffer is left empty,
3582 /// so this is the per-transaction delta after a push batch + `flush`.
3583 pub fn take_sink_changes(&self, sink: NodeId) -> Vec<crate::changes::CaughtChange> {
3584 std::mem::take(&mut *self.collector(sink).caught.borrow_mut())
3585 }
3586
3587 /// Materialize the pipeline feeding a change-sink as the initial set of
3588 /// [`CaughtChange::Add`](crate::changes::CaughtChange) events — the hydration
3589 /// snapshot (one `Add` per top-level node, relationships drained). Does not touch
3590 /// the push buffer. Mirrors `Catch.fetch` (`testkit.rs`) without the testkit gate.
3591 ///
3592 /// Fallible like [`try_hydrate`](Self::try_hydrate), and for the same reason: the
3593 /// cold drain is a `try_*` boundary. A leaf error parked mid-fetch (an unsafe
3594 /// integer, a failed `sqlite3_step`) ended the stream early, and folding reduce
3595 /// groups during the drain can arm the §5.3 integer-sum overflow state (design
3596 /// 226) — an already-out-of-range set total must surface HERE as the typed error,
3597 /// not register as a success whose aggregate cell is `NULL` while every later
3598 /// write on the shared graph fails the overflow check. Callers tear the partial
3599 /// pipeline down on `Err` (the registration error path), which also clears the
3600 /// overflow state the drain armed.
3601 pub fn try_hydrate_change_sink(
3602 &self,
3603 sink: NodeId,
3604 ) -> Result<Vec<crate::changes::CaughtChange>, RindleError> {
3605 self.try_hydrate_change_sink_with(sink, &FetchRequest::all())
3606 }
3607
3608 /// [`try_hydrate_change_sink`](Self::try_hydrate_change_sink) under an explicit
3609 /// [`FetchRequest`] — the constrained form a parameterized query family's
3610 /// per-partition hydrate uses (design 310 §4.4): the request's constraint reaches
3611 /// the leaf unchanged through the spine (joins forward it to the parent, `Skip`
3612 /// keeps it, the root `Take` recognizes it as the partition and hydrates only that
3613 /// partition's window).
3614 pub fn try_hydrate_change_sink_with(
3615 &self,
3616 sink: NodeId,
3617 req: &FetchRequest,
3618 ) -> Result<Vec<crate::changes::CaughtChange>, RindleError> {
3619 let input = self.collector(sink).input;
3620 let initial: Vec<crate::changes::CaughtChange> = self
3621 .fetch(input, req)
3622 .map(|n| crate::changes::CaughtChange::Add(crate::changes::expand_node(&n)))
3623 .collect();
3624 self.take_runtime_error()?;
3625 Ok(initial)
3626 }
3627
3628 // -------------------------------------------------------------------
3629 // Parameterized query families (design 310 §4.4): bind / unbind / hydrate.
3630 // Host commands, never operator side effects — mutate the binding set only
3631 // between pushes (impl plan D3).
3632 // -------------------------------------------------------------------
3633
3634 /// Bind one partition of a family (design 310 §4.4 "bind"): add `binding` to the
3635 /// family's binding set, then hydrate **only that partition** — a fetch of the
3636 /// pipeline top constrained on the parameter columns, returned as the partition's
3637 /// initial `Add` set (the new subscriber's snapshot). Existing partitions are
3638 /// untouched. Errors if `binding` is already bound, or if the hydrate fails (the
3639 /// binding is then rolled back, so the set never names a partition that was not
3640 /// hydrated).
3641 pub fn bind_family_partition(
3642 &self,
3643 fam: &FamilyPipeline,
3644 sink: NodeId,
3645 binding: &CanonKey,
3646 ) -> Result<Vec<crate::changes::CaughtChange>, RindleError> {
3647 metric_inc!(family_binds);
3648 let _timer = metric_timer!(family_partition_hydrate);
3649 let constraint = fam.constraint_for(binding);
3650 if fam.bindings.contains(binding) {
3651 return Err(RindleError::schema_violation(format!(
3652 "family binding {binding:?} is already bound"
3653 )));
3654 }
3655 // The root limiter must hold NO slot for this partition before the hydrate
3656 // below fills it. Unbind evicts the slot (step 5 of `unbind_family_partition`),
3657 // but a slot can reappear while the partition is unbound: a `related` join's
3658 // child-side push fetches its parents through the spine, and a fetch through a
3659 // partitioned `Take` for a key it has no state for hydrates that key from the
3660 // input — which, with the root connection rejecting the unbound partition, is an
3661 // EMPTY window the limiter then caches as this partition's state. Left in place,
3662 // the constrained hydrate at bind would read that stale empty slot instead of
3663 // the base table, and the partition would re-bind to nothing (found by the
3664 // fuzz lane's rebind twist: a child edit landing while the graded partition was
3665 // unbound, `FamilySut`).
3666 if let Some(t) = fam.root_take {
3667 if let Operator::Take(t) = self.node(t) {
3668 t.evict_partition(self, &constraint);
3669 }
3670 }
3671 // Guard add, THEN set insert (design §4.1's ordering discipline): the push
3672 // index is a superset of the predicate at every instant. Both happen between
3673 // pushes (impl plan D3 — the `ConnTable` asserts it).
3674 let sc = self.source_conn(fam.root_conn);
3675 let guard_value = binding[0].to_owned_value();
3676 self.source(sc.source)
3677 .add_guard_value(sc.conn, guard_value.clone());
3678 fam.bindings.insert(binding.clone());
3679 match self.try_hydrate_change_sink_with(sink, &FetchRequest::with_constraint(constraint)) {
3680 Ok(initial) => Ok(initial),
3681 Err(err) => {
3682 fam.bindings.remove(binding);
3683 self.source(sc.source)
3684 .remove_guard_value(sc.conn, &guard_value);
3685 Err(err)
3686 }
3687 }
3688 }
3689
3690 /// Unbind one partition of a family (design 310 §4.4 "unbind", made concrete by
3691 /// impl plan D5 — a synthetic drain, not a slot delete). In order:
3692 ///
3693 /// 1. fetch the partition's current view rows through the pipeline top (bounded by
3694 /// the root limiter) while it is still bound;
3695 /// 2. drop it from the binding set — the root connection now rejects the partition;
3696 /// 3. inject `Change::Remove` for each row at the spine tail's output edge, **above**
3697 /// the root limiter and the gates the rows already passed, so every `related`
3698 /// join runs its ordinary parent-left cleanup (child limiter partitions,
3699 /// pre-check membership) and nothing is refilled from a connection that would now
3700 /// reject it; the sink's caught output for these is discarded;
3701 /// 4. evict the spine EXISTS joins' per-parent child partitions for those rows (the
3702 /// synthetic removes entered above them);
3703 /// 5. evict the root `Take`'s partition slot (it kept a size-0 slot, impl plan D4).
3704 ///
3705 /// Nothing is emitted to surviving partitions. Cost O(partition rows) — the same
3706 /// order a singleton's teardown pays.
3707 pub fn unbind_family_partition(
3708 &self,
3709 fam: &FamilyPipeline,
3710 sink: NodeId,
3711 binding: &CanonKey,
3712 ) -> Result<(), RindleError> {
3713 metric_inc!(family_unbinds);
3714 if !fam.bindings.contains(binding) {
3715 return Err(RindleError::schema_violation(format!(
3716 "family binding {binding:?} is not bound"
3717 )));
3718 }
3719 let constraint = fam.constraint_for(binding);
3720 let req = FetchRequest::with_constraint(constraint.clone());
3721 // 1. The partition's view rows, before membership goes away (a fetch after step
3722 // 2 would find the connection rejecting the whole partition).
3723 let window: Vec<Row> = self.fetch(fam.top, &req).map(|n| n.row).collect();
3724 self.take_runtime_error()?;
3725 // 1b. EVERY root row of the partition, not just the window — but only when the
3726 // spine carries EXISTS joins. Their gates hydrate a per-parent child partition
3727 // for every row they *evaluate*: a pushed row the root limiter then drops, the
3728 // one row past the bound a bounded fetch pulls before it stops. Those parents
3729 // never reached the view, so the window cannot name them; the base table can.
3730 // O(partition rows in the base table), a constrained scan at the root
3731 // connection, once per unbind.
3732 let everyone: Vec<Row> = if fam.spine_joins.is_empty() {
3733 Vec::new()
3734 } else {
3735 let rows = self.fetch(fam.root_conn, &req).map(|n| n.row).collect();
3736 self.take_runtime_error()?;
3737 rows
3738 };
3739 // 2. Membership: set remove, THEN guard remove (the index stays a superset of
3740 // the predicate at every instant, design §4.1).
3741 fam.bindings.remove(binding);
3742 {
3743 let sc = self.source_conn(fam.root_conn);
3744 self.source(sc.source)
3745 .remove_guard_value(sc.conn, &binding[0].to_owned_value());
3746 }
3747 // 3. Synthetic removes above the root limiter.
3748 if let Some(edge) = self.out_edge(fam.spine_tail) {
3749 for row in &window {
3750 self.push(
3751 edge.node,
3752 Change::Remove(Node::leaf(row.clone())),
3753 edge.port,
3754 );
3755 }
3756 }
3757 let _discarded = self.take_sink_changes(sink);
3758 // 4. The spine EXISTS joins' child partitions (the `JoinParent` Remove arm's
3759 // cleanup, minus the downstream forwarding the rows never needed).
3760 for &jid in &fam.spine_joins {
3761 for row in &everyone {
3762 self.evict_join_child_partitions_for(jid, row);
3763 }
3764 }
3765 // 5. The root limiter's slot.
3766 if let Some(t) = fam.root_take {
3767 if let Operator::Take(t) = self.node(t) {
3768 t.evict_partition(self, &constraint);
3769 }
3770 }
3771 self.take_runtime_error()
3772 }
3773
3774 /// Every bound partition's rows, concatenated in binding order — the family form
3775 /// of a change-sink hydrate (`read_snapshot`).
3776 pub fn hydrate_family(
3777 &self,
3778 fam: &FamilyPipeline,
3779 sink: NodeId,
3780 ) -> Result<Vec<crate::changes::CaughtChange>, RindleError> {
3781 let mut out = Vec::new();
3782 for b in fam.bindings.snapshot() {
3783 let req = FetchRequest::with_constraint(fam.constraint_for(&b));
3784 out.extend(self.try_hydrate_change_sink_with(sink, &req)?);
3785 }
3786 Ok(out)
3787 }
3788
3789 /// The design 310 §4.4 constrained-fetch invariant on a family root: membership is
3790 /// never lowered to SQL, so an unconstrained fetch would scan the whole table and
3791 /// filter in-engine. Every fetch a family pipeline issues at its root is constrained
3792 /// by construction (the per-partition hydrate, the root `Take`'s partition-scoped
3793 /// re-fetches, a join child-push's key-constrained parent fetch, the flipped-join /
3794 /// EXISTS probes); a `debug_assert!` pins that, and strict mode parks a typed error.
3795 fn check_family_root_fetch(&self, req: &FetchRequest) {
3796 debug_assert!(
3797 req.constraint.is_some() || req.has_multi(),
3798 "family root fetched without a constraint (design 310 §4.4)"
3799 );
3800 }
3801
3802 /// The exact answer to an unconstrained family-root request: the connection's rows
3803 /// whose first parameter column is one of the bound values (an IN-batch, so the
3804 /// leaf seeks per value — the memory leaf's `fetch_multi`, a native `IN` on
3805 /// SQLite), then the full membership test. Zero bindings ⇒ no rows.
3806 fn fetch_family_root_unconstrained<'g>(
3807 &'g self,
3808 sc: &'g SourceConn,
3809 f: &'g FamilyRootConn,
3810 req: &FetchRequest,
3811 ) -> NodeStream<'g> {
3812 let multi: crate::change::MultiConstraint = f
3813 .bindings
3814 .snapshot()
3815 .iter()
3816 .map(|b| vec![(f.param_cols[0], b[0].to_owned_value())])
3817 .collect();
3818 if multi.is_empty() {
3819 return Box::new(std::iter::empty());
3820 }
3821 let batched = FetchRequest {
3822 multi_constraints: vec![multi],
3823 ..req.clone()
3824 };
3825 Box::new(
3826 self.source(sc.source)
3827 .fetch(sc.conn, &batched)
3828 .filter(move |r| f.bindings.contains_row(r, &f.param_cols))
3829 .map(Node::leaf),
3830 )
3831 }
3832
3833 /// The parent-left cleanup of `join_push`'s `JoinParent` `Remove` arm for one parent
3834 /// `row` of `jid`, without forwarding anything downstream: evict the child limiter
3835 /// partition the parent hydrated (only when the parent key is the parent's primary
3836 /// key, so the partition belongs to exactly this parent) and keep the join
3837 /// membership pre-check exact.
3838 fn evict_join_child_partitions_for(&self, jid: NodeId, row: &Row) {
3839 let j = self.join(jid);
3840 self.join_precheck_parent_remove(jid, row);
3841 if self.input_schema(j.parent).primary_key == j.parent_key {
3842 if let Some(c) = build_join_constraint(row, &j.parent_key, &j.child_key) {
3843 self.evict_child_partitions(j.child, &c);
3844 }
3845 }
3846 }
3847
3848 // --- spec 11 testkit: Catch / Snitch readback ---
3849
3850 /// `Catch.fetch` (`catch.ts:64-66`): materialize the pipeline feeding the
3851 /// catch into a comparable [`CaughtNode`](crate::testkit::CaughtNode) tree,
3852 /// eagerly draining every relationship thunk. Does not record into `pushes`.
3853 #[cfg(any(test, feature = "testkit"))]
3854 pub fn catch_fetch(
3855 &self,
3856 catch: NodeId,
3857 req: &FetchRequest,
3858 ) -> Vec<crate::testkit::CaughtNode> {
3859 let input = self.catch_op(catch).input;
3860 self.fetch(input, req)
3861 .map(|n| crate::testkit::expand_node(&n))
3862 .collect()
3863 }
3864
3865 /// The downstream change stream a [`Catch`](crate::testkit::Catch) recorded.
3866 #[cfg(any(test, feature = "testkit"))]
3867 pub fn catch_pushes(&self, catch: NodeId) -> Vec<crate::testkit::CaughtChange> {
3868 self.catch_op(catch).pushes()
3869 }
3870
3871 /// The `(change, fetch)` pairs recorded when the catch has `fetch_on_push` set.
3872 #[cfg(any(test, feature = "testkit"))]
3873 pub fn catch_pushes_with_fetch(
3874 &self,
3875 catch: NodeId,
3876 ) -> Vec<(
3877 crate::testkit::CaughtChange,
3878 Vec<crate::testkit::CaughtNode>,
3879 )> {
3880 self.catch_op(catch).pushes_with_fetch()
3881 }
3882
3883 /// The message log a [`Snitch`](crate::testkit::Snitch) recorded, in order.
3884 #[cfg(any(test, feature = "testkit"))]
3885 pub fn snitch_log(&self, snitch: NodeId) -> Vec<crate::testkit::SnitchMessage> {
3886 self.snitch_op(snitch).log()
3887 }
3888
3889 /// Clear every `Snitch`'s log (the `clearLog` of `fetch-and-push-tests.ts:81`
3890 /// — the runner calls this after hydrate so the log reflects only the pushes).
3891 #[cfg(any(test, feature = "testkit"))]
3892 pub fn clear_all_snitch_logs(&self) {
3893 for op in &self.nodes {
3894 if let Operator::Snitch(s) = op {
3895 s.clear_log();
3896 }
3897 }
3898 }
3899
3900 /// Test/proof entry: inject a change at `id` (e.g. a `FilterStart`) exactly as
3901 /// a `SourceConn`'s output edge would deliver it. Drives the filter sub-graph
3902 /// in isolation; the full source push path is covered by the source tests.
3903 pub fn push_at<'g>(&'g self, id: NodeId, change: Change<'g>, port: Port) {
3904 self.push(id, change, port);
3905 }
3906
3907 /// `(begin_filter, filter, end_filter)` call counts recorded by a
3908 /// `FilterProbe` — lets a test assert the lifecycle fired, and balanced.
3909 pub fn probe_counts(&self, id: NodeId) -> (usize, usize, usize) {
3910 let p = self.filter_probe_op(id);
3911 (p.begin_count.get(), p.filter_count.get(), p.end_count.get())
3912 }
3913
3914 /// The view's current top-level result snapshot (the `data` getter). A cheap
3915 /// `Arc` clone of the materialized tree. With the `testkit` feature,
3916 /// `testkit::view_data_to_caught` converts it to a comparable [`crate::CaughtNode`] tree.
3917 pub fn view_data(&self, view_id: NodeId) -> crate::view::ViewData {
3918 self.view(view_id).data()
3919 }
3920
3921 /// Test/inspection helper: `[(pk_id, [child_pk_id, ...]), ...]` using column 0.
3922 /// Children are flattened across all relationship slots in slot order (the
3923 /// production [`View`](crate::view::View)'s `dump_col0`).
3924 pub fn dump_view(&self, view_id: NodeId) -> Vec<(i64, Vec<i64>)> {
3925 self.view(view_id).dump_col0()
3926 }
3927
3928 /// Recursive col-0 dump (the deep counterpart of [`Graph::dump_view`](crate::graph::Graph::dump_view)): surfaces
3929 /// grandchildren so a nested relationship (`issue{comments{reactions}}`) is fully
3930 /// observable. See [`crate::view::View::dump_col0_deep`].
3931 pub fn dump_view_deep(&self, view_id: NodeId) -> Vec<crate::view::Col0Node> {
3932 self.view(view_id).dump_col0_deep()
3933 }
3934
3935 /// Test helper: each top row's FULL columns (as `i64`) + its children's full
3936 /// columns. Unlike [`Graph::dump_view`](crate::graph::Graph::dump_view) (column-0 ids only) this surfaces
3937 /// edited non-key columns, so an edit's *value* change is observable. Assumes
3938 /// all columns are `Int`.
3939 pub fn dump_view_rows(&self, view_id: NodeId) -> Vec<(Vec<i64>, Vec<Vec<i64>>)> {
3940 self.view(view_id).dump_rows()
3941 }
3942}
3943
3944// ---------------------------------------------------------------------------
3945// Join overlay splice (node-level)
3946// ---------------------------------------------------------------------------
3947
3948/// Which way [`Graph::attach_child_rel`](crate::graph::Graph::attach_child_rel)'s by-value overlay pushes the
3949/// refetched child stream. `Post` — the spike re-materialize model: make the
3950/// stream reflect the POST-change world ([`splice_join_overlay`]), used for the
3951/// `Change::Child` a child-push emits (the View re-materializes children).
3952/// `Pre` — Zero's `#yieldParentWithOverlay`/`generateWithOverlay` polarity: pull
3953/// the (already post-change) stream BACK to the PRE-change view a not-yet-
3954/// delivered parent must see during a mid-push maintenance fetch
3955/// ([`splice_join_overlay_pre`]), used by [`FlippedJoin`](crate::op::FlippedJoin)'s
3956/// in-flight suppress model (the by-value twin of the live
3957/// [`Graph::join_overlay_for`] path).
3958#[derive(Clone, Copy)]
3959pub(crate) enum OverlayPolarity {
3960 Post,
3961 Pre,
3962}
3963
3964/// Splice the by-value join overlay into a parent's (committed) child stream
3965/// (Primitive #5). The spike's view re-materializes each parent's children, so the
3966/// overlay **adds** the in-progress child (`Add` / the new row of an `Edit`) and
3967/// **removes** the gone child (`Remove` / the old row of an `Edit`) — the opposite
3968/// polarity from `join.ts`'s suppress-and-deliver model, which is co-designed with
3969/// the production `ArrayView` (see [`JoinOverlay`](crate::change::JoinOverlay)).
3970/// Named distinctly from [`crate::source_common`]'s row-level
3971/// `generate_with_overlay` (the node-vs-row collision the audit flagged).
3972///
3973/// Collect + re-sort (rather than a streaming splice) is fine: child relationship
3974/// streams are small, and the output is identical to an in-order insert.
3975fn splice_join_overlay<'g>(
3976 base: NodeStream<'g>,
3977 overlay: &JoinOverlay,
3978 parent_row: &Row,
3979 parent_key: &[ColId],
3980 child_key: &[ColId],
3981 child_pk: &[ColId],
3982 child_sort: &Sort,
3983) -> NodeStream<'g> {
3984 let mut nodes: Vec<Node<'g>> = base.collect();
3985
3986 // Add `add_row` iff it joins to this parent and isn't already present.
3987 let add_if_matches = |nodes: &mut Vec<Node<'g>>, add_row: &Row| {
3988 if is_join_match(parent_row, parent_key, add_row, child_key)
3989 && !nodes.iter().any(|n| same_pk(&n.row, add_row, child_pk))
3990 {
3991 nodes.push(Node::leaf(add_row.clone()));
3992 }
3993 };
3994
3995 match &overlay.change {
3996 SourceChange::Add(row) => add_if_matches(&mut nodes, row),
3997 SourceChange::Remove(row) => nodes.retain(|n| !same_pk(&n.row, row, child_pk)),
3998 SourceChange::Edit { row, old } => {
3999 nodes.retain(|n| !same_pk(&n.row, old, child_pk));
4000 add_if_matches(&mut nodes, row);
4001 }
4002 }
4003
4004 nodes.sort_by(|a, b| compare_rows(child_sort, &a.row, &b.row));
4005 Box::new(nodes.into_iter())
4006}
4007
4008/// Splice the LIVE in-flight child overlay into a parent's child stream with Zero's
4009/// `join-utils.generateWithOverlay` **pull-to-pre** polarity — the production model
4010/// (`change.rs` [`JoinOverlay`] doc), used by the live-field Join path
4011/// ([`Graph::process_parent_node`](crate::graph::Graph::process_parent_node)). The base child stream is already POST-change
4012/// (the cap commits its state synchronously; the source overlay is epoch-gated to
4013/// the post state), so this pulls it back to the PRE-change view a not-yet-processed
4014/// parent must see during a mid-push refetch: a **Remove** re-adds the gone child, an
4015/// **Add** suppresses the just-added child, an **Edit** re-adds `old` and suppresses
4016/// the new row. Identity is by PK (the sort always includes the full PK, so PK
4017/// equality matches Zero's `compareRows == 0`); the result is re-sorted so a
4018/// materialized relationship stays ordered (for an unordered EXISTS child only the
4019/// count matters). This is the EXACT OPPOSITE polarity of [`splice_join_overlay`]
4020/// (the spike push-to-post model, retained for [`FlippedJoin`](crate::op::FlippedJoin)).
4021fn splice_join_overlay_pre<'g>(
4022 base: NodeStream<'g>,
4023 overlay: &JoinOverlay,
4024 child_pk: &[ColId],
4025 child_sort: &Sort,
4026) -> NodeStream<'g> {
4027 let mut nodes: Vec<Node<'g>> = base.collect();
4028
4029 // Re-add the in-flight gone/old child (it is absent from the post-change base),
4030 // unless a row with its PK is somehow still present (no double-add).
4031 let readd_if_absent = |nodes: &mut Vec<Node<'g>>, row: &Row| {
4032 if !nodes.iter().any(|n| same_pk(&n.row, row, child_pk)) {
4033 nodes.push(Node::leaf(row.clone()));
4034 }
4035 };
4036 // Suppress the in-flight added/new child (present in the post-change base).
4037 let suppress = |nodes: &mut Vec<Node<'g>>, row: &Row| {
4038 if let Some(i) = nodes.iter().position(|n| same_pk(&n.row, row, child_pk)) {
4039 nodes.remove(i);
4040 }
4041 };
4042
4043 match &overlay.change {
4044 SourceChange::Add(row) => suppress(&mut nodes, row),
4045 SourceChange::Remove(row) => readd_if_absent(&mut nodes, row),
4046 SourceChange::Edit { row, old } => {
4047 suppress(&mut nodes, row);
4048 readd_if_absent(&mut nodes, old);
4049 }
4050 }
4051
4052 nodes.sort_by(|a, b| compare_rows(child_sort, &a.row, &b.row));
4053 Box::new(nodes.into_iter())
4054}
4055
4056// ---------------------------------------------------------------------------
4057// Filter sub-graph free helpers
4058// ---------------------------------------------------------------------------
4059
4060/// RAII for the Filter sub-graph fetch lifecycle. Holds a *shared* graph borrow
4061/// and the chain head; its [`Drop`] runs `end_filter(chain)`. It is moved into
4062/// the fetch stream's closure, so it fires exactly when the stream is dropped —
4063/// early break, `?`-propagation, or early return — reproducing the JS
4064/// `try/finally` (`filter-operators.ts:98-102`; Primitive #2 / `07` §6.4). A
4065/// panic unwinding through it also fires it, but only in unwinding builds; under
4066/// the shipping `panic = "abort"` client profile a panic aborts and `Drop` is
4067/// skipped. See WS02.
4068struct EndFilterGuard<'g> {
4069 g: &'g Graph,
4070 chain: NodeId,
4071}
4072
4073impl Drop for EndFilterGuard<'_> {
4074 fn drop(&mut self) {
4075 self.g.end_filter(self.chain);
4076 }
4077}
4078
4079/// RAII for a [`Join`]'s in-flight child-change fan-out (`join.ts:222-249`
4080/// `try/finally`): clears [`Join::inprogress_overlay`]/[`Join::inprogress_position`]
4081/// when [`Graph::push_child_change`](crate::graph::Graph::push_child_change) returns — normally, via `?`, or unwinding (the
4082/// latter only under `panic = "unwind"`; under the shipping `panic = "abort"` profile
4083/// a panic aborts and `Drop` is skipped — see WS02). Leaving a stale overlay set
4084/// would corrupt every later fetch's membership, so the clear is not optional.
4085struct InprogressGuard<'g> {
4086 g: &'g Graph,
4087 jid: NodeId,
4088}
4089
4090impl Drop for InprogressGuard<'_> {
4091 fn drop(&mut self) {
4092 let j = self.g.join(self.jid);
4093 *j.inprogress_overlay.borrow_mut() = None;
4094 *j.inprogress_position.borrow_mut() = None;
4095 }
4096}
4097
4098/// The join membership pre-check's **observer** (design 311 §2.4): wraps a join's
4099/// parent enumeration and records each yielded parent's key into the join's region as
4100/// it streams — the ONLY way a region is populated (there is no build fetch). Exhaustion
4101/// (`inner` yields `None`) marks the region complete; a consumer that drops the cursor
4102/// first (a root `Take` whose window filled) leaves the frontier at the last row yielded,
4103/// which is exactly that `Take`'s bound. Nothing to do on `Drop`: the frontier moves
4104/// per row, so an abandoned stream is already exact.
4105struct ObservedParents<'g> {
4106 g: &'g Graph,
4107 jid: NodeId,
4108 inner: NodeStream<'g>,
4109 done: bool,
4110}
4111
4112impl<'g> Iterator for ObservedParents<'g> {
4113 type Item = Node<'g>;
4114
4115 fn next(&mut self) -> Option<Node<'g>> {
4116 if self.done {
4117 return None;
4118 }
4119 match self.inner.next() {
4120 Some(node) => {
4121 self.g.join_precheck_observe_row(self.jid, &node.row);
4122 Some(node)
4123 }
4124 None => {
4125 self.done = true;
4126 self.g.join_precheck_observe_exhausted(self.jid);
4127 None
4128 }
4129 }
4130 }
4131}
4132
4133/// `push_accumulated_changes` (`push-accumulated.ts:87`), the minimal dedup core.
4134/// Each branch of an OR forwarded 0..1 change; collapse the lot into **exactly
4135/// one** output change, keyed by the FAN-OUT's original change type. Dedup uses
4136/// the `Identity` relationship merge (`fan-in.ts:91`): keep the FIRST change of
4137/// each type and drop the later ones (the JS `identity(existing, _) = existing`).
4138///
4139/// The subtle case is `Edit`: a filter that *included* the old row but *excludes*
4140/// the new turns the edit into a `Remove(old)`; a sibling filter that did the
4141/// reverse turns it into an `Add(new)`. When both survive, they **reconstruct**
4142/// the `Edit{node: new, old}` (`push-accumulated.ts:202-213`) — otherwise the
4143/// view would see a bare Add or Remove and break its "remove-only-present /
4144/// add-only-absent" invariant.
4145fn collapse_accumulated<'g>(acc: Vec<Change<'g>>, fan_out_type: ChangeType) -> Vec<Change<'g>> {
4146 let mut add: Option<Node<'g>> = None;
4147 let mut remove: Option<Node<'g>> = None;
4148 let mut edit: Option<Change<'g>> = None;
4149 let mut child: Option<Change<'g>> = None;
4150 for c in acc {
4151 match c {
4152 Change::Add(n) => {
4153 add.get_or_insert(n);
4154 }
4155 Change::Remove(n) => {
4156 remove.get_or_insert(n);
4157 }
4158 Change::Edit { .. } => {
4159 edit.get_or_insert(c);
4160 }
4161 // A branch preserved the Child (a leaf Filter passes it through). Keep
4162 // the FIRST (the `Identity` merge — `fan-in.ts` passes `identity`).
4163 Change::Child { .. } => {
4164 child.get_or_insert(c);
4165 }
4166 }
4167 }
4168 match fan_out_type {
4169 // An Add entering the fan can only stay an Add or be dropped per branch —
4170 // a stateless Filter never manufactures a Remove/Edit from an Add.
4171 ChangeType::Add => {
4172 debug_assert!(
4173 remove.is_none() && edit.is_none() && child.is_none(),
4174 "Add fan-out collapsed a non-Add branch change"
4175 );
4176 add.map(Change::Add).into_iter().collect()
4177 }
4178 ChangeType::Remove => {
4179 debug_assert!(
4180 add.is_none() && edit.is_none() && child.is_none(),
4181 "Remove fan-out collapsed a non-Remove branch change"
4182 );
4183 remove.map(Change::Remove).into_iter().collect()
4184 }
4185 ChangeType::Edit => {
4186 debug_assert!(
4187 child.is_none(),
4188 "Edit fan-out produced a Child branch change"
4189 );
4190 if let Some(e) = edit {
4191 // An Edit survived a branch unsplit → it supersedes (the minimal
4192 // chassis has no relationships to merge in).
4193 vec![e]
4194 } else {
4195 match (add, remove) {
4196 (Some(node), Some(old)) => vec![Change::Edit { node, old }], // reconstruct
4197 (Some(node), None) => vec![Change::Add(node)],
4198 (None, Some(old)) => vec![Change::Remove(old)],
4199 (None, None) => Vec::new(),
4200 }
4201 }
4202 }
4203 // CHILD precedence (`push-accumulated.ts` CHILD case): a Child entering the
4204 // fan reaches each branch, which either **preserves** it (a leaf Filter) or
4205 // **converts** it to an Add/Remove (an `Exists` gate, when the relationship
4206 // change flips the parent's membership 0↔1). If any branch kept the Child,
4207 // it takes precedence over all else. Otherwise exactly one of Add/Remove
4208 // survives (the relationship is unique to one exists check, so the converters
4209 // can't disagree). `edit` is impossible here.
4210 ChangeType::Child => {
4211 debug_assert!(
4212 edit.is_none(),
4213 "Child fan-out produced an Edit branch change"
4214 );
4215 if let Some(c) = child {
4216 vec![c]
4217 } else {
4218 debug_assert!(
4219 !(add.is_some() && remove.is_some()),
4220 "Child fan-out: expected at most one of add/remove"
4221 );
4222 match (add, remove) {
4223 (Some(node), _) => vec![Change::Add(node)],
4224 (None, Some(old)) => vec![Change::Remove(old)],
4225 (None, None) => Vec::new(),
4226 }
4227 }
4228 }
4229 }
4230}
4231
4232#[cfg(test)]
4233mod tests {
4234 use super::*;
4235 use crate::change::{Basis, Start};
4236 use crate::op::Skip;
4237 use crate::storage::StorageValue;
4238 use crate::value::{owned_row, OwnedValue};
4239
4240 /// The operator **fan-out seam** (`op/mod.rs`): an operator whose struct +
4241 /// logic live in their own file (`op/skip.rs`) integrates into the arena and is
4242 /// reached by the graph's dispatch. We assert the *wiring* — the variant, the
4243 /// `add_skip` builder, `set_output`, and that `input_schema` resolves THROUGH
4244 /// the new variant to the source — without invoking the (stub) `fetch`/`push`.
4245 #[test]
4246 fn skip_operator_wires_into_arena() {
4247 let mut g = Graph::new();
4248 let src = g.add_source(
4249 SourceSchema::new(vec!["id"], vec![0], vec![(0, true)]),
4250 Vec::new(),
4251 );
4252 let conn = g.connect(src, Some(vec![(0, true)]), None, Vec::new());
4253 let skip = g.add_skip(Skip::new(
4254 conn,
4255 Start {
4256 row: owned_row(vec![OwnedValue::Int(0)]),
4257 basis: Basis::After,
4258 },
4259 ));
4260 let coll = g.add_collector(skip);
4261 g.set_output(skip, coll);
4262
4263 // Dispatch reaches the out-of-file operator: schema resolves through it.
4264 assert_eq!(self_columns(&g, skip), vec!["id"]);
4265 }
4266
4267 fn self_columns(g: &Graph, id: NodeId) -> Vec<String> {
4268 g.input_schema(id)
4269 .columns
4270 .iter()
4271 .map(|c| c.to_string())
4272 .collect()
4273 }
4274
4275 /// [`Graph::remove_source`](crate::graph::Graph::remove_source) (the inverse of `add_source`, for a synthetic aggregate table
4276 /// whose last query is gone): it must REFUSE while a reader is wired, SUCCEED once the
4277 /// pipeline is torn down, and recycle the freed slot (generation bumped). The
4278 /// optimistic/normalized backend leans on the refusal to keep `unregisterTable` safe.
4279 #[test]
4280 fn remove_source_refuses_a_live_reader_then_frees_the_slot() {
4281 let mut g = Graph::new();
4282 let src = g.add_source(
4283 SourceSchema::new(vec!["id"], vec![0], vec![(0, true)]),
4284 Vec::new(),
4285 );
4286
4287 // A live reader: connect + wire its downstream. remove_source must refuse.
4288 g.begin_recording();
4289 let conn = g.connect(src, Some(vec![(0, true)]), None, Vec::new());
4290 let coll = g.add_collector(conn);
4291 g.set_sink_edge(conn, coll);
4292 let manifest = g.take_recording();
4293 assert!(
4294 g.remove_source(src).is_err(),
4295 "a source with a live reader must not be removed"
4296 );
4297
4298 // Tear the reader down → 0 live connections → remove_source succeeds.
4299 g.destroy_pipeline(&manifest);
4300 assert!(g.remove_source(src).is_ok());
4301
4302 // The freed source slot is recycled with a bumped generation (so a stale id is distinct).
4303 let src2 = g.add_source(
4304 SourceSchema::new(vec!["id"], vec![0], vec![(0, true)]),
4305 Vec::new(),
4306 );
4307 assert_eq!(src2.ix(), src.ix(), "freed source slot is reused");
4308 assert_ne!(src2.gen, src.gen, "generation bumped on free");
4309
4310 // A non-source node is rejected (not silently freed).
4311 let conn2 = g.connect(src2, Some(vec![(0, true)]), None, Vec::new());
4312 assert!(g.remove_source(conn2).is_err());
4313 }
4314
4315 // --- collapse_accumulated: the CHILD-precedence arm (spec-06 push-accumulated) ---
4316 //
4317 // Directly exercises the collapse for a `Child` fan-out — including the
4318 // `Exists`-branch conversion (Child → Add/Remove on a membership flip), which a
4319 // hand-wired leaf-only fan can't produce.
4320
4321 fn leaf_node(id: i64) -> Node<'static> {
4322 Node::leaf(owned_row(vec![OwnedValue::Int(id)]))
4323 }
4324 fn child_chg(parent: i64) -> Change<'static> {
4325 Change::Child {
4326 node: leaf_node(parent),
4327 rel: crate::value::RelId(0),
4328 child: Box::new(Change::Add(leaf_node(parent * 10))),
4329 }
4330 }
4331 fn col0(c: &Change) -> i64 {
4332 let row = match c {
4333 Change::Add(n) | Change::Remove(n) => &n.row,
4334 Change::Edit { node, .. } | Change::Child { node, .. } => &node.row,
4335 };
4336 match row.col(0) {
4337 crate::value::Value::Int(i) => i,
4338 o => panic!("{o:?}"),
4339 }
4340 }
4341
4342 #[test]
4343 fn collapse_child_precedence_keeps_the_child() {
4344 // One branch preserved the Child; another (an Exists gate) converted it to an
4345 // Add. The preserved Child takes precedence — the Add is dropped.
4346 let out = collapse_accumulated(
4347 vec![child_chg(1), Change::Add(leaf_node(1))],
4348 ChangeType::Child,
4349 );
4350 assert_eq!(out.len(), 1);
4351 assert!(matches!(out[0], Change::Child { .. }));
4352 assert_eq!(col0(&out[0]), 1);
4353 }
4354
4355 #[test]
4356 fn collapse_two_preserved_children_dedup_to_one() {
4357 // Two branches preserved the same Child → keep-first dedups to one.
4358 let out = collapse_accumulated(vec![child_chg(1), child_chg(1)], ChangeType::Child);
4359 assert_eq!(out.len(), 1);
4360 assert!(matches!(out[0], Change::Child { .. }));
4361 }
4362
4363 #[test]
4364 fn collapse_child_to_add_on_membership_flip() {
4365 // No branch preserved the Child; an Exists gate flipped it to an Add (the
4366 // parent gained its first matching child).
4367 let out = collapse_accumulated(vec![Change::Add(leaf_node(7))], ChangeType::Child);
4368 assert_eq!(out.len(), 1);
4369 assert!(matches!(out[0], Change::Add(_)));
4370 assert_eq!(col0(&out[0]), 7);
4371 }
4372
4373 #[test]
4374 fn collapse_child_to_remove_on_membership_flip() {
4375 let out = collapse_accumulated(vec![Change::Remove(leaf_node(7))], ChangeType::Child);
4376 assert_eq!(out.len(), 1);
4377 assert!(matches!(out[0], Change::Remove(_)));
4378 assert_eq!(col0(&out[0]), 7);
4379 }
4380
4381 #[test]
4382 fn collapse_child_dropped_when_no_branch_passes() {
4383 assert!(collapse_accumulated(Vec::new(), ChangeType::Child).is_empty());
4384 }
4385
4386 /// The storage arena (foundations §6.4): each `alloc_storage` hands back a
4387 /// fresh, disjoint keyspace, and an operator reads/writes it through a *shared*
4388 /// `&Graph` borrow (the same borrow shape as a reentrant push).
4389 #[test]
4390 fn storage_arena_allocates_disjoint_namespaces() {
4391 let mut g = Graph::new();
4392 let a = g.alloc_storage();
4393 let b = g.alloc_storage();
4394 assert_ne!(a, b, "each slot is a distinct id");
4395
4396 // Same key in two slots → two independent values (disjoint namespaces).
4397 g.storage(a).set(
4398 "k",
4399 StorageValue::Take {
4400 size: 1,
4401 bound: None,
4402 },
4403 );
4404 g.storage(b).set(
4405 "k",
4406 StorageValue::Take {
4407 size: 2,
4408 bound: None,
4409 },
4410 );
4411 let read_size = |id| match g.storage(id).get("k").unwrap() {
4412 StorageValue::Take { size, .. } => size,
4413 other => panic!("unexpected {other:?}"),
4414 };
4415 assert_eq!(read_size(a), 1);
4416 assert_eq!(read_size(b), 2);
4417 assert!(g.storage(a).get("absent").is_none());
4418 }
4419
4420 fn id_schema() -> SourceSchema {
4421 SourceSchema::new(vec!["id"], vec![0], vec![(0, true)])
4422 }
4423
4424 /// Build a minimal recorded "pipeline" over `src`: a connection, a scratch-storage
4425 /// slot, and a collector sink, wired source→sink. Returns the manifest + the ids.
4426 fn build_recorded(g: &mut Graph, src: NodeId) -> (PipelineManifest, NodeId, StorageId, NodeId) {
4427 g.begin_recording();
4428 let conn = g.connect(src, Some(vec![(0, true)]), None, Vec::new());
4429 let store = g.alloc_storage();
4430 let sink = g.add_collector(conn);
4431 g.set_sink_edge(conn, sink);
4432 (g.take_recording(), conn, store, sink)
4433 }
4434
4435 /// `destroy_pipeline` reclaims a torn-down pipeline's storage (clears it) and node
4436 /// slots (tombstones them) WITHOUT growing the arena, and a later build REUSES those
4437 /// freed slots — same index, bumped generation, empty store.
4438 #[test]
4439 fn destroy_pipeline_reclaims_and_reuses_slots() {
4440 let mut g = Graph::new();
4441 let src = g.add_source(id_schema(), Vec::new()); // shared, NOT recorded
4442
4443 let (m, _conn, store, _sink) = build_recorded(&mut g, src);
4444 // Put state in the operator-storage slot so we can prove it is cleared.
4445 g.storage(store).set(
4446 "k",
4447 StorageValue::Take {
4448 size: 9,
4449 bound: None,
4450 },
4451 );
4452 assert!(g.storage(store).get("k").is_some());
4453
4454 let nodes = g.node_count();
4455 let stores = g.storage_count();
4456
4457 g.destroy_pipeline(&m);
4458 // Tombstoned, not removed: the arena does not shrink.
4459 assert_eq!(g.node_count(), nodes);
4460 assert_eq!(g.storage_count(), stores);
4461
4462 // A fresh build reuses the freed slots — no arena growth.
4463 let (_m2, conn2, store2, sink2) = build_recorded(&mut g, src);
4464 assert_eq!(g.node_count(), nodes, "node slots were recycled, not grown");
4465 assert_eq!(g.storage_count(), stores, "storage slot was recycled");
4466
4467 // The reused storage slot: same index, bumped generation, and EMPTY (cleared).
4468 assert_eq!(store2.idx, store.idx);
4469 assert!(store2.gen > store.gen);
4470 assert!(
4471 g.storage(store2).get("k").is_none(),
4472 "a recycled storage slot must start empty"
4473 );
4474
4475 // The reused node slots are the freed indices with bumped generations.
4476 let freed: std::collections::HashSet<u32> = m.nodes().iter().map(|n| n.idx).collect();
4477 assert!(freed.contains(&conn2.idx) && conn2.gen > 0);
4478 assert!(freed.contains(&sink2.idx) && sink2.gen > 0);
4479
4480 // The SHARED source's connection slot was recycled too: the destroy+rebuild did
4481 // not grow its connection table (the conn-slot leak fix). One pipeline = one
4482 // connection, so the table holds exactly one slot before and after.
4483 let conn_slots = match &g.nodes[src.ix()] {
4484 Operator::Source(SourceLeaf::Memory(ms)) => ms.conn_count(),
4485 _ => unreachable!(),
4486 };
4487 assert_eq!(
4488 conn_slots, 1,
4489 "the source connection slot was recycled, not leaked"
4490 );
4491 }
4492
4493 /// After teardown, a stale `NodeId` (the pre-free generation) is a loud fail-fast on
4494 /// any arena access — never a silent alias of whatever recycles the slot.
4495 #[test]
4496 #[should_panic(expected = "stale NodeId")]
4497 fn stale_node_id_after_teardown_fails_fast() {
4498 let mut g = Graph::new();
4499 let src = g.add_source(id_schema(), Vec::new());
4500 let (m, _conn, _store, sink) = build_recorded(&mut g, src);
4501 g.destroy_pipeline(&m);
4502 // `sink` carries the old generation; the slot's generation was bumped on free.
4503 let _ = g.input_schema(sink);
4504 }
4505
4506 /// A stale `StorageId` likewise fails fast (the storage arena is generational too).
4507 #[test]
4508 #[should_panic(expected = "stale StorageId")]
4509 fn stale_storage_id_after_teardown_fails_fast() {
4510 let mut g = Graph::new();
4511 let src = g.add_source(id_schema(), Vec::new());
4512 let (m, _conn, store, _sink) = build_recorded(&mut g, src);
4513 g.destroy_pipeline(&m);
4514 let _ = g.storage(store).get("k");
4515 }
4516}