rindle/flat_protocol.rs
1//! The flat-change **subscription protocol**: the batch envelope, epoch/seq framing,
2//! and the receiver-side safety rules (`FLAT-CHANGES-DESIGN.md` §5.4/§5.5/§2.3).
3//!
4//! This is the layer that turns the parts — the flat change stream ([`crate::flat`]),
5//! the reference [`Receiver`] ([`crate::flat_receiver`]), and the wire schema +
6//! fingerprint ([`crate::wire_schema`]) — into something a transport carries.
7//!
8//! ## Shape
9//!
10//! - A subscription opens with a [`Hello`] (sent once): the `epoch`, the
11//! [`COMPARATOR_VERSION`] contract, the [`WireSchema`], and its [`SchemaFp`].
12//! - The **hydrate snapshot** establishes the baseline (logical seq 0): either a single
13//! [`Batch`] (seq 0) via [`Publisher::snapshot`], or — for a large `O(full result)`
14//! snapshot — a sequence of [`SnapshotChunk`]s via [`Publisher::snapshot_chunks`]
15//! terminated by a `last` marker (the receiver renders only after it).
16//! - Then incremental [`Batch`]es flow at **seq 1, 2, …** (one per non-empty
17//! transaction). Each batch carries `{ epoch, seq, schema_fp, events }`; `events` apply
18//! in array order and the receiver renders only after the whole batch.
19//!
20//! ## Sender ([`Publisher`])
21//!
22//! Drives the seq counter and stamps every batch with the subscription's `epoch` +
23//! `schema_fp`. **Empty transactions emit no batch and consume no seq** — so `seq` is
24//! gap-free *over emitted batches*, which is exactly what lets the receiver treat any
25//! gap as a lost batch.
26//!
27//! ## Receiver ([`Subscriber`])
28//!
29//! Wraps a [`Receiver`] and enforces (§2.3): the comparator contract (at [`Hello`]),
30//! and per batch — epoch match, schema-fingerprint match, and strict in-order seq.
31//! A **duplicate** (already-applied seq) is discarded idempotently (`rc` add/remove are
32//! NOT idempotent, so re-applying would corrupt). A **gap** (seq beyond the next
33//! expected) is unrecoverable from deltas — the sender cannot re-derive a missed one —
34//! so the only repair is a full re-hydrate under a **new epoch**; stale in-flight
35//! batches from the old epoch are then rejected by [`ProtocolError::EpochMismatch`].
36
37use crate::changes::CaughtChange;
38use crate::flat::{flatten_all, FlatChange};
39use crate::flat_receiver::{Receiver, RecvNode};
40use crate::value::Schema;
41use crate::wire_schema::{schema_fp, to_schema, to_wire, SchemaFp, WireSchema, COMPARATOR_VERSION};
42
43/// The subscription handshake, sent once before any [`Batch`].
44#[cfg_attr(
45 any(feature = "testkit", feature = "serde"),
46 derive(serde::Serialize, serde::Deserialize)
47)]
48#[derive(Clone, Debug)]
49pub struct Hello {
50 pub epoch: u64,
51 /// The `compare_values` algorithm-contract version the sender used (§4/§5.5).
52 pub comparator_version: u32,
53 pub schema: WireSchema,
54 pub schema_fp: SchemaFp,
55}
56
57/// One transaction's flat changes (or the seq-0 hydrate snapshot). `events` apply in
58/// order; the receiver renders only after the whole batch (`FLAT-CHANGES-DESIGN.md` §5.4).
59#[cfg_attr(
60 any(feature = "testkit", feature = "serde"),
61 derive(serde::Serialize, serde::Deserialize)
62)]
63#[derive(Clone, Debug)]
64pub struct Batch {
65 pub epoch: u64,
66 pub seq: u64,
67 pub schema_fp: SchemaFp,
68 pub events: Vec<FlatChange>,
69}
70
71/// One frame of a **chunked hydrate snapshot** (`FLAT-CHANGES-DESIGN.md` §5.4). The
72/// initial snapshot is `O(full result)` — the whole tree as top-level `Add`s (each with
73/// its inline subtree) — so it is paged across `SnapshotChunk`s rather than one giant
74/// batch. `adds` is a slice of those top-level `Add`s; `index` is gap-free within the
75/// snapshot; `last` is the `snapshot_complete` marker (the receiver renders only after
76/// it). All chunks carry the subscription `epoch` + `schema_fp` so a mid-snapshot drift
77/// is caught. A chunk's `adds` apply atomically (each carries its full subtree).
78#[cfg_attr(
79 any(feature = "testkit", feature = "serde"),
80 derive(serde::Serialize, serde::Deserialize)
81)]
82#[derive(Clone, Debug)]
83pub struct SnapshotChunk {
84 pub epoch: u64,
85 pub schema_fp: SchemaFp,
86 /// 0-based, gap-free within this snapshot.
87 pub index: u64,
88 /// The `snapshot_complete` marker — `true` on the final chunk.
89 pub last: bool,
90 /// Top-level `Add`s (each with its inline subtree) for this page.
91 pub adds: Vec<FlatChange>,
92}
93
94/// The outcome of applying a [`SnapshotChunk`].
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum SnapStatus {
97 /// Chunk applied; more chunks expected.
98 Accepted,
99 /// The final (`last`) chunk applied — the snapshot is complete; the tree may render
100 /// and incremental [`Batch`]es (seq ≥ 1) may follow.
101 Complete,
102 /// An already-applied (or post-completion) chunk — discarded idempotently.
103 Duplicate,
104}
105
106/// A protocol violation a [`Subscriber`] surfaces. All but a duplicate are fatal to the
107/// current subscription — the consumer must discard its tree and re-subscribe (the
108/// sender re-hydrates under a new epoch).
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub enum ProtocolError {
111 /// The sender's comparator contract differs — reconstruction would silently corrupt.
112 ComparatorMismatch { expected: u32, got: u32 },
113 /// A batch from a different (usually stale) subscription epoch.
114 EpochMismatch { expected: u64, got: u64 },
115 /// The batch (or advertised) schema fingerprint differs from the subscribed one.
116 SchemaMismatch { expected: SchemaFp, got: SchemaFp },
117 /// A seq beyond the next expected ⇒ a batch was lost. Unrecoverable from deltas;
118 /// re-hydrate. (`expected` is the next seq the receiver wanted.)
119 Gap { expected: u64, got: u64 },
120}
121
122/// The outcome of applying a [`Batch`].
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum Applied {
125 /// Applied; the reconstructed tree advanced.
126 Applied,
127 /// An already-applied seq (re-delivery) — discarded idempotently, tree unchanged.
128 Duplicate,
129}
130
131/// Sender side: stamps batches with the subscription `epoch` + `schema_fp` and drives
132/// the gap-free seq. Graph-agnostic — the caller drains the change-sink and hands the
133/// [`CaughtChange`]s here.
134pub struct Publisher {
135 epoch: u64,
136 schema: WireSchema,
137 schema_fp: SchemaFp,
138 next_seq: u64,
139}
140
141impl Publisher {
142 /// Open a publisher at `epoch` over a hierarchical view [`Schema`]. Bump `epoch`
143 /// for each fresh (re-)subscription so the receiver can reject stale batches.
144 pub fn new(epoch: u64, view_schema: &Schema) -> Publisher {
145 let schema = to_wire(view_schema);
146 let fp = schema_fp(&schema);
147 Publisher {
148 epoch,
149 schema,
150 schema_fp: fp,
151 next_seq: 0,
152 }
153 }
154
155 /// The handshake to send first.
156 pub fn hello(&self) -> Hello {
157 Hello {
158 epoch: self.epoch,
159 comparator_version: COMPARATOR_VERSION,
160 schema: self.schema.clone(),
161 schema_fp: self.schema_fp,
162 }
163 }
164
165 /// The hydrate snapshot (the flat `Add`s from
166 /// [`Graph::try_hydrate_change_sink`](crate::graph::Graph::try_hydrate_change_sink)) as a
167 /// single batch (seq 0). Always emitted — even for an empty result — so the receiver
168 /// learns the snapshot is complete. For a large result prefer [`snapshot_chunks`].
169 ///
170 /// [`snapshot_chunks`]: Publisher::snapshot_chunks
171 pub fn snapshot(&mut self, caught: &[CaughtChange]) -> Batch {
172 self.emit(caught)
173 }
174
175 /// The hydrate snapshot as a sequence of [`SnapshotChunk`]s of at most
176 /// `max_per_chunk` top-level entries each (the last chunk carries `last = true`).
177 /// Use this instead of [`snapshot`](Publisher::snapshot) when the result is large:
178 /// the snapshot is `O(full result)`, so a single batch can blow a transport frame /
179 /// pin peak memory. An empty result still yields one terminal (`last`) chunk so the
180 /// receiver gets the completion marker.
181 ///
182 /// Reserves the seq-0 slot for the baseline — incremental [`commit`](Publisher::commit)s
183 /// start at seq 1, exactly as after [`snapshot`](Publisher::snapshot). Call this
184 /// (or `snapshot`) once, **before** any `commit`.
185 pub fn snapshot_chunks(
186 &mut self,
187 caught: &[CaughtChange],
188 max_per_chunk: usize,
189 ) -> Vec<SnapshotChunk> {
190 assert!(max_per_chunk >= 1, "max_per_chunk must be >= 1");
191 // The baseline occupies the logical seq-0 slot; increments start at seq 1.
192 self.next_seq = 1;
193 let events = flatten_all(caught);
194 if events.is_empty() {
195 return vec![SnapshotChunk {
196 epoch: self.epoch,
197 schema_fp: self.schema_fp,
198 index: 0,
199 last: true,
200 adds: Vec::new(),
201 }];
202 }
203 let total = events.len().div_ceil(max_per_chunk);
204 events
205 .chunks(max_per_chunk)
206 .enumerate()
207 .map(|(i, slice)| SnapshotChunk {
208 epoch: self.epoch,
209 schema_fp: self.schema_fp,
210 index: i as u64,
211 last: i + 1 == total,
212 adds: slice.to_vec(),
213 })
214 .collect()
215 }
216
217 /// Wrap one transaction's drained changes
218 /// ([`Graph::take_sink_changes`](crate::graph::Graph::take_sink_changes)). Returns
219 /// `None` for an empty transaction — **no batch, no seq consumed** — keeping seq
220 /// gap-free over emitted batches.
221 pub fn commit(&mut self, caught: &[CaughtChange]) -> Option<Batch> {
222 if caught.is_empty() {
223 return None;
224 }
225 Some(self.emit(caught))
226 }
227
228 fn emit(&mut self, caught: &[CaughtChange]) -> Batch {
229 let seq = self.next_seq;
230 self.next_seq += 1;
231 Batch {
232 epoch: self.epoch,
233 seq,
234 schema_fp: self.schema_fp,
235 events: flatten_all(caught),
236 }
237 }
238
239 /// The subscription epoch.
240 pub fn epoch(&self) -> u64 {
241 self.epoch
242 }
243}
244
245/// The subscriber's lifecycle: establishing the baseline, then live increments.
246#[derive(Clone, Copy, Debug)]
247enum Phase {
248 /// Establishing the baseline. `next_chunk` is the next [`SnapshotChunk`] index
249 /// expected; `0` also admits a single-shot [`snapshot`](Publisher::snapshot) batch.
250 Snapshot { next_chunk: u64 },
251 /// Baseline established; only incremental [`Batch`]es (seq ≥ 1) are accepted.
252 Live { last_seq: u64 },
253}
254
255/// Receiver side: a [`Receiver`] plus the protocol state, enforcing the §2.3/§5.4 rules.
256/// The baseline is established by either a single-shot [`apply`](Subscriber::apply) of
257/// the seq-0 snapshot batch **or** a sequence of [`apply_snapshot_chunk`] calls; then
258/// incremental batches flow.
259///
260/// [`apply_snapshot_chunk`]: Subscriber::apply_snapshot_chunk
261#[derive(Debug)]
262pub struct Subscriber {
263 epoch: u64,
264 schema_fp: SchemaFp,
265 recv: Receiver,
266 phase: Phase,
267}
268
269impl Subscriber {
270 /// Open from a [`Hello`]: hard-reject a comparator-contract mismatch (§5.5), verify
271 /// the advertised fingerprint matches the shipped schema, and build the receiver
272 /// from that schema. Apply the snapshot next (single-shot or chunked).
273 pub fn open(hello: &Hello) -> Result<Subscriber, ProtocolError> {
274 if hello.comparator_version != COMPARATOR_VERSION {
275 return Err(ProtocolError::ComparatorMismatch {
276 expected: COMPARATOR_VERSION,
277 got: hello.comparator_version,
278 });
279 }
280 // Defensive: the advertised fp must equal the fp of the shipped schema.
281 let computed = schema_fp(&hello.schema);
282 if computed != hello.schema_fp {
283 return Err(ProtocolError::SchemaMismatch {
284 expected: hello.schema_fp,
285 got: computed,
286 });
287 }
288 Ok(Subscriber {
289 epoch: hello.epoch,
290 schema_fp: hello.schema_fp,
291 recv: Receiver::new(to_schema(&hello.schema)),
292 phase: Phase::Snapshot { next_chunk: 0 },
293 })
294 }
295
296 #[inline]
297 fn check_frame(&self, epoch: u64, fp: SchemaFp) -> Result<(), ProtocolError> {
298 if epoch != self.epoch {
299 return Err(ProtocolError::EpochMismatch {
300 expected: self.epoch,
301 got: epoch,
302 });
303 }
304 if fp != self.schema_fp {
305 return Err(ProtocolError::SchemaMismatch {
306 expected: self.schema_fp,
307 got: fp,
308 });
309 }
310 Ok(())
311 }
312
313 /// Apply one [`SnapshotChunk`] of a chunked hydrate snapshot. Validates epoch +
314 /// schema-fp + strict in-order chunk index, applies its `adds`, and on the `last`
315 /// chunk transitions to live (snapshot complete — the tree may render).
316 /// A re-delivered (or post-completion) chunk is a no-op [`SnapStatus::Duplicate`];
317 /// a chunk-index gap is a fatal [`ProtocolError::Gap`] (re-hydrate).
318 pub fn apply_snapshot_chunk(
319 &mut self,
320 chunk: &SnapshotChunk,
321 ) -> Result<SnapStatus, ProtocolError> {
322 self.check_frame(chunk.epoch, chunk.schema_fp)?;
323 match &mut self.phase {
324 // Snapshot already complete — any further chunk is a stale re-delivery.
325 Phase::Live { .. } => Ok(SnapStatus::Duplicate),
326 Phase::Snapshot { next_chunk } => {
327 if chunk.index < *next_chunk {
328 return Ok(SnapStatus::Duplicate);
329 }
330 if chunk.index > *next_chunk {
331 return Err(ProtocolError::Gap {
332 expected: *next_chunk,
333 got: chunk.index,
334 });
335 }
336 self.recv.apply_all(&chunk.adds);
337 if chunk.last {
338 self.phase = Phase::Live { last_seq: 0 };
339 Ok(SnapStatus::Complete)
340 } else {
341 *next_chunk += 1;
342 Ok(SnapStatus::Accepted)
343 }
344 }
345 }
346 }
347
348 /// Apply one incremental [`Batch`] (or the single-shot seq-0 snapshot batch).
349 /// Validates epoch + schema-fp + strict in-order seq, then folds the events in
350 /// order. A duplicate (already-applied seq) is a no-op [`Applied::Duplicate`]; a gap
351 /// is a fatal [`ProtocolError::Gap`] (re-hydrate).
352 pub fn apply(&mut self, batch: &Batch) -> Result<Applied, ProtocolError> {
353 self.check_frame(batch.epoch, batch.schema_fp)?;
354 match &mut self.phase {
355 Phase::Snapshot { next_chunk } => {
356 // Single-shot baseline: only valid before any chunk, at seq 0.
357 if *next_chunk == 0 && batch.seq == 0 {
358 self.recv.apply_all(&batch.events);
359 self.phase = Phase::Live { last_seq: 0 };
360 Ok(Applied::Applied)
361 } else {
362 // Mid chunked-snapshot (last chunk lost) or baseline missing —
363 // unrecoverable from deltas; re-hydrate.
364 Err(ProtocolError::Gap {
365 expected: 0,
366 got: batch.seq,
367 })
368 }
369 }
370 Phase::Live { last_seq } => {
371 let expected = *last_seq + 1;
372 if batch.seq < expected {
373 // Already applied (re-delivery). `rc` ops are not idempotent — discard.
374 return Ok(Applied::Duplicate);
375 }
376 if batch.seq > expected {
377 return Err(ProtocolError::Gap {
378 expected,
379 got: batch.seq,
380 });
381 }
382 self.recv.apply_all(&batch.events);
383 *last_seq = batch.seq;
384 Ok(Applied::Applied)
385 }
386 }
387 }
388
389 /// The reconstructed top-level result (`root[""]`). Valid to read once the snapshot
390 /// is complete ([`snapshot_complete`](Subscriber::snapshot_complete)); mid-snapshot
391 /// it is a partial tree (render only after completion — §5.4).
392 pub fn top(&self) -> &[RecvNode] {
393 self.recv.top()
394 }
395
396 /// The subscription epoch this subscriber accepts.
397 pub fn epoch(&self) -> u64 {
398 self.epoch
399 }
400
401 /// Whether the hydrate snapshot is complete (the tree may render).
402 pub fn snapshot_complete(&self) -> bool {
403 matches!(self.phase, Phase::Live { .. })
404 }
405
406 /// The last applied incremental seq (`None` until the snapshot is complete; `Some(0)`
407 /// immediately after the snapshot, then the last applied batch seq).
408 pub fn last_seq(&self) -> Option<u64> {
409 match self.phase {
410 Phase::Live { last_seq } => Some(last_seq),
411 Phase::Snapshot { .. } => None,
412 }
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::changes::{CaughtChange, CaughtNode};
420 use crate::value::{owned_row, OwnedValue as V, Schema};
421
422 fn schema() -> Schema {
423 Schema::new(vec!["id", "val"], vec![0], vec![(0, true)])
424 }
425 fn add(id: i64, val: i64) -> CaughtChange {
426 CaughtChange::Add(CaughtNode {
427 row: owned_row(vec![V::Int(id), V::Int(val)]),
428 relationships: Default::default(),
429 })
430 }
431 fn ids(top: &[RecvNode]) -> Vec<i64> {
432 top.iter()
433 .map(|n| match n.row.col(0) {
434 crate::value::Value::Int(i) => i,
435 o => panic!("{o:?}"),
436 })
437 .collect()
438 }
439
440 #[test]
441 fn open_rejects_comparator_mismatch() {
442 let pubr = Publisher::new(1, &schema());
443 let mut hello = pubr.hello();
444 hello.comparator_version = COMPARATOR_VERSION + 1;
445 assert_eq!(
446 Subscriber::open(&hello).unwrap_err(),
447 ProtocolError::ComparatorMismatch {
448 expected: COMPARATOR_VERSION,
449 got: COMPARATOR_VERSION + 1,
450 }
451 );
452 }
453
454 #[test]
455 fn open_rejects_forged_fingerprint() {
456 let pubr = Publisher::new(1, &schema());
457 let mut hello = pubr.hello();
458 hello.schema_fp = SchemaFp(hello.schema_fp.0 ^ 0xdead); // lie about the fp
459 assert!(matches!(
460 Subscriber::open(&hello),
461 Err(ProtocolError::SchemaMismatch { .. })
462 ));
463 }
464
465 #[test]
466 fn snapshot_then_in_order_commits_apply() {
467 let mut pubr = Publisher::new(1, &schema());
468 let mut sub = Subscriber::open(&pubr.hello()).unwrap();
469
470 let snap = pubr.snapshot(&[add(1, 10), add(2, 20)]);
471 assert_eq!(snap.seq, 0);
472 assert_eq!(sub.apply(&snap), Ok(Applied::Applied));
473 assert_eq!(ids(sub.top()), vec![1, 2]);
474
475 let b1 = pubr.commit(&[add(3, 30)]).expect("non-empty");
476 assert_eq!(b1.seq, 1);
477 assert_eq!(sub.apply(&b1), Ok(Applied::Applied));
478 assert_eq!(ids(sub.top()), vec![1, 2, 3]);
479 assert_eq!(sub.last_seq(), Some(1));
480 }
481
482 #[test]
483 fn empty_commit_emits_no_batch_and_keeps_seq_gap_free() {
484 let mut pubr = Publisher::new(1, &schema());
485 let mut sub = Subscriber::open(&pubr.hello()).unwrap();
486 sub.apply(&pubr.snapshot(&[add(1, 10)])).unwrap(); // seq 0
487
488 assert!(pubr.commit(&[]).is_none(), "empty txn → no batch");
489 // The next real commit is still seq 1 (the empty txn consumed nothing).
490 let b = pubr.commit(&[add(2, 20)]).expect("non-empty");
491 assert_eq!(b.seq, 1);
492 assert_eq!(sub.apply(&b), Ok(Applied::Applied));
493 assert_eq!(ids(sub.top()), vec![1, 2]);
494 }
495
496 #[test]
497 fn gap_is_rejected() {
498 let mut pubr = Publisher::new(1, &schema());
499 let mut sub = Subscriber::open(&pubr.hello()).unwrap();
500 sub.apply(&pubr.snapshot(&[add(1, 10)])).unwrap(); // seq 0, expected next = 1
501
502 // Fabricate a seq-2 batch (seq 1 was lost in transit).
503 let mut b2 = pubr.commit(&[add(2, 20)]).unwrap(); // really seq 1
504 b2.seq = 2;
505 assert_eq!(
506 sub.apply(&b2),
507 Err(ProtocolError::Gap {
508 expected: 1,
509 got: 2
510 })
511 );
512 // The tree did not advance.
513 assert_eq!(ids(sub.top()), vec![1]);
514 }
515
516 #[test]
517 fn duplicate_is_idempotently_discarded() {
518 let mut pubr = Publisher::new(1, &schema());
519 let mut sub = Subscriber::open(&pubr.hello()).unwrap();
520 let snap = pubr.snapshot(&[add(1, 10)]);
521 sub.apply(&snap).unwrap(); // seq 0
522 let b1 = pubr.commit(&[add(2, 20)]).unwrap();
523 sub.apply(&b1).unwrap(); // seq 1
524
525 // Re-deliver seq 1: must NOT double-apply (rc add is not idempotent).
526 assert_eq!(sub.apply(&b1), Ok(Applied::Duplicate));
527 assert_eq!(ids(sub.top()), vec![1, 2]);
528 assert_eq!(sub.top()[1].rc, 1, "duplicate add must not bump rc");
529 // Re-deliver the snapshot (seq 0) too → still a no-op duplicate.
530 assert_eq!(sub.apply(&snap), Ok(Applied::Duplicate));
531 assert_eq!(ids(sub.top()), vec![1, 2]);
532 }
533
534 #[test]
535 fn stale_epoch_batch_is_rejected_after_resubscribe() {
536 // Epoch-1 subscription.
537 let mut pub1 = Publisher::new(1, &schema());
538 let mut sub1 = Subscriber::open(&pub1.hello()).unwrap();
539 sub1.apply(&pub1.snapshot(&[add(1, 10)])).unwrap();
540 let stale = pub1.commit(&[add(2, 20)]).unwrap(); // epoch 1, seq 1
541
542 // A gap forced a re-subscribe at epoch 2 (fresh snapshot of the current state).
543 let mut pub2 = Publisher::new(2, &schema());
544 let mut sub2 = Subscriber::open(&pub2.hello()).unwrap();
545 sub2.apply(&pub2.snapshot(&[add(1, 10), add(2, 20)]))
546 .unwrap();
547 assert_eq!(ids(sub2.top()), vec![1, 2]);
548
549 // The stale epoch-1 batch is rejected by the epoch-2 subscriber.
550 assert_eq!(
551 sub2.apply(&stale),
552 Err(ProtocolError::EpochMismatch {
553 expected: 2,
554 got: 1
555 })
556 );
557 }
558
559 #[test]
560 fn chunked_snapshot_assembles_then_increments() {
561 let mut pubr = Publisher::new(1, &schema());
562 let mut sub = Subscriber::open(&pubr.hello()).unwrap();
563
564 // Five entries, two per chunk → 3 chunks (indices 0,1,2; last on index 2).
565 let chunks = pubr.snapshot_chunks(
566 &[add(1, 10), add(2, 20), add(3, 30), add(4, 40), add(5, 50)],
567 2,
568 );
569 assert_eq!(chunks.len(), 3);
570 assert!(!chunks[0].last && !chunks[1].last && chunks[2].last);
571
572 assert_eq!(
573 sub.apply_snapshot_chunk(&chunks[0]),
574 Ok(SnapStatus::Accepted)
575 );
576 assert!(!sub.snapshot_complete());
577 assert_eq!(
578 sub.apply_snapshot_chunk(&chunks[1]),
579 Ok(SnapStatus::Accepted)
580 );
581 assert_eq!(
582 sub.apply_snapshot_chunk(&chunks[2]),
583 Ok(SnapStatus::Complete)
584 );
585 assert!(sub.snapshot_complete());
586 assert_eq!(ids(sub.top()), vec![1, 2, 3, 4, 5]);
587 assert_eq!(sub.last_seq(), Some(0));
588
589 // Increments resume at seq 1 (the baseline reserved seq 0).
590 let b = pubr.commit(&[add(6, 60)]).expect("non-empty");
591 assert_eq!(b.seq, 1);
592 assert_eq!(sub.apply(&b), Ok(Applied::Applied));
593 assert_eq!(ids(sub.top()), vec![1, 2, 3, 4, 5, 6]);
594 }
595
596 #[test]
597 fn empty_chunked_snapshot_still_completes() {
598 let mut pubr = Publisher::new(1, &schema());
599 let mut sub = Subscriber::open(&pubr.hello()).unwrap();
600 let chunks = pubr.snapshot_chunks(&[], 4);
601 assert_eq!(chunks.len(), 1);
602 assert!(chunks[0].last);
603 assert_eq!(
604 sub.apply_snapshot_chunk(&chunks[0]),
605 Ok(SnapStatus::Complete)
606 );
607 assert!(sub.snapshot_complete());
608 assert!(sub.top().is_empty());
609 }
610
611 #[test]
612 fn snapshot_chunk_gap_and_duplicate() {
613 let mut pubr = Publisher::new(1, &schema());
614 let mut sub = Subscriber::open(&pubr.hello()).unwrap();
615 let chunks = pubr.snapshot_chunks(&[add(1, 10), add(2, 20), add(3, 30)], 1);
616 assert_eq!(chunks.len(), 3);
617
618 sub.apply_snapshot_chunk(&chunks[0]).unwrap();
619 // Skipping chunk 1 (deliver chunk 2) → gap.
620 assert_eq!(
621 sub.apply_snapshot_chunk(&chunks[2]),
622 Err(ProtocolError::Gap {
623 expected: 1,
624 got: 2
625 })
626 );
627 // Re-deliver chunk 0 → duplicate, no double-apply (rc unchanged).
628 assert_eq!(
629 sub.apply_snapshot_chunk(&chunks[0]),
630 Ok(SnapStatus::Duplicate)
631 );
632 assert_eq!(sub.top()[0].rc, 1);
633 // In order from here completes.
634 sub.apply_snapshot_chunk(&chunks[1]).unwrap();
635 assert_eq!(
636 sub.apply_snapshot_chunk(&chunks[2]),
637 Ok(SnapStatus::Complete)
638 );
639 assert_eq!(ids(sub.top()), vec![1, 2, 3]);
640 }
641
642 #[test]
643 fn batch_mid_chunked_snapshot_is_a_gap() {
644 let mut pubr = Publisher::new(1, &schema());
645 let mut sub = Subscriber::open(&pubr.hello()).unwrap();
646 let chunks = pubr.snapshot_chunks(&[add(1, 10), add(2, 20)], 1);
647 sub.apply_snapshot_chunk(&chunks[0]).unwrap(); // chunk 0 of 2; not complete
648 // An incremental batch before the snapshot completes → gap (the `last`
649 // chunk was lost); the consumer must re-hydrate.
650 let b = pubr.commit(&[add(9, 90)]).expect("non-empty");
651 assert!(matches!(sub.apply(&b), Err(ProtocolError::Gap { .. })));
652 }
653}