rindle/op/take.rs
1//! `Take` (`ivm/take.ts`, spec `07` §3.6) — bounded top-N per partition (the
2//! `LIMIT` operator). The **first real consumer of the [`Graph`](crate::graph::Graph) storage arena**
3//! (a [`StorageId`] slot holding the per-partition `TakeState`).
4//!
5//! A faithful port of `take.ts`. Take keeps the first `limit` rows of its input —
6//! per *partition* (a correlation key for a limited subquery; absent for a
7//! top-level limit) — in input sort order, and maintains, per partition, a
8//! `TakeState { size, bound }` where `bound` is the largest accepted row. It holds
9//! the invariant **output size ≤ limit at all times, even mid-push**
10//! (remove-before-add ordering). It also tracks a global `MAX_BOUND` (the max bound
11//! across partitions) for the cross-partition fetch path.
12//!
13//! Like [`Skip`](crate::op::Skip), Take is *ordered* and forwards on its edge's own
14//! [`Port`](crate::change::Port), so a limited relationship can feed a parent join.
15//! Unlike Skip it is **stateful**: it reads/writes its [`StorageId`] slot through a
16//! shared `&Graph` borrow, so the reentrant fetch-during-push composes for free.
17
18use std::cell::{Cell, RefCell};
19use std::cmp::Ordering;
20
21use crate::change::{Basis, Change, Constraint, FetchRequest, Node, NodeStream, OutEdge, Start};
22use crate::graph::{Graph, NodeId, StorageId};
23use crate::op::partition::{
24 constraint_matches_partition_key, encode_state_key, partition_key_unchanged,
25};
26use crate::storage::StorageValue;
27use crate::value::{compare_rows, ColId, OwnedRow as Row, OwnedValue, Sort};
28
29/// The storage key for the global max-bound slot (`take.ts:27` `MAX_BOUND_KEY`).
30const MAX_BOUND_KEY: &str = "maxBound";
31
32/// `Take`: ordered, bounded top-N per partition, backed by a [`StorageId`] slot.
33pub struct Take {
34 /// Upstream input (the connection, a `Skip`, or — once EXISTS lands — a join).
35 pub input: NodeId,
36 /// Handle into the [`Graph`](crate::graph::Graph) storage arena holding this op's per-partition
37 /// `TakeState` (under `encode_state_key("take", …)`) + the `MAX_BOUND` row.
38 pub storage: StorageId,
39 /// The `LIMIT` count. `0` ⇒ the operator yields nothing and stores no state.
40 pub limit: u32,
41 /// The partition columns (the correlation **child** field of a limited
42 /// relationship), or `None` for a top-level limit (a single global partition).
43 pub partition_key: Option<Vec<ColId>>,
44 /// Single-row fetch overlay for displacement pushes (`take.ts:62`,
45 /// `#rowHiddenFromFetch`): while set, [`Take::fetch`] skips the row equal to it,
46 /// hiding an in-flight row from a reentrant refetch. Cleared via a [`Drop`] guard
47 /// (the JS `finally`).
48 row_hidden_from_fetch: RefCell<Option<Row>>,
49 /// The single downstream edge as a **port-carrying** [`OutEdge`] (like
50 /// [`Skip`](crate::op::Skip)/[`Join`](crate::graph)): `Port::Single` to a
51 /// terminal sink, `Port::JoinParent` when this Take feeds a relationship join's
52 /// parent port (a limited relationship). Wired via
53 /// [`Graph::set_output`](crate::graph::Graph::set_output) (Single) or
54 /// [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge) (explicit port).
55 pub output: Cell<Option<OutEdge>>,
56 /// The input sort, resolved at build time (the connection's completed,
57 /// PK-including order). Take requires sorted input (`take.ts:74`); the
58 /// comparator only ever reads these columns.
59 pub sort: Sort,
60 /// Keep a partition's slot when it drains to empty (design 310, impl plan D4). A
61 /// partitioned *child* Take deletes the slot, because its parent join's constrained
62 /// fetch re-hydrates it when the parent re-enters; a **family root** Take has no
63 /// parent, so after such a delete the next `Add` for a still-bound value would find
64 /// `get_state == None` and be dropped — a silent missing row for that binding. Set
65 /// only by the family builder; the slot is then deleted by [`Take::evict_partition`]
66 /// at unbind. The top-level unpartitioned branch already keeps size-0 state for the
67 /// same reason; this flag extends that rule to root partitions.
68 pub retain_empty_partitions: bool,
69}
70
71/// Clears [`Take::row_hidden_from_fetch`] on drop — the RAII rendering of the JS
72/// `#pushWithRowHiddenFromFetch` `finally` (`take.ts:681`), so the overlay is
73/// cleared on early return / `?`. It is also cleared if the downstream push panics
74/// ONLY in unwinding builds; under the shipping `panic = "abort"` client profile a
75/// panic aborts the process and the overlay-clearing `Drop` does not run. See WS02.
76struct HiddenGuard<'a>(&'a RefCell<Option<Row>>);
77impl Drop for HiddenGuard<'_> {
78 fn drop(&mut self) {
79 *self.0.borrow_mut() = None;
80 }
81}
82
83impl Take {
84 pub fn new(
85 input: NodeId,
86 storage: StorageId,
87 limit: u32,
88 partition_key: Option<Vec<ColId>>,
89 sort: Sort,
90 ) -> Take {
91 Take {
92 input,
93 storage,
94 limit,
95 partition_key,
96 row_hidden_from_fetch: RefCell::new(None),
97 output: Cell::new(None),
98 sort,
99 retain_empty_partitions: false,
100 }
101 }
102
103 /// Builder: keep drained-to-empty partition slots (see
104 /// [`Take::retain_empty_partitions`]). The family root limiter sets this.
105 pub fn with_retain_empty_partitions(mut self, on: bool) -> Take {
106 self.retain_empty_partitions = on;
107 self
108 }
109
110 // --- storage helpers ----------------------------------------------------
111
112 /// Read the `TakeState` (`size`, `bound`) at `key`, or `None` if the partition
113 /// was never hydrated.
114 fn get_state(&self, g: &Graph, key: &str) -> Option<(u32, Option<Row>)> {
115 match g.storage(self.storage).get(key) {
116 None => None,
117 Some(StorageValue::Take { size, bound }) => Some((size, bound)),
118 Some(other) => unreachable!("take state slot held {other:?}"),
119 }
120 }
121
122 /// Read the global `MAX_BOUND` row (`None` until any partition sets a bound).
123 fn get_max_bound(&self, g: &Graph) -> Option<Row> {
124 match g.storage(self.storage).get(MAX_BOUND_KEY) {
125 None => None,
126 Some(StorageValue::Bound(r)) => Some(r),
127 Some(other) => unreachable!("maxBound slot held {other:?}"),
128 }
129 }
130
131 /// `#setTakeState` (`take.ts:686`): write the partition's `{size, bound}`, then
132 /// bump `MAX_BOUND` iff the new `bound` exceeds the `max_bound` read by the
133 /// caller (stale-within-a-push, matching the JS which reads it once per push).
134 fn set_state(
135 &self,
136 g: &Graph,
137 key: &str,
138 size: u32,
139 bound: Option<Row>,
140 max_bound: Option<&Row>,
141 ) {
142 let st = g.storage(self.storage);
143 st.set(
144 key,
145 StorageValue::Take {
146 size,
147 bound: bound.clone(),
148 },
149 );
150 if let Some(b) = &bound {
151 if max_bound.is_none_or(|mb| compare_rows(&self.sort, b, mb) == Ordering::Greater) {
152 st.set(MAX_BOUND_KEY, StorageValue::Bound(b.clone()));
153 }
154 }
155 }
156
157 /// The take-state key for a partition identified by a **row** (`take.ts:710`
158 /// `getTakeStateKey` over a `Row`). Unpartitioned ⇒ the single global key.
159 fn key_from_row(&self, row: &Row) -> String {
160 match &self.partition_key {
161 None => encode_state_key("take", &[]),
162 Some(pk) => {
163 let vals: Vec<OwnedValue> = pk.iter().map(|&c| row.col(c).to_owned()).collect();
164 encode_state_key("take", &vals)
165 }
166 }
167 }
168
169 /// The take-state key for a partition identified by a fetch **constraint**
170 /// (`getTakeStateKey` over a `Constraint`). Unpartitioned ⇒ the global key.
171 fn key_from_constraint(&self, c: Option<&Constraint>) -> String {
172 match (&self.partition_key, c) {
173 (Some(pk), Some(c)) => {
174 let vals: Vec<OwnedValue> = pk
175 .iter()
176 .map(|&col| {
177 c.iter()
178 .find(|(cc, _)| *cc == col)
179 .map(|(_, v)| v.clone())
180 .unwrap_or(OwnedValue::Null)
181 })
182 .collect();
183 encode_state_key("take", &vals)
184 }
185 // Unpartitioned (or partitioned-but-no-constraint, which the callers
186 // never hit): the single global partition.
187 _ => encode_state_key("take", &[]),
188 }
189 }
190
191 /// The reentrant-fetch constraint for a push on `row` (`#getStateAndConstraint`,
192 /// `take.ts:225`): the partition columns mapped to the row's values, or `None`
193 /// when unpartitioned (the reentrant fetch is then a bare scan).
194 fn constraint_for(&self, row: &Row) -> Option<Constraint> {
195 self.partition_key
196 .as_ref()
197 .map(|pk| pk.iter().map(|&c| (c, row.col(c).to_owned())).collect())
198 }
199
200 /// Collect the first `n` nodes of a reentrant input fetch starting at `bound`
201 /// (with `basis`/`reverse`/`constraint`). The Take push paths only ever need the
202 /// first one or two, so this bounds the pull.
203 fn fetch_n<'g>(
204 &self,
205 g: &'g Graph,
206 bound: &Row,
207 basis: Basis,
208 constraint: &Option<Constraint>,
209 reverse: bool,
210 n: usize,
211 ) -> Vec<Node<'g>> {
212 let req = FetchRequest {
213 start: Some(Start {
214 row: bound.clone(),
215 basis,
216 }),
217 constraint: constraint.clone(),
218 reverse,
219 ..Default::default()
220 };
221 g.fetch(self.input, &req).take(n).collect()
222 }
223
224 // --- fetch --------------------------------------------------------------
225
226 /// Lazy pull (`take.ts:93`). Two regimes:
227 /// 1. No partition key, or the request constraint matches the partition key:
228 /// bound by that single partition's `TakeState` (hydrating it on first sight).
229 /// 2. Partitioned but the constraint is absent / on a different key (nested
230 /// subqueries): bound by `MAX_BOUND`, re-checking each row against its own
231 /// partition's bound.
232 pub fn fetch<'g>(&'g self, g: &'g Graph, req: &FetchRequest) -> NodeStream<'g> {
233 let partition_match = match &self.partition_key {
234 None => true,
235 Some(pk) => req
236 .constraint
237 .as_ref()
238 .is_some_and(|c| constraint_matches_partition_key(c, pk)),
239 };
240
241 if partition_match {
242 let key = self.key_from_constraint(req.constraint.as_ref());
243 match self.get_state(g, &key) {
244 None => return self.initial_fetch(g, req),
245 // Empty partition (hydrated, no rows) ⇒ nothing.
246 Some((_, None)) => return Box::new(std::iter::empty()),
247 Some((_, Some(bound))) => {
248 // Stream input; stop at the first row past the bound; skip the
249 // in-flight `row_hidden_from_fetch` row if one is set.
250 let hidden = self.row_hidden_from_fetch.borrow().clone();
251 let nodes = g.fetch(self.input, req);
252 return Box::new(
253 nodes
254 .take_while(move |n| {
255 compare_rows(&self.sort, &bound, &n.row) != Ordering::Less
256 })
257 .filter(move |n| {
258 hidden.as_ref().is_none_or(|h| {
259 compare_rows(&self.sort, h, &n.row) != Ordering::Equal
260 })
261 }),
262 );
263 }
264 }
265 }
266
267 // Partitioned, no matching constraint: bound by MAX_BOUND, re-check each
268 // row against its own partition's bound (`take.ts:135`).
269 let Some(max_bound) = self.get_max_bound(g) else {
270 return Box::new(std::iter::empty());
271 };
272 let nodes = g.fetch(self.input, req);
273 Box::new(
274 nodes
275 .take_while(move |n| {
276 compare_rows(&self.sort, &n.row, &max_bound) != Ordering::Greater
277 })
278 .filter_map(move |n| {
279 let key = self.key_from_row(&n.row);
280 match self.get_state(g, &key) {
281 Some((_, Some(bound)))
282 if compare_rows(&self.sort, &bound, &n.row) != Ordering::Less =>
283 {
284 Some(n)
285 }
286 _ => None,
287 }
288 }),
289 )
290 }
291
292 /// `#initialFetch` (`take.ts:158`): hydrate a partition from the top. Pulls the
293 /// first `limit` rows (tracking `size`/`bound`), writes the take-state, and
294 /// yields them. Eager (vs the JS lazy generator + `finally`): every real
295 /// consumer fully drains a hydrate, so pulling up to `limit` up front yields an
296 /// identical result and state while keeping the borrow plumbing simple. The JS
297 /// `downstreamEarlyReturn` assert is therefore vacuous here.
298 fn initial_fetch<'g>(&'g self, g: &'g Graph, req: &FetchRequest) -> NodeStream<'g> {
299 debug_assert!(
300 req.start.is_none(),
301 "Take initial fetch: start must be None"
302 );
303 debug_assert!(!req.reverse, "Take initial fetch: reverse must be false");
304 if self.limit == 0 {
305 // limit 0 stores no state (`take.ts:162`); stays permanently empty.
306 return Box::new(std::iter::empty());
307 }
308 let key = self.key_from_constraint(req.constraint.as_ref());
309 debug_assert!(
310 self.get_state(g, &key).is_none(),
311 "Take initial fetch: state should be undefined"
312 );
313
314 let mut size = 0u32;
315 let mut bound: Option<Row> = None;
316 let mut out: Vec<Node<'g>> = Vec::with_capacity(self.limit as usize);
317 for node in g.fetch(self.input, req) {
318 bound = Some(node.row.clone());
319 out.push(node);
320 size += 1;
321 if size == self.limit {
322 break;
323 }
324 }
325 let max_bound = self.get_max_bound(g);
326 self.set_state(g, &key, size, bound, max_bound.as_ref());
327 Box::new(out.into_iter())
328 }
329
330 /// Delete the per-partition slot identified by `constraint` (the parent→child
331 /// correlation key), if this Take is partitioned and the constraint covers its
332 /// partition key. Called by the relationship join when a parent **leaves a bounded
333 /// parent view** (a parent `Remove` on `Port::JoinParent`): the child partition that
334 /// parent hydrated is no longer referenced by any result row, but — unlike the
335 /// drain-to-empty case in `push_remove` — its child rows are still live in the
336 /// window, so nothing else ever deletes the slot. Without this, a partitioned child
337 /// Take accumulates one zombie slot per distinct parent that *ever* floated through
338 /// the bounded view (e.g. every page that hit the wiki demo's "latest" board as the
339 /// `last_ts` ordering churns), leaking operator state proportional to the retention
340 /// window rather than the (bounded) view.
341 ///
342 /// Safe — and symmetric with the drain-time delete — because a partitioned Take is
343 /// re-hydrated from source by the parent join's constrained `fetch` when its parent
344 /// re-enters ([`Take::fetch`]'s no-state → `initial_fetch`), and a `push` to an
345 /// un-hydrated partition is already dropped (`get_state == None`). The caller only
346 /// invokes this when the parent correlation key is unique (the parent's primary key),
347 /// so the evicted slot belongs to exactly the one parent that left. No-op for an
348 /// unpartitioned (top-level) Take or a constraint that does not cover the partition
349 /// key.
350 pub fn evict_partition(&self, g: &Graph, constraint: &Constraint) {
351 let Some(pk) = &self.partition_key else {
352 return;
353 };
354 if !constraint_matches_partition_key(constraint, pk) {
355 return;
356 }
357 let key = self.key_from_constraint(Some(constraint));
358 g.storage(self.storage).del(&key);
359 }
360
361 /// `row` is outside this partition's window and the window is **full**: reclaim the
362 /// child state of every relationship join UPSTREAM of this Take
363 /// ([`Graph::evict_upstream_child_partitions`], which carries the safety argument).
364 ///
365 /// The twin of [`evict_partition`](Self::evict_partition) from the other side of the
366 /// pipeline. `evict_partition` reclaims a CHILD limiter's slot when its parent leaves
367 /// a bounded parent view — the `related` shape, where the join is downstream of the
368 /// Take. This reclaims a spine **EXISTS/aggregate** join's child slot when the parent
369 /// leaves *this* Take's window — the shape where the join is upstream, so no `Remove`
370 /// ever reaches it and nothing else can tear the partition down
371 /// (`follow-ups/04-cap-partition-leak.md`).
372 ///
373 /// The `size == limit` guard is the whole safety condition, so it lives here rather
374 /// than at each call site: with room left in the window a parent beyond the bound can
375 /// still be admitted by a gate flip, and its child slot is the subscription that flip
376 /// rides on. (`size < limit` also implies nothing is outside the window at all —
377 /// `push_add`'s room branch extends the bound — so this is belt-and-braces at every
378 /// caller but one.)
379 fn evict_upstream_if_full(&self, g: &Graph, size: u32, row: &Row) {
380 if size >= self.limit {
381 g.evict_upstream_child_partitions(self.input, row);
382 }
383 }
384
385 // --- push ---------------------------------------------------------------
386
387 /// Eager push (`take.ts:247`). Edit routes to `Take::push_edit`; otherwise
388 /// look up the partition's state (drop the change if it was never hydrated) and
389 /// dispatch Add / Remove / Child. Forwards on the edge's own port so a limited
390 /// relationship can feed a parent join.
391 pub fn push<'g>(&'g self, g: &'g Graph, change: Change<'g>) {
392 if matches!(change, Change::Edit { .. }) {
393 return self.push_edit(g, change);
394 }
395
396 let row = match &change {
397 Change::Add(n) | Change::Remove(n) => &n.row,
398 Change::Child { node, .. } => &node.row,
399 Change::Edit { .. } => unreachable!(),
400 };
401 let key = self.key_from_row(row);
402 // No state for this partition ⇒ it was never observed; drop (`take.ts:255`).
403 let Some((size, bound)) = self.get_state(g, &key) else {
404 return;
405 };
406 let max_bound = self.get_max_bound(g);
407 let constraint = self.constraint_for(row);
408 let out = self.output.get().expect("Take output not wired");
409
410 match change {
411 Change::Add(node) => {
412 self.push_add(g, node, out, &key, size, bound, max_bound, &constraint)
413 }
414 Change::Remove(node) => {
415 self.push_remove(g, node, out, &key, size, bound, max_bound, &constraint)
416 }
417 Change::Child { node, rel, child } => {
418 // Forward iff the row is within the window (`take.ts:420`).
419 if bound
420 .as_ref()
421 .is_some_and(|b| compare_rows(&self.sort, &node.row, b) != Ordering::Greater)
422 {
423 g.push(out.node, Change::Child { node, rel, child }, out.port);
424 }
425 }
426 Change::Edit { .. } => unreachable!(),
427 }
428 }
429
430 /// Add (`take.ts:261`). Room (`size < limit`) ⇒ extend bound, forward. Full and
431 /// the new row is outside the window ⇒ drop. Full and the new row displaces the
432 /// boundary ⇒ Remove(boundNode) then Add(new), keeping size ≤ limit.
433 #[allow(clippy::too_many_arguments)]
434 fn push_add<'g>(
435 &'g self,
436 g: &'g Graph,
437 node: Node<'g>,
438 out: OutEdge,
439 key: &str,
440 size: u32,
441 bound: Option<Row>,
442 max_bound: Option<Row>,
443 constraint: &Option<Constraint>,
444 ) {
445 if size < self.limit {
446 // Extend the bound iff the new row sorts past it (or there was none).
447 let new_bound = match &bound {
448 Some(b) if compare_rows(&self.sort, b, &node.row) != Ordering::Less => b.clone(),
449 _ => node.row.clone(),
450 };
451 self.set_state(g, key, size + 1, Some(new_bound), max_bound.as_ref());
452 g.push(out.node, Change::Add(node), out.port);
453 return;
454 }
455
456 // size == limit. Drop unless the new row sorts strictly before the bound.
457 let bound = match &bound {
458 Some(b) if compare_rows(&self.sort, &node.row, b) == Ordering::Less => b.clone(),
459 _ => {
460 // Outside a full window: nothing downstream will ever see this row
461 // again without a refill fetch, so its upstream EXISTS/aggregate child
462 // state is dead. See `Take::evict_upstream_if_full`.
463 self.evict_upstream_if_full(g, size, &node.row);
464 return;
465 }
466 };
467
468 // Displacement: fetch the boundary (and, for limit > 1, the row before it).
469 let mut found = if self.limit == 1 {
470 self.fetch_n(g, &bound, Basis::At, constraint, false, 1)
471 } else {
472 self.fetch_n(g, &bound, Basis::At, constraint, true, 2)
473 };
474 assert!(
475 !found.is_empty(),
476 "Take: boundNode must be found during fetch"
477 );
478 let bound_node = found.remove(0);
479 let before_bound_node = (!found.is_empty()).then(|| found.remove(0));
480
481 // New bound = max(new row, the row before the displaced boundary).
482 let new_bound = match &before_bound_node {
483 Some(bbn) if compare_rows(&self.sort, &node.row, &bbn.row) != Ordering::Greater => {
484 bbn.row.clone()
485 }
486 _ => node.row.clone(),
487 };
488 // Remove before add to hold size ≤ limit; hide the new row from the remove's
489 // reentrant refetch.
490 self.set_state(g, key, size, Some(new_bound), max_bound.as_ref());
491 let displaced = bound_node.row.clone();
492 self.push_with_row_hidden(g, node.row.clone(), out, Change::Remove(bound_node));
493 g.push(out.node, Change::Add(node), out.port);
494 // AFTER both pushes, so the downstream has finished draining the removed row's
495 // relationships against the still-present partition (the ordering the join's
496 // `JoinParent` `Remove` arm keeps for the same reason).
497 self.evict_upstream_if_full(g, size, &displaced);
498 }
499
500 /// Remove (`take.ts:341`). After the bound ⇒ drop. Otherwise it was inside the
501 /// window: refill from the next row past the bound if the partition has one
502 /// (Remove then Add(refill)); else just Remove, shrinking the partition.
503 #[allow(clippy::too_many_arguments)]
504 fn push_remove<'g>(
505 &'g self,
506 g: &'g Graph,
507 node: Node<'g>,
508 out: OutEdge,
509 key: &str,
510 size: u32,
511 bound: Option<Row>,
512 max_bound: Option<Row>,
513 constraint: &Option<Constraint>,
514 ) {
515 let Some(bound) = bound else {
516 return; // empty partition ⇒ change is after the (absent) bound
517 };
518 if compare_rows(&self.sort, &node.row, &bound) == Ordering::Greater {
519 return; // after the bound ⇒ outside the window
520 }
521
522 // Look for the row just before the bound (reverse from after the bound).
523 let mut new_bound: Option<(Node<'g>, bool)> = self
524 .fetch_n(g, &bound, Basis::After, constraint, true, 1)
525 .into_iter()
526 .next()
527 .map(|bbn| {
528 let push = compare_rows(&self.sort, &bbn.row, &bound) == Ordering::Greater;
529 (bbn, push)
530 });
531
532 // If that didn't yield a refill, scan forward from the bound for the first
533 // row strictly after it (the refill candidate). This backfill scan is one leg of the
534 // take-refill cascade (O(N)-by-design since the committed-membership fix), so it
535 // carries a push-deadline checkpoint (FOLLOWER-LAG-SHED §6.6) — on expiry, park and
536 // bail with the window state untouched (torn is fine; the host discards the engine).
537 if !matches!(&new_bound, Some((_, true))) {
538 let req = FetchRequest {
539 start: Some(Start {
540 row: bound.clone(),
541 basis: Basis::At,
542 }),
543 constraint: constraint.clone(),
544 ..Default::default()
545 };
546 for n in g.fetch(self.input, &req) {
547 if g.push_deadline_exceeded() {
548 g.park_runtime_error(crate::error::RindleError::PushDeadlineExceeded {
549 site: "take backfill scan",
550 });
551 return;
552 }
553 let push = compare_rows(&self.sort, &n.row, &bound) == Ordering::Greater;
554 new_bound = Some((n, push));
555 if push {
556 break;
557 }
558 }
559 }
560
561 match new_bound {
562 Some((refill, true)) => {
563 // Refill exists: remove the gone row, slide the bound, add the refill.
564 g.push(out.node, Change::Remove(node), out.port);
565 self.set_state(g, key, size, Some(refill.row.clone()), max_bound.as_ref());
566 g.push(out.node, Change::Add(refill), out.port);
567 }
568 other => {
569 // No refill: the partition shrank by one.
570 let new_b = other.map(|(n, _)| n.row.clone());
571 if self.partition_key.is_some() && size - 1 == 0 && !self.retain_empty_partitions {
572 // A PARTITIONED partition just drained to empty. Delete its slot
573 // instead of parking a zombie `Take{size:0, bound:None}` — otherwise
574 // every correlation key that ever passed through (e.g. every wiki
575 // page/editor that floated into a board's window and was later
576 // pruned) leaks one operator-state entry forever. This mirrors
577 // `Reduce`'s eager-death slot delete. Safe because a partitioned Take
578 // is re-hydrated from source by the parent join's constrained `fetch`
579 // when its parent re-enters (Take::fetch's no-state → initial_fetch),
580 // and a `push` to an un-hydrated partition is already dropped
581 // (get_state == None) — so an emptied partition behaves exactly like
582 // one never observed. A TOP-LEVEL (unpartitioned) Take must instead
583 // keep its size-0 state: it has no parent to trigger re-hydration, so
584 // a later `Add` push must find the slot to grow the window. A FAMILY
585 // ROOT partition (`retain_empty_partitions`) is a top-level window per
586 // binding and keeps its slot for the same reason (design 310, D4).
587 debug_assert!(
588 new_b.is_none(),
589 "Take: partition drained to size 0 but a bound remained"
590 );
591 g.storage(self.storage).del(key);
592 } else {
593 self.set_state(g, key, size - 1, new_b, max_bound.as_ref());
594 }
595 g.push(out.node, Change::Remove(node), out.port);
596 }
597 }
598 }
599
600 /// `#pushEditChange` (`take.ts:432`): the full bound-crossing matrix on
601 /// `(oldCmp, newCmp)` = the old/new rows compared against the bound. Asserts the
602 /// partition key is unchanged (the source split-edits a key change into
603 /// remove+add, so an Edit never crosses a partition).
604 fn push_edit<'g>(&'g self, g: &'g Graph, change: Change<'g>) {
605 let Change::Edit { node, old } = change else {
606 unreachable!()
607 };
608 let out = self.output.get().expect("Take output not wired");
609
610 if let Some(pk) = &self.partition_key {
611 debug_assert!(
612 partition_key_unchanged(&old.row, &node.row, pk),
613 "Take: unexpected change of partition key"
614 );
615 }
616
617 let key = self.key_from_row(&old.row);
618 let Some((size, bound)) = self.get_state(g, &key) else {
619 return; // partition never observed ⇒ drop
620 };
621 let bound = bound.expect("Take: bound should be set");
622 let max_bound = self.get_max_bound(g);
623 let constraint = self.constraint_for(&old.row);
624
625 let old_cmp = compare_rows(&self.sort, &old.row, &bound);
626 let new_cmp = compare_rows(&self.sort, &node.row, &bound);
627
628 // Replace the bound with the new row and forward the Edit unchanged.
629 let replace_and_forward = |node: Node<'g>, old: Node<'g>| {
630 self.set_state(g, &key, size, Some(node.row.clone()), max_bound.as_ref());
631 g.push(out.node, Change::Edit { node, old }, out.port);
632 };
633
634 match old_cmp {
635 // The bound row itself was edited.
636 Ordering::Equal => match new_cmp {
637 // Still the bound: forward, no state change.
638 Ordering::Equal => g.push(out.node, Change::Edit { node, old }, out.port),
639 // New moved before the bound.
640 Ordering::Less => {
641 if self.limit == 1 {
642 replace_and_forward(node, old);
643 return;
644 }
645 // Find the row before the old bound; it becomes the new bound.
646 let bbn = self
647 .fetch_n(g, &bound, Basis::After, &constraint, true, 1)
648 .into_iter()
649 .next()
650 .expect("Take: beforeBoundNode must be found during fetch");
651 self.set_state(g, &key, size, Some(bbn.row.clone()), max_bound.as_ref());
652 g.push(out.node, Change::Edit { node, old }, out.port);
653 }
654 // New moved past the bound: the first row at the old bound is the
655 // next candidate.
656 Ordering::Greater => {
657 let nbn = self
658 .fetch_n(g, &bound, Basis::At, &constraint, false, 1)
659 .into_iter()
660 .next()
661 .expect("Take: newBoundNode must be found during fetch");
662 if compare_rows(&self.sort, &nbn.row, &node.row) == Ordering::Equal {
663 // New is still the bound: forward the Edit.
664 replace_and_forward(node, old);
665 } else {
666 // New fell outside the window: drop it, pull the candidate in.
667 self.set_state(g, &key, size, Some(nbn.row.clone()), max_bound.as_ref());
668 self.push_with_row_hidden(g, nbn.row.clone(), out, Change::Remove(old));
669 g.push(out.node, Change::Add(nbn), out.port);
670 self.evict_upstream_if_full(g, size, &node.row);
671 }
672 }
673 },
674 // The old row was outside the window.
675 Ordering::Greater => {
676 debug_assert!(
677 new_cmp != Ordering::Equal,
678 "Invalid state. Row has duplicate primary key"
679 );
680 match new_cmp {
681 // Both outside ⇒ drop. Still worth evicting: the `Exists` gate
682 // re-hydrated this parent's child partition on the way here
683 // (`fetch_size` for every parent it evaluates), so without this the
684 // edit path re-creates exactly the slot `push_add` reclaimed.
685 Ordering::Greater => self.evict_upstream_if_full(g, size, &node.row),
686 // New moves into the window, pushing the current boundary out.
687 _ => {
688 let mut v = self.fetch_n(g, &bound, Basis::At, &constraint, true, 2);
689 assert!(
690 v.len() >= 2,
691 "Take: old/newBoundNode must be found during fetch"
692 );
693 let old_bound_node = v.remove(0);
694 let new_bound_node = v.remove(0);
695 let displaced = old_bound_node.row.clone();
696 self.set_state(
697 g,
698 &key,
699 size,
700 Some(new_bound_node.row.clone()),
701 max_bound.as_ref(),
702 );
703 self.push_with_row_hidden(
704 g,
705 node.row.clone(),
706 out,
707 Change::Remove(old_bound_node),
708 );
709 g.push(out.node, Change::Add(node), out.port);
710 self.evict_upstream_if_full(g, size, &displaced);
711 }
712 }
713 }
714 // The old row was inside the window.
715 Ordering::Less => {
716 debug_assert!(
717 new_cmp != Ordering::Equal,
718 "Invalid state. Row has duplicate primary key"
719 );
720 match new_cmp {
721 Ordering::Less => g.push(out.node, Change::Edit { node, old }, out.port),
722 // New moves past the bound: pull the first row after the bound.
723 _ => {
724 let abn = self
725 .fetch_n(g, &bound, Basis::After, &constraint, false, 1)
726 .into_iter()
727 .next()
728 .expect("Take: afterBoundNode must be found during fetch");
729 if compare_rows(&self.sort, &abn.row, &node.row) == Ordering::Equal {
730 // New is the new bound: forward the Edit.
731 replace_and_forward(node, old);
732 } else {
733 g.push(out.node, Change::Remove(old), out.port);
734 self.set_state(
735 g,
736 &key,
737 size,
738 Some(abn.row.clone()),
739 max_bound.as_ref(),
740 );
741 g.push(out.node, Change::Add(abn), out.port);
742 self.evict_upstream_if_full(g, size, &node.row);
743 }
744 }
745 }
746 }
747 }
748 }
749
750 /// `#pushWithRowHiddenFromFetch` (`take.ts:677`): set the single-row fetch
751 /// overlay, push `change` (which may reentrantly refetch this Take), then clear
752 /// the overlay via the [`HiddenGuard`] `Drop` — on normal return, `?`, or early
753 /// return. The `Drop` also clears it on panic ONLY in unwinding builds; under the
754 /// shipping `panic = "abort"` client profile a panic aborts and `Drop` is skipped.
755 /// See WS02.
756 fn push_with_row_hidden<'g>(
757 &'g self,
758 g: &'g Graph,
759 row: Row,
760 out: OutEdge,
761 change: Change<'g>,
762 ) {
763 *self.row_hidden_from_fetch.borrow_mut() = Some(row);
764 let _guard = HiddenGuard(&self.row_hidden_from_fetch);
765 g.push(out.node, change, out.port);
766 }
767}