Rindle docs and package mapSkip to main content

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}