rindle/op/flipped_join.rs
1//! `FlippedJoin` (`ivm/flipped-join.ts`, spec `06` §3.2) — the **flipped EXISTS**
2//! inner join: child-driven, batched, outputs **parent** rows that have ≥1 related
3//! child.
4//!
5//! Where [`Join`](crate::graph::Join) is parent-driven (fetch parents, attach a
6//! lazy child relationship to each), `FlippedJoin` is child-driven: it fetches
7//! **all children first**, groups them by their join key, batch-fetches the
8//! matching parents in one `multi_constraints` request (the source does the k-way
9//! IN-fanout merge), and yields each returned parent **with its related children
10//! attached** — but only parents some child pointed at. That inner-join gate (drop
11//! a parent with no related child) is exactly the `where EXISTS(rel)` semantics, so
12//! the builder lowers a *flipped* EXISTS condition to a bare `FlippedJoin` (no
13//! separate [`Exists`](crate::op::Exists) gate).
14//!
15//! ## What it reuses from `Join` (the re-materialize overlay model)
16//!
17//! The relationship `FlippedJoin` attaches to each parent is built **identically**
18//! to `Join`'s — [`Graph::attach_child_rel`](crate::graph::Graph::attach_child_rel)
19//! with the same by-value [`JoinOverlay`] + `splice_join_overlay` (Primitive #5).
20//! So `FlippedJoin` inherits `Join`'s proven, View-`re-materialize` overlay
21//! behavior verbatim. On top of it sits the spec's `{change, position}` in-flight
22//! suppress model (`#inprogressChildChange`/`#inprogressChildChangePosition`,
23//! `flipped-join.ts:103`) — the flipped twin of `Graph::join_overlay_for`,
24//! see [`FlippedJoin::inprogress_overlay`] and `inflight_fetch_action`. Without
25//! it, a child change fanning out to SEVERAL parents (a non-unique parent join
26//! key) lets every mid-fan-out maintenance fetch see the FINAL post-change world,
27//! so a downstream `Take`'s bound jumps instead of cascading and it emits phantom
28//! removes (`follow-ups/take-flip-bound-crossing-edit.md`, defect #2).
29//!
30//! ## What is `FlippedJoin`-specific
31//!
32//! - **fetch** (child-first): translate the request constraint parent→child, fetch
33//! all children, group by [`canonical_key`], one batched parent fetch, drop a
34//! returned parent whose key no child produced (the inner-join gate + the
35//! pass-through filter for chained flipped joins).
36//! - **push child port**: the changed child is re-projected onto its parents; for
37//! each, decide whether the parent still has *another* related child (`exists`).
38//! If yes → a `Child` change (the parent stays, its relationship changed); if no →
39//! the changed child is the parent's **sole** child, so its existence flips:
40//! `Add(parent)` on a child add, `Remove(parent)` on a child remove.
41//! - **push parent port**: forward the change with the relationship flipped on,
42//! **unless** the parent has no related child (inner-join drop).
43//!
44//! ## Fully implemented (formerly deferred)
45//!
46//! - **Chunked fetch** (`computed_multi.len() > chunk_size`): slices the IN-batch and
47//! node-level-merges the chunk streams via
48//! [`merge_node_streams`](crate::op::merge_node_streams) (default `chunk_size` 256;
49//! the `chunk_size` field lets a test drive it).
50//! - **Nested `Change::Child` on the child port** (a grandchild change under the
51//! flipped child): carried up as a full `Change` and rebuilt per parent, exactly as
52//! [`Join`](crate::graph)'s child port — see `push_child`/`push_child_change`.
53//! - **The `{change, position}` in-flight suppress model** (see above): published by
54//! `push_child_change` for the duration of the fan-out (RAII-cleared), consulted
55//! by the fetch path. (A nested `Change::Child` publishes no overlay — it never
56//! changes membership; same reduced scope as the non-flipped twin, see
57//! [`JoinOverlay::for_child_change`].)
58
59use std::cell::{Cell, RefCell};
60use std::cmp::Ordering;
61use std::collections::HashSet;
62
63use crate::change::{
64 build_join_constraint, constraints_are_compatible, materialize_change, rebuild_change,
65 rebuild_node, Change, Constraint, FetchRequest, JoinOverlay, MultiConstraint, Node, NodeStream,
66 OutEdge, OwnedChange, OwnedNode, Port, Relationship, SourceChange,
67};
68use crate::graph::{Graph, NodeId, OverlayPolarity};
69use crate::value::{compare_rows, ColId, OwnedRow as Row, RelId, Sort};
70
71use super::join_util::{
72 canonical_key, canonical_key_of_constraint, is_join_match, row_equals_for_compound_key,
73 CanonKey,
74};
75
76/// `MULTI_CONSTRAINT_CHUNK_SIZE` (`flipped-join.ts:55`): the IN-batch is sliced
77/// into windows of this many constraints, each its own `parent.fetch`, merged.
78pub const DEFAULT_CHUNK_SIZE: usize = 256;
79
80#[cfg(any(test, feature = "testkit"))]
81thread_local! {
82 /// Test-only override of [`DEFAULT_CHUNK_SIZE`], read at [`FlippedJoin::new`]
83 /// time (the `setMultiConstraintChunkSizeForTest` seam, `flipped-join.ts:57`).
84 static CHUNK_SIZE_OVERRIDE: std::cell::Cell<Option<usize>> =
85 const { std::cell::Cell::new(None) };
86}
87
88/// Override `DEFAULT_CHUNK_SIZE` for every `FlippedJoin` built after this call on
89/// the current thread (`None` restores the default) — the
90/// `setMultiConstraintChunkSizeForTest` seam (`flipped-join.ts:57`). Lets a test or
91/// fuzz lane force the chunked k-way merge path (`merge_node_streams`) on small
92/// fixtures, whose distinct child keys never approach the production 256.
93/// Build-time only: an already-built graph keeps the `chunk_size` it was
94/// constructed with.
95#[cfg(any(test, feature = "testkit"))]
96pub fn set_multi_constraint_chunk_size_for_test(n: Option<usize>) {
97 CHUNK_SIZE_OVERRIDE.set(n);
98}
99
100/// [`DEFAULT_CHUNK_SIZE`], unless a test override is active on this thread.
101fn default_chunk_size() -> usize {
102 #[cfg(any(test, feature = "testkit"))]
103 {
104 CHUNK_SIZE_OVERRIDE.get().unwrap_or(DEFAULT_CHUNK_SIZE)
105 }
106 #[cfg(not(any(test, feature = "testkit")))]
107 DEFAULT_CHUNK_SIZE
108}
109
110/// How an in-flight child fan-out reshapes one parent's place in a mid-push
111/// maintenance fetch (`FlippedJoin::inflight_fetch_action`).
112enum InflightAction {
113 /// No in-flight change touches this parent, or the change was already
114 /// delivered for it and the post-change fetch is right as-is.
115 Keep,
116 /// The parent must not appear in this fetch at all.
117 Drop,
118 /// Keep the parent, with its relationship pulled back to the PRE-change view.
119 KeepPre(JoinOverlay),
120}
121
122/// `FlippedJoin`: child-driven inner join (spec `06` §3.2). Two input ports
123/// ([`Port::JoinParent`]/[`Port::JoinChild`]) like `Join`;
124/// the **child** drives the fetch but the **parent** is the output row, so
125/// `rel_slot` resolves against the parent schema and `input_schema` is the parent's.
126pub struct FlippedJoin {
127 /// The output side (parent rows). The attached relationship hangs here.
128 pub parent: NodeId,
129 /// The driving side: children are fetched first and grouped by join key.
130 pub child: NodeId,
131 pub parent_key: Vec<ColId>,
132 pub child_key: Vec<ColId>,
133 /// Relationship slot (resolved from the alias against the **parent** schema at
134 /// build time, like `Join`).
135 pub rel_slot: RelId,
136 /// IN-batch chunking threshold (test/seam; default `DEFAULT_CHUNK_SIZE`). A
137 /// batch above this needs the node-level merge (deferred — see module docs).
138 pub chunk_size: usize,
139 /// The single downstream edge as a port-carrying [`OutEdge`] (like
140 /// `Join`): `Port::Single` to a terminal sink, or a join
141 /// port when this flipped join feeds another join.
142 pub output: Cell<Option<OutEdge>>,
143 /// The in-flight child change being fanned out by `push_child_change` — the
144 /// flipped twin of `Join::inprogress_overlay` (Zero's `#inprogressChildChange`,
145 /// `flipped-join.ts:103`). Consulted by the reentrant maintenance fetch a
146 /// downstream `Take` triggers mid-push: [`FlippedJoin::fetch`] splices an
147 /// in-flight REMOVEd child back into the child enumeration (so parents whose
148 /// SOLE child it was are enumerated at all), and `fetch_batched` gates each
149 /// parent's membership + relationship content on the fan-out position via
150 /// `inflight_fetch_action`. `None` outside a child-push.
151 pub(crate) inprogress_overlay: RefCell<Option<JoinOverlay>>,
152 /// Zero's `#inprogressChildChangePosition` (`flipped-join.ts:104`): the row of
153 /// the parent currently being delivered by the fan-out. Parents at-or-before it
154 /// (in the parent's effective per-query order — the fan-out order) have had the
155 /// change delivered and must see the POST-change world; parents strictly after
156 /// must still see PRE-change. `None` until the fan-out reaches its first
157 /// parent.
158 pub(crate) inprogress_position: RefCell<Option<Row>>,
159}
160
161impl FlippedJoin {
162 pub fn new(
163 parent: NodeId,
164 child: NodeId,
165 parent_key: Vec<ColId>,
166 child_key: Vec<ColId>,
167 rel_slot: RelId,
168 ) -> FlippedJoin {
169 FlippedJoin {
170 parent,
171 child,
172 parent_key,
173 child_key,
174 rel_slot,
175 chunk_size: default_chunk_size(),
176 output: Cell::new(None),
177 inprogress_overlay: RefCell::new(None),
178 inprogress_position: RefCell::new(None),
179 }
180 }
181
182 /// Override the IN-batch chunk size (the `setMultiConstraintChunkSizeForTest`
183 /// seam, `flipped-join.ts:57`). Used by the chunking-equivalence test once the
184 /// chunked fetch path lands.
185 pub fn with_chunk_size(mut self, chunk_size: usize) -> FlippedJoin {
186 self.chunk_size = chunk_size;
187 self
188 }
189
190 // ---------------------------------------------------------------------
191 // FETCH (child-driven, batched)
192 // ---------------------------------------------------------------------
193
194 /// Lazy pull (`flipped-join.ts:161`): translate the request constraint onto the
195 /// child key, fetch **all** children, then `fetch_batched`.
196 pub fn fetch<'g>(&'g self, g: &'g Graph, req: &FetchRequest) -> NodeStream<'g> {
197 let child_constraint = self.translate_constraint(req.constraint.as_ref());
198 let child_req = FetchRequest {
199 constraint: child_constraint,
200 ..Default::default()
201 };
202 let mut children: Vec<Node<'g>> = g.fetch(self.child, &child_req).collect();
203 // `flipped-join.ts:187-202`: during an in-flight child REMOVE the child
204 // source already shows the row gone, so a parent whose SOLE child it was
205 // would not be enumerated at all — and would silently vanish from a
206 // mid-push maintenance fetch even though the fan-out has not delivered its
207 // remove yet. Splice the removed child back into the enumeration; the
208 // per-parent disposition in `fetch_batched` re-hides it for parents the
209 // fan-out has already reached. (Append instead of Zero's sorted insert:
210 // `children` only seeds the IN-batch keys here — the relationship content
211 // is a re-fetch thunk, not this list.)
212 if let Some(ov) = self.inprogress_overlay.borrow().as_ref() {
213 if let SourceChange::Remove(row) = &ov.change {
214 children.push(Node::leaf(row.clone()));
215 }
216 }
217 self.fetch_batched(g, req, children)
218 }
219
220 /// `#fetchBatched` (`flipped-join.ts:230`): build the deduped IN-batch of
221 /// child-derived parent constraints + the set of parent keys that some child
222 /// produced, then one batched `parent.fetch`. Each returned parent whose key a
223 /// child produced is yielded with the join relationship attached; a parent whose
224 /// key no child produced is dropped (the inner-join gate, and the pass-through
225 /// filter for a chained flipped join's `multi_constraints`).
226 fn fetch_batched<'g>(
227 &'g self,
228 g: &'g Graph,
229 req: &FetchRequest,
230 children: Vec<Node<'g>>,
231 ) -> NodeStream<'g> {
232 let mut seen: HashSet<CanonKey> = HashSet::with_capacity(children.len());
233 let mut computed_multi: MultiConstraint = Vec::new();
234 for child in &children {
235 let c = match build_join_constraint(&child.row, &self.child_key, &self.parent_key) {
236 Some(c) => c,
237 None => continue, // null child key cannot join
238 };
239 // Drop a child-derived constraint that contradicts the incoming request
240 // constraint — it could never match, so it must not enter the batch.
241 if let Some(rc) = req.constraint.as_ref() {
242 if !constraints_are_compatible(&c, rc) {
243 continue;
244 }
245 }
246 let k = canonical_key_of_constraint(&c);
247 if seen.insert(k) {
248 // First sight of this key → one IN-batch entry (dedup).
249 computed_multi.push(c);
250 }
251 }
252 if computed_multi.is_empty() {
253 return Box::new(std::iter::empty());
254 }
255
256 // Build the parent stream: one batched fetch when the IN-batch fits a single
257 // chunk, else one fetch per `chunk_size` window, k-way-merged in parent order
258 // (`#fetchChunked`, `flipped-join.ts:311`). The windows are disjoint key-sets
259 // so the merge does NOT dedup. The source turns each request's
260 // `multi_constraints` into the per-entry IN-fanout + merge.
261 let parent_stream: NodeStream<'g> = if computed_multi.len() <= self.chunk_size {
262 g.fetch(self.parent, &self.parent_batch_req(req, computed_multi))
263 } else {
264 // Each chunk fetch streams in the parent's **resolved per-query order**
265 // (`input_sort` — e.g. `order by Milliseconds`), NOT the base table's PK
266 // sort (`input_schema().sort`). The k-way merge must compare on that same
267 // resolved order or it interleaves the chunks wrongly and a downstream
268 // `Take` keeps the wrong top-N. (Only bites above `chunk_size` distinct
269 // child keys, so the mini fixtures never hit it — the chinook scale sweep
270 // did.) See `input_sort` vs `input_schema` in `graph.rs`.
271 let parent_sort = g.input_sort(self.parent);
272 let chunks: Vec<NodeStream<'g>> = computed_multi
273 .chunks(self.chunk_size)
274 .map(|window| g.fetch(self.parent, &self.parent_batch_req(req, window.to_vec())))
275 .collect();
276 crate::op::merge_node_streams(chunks, parent_sort, req.reverse, None)
277 };
278
279 // Capture by value for the `'g` filter_map closure (`self` is `&'g` too —
280 // the in-flight disposition below re-reads the live fields through it).
281 let parent_key = self.parent_key.clone();
282 let child_key = self.child_key.clone();
283 let child_id = self.child;
284 let rel_slot = self.rel_slot;
285 // The fan-out position gate compares in the parent's effective per-query
286 // order; resolving it walks (and allocates) the operator chain, so it is
287 // cached once per fetch here, not recomputed per yielded parent.
288 let mut parent_sort: Option<Sort> = None;
289 Box::new(parent_stream.filter_map(move |pnode| {
290 let k = canonical_key(&pnode.row, &parent_key);
291 // Miss ⇒ no child produced this parent's key ⇒ drop it (inner-join gate
292 // / chained-flip pass-through filter, `flipped-join.ts:295`).
293 if !seen.contains(&k) {
294 return None;
295 }
296 // While a child change is being fanned out, membership and relationship
297 // content are position-dependent (Zero `#yieldParentWithOverlay`,
298 // `flipped-join.ts:332`) — the flipped twin of `Graph::join_overlay_for`.
299 let overlay = match self.inflight_fetch_action(g, &pnode.row, &mut parent_sort) {
300 InflightAction::Keep => None,
301 InflightAction::Drop => return None,
302 InflightAction::KeepPre(ov) => Some((ov, OverlayPolarity::Pre)),
303 };
304 Some(g.attach_child_rel(child_id, &parent_key, &child_key, rel_slot, pnode, overlay))
305 }))
306 }
307
308 /// Decide, at yield time, how the in-flight child change reshapes `parent_row`'s
309 /// place in a mid-push maintenance fetch — the flipped twin of
310 /// `Graph::join_overlay_for`, porting Zero's `#yieldParentWithOverlay`
311 /// (`flipped-join.ts:332-383`) to the re-fetch relationship model.
312 ///
313 /// A parent at-or-before the fan-out position (in the parent's effective
314 /// per-query order — the order `push_child_change` delivers) has had the change
315 /// delivered: the post-change world is correct, but an in-flight REMOVE needs
316 /// its membership re-checked (the removed child was spliced back into the
317 /// enumeration by `fetch`, so a sole-child parent would otherwise reappear). A
318 /// parent strictly after the position must still see the PRE-change world:
319 /// membership (an in-flight ADD that is its only child does not exist for it
320 /// yet) and relationship content (the `Pre`-polarity overlay splice).
321 fn inflight_fetch_action(
322 &self,
323 g: &Graph,
324 parent_row: &Row,
325 parent_sort: &mut Option<Sort>,
326 ) -> InflightAction {
327 // Gate on a BORROWED overlay: the common outcomes (no in-flight change, an
328 // unrelated parent, already-delivered) must not pay the two-row overlay
329 // clone — only the `KeepPre` arms clone. Holding the `Ref` across the
330 // fetches below is fine: fetch paths only ever `borrow()` these fields;
331 // the sole `borrow_mut` writers are in `push_child_change`, outside any
332 // fetch.
333 let ov_ref = self.inprogress_overlay.borrow();
334 let Some(ov) = ov_ref.as_ref() else {
335 return InflightAction::Keep;
336 };
337 if !is_join_match(
338 parent_row,
339 &self.parent_key,
340 ov.change.row(),
341 &self.child_key,
342 ) {
343 return InflightAction::Keep;
344 }
345 let delivered = match self.inprogress_position.borrow().as_ref() {
346 // Same comparator note as `Graph::join_overlay_for`: the fan-out
347 // delivers parents in the parent's EFFECTIVE per-query order, so the
348 // "already delivered?" gate must compare in that order (resolved once
349 // per fetch — the caller-held cache).
350 Some(pos) => {
351 let sort = parent_sort.get_or_insert_with(|| g.input_sort(self.parent));
352 compare_rows(sort, parent_row, pos) != Ordering::Greater
353 }
354 // The fan-out has not reached its first parent — nothing delivered.
355 None => false,
356 };
357 match (&ov.change, delivered) {
358 // Delivered REMOVE: post-change is right, but `fetch` spliced the
359 // removed child back into the enumeration — re-gate membership on the
360 // post-change truth so a sole-child parent stays gone.
361 (SourceChange::Remove(_), true) => {
362 if self.has_related_child(g, parent_row) {
363 InflightAction::Keep
364 } else {
365 InflightAction::Drop
366 }
367 }
368 // Pending REMOVE: pre-change world — the child is still related (the
369 // splice keeps it in the enumeration; the Pre overlay re-adds it to the
370 // relationship refetch).
371 (SourceChange::Remove(_), false) => InflightAction::KeepPre(ov.clone()),
372 // Pending ADD: pre-change world — the added child does not exist yet
373 // for this parent; if it is the parent's ONLY child, the parent was not
374 // in the pre-change output at all.
375 (SourceChange::Add(row), false) => {
376 let child_pk = g.input_schema(self.child).primary_key.clone();
377 if self.parent_has_other_child(g, parent_row, row, &child_pk) {
378 InflightAction::KeepPre(ov.clone())
379 } else {
380 InflightAction::Drop
381 }
382 }
383 // Delivered ADD: the post-change fetch (child present) is correct.
384 (SourceChange::Add(_), true) => InflightAction::Keep,
385 // EDIT never changes membership (the join key is immutable, asserted on
386 // entry); a pending parent still sees the OLD child content.
387 (SourceChange::Edit { .. }, false) => InflightAction::KeepPre(ov.clone()),
388 (SourceChange::Edit { .. }, true) => InflightAction::Keep,
389 }
390 }
391
392 /// Build the parent fetch request for one IN-batch (the whole `computed_multi`
393 /// or a single chunk window): the incoming constraint ANDed with
394 /// `[...incoming_multi, batch]` (a chained flipped join above prepends its own
395 /// `multi_constraints`), carrying the request's `start`/`reverse`.
396 fn parent_batch_req(&self, req: &FetchRequest, batch: MultiConstraint) -> FetchRequest {
397 let mut multi = req.multi_constraints.clone();
398 multi.push(batch);
399 FetchRequest {
400 constraint: req.constraint.clone(),
401 multi_constraints: multi,
402 start: req.start.clone(),
403 reverse: req.reverse,
404 }
405 }
406
407 /// Translate the parent-key columns of an incoming request constraint to the
408 /// corresponding child-key columns (`flipped-join.ts:164`). Columns that aren't
409 /// parent-key columns are dropped; if none translate, the child is fetched
410 /// unconstrained (`None`).
411 fn translate_constraint(&self, c: Option<&Constraint>) -> Option<Constraint> {
412 let c = c?;
413 let mut out = Constraint::new();
414 for (col, val) in c {
415 if let Some(i) = self.parent_key.iter().position(|pk| pk == col) {
416 out.push((self.child_key[i], val.clone()));
417 }
418 }
419 if out.is_empty() {
420 None
421 } else {
422 Some(out)
423 }
424 }
425
426 // ---------------------------------------------------------------------
427 // PUSH (two ports)
428 // ---------------------------------------------------------------------
429
430 pub fn push<'g>(&'g self, g: &'g Graph, change: Change<'g>, port: Port) {
431 match port {
432 Port::JoinChild => self.push_child(g, change),
433 Port::JoinParent => self.push_parent(g, change),
434 Port::Single => panic!("FlippedJoin received Single port"),
435 }
436 }
437
438 /// Child port (`flipped-join.ts:385`): the changed child is re-projected onto
439 /// its parents with the existence decision. EDIT/CHILD can't flip a parent's
440 /// existence, so they pre-set `exists = true`. A key edit is rejected (the
441 /// source split it, like [`Join`](crate::graph)).
442 fn push_child<'g>(&'g self, g: &'g Graph, change: Change<'g>) {
443 let exists_pre = match &change {
444 Change::Add(_) | Change::Remove(_) => false,
445 Change::Edit { node, old } => {
446 assert!(
447 row_equals_for_compound_key(&old.row, &node.row, &self.child_key),
448 "flipped child edit must not change the join relationship key"
449 );
450 // A non-key edit cannot make a parent appear/disappear → forward a
451 // Child change (existence is unchanged).
452 true
453 }
454 // A nested `Change::Child` (a grandchild change) leaves the direct child
455 // present, so the parent's EXISTS-membership is unchanged → forward it as
456 // a `Child`, carried verbatim. `exists_pre = true` skips the membership
457 // re-check; without it, `parent_has_other_child` (which excludes the
458 // *changed* child) could see "no other child" and wrongly emit an
459 // Add/Remove of the parent for what is only a grandchild edit.
460 Change::Child { .. } => true,
461 };
462 self.push_child_change(g, change, exists_pre);
463 }
464
465 /// `#pushChildChange` (`flipped-join.ts:409`), the reentrant crux. Build the
466 /// parent constraint from the changed child, re-enter `parent.fetch`, and for
467 /// each parent decide existence (does it have *another* related child?). Yes →
468 /// `Child`; no → the child is the parent's sole child, so the parent's existence
469 /// flips (`Add` on child-add / `Remove` on child-remove).
470 fn push_child_change<'g>(&'g self, g: &'g Graph, child_change: Change<'g>, exists_pre: bool) {
471 let out = self.output.get().expect("FlippedJoin output not wired");
472 let key_row = child_change.primary_row().clone();
473 let constraint = match build_join_constraint(&key_row, &self.child_key, &self.parent_key) {
474 Some(c) => c,
475 None => return, // null child key cannot join
476 };
477 let overlay = JoinOverlay::for_child_change(&child_change);
478 let child_pk = g.input_schema(self.child).primary_key.clone();
479 let is_add = matches!(child_change, Change::Add(_));
480 // Materialize once (drains thunks now), rebuild a fresh `Change<'g>` per
481 // parent in the `exists` branch — a child can join to many parents.
482 let owned = materialize_change(child_change);
483
484 // Publish the in-flight child change LIVE for the duration of the fan-out
485 // (Zero `flipped-join.ts:410`); the guard clears both fields on every exit
486 // (the JS `try/finally`, `flipped-join.ts:485` — under `panic = "abort"` a
487 // panic aborts instead and `Drop` is skipped, see WS02).
488 *self.inprogress_overlay.borrow_mut() = overlay.clone();
489 let _guard = InprogressGuard { j: self };
490
491 // Collect the reentrant parent fetch (relationships preserved) so its stream
492 // borrow is dropped before the per-parent existence fetch + downstream push.
493 let parents: Vec<Node> = g
494 .fetch(self.parent, &FetchRequest::with_constraint(constraint))
495 .collect();
496
497 // `exists` is sticky across the parent loop (the JS captures it in method
498 // scope, `flipped-join.ts:436`): once any parent has another child, later
499 // parents stay in the `Child` branch. Moot in the common 1:1 case.
500 let mut exists = exists_pre;
501 // The flipped twin of the unbounded join fan-out (RUNAWAY-PUSH-FINDINGS §3), with a
502 // per-parent existence fetch on top — deadline-checkpointed like the non-flipped loop
503 // (FOLLOWER-LAG-SHED §6.6).
504 for pnode in parents {
505 if g.push_deadline_exceeded() {
506 g.park_runtime_error(crate::error::RindleError::PushDeadlineExceeded {
507 site: "flipped-join child fan-out",
508 });
509 break; // torn is fine — the host discards the engine
510 }
511 // Advance the fan-out position to the parent now being delivered
512 // (`flipped-join.ts:427`) BEFORE the downstream push: a maintenance
513 // fetch it triggers must see this parent as delivered, and every
514 // later parent as still pre-change.
515 *self.inprogress_position.borrow_mut() = Some(pnode.row.clone());
516 if !exists {
517 exists = self.parent_has_other_child(g, &pnode.row, &key_row, &child_pk);
518 }
519 if exists {
520 // Parent stays; its relationship changed → a Child change. (Both the
521 // View and Catch read only the parent row on a `Child` and recurse
522 // into the carried sub-change; the overlay is `None` for a nested
523 // Child and a leaf-row splice otherwise.)
524 let pn = g.attach_child_rel(
525 self.child,
526 &self.parent_key,
527 &self.child_key,
528 self.rel_slot,
529 pnode,
530 overlay.clone().map(|ov| (ov, OverlayPolarity::Post)),
531 );
532 let child = Box::new(rebuild_change(owned.clone()));
533 g.push(
534 out.node,
535 Change::Child {
536 node: pn,
537 rel: self.rel_slot,
538 child,
539 },
540 out.port,
541 );
542 } else {
543 // The changed child is the parent's SOLE related child → existence
544 // flips. The relationship is exactly that one child (JS `[change[NODE]]`,
545 // `flipped-join.ts:474`) — NOT a re-fetch (which, on a remove, would
546 // show the child already gone). This makes the emitted Add/Remove
547 // carry the changed child, matching the `Exists`-gate oracle.
548 //
549 // `[change[NODE]]` is the change's NODE — the child *with its own
550 // relationships* — not its row. Passing the bare row here truncated the
551 // child's own witness subtree, which the `View`, the cluster snapshot and
552 // the d2s oracle all discard anyway (none projects a `where`-EXISTS slot)
553 // but the normalized wire fold refcounts in full: a nested EXISTS witness
554 // dropped here never enters the client's footprint, so its re-derivation
555 // fails the inner gate and drops the root. `owned` is the same
556 // materialization the `Child` arm above forwards.
557 let child_owned: OwnedNode = match &owned {
558 OwnedChange::Add(n) | OwnedChange::Remove(n) => n.clone(),
559 // Unreachable: `push_child` pre-sets `exists = true` for both, so
560 // they never reach the flip branch. Matched for totality.
561 OwnedChange::Edit { node, .. } | OwnedChange::Child { node, .. } => {
562 node.clone()
563 }
564 };
565 let pn = self.parent_with_only_child(pnode, child_owned);
566 let flipped = if is_add {
567 Change::Add(pn)
568 } else {
569 Change::Remove(pn)
570 };
571 g.push(out.node, flipped, out.port);
572 }
573 }
574 }
575
576 /// Does `parent_row` have a related child whose PK differs from the changed
577 /// child (`change.node`)? Reentrant `child.fetch` (the child source overlay is
578 /// active, so a child add is visible / a child remove is gone), then look for any
579 /// child that is **not** the changed one — a PK match (the robust port of the JS
580 /// `compareRows(child, change.node) !== 0`, `flipped-join.ts:441`; spec OQ-7).
581 fn parent_has_other_child(
582 &self,
583 g: &Graph,
584 parent_row: &Row,
585 changed_child: &Row,
586 child_pk: &[ColId],
587 ) -> bool {
588 let Some(cc) = build_join_constraint(parent_row, &self.parent_key, &self.child_key) else {
589 return false;
590 };
591 for cn in g.fetch(self.child, &FetchRequest::with_constraint(cc)) {
592 if !row_equals_for_compound_key(&cn.row, changed_child, child_pk) {
593 return true;
594 }
595 }
596 false
597 }
598
599 /// Parent port (`flipped-join.ts:490`): forward the change with the relationship
600 /// flipped on — **unless** the parent has no related child, in which case it is
601 /// not in the (inner-join) output and the change is dropped.
602 fn push_parent<'g>(&'g self, g: &'g Graph, change: Change<'g>) {
603 let out = self.output.get().expect("FlippedJoin output not wired");
604 match change {
605 Change::Add(node) => {
606 if !self.has_related_child(g, &node.row) {
607 return;
608 }
609 let pn = self.flip(g, node);
610 g.push(out.node, Change::Add(pn), out.port);
611 }
612 Change::Remove(node) => {
613 if !self.has_related_child(g, &node.row) {
614 return;
615 }
616 let pn = self.flip(g, node);
617 g.push(out.node, Change::Remove(pn), out.port);
618 }
619 Change::Edit { node, old } => {
620 assert!(
621 row_equals_for_compound_key(&old.row, &node.row, &self.parent_key),
622 "flipped parent edit must not change the join key"
623 );
624 // Key unchanged ⇒ old and new have the same related children; gate on
625 // either. No children ⇒ the parent isn't in the output ⇒ drop.
626 if !self.has_related_child(g, &node.row) {
627 return;
628 }
629 let pn = self.flip(g, node);
630 let po = self.flip(g, old);
631 g.push(out.node, Change::Edit { node: pn, old: po }, out.port);
632 }
633 Change::Child { node, rel, child } => {
634 // Passthrough of a relationship above this flipped join.
635 if !self.has_related_child(g, &node.row) {
636 return;
637 }
638 let pn = self.flip(g, node);
639 g.push(
640 out.node,
641 Change::Child {
642 node: pn,
643 rel,
644 child,
645 },
646 out.port,
647 );
648 }
649 }
650 }
651
652 /// The inner-join gate: does `parent_row` have ≥1 related child? (`child.fetch`
653 /// of the correlation constraint has a first row.)
654 fn has_related_child(&self, g: &Graph, parent_row: &Row) -> bool {
655 match build_join_constraint(parent_row, &self.parent_key, &self.child_key) {
656 Some(c) => g
657 .fetch(self.child, &FetchRequest::with_constraint(c))
658 .next()
659 .is_some(),
660 None => false,
661 }
662 }
663
664 /// Attach the join's child relationship to a parent node (the re-materialize
665 /// thunk, no overlay — the parent-port push has no in-flight child of its own).
666 fn flip<'g>(&self, g: &'g Graph, node: Node<'g>) -> Node<'g> {
667 g.attach_child_rel(
668 self.child,
669 &self.parent_key,
670 &self.child_key,
671 self.rel_slot,
672 node,
673 None,
674 )
675 }
676
677 /// Attach a relationship that yields exactly the one given child — the parent's
678 /// sole related child on an existence flip (JS `[change[NODE]]`). The child is an
679 /// [`OwnedNode`], not a row: it carries its own relationships (a nested EXISTS
680 /// witness, a `related` subtree), and every consumer that walks the whole subtree
681 /// — the normalized wire fold above all — needs them.
682 fn parent_with_only_child<'g>(&self, mut parent: Node<'g>, child: OwnedNode) -> Node<'g> {
683 parent.rels.push(Relationship {
684 slot: self.rel_slot,
685 thunk: Box::new(move || Box::new(std::iter::once(rebuild_node(child.clone())))),
686 });
687 parent
688 }
689}
690
691/// RAII for the flipped join's in-flight child fan-out (`flipped-join.ts:485-487`
692/// `finally`): clears [`FlippedJoin::inprogress_overlay`]/
693/// [`FlippedJoin::inprogress_position`] when `push_child_change` returns —
694/// normally, via `?`, or unwinding (the latter only under `panic = "unwind"`;
695/// under the shipping `panic = "abort"` profile a panic aborts and `Drop` is
696/// skipped — see WS02). A stale overlay would corrupt every later fetch's
697/// membership, so the clear is not optional. The twin of `graph.rs`'s
698/// `InprogressGuard` for the non-flipped `Join`.
699struct InprogressGuard<'a> {
700 j: &'a FlippedJoin,
701}
702
703impl Drop for InprogressGuard<'_> {
704 fn drop(&mut self) {
705 *self.j.inprogress_overlay.borrow_mut() = None;
706 *self.j.inprogress_position.borrow_mut() = None;
707 }
708}