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 ®.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, ¶m_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(®.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}