Rindle docs and package mapSkip to main content

rindle_replica/
drain.rs

1//! The **drain thread** — the keystone of the cluster fold-in (`CLUSTER-FOLD-IN-DESIGN.md`
2//! §5/§6/§8). It moves the consumer-facing fold (per-query `NormalizedPublisher`) and the
3//! progress/`cv_min` layer off the single-thread `Db`'s synchronous `commit` callback and
4//! onto an asynchronous consumer of the [`Cluster`](crate::Cluster) channel-out.
5//!
6//! ## What it owns
7//! - the `Receiver<ClusterEvent>` returned by [`Cluster::open`](crate::Cluster::open)
8//!   (events cross from the worker threads as `Send` data);
9//! - a control channel from the coordinator (the Node main thread) carrying query
10//!   installs/removals and connection lifecycle;
11//! - the `query_id → NormalizedPublisher` map (the stateful `CaughtChange → NormalizedBatch`
12//!   fold that on the single-thread path lived in the sync subscribe callback);
13//! - the **per-worker position table** + the event-driven progress core (§8): a query's
14//!   known-current `cv` is the position of its hosting worker, so a slow worker holds back
15//!   only the clients with a query on it.
16//!
17//! There is NO per-commit metadata channel: mutation confirmation (`lmid`) is a row in
18//! [`crate::CLIENT_MUTATIONS_TABLE`], delivered through the client's own system query
19//! like any data — so it shares the worker event stream's ordering instead of racing it
20//! from the coordinator thread (the root cause of the stale-lmid release bug this
21//! replaced; see `CLUSTER-FOLD-IN-FINDINGS.md`).
22//!
23//! ## How the two input streams are merged
24//! `std::sync::mpsc` has no select, so both inputs are funnelled into one ordered
25//! [`DrainMsg`] channel: a tiny forwarder thread wraps each [`ClusterEvent`] as
26//! `DrainMsg::Event`, and the coordinator sends `DrainMsg::Control` on a clone of the same
27//! sender. This preserves the **install-before-`Hydrated`** ordering for free: the
28//! coordinator sends `InstallQuery` and *then* calls `cluster.query` (which only emits
29//! `Hydrated` after the worker replies), so the install is enqueued before the worker
30//! event it pairs with. (A not-yet-installed query's events are still buffered defensively.)
31//!
32//! ## Ordering invariants preserved
33//! - **`Changed(N)` before `Progressed(N)`** per worker (the worker emits in that order):
34//!   the data frames for tx N are folded + delivered before the progress frame that
35//!   releases them.
36//! - **data frames before the progress frame** per connection: a connection's progress
37//!   frame for tx N is emitted only once its queries' workers have reported `Progressed(N)`,
38//!   so its `cv_min` advance never out-runs the data it releases.
39
40use std::collections::{BTreeSet, HashMap};
41use std::sync::mpsc::{self, Receiver, SyncSender};
42use std::thread::{self, JoinHandle};
43
44use crate::normalize::NormalizeFold;
45use crate::normalize_protocol::{project_ops, NormalizedBatch, ProgressFrame, ProjMap};
46use crate::{ChangeEvent, ClusterEvent, QueryId, Update};
47use rindle::canon::canonical_key;
48use rindle::value::ColId;
49use rindle_wire::family_key::Binding;
50
51/// A consumer-assigned connection identifier (one per subscriber/WebSocket). The drain
52/// groups queries by connection for the `cv_min`/poke computation; it never interprets it.
53pub type ConnId = u64;
54
55/// The sink the drain delivers finished output to. Implemented by the napi layer over a
56/// `ThreadsafeFunction` (→ JS `onEvent`) and by tests over a collector. Called **on the
57/// drain thread**, so keep each call cheap (marshal + hand off).
58pub trait DrainSink: Send {
59    /// A query's normalized batch (the seq-0 hydrate snapshot or an incremental tick) is
60    /// ready — route it to the connection that owns the query. Delivered **eagerly**
61    /// (before the progress frame that releases it).
62    fn batch(&mut self, conn: ConnId, query_id: QueryId, batch: NormalizedBatch);
63    /// A connection's progress frame (`{cv_min}`) — emitted after the data frames it
64    /// releases, per the poke rule.
65    fn progress(&mut self, conn: ConnId, frame: ProgressFrame);
66    /// A query faulted on its worker (terminal): the connection should re-subscribe
67    /// (re-hydrate). No further events arrive for this `query_id`.
68    fn faulted(&mut self, conn: ConnId, query_id: QueryId, reason: String);
69    /// An **engine query** faulted — the cluster discarded its pipeline (`parallel.rs`
70    /// `fault_recover`), so a consumer that owns the query's lifecycle (e.g. a
71    /// materialization manager) must arrange to **re-register** it; its subscribers are
72    /// separately told to re-subscribe via [`faulted`](Self::faulted). Called once per
73    /// faulted engine query, BEFORE its subscribers are notified, with the **engine** query
74    /// id (not a subscription id) and the fault's classification — a push-deadline bail
75    /// (FOLLOWER-LAG-SHED §6.6) must reach the owner's mode machine before the re-register
76    /// decision. Default: no-op (the 1:1/napi path re-registers on the next subscribe).
77    fn query_faulted(&mut self, _query_id: QueryId, _cause: crate::FaultCause) {}
78}
79
80/// A subscription id — one per attached subscriber (the routing identity the [`DrainSink`]
81/// delivers on, surfaced as a [`QueryId`] in the sink callbacks). Distinct from the
82/// **engine** query id: many subscribers can share one engine query (dedup), each with its
83/// own epoch / seq cursor / connection route.
84pub type SubId = u64;
85
86/// The control plane: coordinator → drain. Kept private; the coordinator drives it through
87/// [`DrainHandle`]'s methods.
88enum DrainControl {
89    /// Register a (possibly shared) engine query: its hosting worker, the shared footprint
90    /// fold + table set/fingerprint, and the hydrate watermark floor. Subscribers attach
91    /// separately so an identical AST registers ONE engine pipeline (`§11`).
92    RegisterQuery {
93        query_id: QueryId,
94        worker: usize,
95        fold: Box<NormalizeFold>,
96        normalized_fp: u64,
97        /// Project-at-emit map: every batch emitted for this query is projected through it
98        /// before fan-out (empty ⇒ pass-through). Shared by all subscribers (one AST).
99        proj: ProjMap,
100        hydrated_cv: u64,
101    },
102    /// Register a parameterized query family (design 310 §5.2): routing/progress state plus
103    /// the partition key the drain demuxes `Changed` events by; partitions arrive
104    /// separately via [`BindPartition`](DrainControl::BindPartition).
105    RegisterFamily {
106        query_id: QueryId,
107        worker: usize,
108        param_cols: Vec<ColId>,
109        hydrated_cv: u64,
110    },
111    /// Add one partition to a family: its own footprint fold / fingerprint / projection,
112    /// built from the **concrete** member AST (impl plan D8) so every frame equals the
113    /// standalone query's byte for byte. A `PartitionHydrated` (or a `Changed` bucket)
114    /// that raced ahead of this control is replayed onto it.
115    BindPartition {
116        query_id: QueryId,
117        binding: Binding,
118        fold: Box<NormalizeFold>,
119        normalized_fp: u64,
120        proj: ProjMap,
121        hydrated_cv: u64,
122    },
123    /// Drop one partition's footprint (the "view slice" of design §4.4); any subscriber
124    /// still on it is faulted with a distinct reason.
125    UnbindPartition {
126        query_id: QueryId,
127        binding: Binding,
128    },
129    /// Attach a subscriber to an engine query: its own conn route + epoch. If the query is
130    /// already hydrated the seq-0 snapshot is emitted from the cached footprint immediately;
131    /// otherwise it lands when the query hydrates. `binding` names the partition when the
132    /// query is a family (an attach to an unbound partition is ignored).
133    AttachSubscriber {
134        query_id: QueryId,
135        sub: SubId,
136        conn: ConnId,
137        epoch: u64,
138        binding: Option<Binding>,
139    },
140    /// Detach one subscriber (its lease/connection went away); the shared engine query and
141    /// its other subscribers are untouched.
142    DetachSubscriber {
143        sub: SubId,
144    },
145    /// Notify every subscriber on an engine query that it faulted/was revoked (each gets a
146    /// terminal `faulted`) and detach them, WITHOUT removing the query. Used to give active
147    /// subscribers a re-subscribe signal before an explicit dematerialize tears the query
148    /// down (a silent [`RemoveQuery`](DrainControl::RemoveQuery) would just drop them).
149    FaultSubscribers {
150        query_id: QueryId,
151        reason: String,
152    },
153    /// Tear down an engine query and every subscriber still on it (the materialization was
154    /// dematerialized / swept).
155    RemoveQuery {
156        query_id: QueryId,
157    },
158    Connect {
159        conn: ConnId,
160    },
161    Disconnect {
162        conn: ConnId,
163    },
164    Shutdown,
165}
166
167/// Bound on the drain loop's unified input queue, in messages (FOLLOWER-LAG-SHED §4,
168/// rung 0b) — the second hop of the delivery chain. A full queue blocks the forwarder (which
169/// stops draining the worker channel-out) and, transiently, a control sender; the loop's sink
170/// never blocks, so the queue always drains.
171const LOOP_CHANNEL_BOUND: usize = 256;
172
173/// The unified, ordered input to the drain loop (worker events + coordinator control).
174enum DrainMsg {
175    Event(ClusterEvent),
176    Control(DrainControl),
177}
178
179/// The coordinator-side handle: a cheap clone of the control sender. Lives on the Node main
180/// thread; its methods are called from the napi `Db` surface as queries are
181/// registered/destroyed, connections come and go, and commits land.
182#[derive(Clone)]
183pub struct DrainHandle {
184    tx: SyncSender<DrainMsg>,
185}
186
187impl DrainHandle {
188    /// Register a (possibly shared) engine query's footprint fold + routing/progress state.
189    /// Sent right after `cluster.query(query_id, ..)` returns the hosting `worker`; a
190    /// `Hydrated` that races ahead of it is buffered and replayed on register.
191    pub fn register_query(
192        &self,
193        query_id: QueryId,
194        worker: usize,
195        fold: NormalizeFold,
196        normalized_fp: u64,
197        proj: ProjMap,
198        hydrated_cv: u64,
199    ) {
200        let _ = self.tx.send(DrainMsg::Control(DrainControl::RegisterQuery {
201            query_id,
202            worker,
203            fold: Box::new(fold),
204            normalized_fp,
205            proj,
206            hydrated_cv,
207        }));
208    }
209
210    /// Register a parameterized query family's routing/progress state and partition key
211    /// (design 310 §5.2). Sent right after `cluster.family(..)` returns the hosting worker;
212    /// its partitions follow via [`bind_partition`](Self::bind_partition).
213    pub fn register_family(
214        &self,
215        query_id: QueryId,
216        worker: usize,
217        param_cols: Vec<ColId>,
218        hydrated_cv: u64,
219    ) {
220        let _ = self
221            .tx
222            .send(DrainMsg::Control(DrainControl::RegisterFamily {
223                query_id,
224                worker,
225                param_cols,
226                hydrated_cv,
227            }));
228    }
229
230    /// Add one partition (its footprint fold, built from the concrete member AST) to a
231    /// registered family.
232    pub fn bind_partition(
233        &self,
234        query_id: QueryId,
235        binding: Binding,
236        fold: NormalizeFold,
237        normalized_fp: u64,
238        proj: ProjMap,
239        hydrated_cv: u64,
240    ) {
241        let _ = self.tx.send(DrainMsg::Control(DrainControl::BindPartition {
242            query_id,
243            binding,
244            fold: Box::new(fold),
245            normalized_fp,
246            proj,
247            hydrated_cv,
248        }));
249    }
250
251    /// Drop one partition's drain-side state (after `cluster.unbind`).
252    pub fn unbind_partition(&self, query_id: QueryId, binding: Binding) {
253        let _ = self
254            .tx
255            .send(DrainMsg::Control(DrainControl::UnbindPartition {
256                query_id,
257                binding,
258            }));
259    }
260
261    /// Attach a subscriber (its own conn route + epoch) to a registered engine query.
262    pub fn attach_subscriber(&self, query_id: QueryId, sub: SubId, conn: ConnId, epoch: u64) {
263        let _ = self
264            .tx
265            .send(DrainMsg::Control(DrainControl::AttachSubscriber {
266                query_id,
267                sub,
268                conn,
269                epoch,
270                binding: None,
271            }));
272    }
273
274    /// Attach a subscriber to one partition of a registered family.
275    pub fn attach_partition_subscriber(
276        &self,
277        query_id: QueryId,
278        binding: Binding,
279        sub: SubId,
280        conn: ConnId,
281        epoch: u64,
282    ) {
283        let _ = self
284            .tx
285            .send(DrainMsg::Control(DrainControl::AttachSubscriber {
286                query_id,
287                sub,
288                conn,
289                epoch,
290                binding: Some(binding),
291            }));
292    }
293
294    /// Detach one subscriber, leaving the shared engine query (and its peers) running.
295    pub fn detach_subscriber(&self, sub: SubId) {
296        let _ = self
297            .tx
298            .send(DrainMsg::Control(DrainControl::DetachSubscriber { sub }));
299    }
300
301    /// Fault every subscriber on an engine query (terminal `faulted` each) and detach them,
302    /// leaving the query itself in place for the caller to [`remove_query`](Self::remove_query)
303    /// next — e.g. an explicit dematerialize that has active subscribers to notify.
304    pub fn fault_subscribers(&self, query_id: QueryId, reason: String) {
305        let _ = self
306            .tx
307            .send(DrainMsg::Control(DrainControl::FaultSubscribers {
308                query_id,
309                reason,
310            }));
311    }
312
313    /// Tear down an engine query's drain-side state (after `cluster.destroy_query`).
314    pub fn remove_query(&self, query_id: QueryId) {
315        let _ = self
316            .tx
317            .send(DrainMsg::Control(DrainControl::RemoveQuery { query_id }));
318    }
319
320    /// Register a connection under a consumer-assigned id.
321    pub fn connect(&self, conn: ConnId) {
322        let _ = self
323            .tx
324            .send(DrainMsg::Control(DrainControl::Connect { conn }));
325    }
326
327    /// Drop a connection and all its queries' drain-side state.
328    pub fn disconnect(&self, conn: ConnId) {
329        let _ = self
330            .tx
331            .send(DrainMsg::Control(DrainControl::Disconnect { conn }));
332    }
333}
334
335/// The running drain: owns the two worker/forwarder/loop threads. Dropping (or
336/// [`shutdown`](Self::shutdown)) stops the loop; the forwarder exits when the cluster's
337/// channel-out closes (i.e. when the `Cluster` is dropped).
338pub struct Drain {
339    tx: SyncSender<DrainMsg>,
340    loop_join: Option<JoinHandle<()>>,
341    _forward_join: Option<JoinHandle<()>>,
342}
343
344impl Drain {
345    /// Spawn the drain over a cluster's channel-out `events`, with `initial_cv` the
346    /// replica's committed watermark at open (the progress frontier) and `sink` the
347    /// delivery target. Returns the handle plus a [`DrainHandle`] for the coordinator.
348    pub fn spawn<S: DrainSink + 'static>(
349        events: Receiver<ClusterEvent>,
350        initial_cv: u64,
351        sink: S,
352    ) -> Drain {
353        // BOUNDED (FOLLOWER-LAG-SHED §4, rung 0b): this loop queue is the second unbounded hop
354        // that used to hide behind the worker channel-out — bounding the first alone just
355        // relocates the backlog here. A full queue blocks the forwarder, which stops draining
356        // the channel-out, which blocks the workers: pressure propagates upstream by
357        // construction. The loop's sink never blocks (a slow client is evicted), so the loop
358        // always drains. Control senders (the engine thread) share the bound — a backlogged
359        // drain briefly backpressures control too, which is the intended lock-step.
360        let (tx, rx) = mpsc::sync_channel::<DrainMsg>(LOOP_CHANNEL_BOUND);
361
362        // Forwarder: relay every worker event into the unified ordered stream. Exits when
363        // the cluster's channel-out closes (Cluster dropped) or the loop has gone away.
364        let fwd_tx = tx.clone();
365        let forward_join = thread::Builder::new()
366            .name("ivm-drain-forward".into())
367            .spawn(move || {
368                while let Ok(ev) = events.recv() {
369                    if fwd_tx.send(DrainMsg::Event(ev)).is_err() {
370                        break;
371                    }
372                }
373            })
374            .expect("spawn drain forwarder");
375
376        let mut core = DrainCore::new(Box::new(sink), initial_cv);
377        let loop_join = thread::Builder::new()
378            .name("ivm-drain".into())
379            .spawn(move || {
380                while let Ok(msg) = rx.recv() {
381                    match msg {
382                        DrainMsg::Event(ev) => core.on_event(ev),
383                        DrainMsg::Control(DrainControl::Shutdown) => break,
384                        DrainMsg::Control(c) => core.on_control(c),
385                    }
386                }
387            })
388            .expect("spawn drain loop");
389
390        Drain {
391            tx,
392            loop_join: Some(loop_join),
393            _forward_join: Some(forward_join),
394        }
395    }
396
397    /// A coordinator-side control handle (cheap clone).
398    pub fn handle(&self) -> DrainHandle {
399        DrainHandle {
400            tx: self.tx.clone(),
401        }
402    }
403
404    /// Stop the loop and join it. The forwarder is left to exit on cluster close.
405    pub fn shutdown(mut self) {
406        self.stop();
407    }
408
409    fn stop(&mut self) {
410        let _ = self.tx.send(DrainMsg::Control(DrainControl::Shutdown));
411        if let Some(j) = self.loop_join.take() {
412            let _ = j.join();
413        }
414    }
415}
416
417impl Drop for Drain {
418    fn drop(&mut self) {
419        self.stop();
420    }
421}
422
423// ---------------------------------------------------------------------------
424// The drain core (single-thread state machine; runs on the drain thread)
425// ---------------------------------------------------------------------------
426
427/// One footprint fold and the subscribers fed from it: a singleton query's whole
428/// state, or ONE partition of a parameterized query family (design 310 §5.2 / impl plan
429/// D8 — a partition holds its own fold built from the concrete member AST, so its
430/// `hello`, `normalized_fp`, and projection are byte-identical to the standalone's).
431struct Footprint {
432    /// The committed watermark this footprint hydrated at — a floor on its known-current
433    /// `cv` (the hydrate reflects every commit ≤ this, even before the worker reports a
434    /// position).
435    hydrated_cv: u64,
436    /// The latest commit version the footprint reflects — what a late subscriber's
437    /// snapshot frame is stamped with (≥ `hydrated_cv`). Advances on each delivered tick;
438    /// NOT used for release (that stays gated on the worker position).
439    footprint_cv: u64,
440    /// `true` once the hydrate snapshot has folded in — late subscribers can baseline off
441    /// the cached footprint, earlier ones were baselined when it arrived.
442    hydrated: bool,
443    /// The shared footprint serializer: ONE per engine query / partition (the dedup win —
444    /// identical ASTs fold once, §11). Snapshots a late subscriber via
445    /// [`NormalizeFold::footprint_snapshot`].
446    fold: NormalizeFold,
447    normalized_fp: u64,
448    /// Project-at-emit map (`PROJECTION-SUPPORT-DESIGN.md` §5.2): table → projected base-column
449    /// indices. The shared fold runs over FULL rows; each emitted snapshot/batch is projected
450    /// through this before fan-out. Empty ⇒ pass-through (a `'*'` query).
451    proj: ProjMap,
452    /// The subscribers fed from this footprint.
453    subscribers: BTreeSet<SubId>,
454}
455
456impl Footprint {
457    fn new(fold: NormalizeFold, normalized_fp: u64, proj: ProjMap, hydrated_cv: u64) -> Footprint {
458        Footprint {
459            hydrated_cv,
460            footprint_cv: hydrated_cv,
461            hydrated: false,
462            fold,
463            normalized_fp,
464            proj,
465            subscribers: BTreeSet::new(),
466        }
467    }
468}
469
470/// What kind of engine query a [`QueryReg`] routes: a singleton owns one footprint; a
471/// family owns one per bound partition, keyed by the binding the root row's partition
472/// columns canonicalize to.
473enum QueryKind {
474    Singleton(Footprint),
475    Family {
476        /// The partition key — the parameter columns on the root row (design §4.2).
477        param_cols: Vec<ColId>,
478        partitions: HashMap<Binding, Footprint>,
479        /// Events for a binding whose `BindPartition` control has not landed yet (the
480        /// worker's `PartitionHydrated` and any `Changed` bucket behind it can race the
481        /// coordinator's control), replayed in order on the control. Only a
482        /// `PartitionHydrated` opens an entry: a `Changed` bucket for a binding with
483        /// neither a footprint nor a pending entry is the unbind race — dropped.
484        pending: HashMap<Binding, Vec<Update>>,
485    },
486}
487
488/// Per-registered **engine query** on the drain — its routing/progress state and its
489/// footprint(s), shared across every subscriber on it.
490struct QueryReg {
491    worker: usize,
492    /// The committed watermark the query registered at — the `cv` floor for a subscriber
493    /// whose footprint is not (yet) known.
494    hydrated_cv: u64,
495    kind: QueryKind,
496}
497
498impl QueryReg {
499    fn footprint(&self, binding: Option<&Binding>) -> Option<&Footprint> {
500        match (&self.kind, binding) {
501            (QueryKind::Singleton(f), _) => Some(f),
502            (QueryKind::Family { partitions, .. }, Some(b)) => partitions.get(b),
503            (QueryKind::Family { .. }, None) => None,
504        }
505    }
506
507    fn footprint_mut(&mut self, binding: Option<&Binding>) -> Option<&mut Footprint> {
508        match (&mut self.kind, binding) {
509            (QueryKind::Singleton(f), _) => Some(f),
510            (QueryKind::Family { partitions, .. }, Some(b)) => partitions.get_mut(b),
511            (QueryKind::Family { .. }, None) => None,
512        }
513    }
514
515    /// Every subscriber across every footprint.
516    fn all_subscribers(&self) -> Vec<SubId> {
517        match &self.kind {
518            QueryKind::Singleton(f) => f.subscribers.iter().copied().collect(),
519            QueryKind::Family { partitions, .. } => partitions
520                .values()
521                .flat_map(|f| f.subscribers.iter().copied())
522                .collect(),
523        }
524    }
525
526    /// Detach every subscriber across every footprint, returning them.
527    fn take_all_subscribers(&mut self) -> Vec<SubId> {
528        match &mut self.kind {
529            QueryKind::Singleton(f) => std::mem::take(&mut f.subscribers).into_iter().collect(),
530            QueryKind::Family { partitions, .. } => partitions
531                .values_mut()
532                .flat_map(|f| std::mem::take(&mut f.subscribers).into_iter())
533                .collect(),
534        }
535    }
536
537    fn remove_subscriber(&mut self, sub: SubId, binding: Option<&Binding>) {
538        if let Some(f) = self.footprint_mut(binding) {
539            f.subscribers.remove(&sub);
540        }
541    }
542}
543
544/// Per-subscriber framing + routing — many of these share one [`QueryReg`].
545struct SubReg {
546    query_id: QueryId,
547    /// The partition this subscriber is on when the query is a family; `None` for a
548    /// singleton.
549    binding: Option<Binding>,
550    conn: ConnId,
551    epoch: u64,
552    /// Gap-free seq over *emitted* batches; `0` until the seq-0 snapshot is sent.
553    next_seq: u64,
554    /// `false` until this subscriber's seq-0 snapshot has been emitted (a pre-hydrate
555    /// attach waits for the engine query to hydrate).
556    baselined: bool,
557}
558
559/// Per-connection state for the poke rule + coherent-release tracking.
560struct ConnReg {
561    /// The subscriptions on this connection (its `cv_min` floor is the min over them).
562    subs: BTreeSet<SubId>,
563    /// `cv_min` of the last progress frame sent (0 if none).
564    released_cv: u64,
565    /// Highest `cv` of a data frame (snapshot / batch) delivered to this connection — the
566    /// release target. A progress frame is due only while this exceeds `released_cv`.
567    max_delivered: u64,
568}
569
570struct DrainCore {
571    sink: Box<dyn DrainSink>,
572    /// The committed watermark (max `tx_id` seen) — the `cv_min` of a query-less connection.
573    frontier: u64,
574    /// worker → highest `tx_id` it has fully delivered (`Progressed`).
575    worker_pos: HashMap<usize, u64>,
576    queries: HashMap<QueryId, QueryReg>,
577    subs: HashMap<SubId, SubReg>,
578    conns: HashMap<ConnId, ConnReg>,
579    /// Events that arrived for a query before its `RegisterQuery` (defensive; the
580    /// coordinator registers right after `cluster.query`, but a `Hydrated` can race ahead).
581    pending_events: HashMap<QueryId, Vec<Update>>,
582}
583
584impl DrainCore {
585    fn new(sink: Box<dyn DrainSink>, initial_cv: u64) -> DrainCore {
586        DrainCore {
587            sink,
588            frontier: initial_cv,
589            worker_pos: HashMap::new(),
590            queries: HashMap::new(),
591            subs: HashMap::new(),
592            conns: HashMap::new(),
593            pending_events: HashMap::new(),
594        }
595    }
596
597    // --- worker-event handling --------------------------------------------
598
599    fn on_event(&mut self, ev: ClusterEvent) {
600        match ev {
601            ClusterEvent::Update { query_id, update } => self.on_update(query_id, update),
602            ClusterEvent::Progressed { worker, tx_id } => self.on_progressed(worker, tx_id.0),
603            ClusterEvent::Faulted {
604                query_id,
605                reason,
606                cause,
607            } => self.on_faulted(query_id, reason, cause),
608        }
609    }
610
611    fn on_update(&mut self, query_id: QueryId, update: Update) {
612        if !self.queries.contains_key(&query_id) {
613            // Not yet registered — buffer and replay on RegisterQuery (never drop a baseline).
614            self.pending_events
615                .entry(query_id)
616                .or_default()
617                .push(update);
618            return;
619        }
620        match update {
621            Update::Hydrated { tx_id, changes } => {
622                self.hydrate_footprint(query_id, None, tx_id.0, &changes);
623            }
624            Update::PartitionHydrated {
625                tx_id,
626                binding,
627                changes,
628            } => {
629                let reg = self.queries.get_mut(&query_id).expect("present");
630                match &mut reg.kind {
631                    // A singleton never receives one; ignore rather than mis-fold.
632                    QueryKind::Singleton(_) => {}
633                    QueryKind::Family {
634                        partitions,
635                        pending,
636                        ..
637                    } => {
638                        if partitions.contains_key(&binding) {
639                            self.hydrate_footprint(query_id, Some(&binding), tx_id.0, &changes);
640                        } else {
641                            // The bind race: the worker's hydrate beat the coordinator's
642                            // `BindPartition` control. Open the pending entry; replayed then.
643                            pending.entry(binding.clone()).or_default().push(
644                                Update::PartitionHydrated {
645                                    tx_id,
646                                    binding,
647                                    changes,
648                                },
649                            );
650                        }
651                    }
652                }
653            }
654            Update::Changed { tx_id, changes } => {
655                let reg = self.queries.get(&query_id).expect("present");
656                let param_cols = match &reg.kind {
657                    QueryKind::Singleton(_) => {
658                        self.change_footprint(query_id, None, tx_id.0, changes);
659                        return;
660                    }
661                    QueryKind::Family { param_cols, .. } => param_cols.clone(),
662                };
663                for (binding, bucket) in demux_by_partition(changes, &param_cols) {
664                    let reg = self.queries.get_mut(&query_id).expect("present");
665                    let QueryKind::Family {
666                        partitions,
667                        pending,
668                        ..
669                    } = &mut reg.kind
670                    else {
671                        unreachable!("kind checked above")
672                    };
673                    if partitions.contains_key(&binding) {
674                        self.change_footprint(query_id, Some(&binding), tx_id.0, bucket);
675                    } else if let Some(p) = pending.get_mut(&binding) {
676                        p.push(Update::Changed {
677                            tx_id,
678                            changes: bucket,
679                        });
680                    }
681                    // else: the unbind race — a delta for a partition no longer bound.
682                }
683            }
684        }
685    }
686
687    /// Fold a hydrate batch into one footprint (a singleton's, or a family partition's),
688    /// mark it hydrated, and baseline every subscriber already waiting on it from that one
689    /// footprint. The footprint-only fold skips the wire-op materialization — late
690    /// subscribers baseline off `footprint_snapshot`, never the hydrate fold's return value.
691    fn hydrate_footprint(
692        &mut self,
693        query_id: QueryId,
694        binding: Option<&Binding>,
695        cv: u64,
696        changes: &[ChangeEvent],
697    ) {
698        let waiting: Vec<SubId> = {
699            let reg = self.queries.get_mut(&query_id).expect("present");
700            let Some(fp) = reg.footprint_mut(binding) else {
701                return;
702            };
703            fp.fold.fold_footprint_only(changes);
704            fp.hydrated = true;
705            fp.footprint_cv = fp.footprint_cv.max(cv);
706            fp.subscribers.iter().copied().collect()
707        };
708        for sub in waiting {
709            self.baseline_subscriber(sub);
710        }
711    }
712
713    /// Fold one commit's changes ONCE through a footprint; fan the net deltas out to every
714    /// baselined subscriber on it, each stamped with its own epoch + seq. A fold that nets
715    /// to nothing yields no batch (and no seq, keeping seq gap-free).
716    fn change_footprint(
717        &mut self,
718        query_id: QueryId,
719        binding: Option<&Binding>,
720        cv: u64,
721        changes: Vec<ChangeEvent>,
722    ) {
723        let (ops, subscribers, fp_id) = {
724            let reg = self.queries.get_mut(&query_id).expect("present");
725            let Some(fp) = reg.footprint_mut(binding) else {
726                return;
727            };
728            fp.footprint_cv = fp.footprint_cv.max(cv);
729            // No subscribers ⇒ keep the shared footprint current for late joiners
730            // (their snapshot reads it), but skip building + projecting wire ops
731            // nobody will receive. This is the steady-state pinned-board case — the
732            // bulk of the per-commit allocation churn the wiki-demo RSS profiling
733            // traced the glibc retention to.
734            if fp.subscribers.is_empty() {
735                fp.fold.fold_footprint_only(&changes);
736                return;
737            }
738            // Fold over FULL rows once (the shared dedup win), then project the net
739            // deltas once before fanning out — projection is per-query, so every
740            // subscriber of this AST gets the same projected ops (§2.1/§5.2).
741            let ops = project_ops(fp.fold.fold(&changes), &fp.proj);
742            (
743                ops,
744                fp.subscribers.iter().copied().collect::<Vec<_>>(),
745                fp.normalized_fp,
746            )
747        };
748        if ops.is_empty() {
749            return;
750        }
751        for sub in subscribers {
752            let Some(s) = self.subs.get_mut(&sub) else {
753                continue;
754            };
755            if !s.baselined {
756                continue;
757            }
758            let batch = NormalizedBatch {
759                epoch: s.epoch,
760                seq: s.next_seq,
761                cv,
762                normalized_fp: fp_id,
763                ops: ops.clone(),
764            };
765            s.next_seq += 1;
766            let conn = s.conn;
767            self.sink.batch(conn, QueryId(sub), batch);
768            if let Some(c) = self.conns.get_mut(&conn) {
769                c.max_delivered = c.max_delivered.max(cv);
770            }
771        }
772    }
773
774    /// Emit a subscriber's seq-0 snapshot from its footprint's cached fold and mark it
775    /// live. No-op if the footprint is not yet hydrated (the subscriber waits) or the
776    /// subscriber already baselined. Mirrors the single-thread server's
777    /// "subscribe → hello, snapshot, progress": the snapshot always gets an initial frame.
778    fn baseline_subscriber(&mut self, sub: SubId) {
779        let Some(s) = self.subs.get(&sub) else {
780            return;
781        };
782        if s.baselined {
783            return;
784        }
785        let Some(fp) = self
786            .queries
787            .get(&s.query_id)
788            .and_then(|reg| reg.footprint(s.binding.as_ref()))
789        else {
790            return;
791        };
792        if !fp.hydrated {
793            return;
794        }
795        let batch = NormalizedBatch {
796            epoch: s.epoch,
797            seq: 0,
798            cv: fp.footprint_cv,
799            normalized_fp: fp.normalized_fp,
800            // Project the cached full-row footprint to this query's synced columns (§5.2).
801            ops: project_ops(fp.fold.footprint_snapshot(), &fp.proj),
802        };
803        let (conn, cv) = (s.conn, fp.footprint_cv);
804        let s = self.subs.get_mut(&sub).expect("present");
805        s.baselined = true;
806        s.next_seq = 1;
807        self.sink.batch(conn, QueryId(sub), batch);
808        if let Some(c) = self.conns.get_mut(&conn) {
809            c.max_delivered = c.max_delivered.max(cv);
810        }
811        self.emit_frame(conn, true);
812    }
813
814    fn on_progressed(&mut self, worker: usize, tx_id: u64) {
815        let slot = self.worker_pos.entry(worker).or_insert(0);
816        if tx_id <= *slot {
817            return;
818        }
819        *slot = tx_id;
820        self.frontier = self.frontier.max(tx_id);
821        // Every connection with a subscription on this worker may have advanced its cv_min.
822        let conns = self.conns_on_worker(worker);
823        for conn in conns {
824            self.emit_frame(conn, false);
825        }
826    }
827
828    fn on_faulted(&mut self, query_id: QueryId, reason: String, cause: crate::FaultCause) {
829        // Terminal teardown of one engine query: drop it + every subscriber on it (each
830        // re-subscribes), so it stops pinning cv_min.
831        self.pending_events.remove(&query_id);
832        let Some(reg) = self.queries.remove(&query_id) else {
833            return;
834        };
835        // Signal the query owner FIRST (before notifying subscribers), so any re-registration
836        // it triggers is enqueued ahead of the re-subscribes the subscriber faults provoke —
837        // the re-registered query is then present when those re-subscribes attach. The cause
838        // rides along so the owner's mode machine can consult it BEFORE deciding to
839        // re-register (FOLLOWER-LAG-SHED §6.2 item 5 / §6.6 item 4).
840        self.sink.query_faulted(query_id, cause);
841        for sub in reg.all_subscribers() {
842            if let Some(s) = self.subs.remove(&sub) {
843                if let Some(c) = self.conns.get_mut(&s.conn) {
844                    c.subs.remove(&sub);
845                }
846                self.sink.faulted(s.conn, QueryId(sub), reason.clone());
847                // A pinned-back cv_min may now be free to advance for the connection.
848                self.emit_frame(s.conn, false);
849            }
850        }
851    }
852
853    /// Fault + detach every subscriber on a query without removing the query (the caller
854    /// removes it next). Unlike [`on_faulted`](Self::on_faulted) this does NOT signal the
855    /// query owner — the query is being deliberately revoked, not recovered.
856    fn fault_subscribers(&mut self, query_id: QueryId, reason: &str) {
857        let subs: Vec<SubId> = match self.queries.get_mut(&query_id) {
858            Some(reg) => reg.take_all_subscribers(),
859            None => return,
860        };
861        self.fault_subs(subs, reason);
862    }
863
864    /// Fault + detach the given (already-taken) subscribers.
865    fn fault_subs(&mut self, subs: Vec<SubId>, reason: &str) {
866        for sub in subs {
867            if let Some(s) = self.subs.remove(&sub) {
868                if let Some(c) = self.conns.get_mut(&s.conn) {
869                    c.subs.remove(&sub);
870                }
871                self.sink.faulted(s.conn, QueryId(sub), reason.to_string());
872                self.emit_frame(s.conn, false);
873            }
874        }
875    }
876
877    // --- control handling -------------------------------------------------
878
879    fn on_control(&mut self, c: DrainControl) {
880        match c {
881            DrainControl::RegisterQuery {
882                query_id,
883                worker,
884                fold,
885                normalized_fp,
886                proj,
887                hydrated_cv,
888            } => {
889                self.queries.insert(
890                    query_id,
891                    QueryReg {
892                        worker,
893                        hydrated_cv,
894                        kind: QueryKind::Singleton(Footprint::new(
895                            *fold,
896                            normalized_fp,
897                            proj,
898                            hydrated_cv,
899                        )),
900                    },
901                );
902                self.replay_pending(query_id);
903            }
904            DrainControl::RegisterFamily {
905                query_id,
906                worker,
907                param_cols,
908                hydrated_cv,
909            } => {
910                self.queries.insert(
911                    query_id,
912                    QueryReg {
913                        worker,
914                        hydrated_cv,
915                        kind: QueryKind::Family {
916                            param_cols,
917                            partitions: HashMap::new(),
918                            pending: HashMap::new(),
919                        },
920                    },
921                );
922                // A `PartitionHydrated` that beat the register lands in the per-binding
923                // pending map through the ordinary route.
924                self.replay_pending(query_id);
925            }
926            DrainControl::BindPartition {
927                query_id,
928                binding,
929                fold,
930                normalized_fp,
931                proj,
932                hydrated_cv,
933            } => {
934                let buffered = {
935                    let Some(reg) = self.queries.get_mut(&query_id) else {
936                        return;
937                    };
938                    let QueryKind::Family {
939                        partitions,
940                        pending,
941                        ..
942                    } = &mut reg.kind
943                    else {
944                        return; // a bind on a singleton — ignore
945                    };
946                    partitions.insert(
947                        binding.clone(),
948                        Footprint::new(*fold, normalized_fp, proj, hydrated_cv),
949                    );
950                    pending.remove(&binding).unwrap_or_default()
951                };
952                // Replay the partition's own hydrate (and any deltas behind it) in order.
953                for u in buffered {
954                    self.on_update(query_id, u);
955                }
956            }
957            DrainControl::UnbindPartition { query_id, binding } => {
958                let subs = {
959                    let Some(reg) = self.queries.get_mut(&query_id) else {
960                        return;
961                    };
962                    let QueryKind::Family {
963                        partitions,
964                        pending,
965                        ..
966                    } = &mut reg.kind
967                    else {
968                        return;
969                    };
970                    pending.remove(&binding);
971                    match partitions.remove(&binding) {
972                        Some(fp) => fp.subscribers.into_iter().collect::<Vec<_>>(),
973                        None => return,
974                    }
975                };
976                self.fault_subs(subs, "partition unbound");
977            }
978            DrainControl::AttachSubscriber {
979                query_id,
980                sub,
981                conn,
982                epoch,
983                binding,
984            } => {
985                let Some(reg) = self.queries.get_mut(&query_id) else {
986                    return; // attach to an unknown query — ignore
987                };
988                let Some(fp) = reg.footprint_mut(binding.as_ref()) else {
989                    return; // attach to an unbound partition — ignore
990                };
991                fp.subscribers.insert(sub);
992                self.subs.insert(
993                    sub,
994                    SubReg {
995                        query_id,
996                        binding,
997                        conn,
998                        epoch,
999                        next_seq: 0,
1000                        baselined: false,
1001                    },
1002                );
1003                if let Some(c) = self.conns.get_mut(&conn) {
1004                    c.subs.insert(sub);
1005                }
1006                // Already hydrated ⇒ snapshot off the cached footprint now; else it lands
1007                // when the engine query / partition hydrates.
1008                self.baseline_subscriber(sub);
1009            }
1010            DrainControl::DetachSubscriber { sub } => {
1011                self.drop_subscriber(sub);
1012            }
1013            DrainControl::FaultSubscribers { query_id, reason } => {
1014                self.fault_subscribers(query_id, &reason);
1015            }
1016            DrainControl::RemoveQuery { query_id } => {
1017                self.pending_events.remove(&query_id);
1018                if let Some(reg) = self.queries.remove(&query_id) {
1019                    for sub in reg.all_subscribers() {
1020                        if let Some(s) = self.subs.remove(&sub) {
1021                            if let Some(c) = self.conns.get_mut(&s.conn) {
1022                                c.subs.remove(&sub);
1023                            }
1024                        }
1025                    }
1026                }
1027            }
1028            DrainControl::Connect { conn } => {
1029                self.conns.insert(
1030                    conn,
1031                    ConnReg {
1032                        subs: BTreeSet::new(),
1033                        released_cv: 0,
1034                        max_delivered: 0,
1035                    },
1036                );
1037            }
1038            DrainControl::Disconnect { conn } => {
1039                // Drop the connection's subscriptions (its shared engine queries persist —
1040                // the manager owns their lifecycle).
1041                if let Some(c) = self.conns.remove(&conn) {
1042                    for sub in c.subs {
1043                        if let Some(s) = self.subs.remove(&sub) {
1044                            if let Some(reg) = self.queries.get_mut(&s.query_id) {
1045                                reg.remove_subscriber(sub, s.binding.as_ref());
1046                            }
1047                        }
1048                    }
1049                }
1050            }
1051            DrainControl::Shutdown => {}
1052        }
1053    }
1054
1055    /// Replay any events (a `Hydrated` / `PartitionHydrated`) that beat a register.
1056    fn replay_pending(&mut self, query_id: QueryId) {
1057        if let Some(buffered) = self.pending_events.remove(&query_id) {
1058            for u in buffered {
1059                self.on_update(query_id, u);
1060            }
1061        }
1062    }
1063
1064    /// Detach one subscriber from its (persisting) engine query and connection.
1065    fn drop_subscriber(&mut self, sub: SubId) {
1066        if let Some(s) = self.subs.remove(&sub) {
1067            if let Some(reg) = self.queries.get_mut(&s.query_id) {
1068                reg.remove_subscriber(sub, s.binding.as_ref());
1069            }
1070            if let Some(c) = self.conns.get_mut(&s.conn) {
1071                c.subs.remove(&sub);
1072            }
1073        }
1074    }
1075
1076    // --- the poke decision ------------------------------------------------
1077
1078    /// Recompute `conn`'s `cv_min` and emit a progress frame iff the poke rule fires:
1079    /// there is undelivered-but-now-releasable data. `force` (the initial snapshot
1080    /// frame) always emits. Mutation confirmation needs no extra trigger — a client's
1081    /// own lmid advance is a data frame on its system query, so it IS releasable data.
1082    fn emit_frame(&mut self, conn: ConnId, force: bool) {
1083        let Some(c) = self.conns.get(&conn) else {
1084            return;
1085        };
1086        let cv_min = self.conn_cv_min(c);
1087        let data_to_release = c.max_delivered > c.released_cv && cv_min > c.released_cv;
1088
1089        if !(force || data_to_release) {
1090            return;
1091        }
1092        let c = self.conns.get_mut(&conn).expect("present");
1093        c.released_cv = c.released_cv.max(cv_min);
1094        self.sink.progress(conn, ProgressFrame { cv_min });
1095    }
1096
1097    /// The commit version every one of `conn`'s live subscriptions is known-current through —
1098    /// the min over them of `max(hydrated_cv, host-worker position)`; the frontier for a
1099    /// subscription-less connection.
1100    fn conn_cv_min(&self, c: &ConnReg) -> u64 {
1101        if c.subs.is_empty() {
1102            return self.frontier;
1103        }
1104        c.subs
1105            .iter()
1106            .map(|s| self.sub_cv(*s))
1107            .min()
1108            .unwrap_or(self.frontier)
1109    }
1110
1111    fn sub_cv(&self, sub: SubId) -> u64 {
1112        let Some(s) = self.subs.get(&sub) else {
1113            return self.frontier;
1114        };
1115        match self.queries.get(&s.query_id) {
1116            Some(reg) => {
1117                // A partition hydrated later than its family registered carries its own
1118                // (higher) floor.
1119                let floor = reg
1120                    .footprint(s.binding.as_ref())
1121                    .map(|f| f.hydrated_cv)
1122                    .unwrap_or(reg.hydrated_cv);
1123                floor.max(self.worker_pos.get(&reg.worker).copied().unwrap_or(0))
1124            }
1125            None => self.frontier,
1126        }
1127    }
1128
1129    fn conns_on_worker(&self, worker: usize) -> Vec<ConnId> {
1130        let mut set: BTreeSet<ConnId> = BTreeSet::new();
1131        for s in self.subs.values() {
1132            if self
1133                .queries
1134                .get(&s.query_id)
1135                .is_some_and(|reg| reg.worker == worker)
1136            {
1137                set.insert(s.conn);
1138            }
1139        }
1140        set.into_iter().collect()
1141    }
1142}
1143
1144/// Distinct partitions a batch may span before the demux stops scanning its buckets
1145/// linearly and builds a hash index over them. Measured, not guessed (follow-up 310 B3, on
1146/// a 4-core Xeon @ 2.80 GHz, two parameter columns, every change a distinct partition):
1147/// bucketing 32 changes costs 3.3 µs linearly against 8.4 µs through a `HashMap`, so the
1148/// scan wins outright at the width a real commit has — but 512 costs 345 µs against
1149/// 117 µs, 2 048 costs 5.2 ms against 0.49 ms, and 8 192 costs 80 ms against 2.7 ms. The
1150/// crossover sits near 100 partitions; 64 keeps the common case byte-for-byte the scan it
1151/// always was and caps the quadratic before it can matter.
1152const LINEAR_DEMUX_BUCKETS: usize = 64;
1153
1154/// Demux one `Changed` batch by the root row's partition key (impl plan D8): every change
1155/// is routable by a column read, buckets keep the batch's arrival order, and the changes
1156/// within a bucket keep theirs.
1157///
1158/// Batch width is a property of the WRITE, not of the family: one commit updating many
1159/// users' rows — the `zero-hot` bulk-update shape — lands as one batch spanning as many
1160/// partitions. A plain scan over the buckets seen so far is O(partitions²) in that width,
1161/// which is why it hands off to a hash index past [`LINEAR_DEMUX_BUCKETS`]; the index maps
1162/// a binding to its slot in `buckets`, so first-seen order survives the switch.
1163fn demux_by_partition(
1164    changes: Vec<ChangeEvent>,
1165    param_cols: &[ColId],
1166) -> Vec<(Binding, Vec<ChangeEvent>)> {
1167    let mut buckets: Vec<(Binding, Vec<ChangeEvent>)> = Vec::new();
1168    // Built only once the scan stops being the cheaper lookup; `None` is the scan.
1169    let mut index: Option<HashMap<Binding, usize>> = None;
1170    for c in changes {
1171        let key = canonical_key(c.root_row(), param_cols);
1172        let slot = match &index {
1173            Some(ix) => ix.get(&key).copied(),
1174            None => buckets.iter().position(|(b, _)| *b == key),
1175        };
1176        match slot {
1177            Some(i) => buckets[i].1.push(c),
1178            None => {
1179                let i = buckets.len();
1180                buckets.push((key.clone(), vec![c]));
1181                match &mut index {
1182                    Some(ix) => {
1183                        ix.insert(key, i);
1184                    }
1185                    None if buckets.len() > LINEAR_DEMUX_BUCKETS => {
1186                        index = Some(
1187                            buckets
1188                                .iter()
1189                                .enumerate()
1190                                .map(|(i, (b, _))| (b.clone(), i))
1191                                .collect(),
1192                        );
1193                    }
1194                    None => {}
1195                }
1196            }
1197        }
1198    }
1199    buckets
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204    use super::*;
1205    use crate::TxId;
1206    use rindle::value::{owned_row, OwnedValue};
1207    use rindle::{CaughtChange, CaughtNode};
1208    use std::sync::{Arc, Mutex};
1209
1210    use crate::normalize_protocol::TableWireSchema;
1211
1212    // NOTE (regression context): the drain used to carry per-commit lmid/rejected
1213    // metadata on the control channel, racing the worker event stream into the one
1214    // queue — a `Progressed(N)` that beat `CommitMeta(N)` emitted a frame releasing
1215    // commit N's data with a STALE lmid, making the client re-invoke its own
1216    // already-confirmed mutation (the dup-ADD `debug_assert`,
1217    // `CLUSTER-FOLD-IN-FINDINGS.md`). That class of race is now structurally
1218    // impossible: lmid is a row delivered through the client's own system query, so
1219    // it shares the data stream's ordering and the frame carries only `cv_min`.
1220
1221    /// A collector usable as the drain sink — DrainCore owns it (boxed), so it shares its
1222    /// state through an `Arc<Mutex<_>>` the test also holds.
1223    #[derive(Clone, Default)]
1224    struct Out {
1225        batches: Arc<Mutex<Vec<(ConnId, u64, NormalizedBatch)>>>,
1226        frames: Arc<Mutex<Vec<(ConnId, ProgressFrame)>>>,
1227        faults: Arc<Mutex<Vec<(ConnId, u64)>>>,
1228        query_faults: Arc<Mutex<Vec<u64>>>,
1229    }
1230    impl DrainSink for Out {
1231        fn batch(&mut self, conn: ConnId, q: QueryId, b: NormalizedBatch) {
1232            self.batches.lock().unwrap().push((conn, q.0, b));
1233        }
1234        fn progress(&mut self, conn: ConnId, f: ProgressFrame) {
1235            self.frames.lock().unwrap().push((conn, f));
1236        }
1237        fn faulted(&mut self, conn: ConnId, q: QueryId, _r: String) {
1238            self.faults.lock().unwrap().push((conn, q.0));
1239        }
1240        fn query_faulted(&mut self, q: QueryId, _cause: crate::FaultCause) {
1241            self.query_faults.lock().unwrap().push(q.0);
1242        }
1243    }
1244
1245    use crate::normalize::NormalizeFold;
1246    use crate::normalize_protocol::build_query_parts;
1247
1248    fn query_parts() -> (NormalizeFold, u64) {
1249        let ast = rindle::table("t").build();
1250        let schemas = vec![TableWireSchema {
1251            name: "t".into(),
1252            columns: vec!["id".into(), "val".into()],
1253            primary_key: vec![0],
1254        }];
1255        let (fold, _tables, fp, _proj) = build_query_parts(&ast, schemas);
1256        (fold, fp)
1257    }
1258
1259    /// Register engine query `q` and (by default) attach a single subscriber with sub id
1260    /// `== q` on `conn` — the 1:1 shape the old `install` helper drove.
1261    fn register(core: &mut DrainCore, q: u64, worker: usize, cv: u64) {
1262        let (fold, fp) = query_parts();
1263        core.on_control(DrainControl::RegisterQuery {
1264            query_id: QueryId(q),
1265            worker,
1266            fold: Box::new(fold),
1267            normalized_fp: fp,
1268            proj: ProjMap::new(),
1269            hydrated_cv: cv,
1270        });
1271    }
1272
1273    fn attach(core: &mut DrainCore, q: u64, sub: u64, conn: ConnId, epoch: u64) {
1274        core.on_control(DrainControl::AttachSubscriber {
1275            query_id: QueryId(q),
1276            sub,
1277            conn,
1278            epoch,
1279            binding: None,
1280        });
1281    }
1282
1283    fn add(id: i64) -> CaughtChange {
1284        add_val(id, "x")
1285    }
1286
1287    fn add_val(id: i64, val: &str) -> CaughtChange {
1288        CaughtChange::Add(CaughtNode {
1289            row: owned_row(vec![OwnedValue::Int(id), OwnedValue::str(val)]),
1290            relationships: Default::default(),
1291        })
1292    }
1293
1294    // --- parameterized query families (design 310 §5.2 / impl plan D8) ------------
1295
1296    fn sb(val: &str) -> Binding {
1297        vec![rindle::canon::CanonVal::Str(val.into())]
1298    }
1299
1300    /// Register family `q` partitioned on column 1 (`val`).
1301    fn register_family(core: &mut DrainCore, q: u64, worker: usize, cv: u64) {
1302        core.on_control(DrainControl::RegisterFamily {
1303            query_id: QueryId(q),
1304            worker,
1305            param_cols: vec![1],
1306            hydrated_cv: cv,
1307        });
1308    }
1309
1310    fn bind(core: &mut DrainCore, q: u64, val: &str, cv: u64) {
1311        let (fold, fp) = query_parts();
1312        core.on_control(DrainControl::BindPartition {
1313            query_id: QueryId(q),
1314            binding: sb(val),
1315            fold: Box::new(fold),
1316            normalized_fp: fp,
1317            proj: ProjMap::new(),
1318            hydrated_cv: cv,
1319        });
1320    }
1321
1322    fn attach_partition(core: &mut DrainCore, q: u64, val: &str, sub: u64, conn: ConnId) {
1323        core.on_control(DrainControl::AttachSubscriber {
1324            query_id: QueryId(q),
1325            sub,
1326            conn,
1327            epoch: 1,
1328            binding: Some(sb(val)),
1329        });
1330    }
1331
1332    fn partition_hydrated(
1333        core: &mut DrainCore,
1334        q: u64,
1335        val: &str,
1336        cv: u64,
1337        rows: Vec<(i64, &str)>,
1338    ) {
1339        core.on_event(ClusterEvent::Update {
1340            query_id: QueryId(q),
1341            update: Update::PartitionHydrated {
1342                tx_id: TxId(cv),
1343                binding: sb(val),
1344                changes: rows.into_iter().map(|(id, v)| add_val(id, v)).collect(),
1345            },
1346        });
1347    }
1348
1349    fn changed_rows(core: &mut DrainCore, q: u64, tx: u64, rows: Vec<(i64, &str)>) {
1350        core.on_event(ClusterEvent::Update {
1351            query_id: QueryId(q),
1352            update: Update::Changed {
1353                tx_id: TxId(tx),
1354                changes: rows.into_iter().map(|(id, v)| add_val(id, v)).collect(),
1355            },
1356        });
1357    }
1358
1359    #[test]
1360    fn family_changed_is_demuxed_per_partition() {
1361        let out = Out::default();
1362        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1363        core.on_control(DrainControl::Connect { conn: 7 });
1364        register_family(&mut core, 1, 0, 0);
1365        bind(&mut core, 1, "x", 0);
1366        bind(&mut core, 1, "y", 0);
1367        attach_partition(&mut core, 1, "x", 10, 7);
1368        attach_partition(&mut core, 1, "y", 11, 7);
1369        partition_hydrated(&mut core, 1, "x", 0, vec![]);
1370        partition_hydrated(&mut core, 1, "y", 0, vec![]);
1371        // Both baselined (seq 0 snapshots).
1372        assert_eq!(batches_for(&out, 10), vec![(1, 0, 0)]);
1373        assert_eq!(batches_for(&out, 11), vec![(1, 0, 0)]);
1374        // One family `Changed` carrying three partitions' rows: x gets two, y one, and
1375        // the unbound `z` row reaches nobody.
1376        changed_rows(
1377            &mut core,
1378            1,
1379            5,
1380            vec![(1, "x"), (2, "y"), (3, "x"), (4, "z")],
1381        );
1382        assert_eq!(batches_for(&out, 10), vec![(1, 0, 0), (1, 1, 2)]);
1383        assert_eq!(batches_for(&out, 11), vec![(1, 0, 0), (1, 1, 1)]);
1384        // A commit touching only y leaves x's seq untouched (gap-free per partition).
1385        changed_rows(&mut core, 1, 6, vec![(5, "y")]);
1386        assert_eq!(batches_for(&out, 10), vec![(1, 0, 0), (1, 1, 2)]);
1387        assert_eq!(batches_for(&out, 11), vec![(1, 0, 0), (1, 1, 1), (1, 2, 1)]);
1388    }
1389
1390    /// A batch's width is a property of the WRITE, not of the family: one bulk update
1391    /// lands as one `Changed` spanning as many partitions as it touched. Past
1392    /// `LINEAR_DEMUX_BUCKETS` the demux stops scanning its buckets and builds a hash index
1393    /// over them (follow-up 310 B3), so what has to hold across that handoff is that the
1394    /// output is identical either way: one bucket per partition, buckets in first-seen
1395    /// order, and each bucket's changes in arrival order.
1396    #[test]
1397    fn a_wide_batch_demuxes_exactly_as_a_narrow_one_does() {
1398        let id_of = |c: &ChangeEvent| match c.root_row().get(0) {
1399            Some(rindle::value::Value::Int(i)) => i,
1400            other => panic!("expected an Int id, got {other:?}"),
1401        };
1402        for n in [
1403            4,
1404            LINEAR_DEMUX_BUCKETS,
1405            LINEAR_DEMUX_BUCKETS + 1,
1406            4 * LINEAR_DEMUX_BUCKETS,
1407        ] {
1408            // Two interleaved passes over the same n partitions, so both the bucket order
1409            // and the within-bucket order are observable: partition i sees ids i and n+i.
1410            let changes: Vec<ChangeEvent> = (0..2 * n)
1411                .map(|j| add_val(j as i64, &format!("p{}", j % n)))
1412                .collect();
1413            let got = demux_by_partition(changes, &[1]);
1414            assert_eq!(got.len(), n, "one bucket per partition at n={n}");
1415            for (i, (binding, bucket)) in got.iter().enumerate() {
1416                assert_eq!(*binding, sb(&format!("p{i}")), "first-seen order at n={n}");
1417                let ids: Vec<i64> = bucket.iter().map(id_of).collect();
1418                assert_eq!(
1419                    ids,
1420                    vec![i as i64, (n + i) as i64],
1421                    "arrival order at n={n}"
1422                );
1423            }
1424        }
1425    }
1426
1427    #[test]
1428    fn partition_hydrate_racing_its_bind_is_replayed_in_order() {
1429        let out = Out::default();
1430        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1431        core.on_control(DrainControl::Connect { conn: 7 });
1432        register_family(&mut core, 1, 0, 0);
1433        // The worker's hydrate and a following delta beat the coordinator's control.
1434        partition_hydrated(&mut core, 1, "x", 3, vec![(1, "x")]);
1435        changed_rows(&mut core, 1, 4, vec![(2, "x")]);
1436        bind(&mut core, 1, "x", 3);
1437        // A late subscriber baselines from the footprint that folded BOTH.
1438        attach_partition(&mut core, 1, "x", 10, 7);
1439        let b = out.batches.lock().unwrap();
1440        let (_, sub, batch) = &b[0];
1441        assert_eq!(*sub, 10);
1442        assert_eq!(batch.seq, 0);
1443        assert_eq!(batch.ops.len(), 2, "hydrate + the replayed delta");
1444        assert_eq!(batch.cv, 4);
1445    }
1446
1447    #[test]
1448    fn unbind_partition_faults_only_its_subscribers_and_drops_its_deltas() {
1449        let out = Out::default();
1450        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1451        core.on_control(DrainControl::Connect { conn: 7 });
1452        register_family(&mut core, 1, 0, 0);
1453        bind(&mut core, 1, "x", 0);
1454        bind(&mut core, 1, "y", 0);
1455        attach_partition(&mut core, 1, "x", 10, 7);
1456        attach_partition(&mut core, 1, "y", 11, 7);
1457        partition_hydrated(&mut core, 1, "x", 0, vec![]);
1458        partition_hydrated(&mut core, 1, "y", 0, vec![]);
1459        core.on_control(DrainControl::UnbindPartition {
1460            query_id: QueryId(1),
1461            binding: sb("x"),
1462        });
1463        assert_eq!(*out.faults.lock().unwrap(), vec![(7, 10)]);
1464        // A late delta for x (the unbind race) is dropped; y keeps flowing.
1465        changed_rows(&mut core, 1, 5, vec![(1, "x"), (2, "y")]);
1466        assert_eq!(batches_for(&out, 10), vec![(1, 0, 0)]);
1467        assert_eq!(batches_for(&out, 11), vec![(1, 0, 0), (1, 1, 1)]);
1468        // An attach to the unbound partition is ignored.
1469        attach_partition(&mut core, 1, "x", 12, 7);
1470        assert!(batches_for(&out, 12).is_empty());
1471    }
1472
1473    #[test]
1474    fn family_fault_faults_every_partition_subscriber() {
1475        let out = Out::default();
1476        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1477        core.on_control(DrainControl::Connect { conn: 7 });
1478        register_family(&mut core, 1, 0, 0);
1479        bind(&mut core, 1, "x", 0);
1480        bind(&mut core, 1, "y", 0);
1481        attach_partition(&mut core, 1, "x", 10, 7);
1482        attach_partition(&mut core, 1, "y", 11, 7);
1483        core.on_event(ClusterEvent::Faulted {
1484            query_id: QueryId(1),
1485            reason: "boom".into(),
1486            cause: crate::FaultCause::Derive,
1487        });
1488        let mut faults = out.faults.lock().unwrap().clone();
1489        faults.sort();
1490        assert_eq!(faults, vec![(7, 10), (7, 11)]);
1491        assert_eq!(*out.query_faults.lock().unwrap(), vec![1]);
1492    }
1493
1494    /// Register query `q` on `worker`, attach subscriber `q` to `conn`, then hydrate (empty).
1495    fn install(core: &mut DrainCore, q: u64, conn: ConnId, worker: usize, cv: u64) {
1496        register(core, q, worker, cv);
1497        attach(core, q, q, conn, 1);
1498        core.on_event(ClusterEvent::Update {
1499            query_id: QueryId(q),
1500            update: Update::Hydrated {
1501                tx_id: TxId(cv),
1502                changes: vec![],
1503            },
1504        });
1505    }
1506
1507    fn changed(core: &mut DrainCore, q: u64, tx: u64, id: i64) {
1508        core.on_event(ClusterEvent::Update {
1509            query_id: QueryId(q),
1510            update: Update::Changed {
1511                tx_id: TxId(tx),
1512                changes: vec![add(id)],
1513            },
1514        });
1515    }
1516
1517    /// The batches a routing id (sub id) received, as `(epoch, seq, op-count)` in order.
1518    fn batches_for(out: &Out, sub: u64) -> Vec<(u64, u64, usize)> {
1519        out.batches
1520            .lock()
1521            .unwrap()
1522            .iter()
1523            .filter(|(_, q, _)| *q == sub)
1524            .map(|(_, _, b)| (b.epoch, b.seq, b.ops.len()))
1525            .collect()
1526    }
1527
1528    /// The frames a connection received (cv_min values, in order).
1529    fn cvs(out: &Out, conn: ConnId) -> Vec<u64> {
1530        out.frames
1531            .lock()
1532            .unwrap()
1533            .iter()
1534            .filter(|(c, _)| *c == conn)
1535            .map(|(_, f)| f.cv_min)
1536            .collect()
1537    }
1538
1539    /// A slow worker holds back only its own clients: advancing worker 0 releases a
1540    /// connection whose query lives on worker 0, while a connection on worker 1 (not yet
1541    /// progressed) stays pinned — the read-side isolation win (§8).
1542    #[test]
1543    fn per_worker_position_isolates_clients() {
1544        let out = Out::default();
1545        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1546        core.on_control(DrainControl::Connect { conn: 1 });
1547        core.on_control(DrainControl::Connect { conn: 2 });
1548        install(&mut core, 1, 1, 0, 0); // Q1 on worker 0, conn 1
1549        install(&mut core, 2, 2, 1, 0); // Q2 on worker 1, conn 2
1550
1551        // A commit at tx 1 changes both queries → both get a data frame at cv 1 (buffered).
1552        changed(&mut core, 1, 1, 10);
1553        changed(&mut core, 2, 1, 20);
1554        assert_eq!(
1555            cvs(&out, 1),
1556            vec![0],
1557            "no release before worker 0 progresses"
1558        );
1559        assert_eq!(
1560            cvs(&out, 2),
1561            vec![0],
1562            "no release before worker 1 progresses"
1563        );
1564
1565        // Worker 0 reports tx 1 → ONLY conn 1 is released to cv_min 1; conn 2 stays pinned.
1566        core.on_event(ClusterEvent::Progressed {
1567            worker: 0,
1568            tx_id: TxId(1),
1569        });
1570        assert_eq!(cvs(&out, 1), vec![0, 1], "conn 1 released by its worker");
1571        assert_eq!(
1572            cvs(&out, 2),
1573            vec![0],
1574            "conn 2 still held back by its own (slow) worker 1"
1575        );
1576
1577        // Worker 1 catches up → conn 2 releases too.
1578        core.on_event(ClusterEvent::Progressed {
1579            worker: 1,
1580            tx_id: TxId(1),
1581        });
1582        assert_eq!(
1583            cvs(&out, 2),
1584            vec![0, 1],
1585            "conn 2 released when worker 1 caught up"
1586        );
1587    }
1588
1589    /// The poke rule: a foreign write that doesn't touch a connection's data produces no
1590    /// progress frame for it, even as its worker's position (and cv_min) advances.
1591    #[test]
1592    fn idle_connection_not_poked_by_foreign_write() {
1593        let out = Out::default();
1594        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1595        core.on_control(DrainControl::Connect { conn: 1 });
1596        install(&mut core, 1, 1, 0, 0); // Q1 on worker 0
1597        assert_eq!(cvs(&out, 1), vec![0], "the initial snapshot frame");
1598
1599        // A foreign write at tx 5 advances worker 0 but never delivered Q1 a data frame.
1600        core.on_event(ClusterEvent::Progressed {
1601            worker: 0,
1602            tx_id: TxId(5),
1603        });
1604        assert_eq!(
1605            cvs(&out, 1),
1606            vec![0],
1607            "idle connection sees no extra frame for an irrelevant write"
1608        );
1609    }
1610
1611    /// An lmid-as-data confirmation: the client's own system-query batch (its lmid row)
1612    /// is releasable data like any other, so a "mutation-only" commit still pokes the
1613    /// mutating connection — via the normal data path, with no metadata side-channel.
1614    #[test]
1615    fn lmid_query_batch_releases_like_any_data() {
1616        let out = Out::default();
1617        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1618        core.on_control(DrainControl::Connect { conn: 1 });
1619        core.on_control(DrainControl::Connect { conn: 2 });
1620        install(&mut core, 1, 1, 0, 0); // conn 1's lmid system query, worker 0
1621        install(&mut core, 2, 2, 1, 0); // conn 2's data query, worker 1
1622        let before2 = cvs(&out, 2).len();
1623
1624        // Commit tx 1 = conn 1's mutation whose only derived change is its lmid row.
1625        changed(&mut core, 1, 1, 10); // the lmid-query batch (data frame, cv 1)
1626        core.on_event(ClusterEvent::Progressed {
1627            worker: 0,
1628            tx_id: TxId(1),
1629        });
1630        core.on_event(ClusterEvent::Progressed {
1631            worker: 1,
1632            tx_id: TxId(1),
1633        });
1634
1635        assert_eq!(
1636            cvs(&out, 1),
1637            vec![0, 1],
1638            "the mutating connection's lmid batch released at cv 1"
1639        );
1640        assert_eq!(
1641            cvs(&out, 2).len(),
1642            before2,
1643            "the other connection saw no frame (no data delivered to it)"
1644        );
1645    }
1646
1647    /// Dedup fan-out: ONE engine query feeds two subscribers on different connections. The
1648    /// shared footprint folds a commit once, but each subscriber gets its own framed batch
1649    /// (its own epoch, its own seq cursor). There is only one engine query id in play.
1650    #[test]
1651    fn shared_query_fans_one_engine_query_to_many_subscribers() {
1652        let out = Out::default();
1653        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1654        core.on_control(DrainControl::Connect { conn: 1 });
1655        core.on_control(DrainControl::Connect { conn: 2 });
1656        // One engine query (eqid 100) on worker 0; two subscribers with distinct epochs.
1657        register(&mut core, 100, 0, 0);
1658        attach(&mut core, 100, 10, 1, 7);
1659        attach(&mut core, 100, 20, 2, 9);
1660        core.on_event(ClusterEvent::Update {
1661            query_id: QueryId(100),
1662            update: Update::Hydrated {
1663                tx_id: TxId(0),
1664                changes: vec![],
1665            },
1666        });
1667        // Each subscriber got its own seq-0 snapshot stamped with its own epoch.
1668        assert_eq!(batches_for(&out, 10), vec![(7, 0, 0)]);
1669        assert_eq!(batches_for(&out, 20), vec![(9, 0, 0)]);
1670
1671        // A commit folds ONCE but fans to both, each at seq 1 under its own epoch.
1672        changed(&mut core, 100, 1, 42);
1673        assert_eq!(batches_for(&out, 10), vec![(7, 0, 0), (7, 1, 1)]);
1674        assert_eq!(batches_for(&out, 20), vec![(9, 0, 0), (9, 1, 1)]);
1675
1676        // Detaching one leaves the other (and the shared engine query) live.
1677        core.on_control(DrainControl::DetachSubscriber { sub: 10 });
1678        changed(&mut core, 100, 2, 43);
1679        assert_eq!(
1680            batches_for(&out, 10),
1681            vec![(7, 0, 0), (7, 1, 1)],
1682            "detached subscriber gets no further batches"
1683        );
1684        assert_eq!(batches_for(&out, 20), vec![(9, 0, 0), (9, 1, 1), (9, 2, 1)]);
1685    }
1686
1687    /// A late subscriber attaching to an already-hydrated shared query is baselined from the
1688    /// cached footprint — its seq-0 snapshot carries every live row, with NO second engine
1689    /// query / re-hydrate.
1690    #[test]
1691    fn late_subscriber_baselines_from_cached_footprint() {
1692        let out = Out::default();
1693        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1694        core.on_control(DrainControl::Connect { conn: 1 });
1695        core.on_control(DrainControl::Connect { conn: 2 });
1696        register(&mut core, 100, 0, 0);
1697        attach(&mut core, 100, 10, 1, 1);
1698        // Hydrate with one row, then a commit adds a second — footprint now has 2 rows.
1699        core.on_event(ClusterEvent::Update {
1700            query_id: QueryId(100),
1701            update: Update::Hydrated {
1702                tx_id: TxId(0),
1703                changes: vec![add(1)],
1704            },
1705        });
1706        changed(&mut core, 100, 1, 2);
1707        assert_eq!(batches_for(&out, 10), vec![(1, 0, 1), (1, 1, 1)]);
1708
1709        // A late subscriber joins after both commits — one seq-0 snapshot with BOTH rows.
1710        attach(&mut core, 100, 20, 2, 5);
1711        assert_eq!(
1712            batches_for(&out, 20),
1713            vec![(5, 0, 2)],
1714            "late subscriber snapshots the full 2-row footprint from cache"
1715        );
1716
1717        // From here both share the same stream; the late one continues at seq 1.
1718        changed(&mut core, 100, 2, 3);
1719        assert_eq!(batches_for(&out, 10), vec![(1, 0, 1), (1, 1, 1), (1, 2, 1)]);
1720        assert_eq!(batches_for(&out, 20), vec![(5, 0, 2), (5, 1, 1)]);
1721    }
1722
1723    /// A worker fault signals the QUERY OWNER first (so it can re-register), then faults
1724    /// every subscriber on it, then removes the engine query.
1725    #[test]
1726    fn fault_signals_owner_before_faulting_subscribers() {
1727        let out = Out::default();
1728        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1729        core.on_control(DrainControl::Connect { conn: 1 });
1730        core.on_control(DrainControl::Connect { conn: 2 });
1731        register(&mut core, 100, 0, 0);
1732        attach(&mut core, 100, 10, 1, 1);
1733        attach(&mut core, 100, 20, 2, 1);
1734        core.on_event(ClusterEvent::Update {
1735            query_id: QueryId(100),
1736            update: Update::Hydrated {
1737                tx_id: TxId(0),
1738                changes: vec![],
1739            },
1740        });
1741
1742        core.on_faulted(QueryId(100), "boom".into(), crate::FaultCause::Derive);
1743        assert_eq!(
1744            *out.query_faults.lock().unwrap(),
1745            vec![100],
1746            "owner signalled once with the ENGINE query id"
1747        );
1748        let mut faulted_subs: Vec<u64> =
1749            out.faults.lock().unwrap().iter().map(|(_, s)| *s).collect();
1750        faulted_subs.sort();
1751        assert_eq!(faulted_subs, vec![10, 20], "both subscribers faulted");
1752        assert!(
1753            !core.queries.contains_key(&QueryId(100)),
1754            "engine query removed"
1755        );
1756        assert!(core.subs.is_empty(), "subscribers dropped");
1757    }
1758
1759    /// Revoking subscribers (the dematerialize-with-active-subscribers path) faults + detaches
1760    /// them WITHOUT signalling the owner (no re-registration) and leaves the query for the
1761    /// caller's following RemoveQuery.
1762    #[test]
1763    fn fault_subscribers_revokes_without_owner_signal() {
1764        let out = Out::default();
1765        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1766        core.on_control(DrainControl::Connect { conn: 1 });
1767        register(&mut core, 100, 0, 0);
1768        attach(&mut core, 100, 10, 1, 1);
1769        core.on_event(ClusterEvent::Update {
1770            query_id: QueryId(100),
1771            update: Update::Hydrated {
1772                tx_id: TxId(0),
1773                changes: vec![],
1774            },
1775        });
1776
1777        core.on_control(DrainControl::FaultSubscribers {
1778            query_id: QueryId(100),
1779            reason: "materialization removed".into(),
1780        });
1781        assert_eq!(
1782            out.faults.lock().unwrap().as_slice(),
1783            &[(1, 10)],
1784            "the subscriber was faulted"
1785        );
1786        assert!(
1787            out.query_faults.lock().unwrap().is_empty(),
1788            "owner NOT signalled — this is a revoke, not a recover"
1789        );
1790        assert!(
1791            core.queries.contains_key(&QueryId(100)),
1792            "query left in place for the caller's RemoveQuery"
1793        );
1794        assert!(core.subs.is_empty(), "subscriber detached");
1795    }
1796
1797    /// Streaming fan-out (`CLUSTER-INCREMENTAL-FANOUT-DESIGN.md` §4): one transaction arrives as
1798    /// **several `Changed(N)` slices** (a chunked `TxPush` stream). The drain fans each out as
1799    /// its own `cv=N` data frame eagerly, but holds the release until the `Progressed(N)` commit
1800    /// marker — so the whole transaction becomes visible at once, never a partial slice.
1801    #[test]
1802    fn multi_slice_txn_buffers_data_frames_and_releases_together_on_marker() {
1803        let out = Out::default();
1804        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1805        core.on_control(DrainControl::Connect { conn: 1 });
1806        install(&mut core, 1, 1, 0, 0); // Q1 on worker 0, conn 1, hydrated at cv 0
1807        assert_eq!(cvs(&out, 1), vec![0], "the initial snapshot frame");
1808
1809        // Tx 1 streams in as TWO speculative slices (a >1-chunk fan-out), both stamped cv 1.
1810        changed(&mut core, 1, 1, 10);
1811        changed(&mut core, 1, 1, 11);
1812        assert_eq!(
1813            batches_for(&out, 1),
1814            vec![(1, 0, 0), (1, 1, 1), (1, 2, 1)],
1815            "seq-0 snapshot, then one data frame per slice (both at cv 1)"
1816        );
1817        assert_eq!(
1818            cvs(&out, 1),
1819            vec![0],
1820            "no progress frame yet — slices are buffered until the commit marker"
1821        );
1822
1823        // The commit marker releases the whole tx (both slices) at once.
1824        core.on_event(ClusterEvent::Progressed {
1825            worker: 0,
1826            tx_id: TxId(1),
1827        });
1828        assert_eq!(
1829            cvs(&out, 1),
1830            vec![0, 1],
1831            "the marker releases cv 1 — both slices become visible together, atomically"
1832        );
1833    }
1834
1835    /// Streaming fault, consumer side (`CLUSTER-INCREMENTAL-FANOUT-DESIGN.md` §4.1, abort≙rehydrate):
1836    /// a worker that dies mid-stream stops reporting `Progressed`, so a connection with a query on
1837    /// it is pinned at that worker's last position — even as a query on a HEALTHY worker advances
1838    /// (its data is buffered, held). The terminal `Faulted` for the dead worker's query (its
1839    /// re-subscribe signal) removes it, so the connection's `cv_min` jumps to the healthy worker's
1840    /// position and the held data releases. This is the read-side recovery the worker-side
1841    /// tear-down + re-hydrate pairs with.
1842    #[test]
1843    fn faulted_worker_unpins_connection_and_releases_held_data() {
1844        let out = Out::default();
1845        let mut core = DrainCore::new(Box::new(out.clone()), 0);
1846        core.on_control(DrainControl::Connect { conn: 1 });
1847        install(&mut core, 1, 1, 0, 0); // Q1 on worker 0, conn 1
1848        install(&mut core, 2, 1, 1, 0); // Q2 on worker 1, SAME conn 1
1849        assert_eq!(
1850            cvs(&out, 1),
1851            vec![0, 0],
1852            "an initial snapshot frame per install"
1853        );
1854
1855        // Tx 3 touches both; both workers progress → conn releases to cv 3.
1856        changed(&mut core, 1, 3, 10);
1857        changed(&mut core, 2, 3, 20);
1858        core.on_event(ClusterEvent::Progressed {
1859            worker: 0,
1860            tx_id: TxId(3),
1861        });
1862        core.on_event(ClusterEvent::Progressed {
1863            worker: 1,
1864            tx_id: TxId(3),
1865        });
1866        assert_eq!(
1867            cvs(&out, 1),
1868            vec![0, 0, 3],
1869            "both workers at 3 → conn released to 3"
1870        );
1871
1872        // Worker 1 now dies mid-stream. A later tx 7 touches ONLY Q1 (worker 0), and worker 0
1873        // progresses to 7 — but the connection stays pinned at 3 (Q2's dead worker never moved),
1874        // so Q1's tx-7 data is buffered, NOT released.
1875        changed(&mut core, 1, 7, 11);
1876        core.on_event(ClusterEvent::Progressed {
1877            worker: 0,
1878            tx_id: TxId(7),
1879        });
1880        assert_eq!(
1881            cvs(&out, 1),
1882            vec![0, 0, 3],
1883            "Q1's tx-7 data is held — the dead worker's Q2 pins cv_min at 3"
1884        );
1885
1886        // Faulting Q2 (the dead worker's query) unpins the connection: cv_min jumps to worker 0's
1887        // position (7) and the held tx-7 data releases.
1888        core.on_faulted(
1889            QueryId(2),
1890            "worker 1 died mid-stream".into(),
1891            crate::FaultCause::WorkerLost,
1892        );
1893        assert_eq!(
1894            cvs(&out, 1),
1895            vec![0, 0, 3, 7],
1896            "the fault unpins cv_min → the held Q1 data releases at cv 7"
1897        );
1898        assert_eq!(
1899            out.faults.lock().unwrap().as_slice(),
1900            &[(1, 2)],
1901            "Q2's subscriber got the terminal fault (its re-subscribe signal)"
1902        );
1903    }
1904}