1use std::collections::HashMap;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::sync::Mutex;
15use std::time::Duration;
16
17use crate::proto;
18
19pub trait CheckpointStore: Send + Sync {
26 fn load(&self, subscription_id: &str) -> io::Result<Option<u64>>;
29
30 fn commit(&self, subscription_id: &str, offset: u64) -> io::Result<()>;
32}
33
34pub struct FileCheckpointStore {
41 path: PathBuf,
42 state: Mutex<HashMap<String, u64>>,
43}
44
45impl FileCheckpointStore {
46 pub fn open<P: AsRef<Path>>(path: P) -> io::Result<Self> {
50 let path = path.as_ref().to_path_buf();
51 let state = match std::fs::read(&path) {
52 Ok(bytes) => parse_state(&bytes),
53 Err(e) if e.kind() == io::ErrorKind::NotFound => HashMap::new(),
54 Err(_) => HashMap::new(),
56 };
57 Ok(Self {
58 path,
59 state: Mutex::new(state),
60 })
61 }
62}
63
64fn parse_state(bytes: &[u8]) -> HashMap<String, u64> {
72 let s = match std::str::from_utf8(bytes) {
73 Ok(s) => s.trim(),
74 Err(_) => return HashMap::new(),
75 };
76 let inner = match s.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
77 Some(inner) => inner.trim(),
78 None => return HashMap::new(),
79 };
80 parse_pairs(inner).unwrap_or_default()
81}
82
83fn parse_pairs(inner: &str) -> Option<HashMap<String, u64>> {
86 let mut out = HashMap::new();
87 let mut chars = inner.chars().peekable();
88 loop {
89 while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
91 chars.next();
92 }
93 if chars.peek().is_none() {
94 return Some(out);
95 }
96 let key = parse_json_string(&mut chars)?;
97 skip_ws(&mut chars);
98 if chars.next() != Some(':') {
99 return None;
100 }
101 skip_ws(&mut chars);
102 let mut num = String::new();
104 while matches!(chars.peek(), Some(c) if c.is_ascii_digit()) {
105 num.push(chars.next().unwrap());
106 }
107 let n: u64 = num.parse().ok()?;
108 out.insert(key, n);
109 skip_ws(&mut chars);
110 match chars.next() {
111 Some(',') => continue,
112 None => return Some(out),
113 Some(_) => return None,
114 }
115 }
116}
117
118fn skip_ws(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
119 while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
120 chars.next();
121 }
122}
123
124fn parse_json_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Option<String> {
127 if chars.next() != Some('"') {
128 return None;
129 }
130 let mut out = String::new();
131 loop {
132 match chars.next()? {
133 '"' => return Some(out),
134 '\\' => match chars.next()? {
135 '"' => out.push('"'),
136 '\\' => out.push('\\'),
137 'n' => out.push('\n'),
138 'r' => out.push('\r'),
139 't' => out.push('\t'),
140 _ => return None,
141 },
142 c => out.push(c),
143 }
144 }
145}
146
147fn serialize_state(state: &HashMap<String, u64>) -> String {
150 let mut parts: Vec<String> = state
151 .iter()
152 .map(|(k, v)| format!("{}:{}", json_string(k), v))
153 .collect();
154 parts.sort();
155 format!("{{{}}}", parts.join(","))
156}
157
158fn json_string(s: &str) -> String {
159 let mut out = String::with_capacity(s.len() + 2);
160 out.push('"');
161 for c in s.chars() {
162 match c {
163 '"' => out.push_str("\\\""),
164 '\\' => out.push_str("\\\\"),
165 '\n' => out.push_str("\\n"),
166 '\r' => out.push_str("\\r"),
167 '\t' => out.push_str("\\t"),
168 _ => out.push(c),
169 }
170 }
171 out.push('"');
172 out
173}
174
175impl CheckpointStore for FileCheckpointStore {
176 fn load(&self, subscription_id: &str) -> io::Result<Option<u64>> {
177 let state = self.state.lock().unwrap();
178 Ok(state.get(subscription_id).copied())
179 }
180
181 fn commit(&self, subscription_id: &str, offset: u64) -> io::Result<()> {
182 let mut state = self.state.lock().unwrap();
183 state.insert(subscription_id.to_string(), offset);
184 let data = serialize_state(&state);
185
186 let dir = self
187 .path
188 .parent()
189 .filter(|p| !p.as_os_str().is_empty())
190 .map(Path::to_path_buf)
191 .unwrap_or_else(|| PathBuf::from("."));
192 std::fs::create_dir_all(&dir)?;
193
194 let tmp = dir.join(format!(".ckpt-{}.tmp", unique_suffix()));
197 let write_and_rename = || -> io::Result<()> {
198 use std::io::Write;
199 let mut f = std::fs::File::create(&tmp)?;
200 f.write_all(data.as_bytes())?;
201 f.sync_all()?;
202 drop(f);
203 std::fs::rename(&tmp, &self.path)
204 };
205 if let Err(e) = write_and_rename() {
206 let _ = std::fs::remove_file(&tmp);
207 return Err(e);
208 }
209 Ok(())
210 }
211}
212
213fn unique_suffix() -> String {
215 use std::sync::atomic::{AtomicU64, Ordering};
216 static COUNTER: AtomicU64 = AtomicU64::new(0);
217 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
218 let nanos = std::time::SystemTime::now()
219 .duration_since(std::time::UNIX_EPOCH)
220 .map(|d| d.as_nanos())
221 .unwrap_or(0);
222 format!("{}-{}-{}", std::process::id(), nanos, n)
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct CommittedChange {
233 pub offset: u64,
234 pub term: u64,
235 pub seq_in_entry: u32,
236 pub cf: u32,
237 pub key: Vec<u8>,
238 pub op: String,
240 pub value: Vec<u8>,
241 pub is_snapshot: bool,
242}
243
244pub struct SubscribeCommittedOptions<'a> {
249 pub from_offset: u64,
251 pub cf: Option<u32>,
252 pub key_prefix: Vec<u8>,
253 pub include_values: bool,
254 pub subscription_id: Option<String>,
255 pub checkpoint: Option<&'a dyn CheckpointStore>,
256 pub auto_commit: bool,
257 pub shard_id: u64,
258}
259
260impl<'a> Default for SubscribeCommittedOptions<'a> {
261 fn default() -> Self {
262 Self {
263 from_offset: 0,
264 cf: None,
265 key_prefix: Vec::new(),
266 include_values: true,
267 subscription_id: None,
268 checkpoint: None,
269 auto_commit: true,
270 shard_id: 0,
271 }
272 }
273}
274
275#[derive(Debug, Clone)]
278pub enum FeedItem {
279 Change(CommittedChange),
280 Heartbeat(u64),
282 Compacted {
284 earliest_offset: u64,
285 snapshot_offset: u64,
286 },
287}
288
289impl FeedItem {
290 pub fn from_proto(item: proto::CommittedFeedItem) -> Option<FeedItem> {
293 use proto::committed_feed_item::Item;
294 match item.item? {
295 Item::Change(c) => Some(FeedItem::Change(CommittedChange {
296 offset: c.offset,
297 term: c.term,
298 seq_in_entry: c.seq_in_entry,
299 cf: c.cf,
300 key: c.key,
301 op: c.op,
302 value: c.value,
303 is_snapshot: false,
304 })),
305 Item::Heartbeat(hw) => Some(FeedItem::Heartbeat(hw)),
306 Item::Compacted(n) => Some(FeedItem::Compacted {
307 earliest_offset: n.earliest_offset,
308 snapshot_offset: n.snapshot_offset,
309 }),
310 }
311 }
312}
313
314#[tonic::async_trait]
319pub trait FeedStream: Send {
320 async fn recv(&mut self) -> Result<Option<FeedItem>, tonic::Status>;
321}
322
323#[tonic::async_trait]
327pub trait FeedTransport: Send {
328 type Stream: FeedStream;
329
330 async fn open_feed(
332 &mut self,
333 shard_id: u64,
334 from_offset: u64,
335 cf: u32,
336 key_prefix: &[u8],
337 include_values: bool,
338 ) -> Result<Self::Stream, tonic::Status>;
339
340 async fn scan_page(
343 &mut self,
344 prefix: &[u8],
345 cursor: Option<&[u8]>,
346 limit: u32,
347 cf: u32,
348 ) -> Result<(Vec<(Vec<u8>, Vec<u8>)>, Option<Vec<u8>>), tonic::Status>;
349}
350
351#[derive(Debug)]
354pub enum ConsumeError<E> {
355 Transport(tonic::Status),
356 Checkpoint(io::Error),
357 Handler(E),
358}
359
360impl<E: std::fmt::Display> std::fmt::Display for ConsumeError<E> {
361 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362 match self {
363 ConsumeError::Transport(s) => write!(f, "transport error: {s}"),
364 ConsumeError::Checkpoint(e) => write!(f, "checkpoint error: {e}"),
365 ConsumeError::Handler(e) => write!(f, "handler error: {e}"),
366 }
367 }
368}
369
370impl<E: std::fmt::Debug + std::fmt::Display> std::error::Error for ConsumeError<E> {}
371
372const BACKOFF_BASE: Duration = Duration::from_millis(500);
374const BACKOFF_MAX: Duration = Duration::from_secs(30);
375
376#[tonic::async_trait]
379pub trait Sleeper: Send {
380 async fn sleep(&mut self, dur: Duration);
381}
382
383pub struct TokioSleeper;
385
386#[tonic::async_trait]
387impl Sleeper for TokioSleeper {
388 async fn sleep(&mut self, dur: Duration) {
389 tokio::time::sleep(dur).await;
390 }
391}
392
393pub async fn run_consumer<T, S, H, E>(
404 transport: &mut T,
405 sleeper: &mut S,
406 opts: SubscribeCommittedOptions<'_>,
407 default_cf: u32,
408 mut handler: H,
409) -> Result<(), ConsumeError<E>>
410where
411 T: FeedTransport,
412 S: Sleeper,
413 H: FnMut(CommittedChange) -> Result<bool, E>,
414{
415 let effective_cf = opts.cf.unwrap_or(default_cf);
416 let do_commit = opts.checkpoint.is_some() && opts.subscription_id.is_some() && opts.auto_commit;
417 let sub_id = opts.subscription_id.clone();
418
419 let commit = |offset: u64| -> Result<(), ConsumeError<E>> {
420 if do_commit {
421 let ckpt = opts.checkpoint.unwrap();
422 let sid = sub_id.as_deref().unwrap();
423 ckpt.commit(sid, offset).map_err(ConsumeError::Checkpoint)?;
424 }
425 Ok(())
426 };
427
428 let mut next_offset = opts.from_offset;
430 if let (Some(ckpt), Some(sid)) = (opts.checkpoint, sub_id.as_deref()) {
431 if let Some(saved) = ckpt.load(sid).map_err(ConsumeError::Checkpoint)? {
432 next_offset = saved + 1;
433 }
434 }
435
436 let mut last_offset = next_offset.saturating_sub(1);
438 let mut backoff = BACKOFF_BASE;
439
440 loop {
441 let stream = transport
442 .open_feed(
443 opts.shard_id,
444 next_offset,
445 effective_cf,
446 &opts.key_prefix,
447 opts.include_values,
448 )
449 .await;
450 let mut stream = match stream {
451 Ok(s) => s,
452 Err(_status) => {
453 next_offset = last_offset + 1;
454 sleeper.sleep(backoff).await;
455 backoff = next_backoff(backoff);
456 continue;
457 }
458 };
459
460 let mut stream_err = false;
461 let mut compacted = false;
462 loop {
463 let item = match stream.recv().await {
464 Ok(Some(item)) => item,
465 Ok(None) => {
466 next_offset = last_offset + 1;
468 break;
469 }
470 Err(_status) => {
471 stream_err = true;
472 break;
473 }
474 };
475
476 match item {
477 FeedItem::Change(change) => {
478 let offset = change.offset;
479 match handler(change).map_err(ConsumeError::Handler)? {
480 true => {}
481 false => return Ok(()),
482 }
483 if offset > last_offset {
484 last_offset = offset;
485 }
486 next_offset = last_offset + 1;
487 commit(offset)?;
488 }
489 FeedItem::Heartbeat(hw) => {
490 if hw > last_offset {
493 last_offset = hw;
494 next_offset = hw + 1;
495 commit(hw)?;
496 }
497 }
498 FeedItem::Compacted {
499 earliest_offset,
500 snapshot_offset,
501 } => {
502 if snapshot_offset > 0 {
503 let mut cursor: Option<Vec<u8>> = None;
506 loop {
507 let (entries, next) = transport
508 .scan_page(&opts.key_prefix, cursor.as_deref(), 500, effective_cf)
509 .await
510 .map_err(ConsumeError::Transport)?;
511 for (key, value) in entries {
512 let change = CommittedChange {
513 offset: snapshot_offset,
514 term: 0,
515 seq_in_entry: 0,
516 cf: effective_cf,
517 key,
518 op: "put".to_string(),
519 value,
520 is_snapshot: true,
521 };
522 match handler(change).map_err(ConsumeError::Handler)? {
523 true => {}
524 false => return Ok(()),
525 }
526 }
527 match next {
528 Some(c) => cursor = Some(c),
529 None => break,
530 }
531 }
532 if snapshot_offset > last_offset {
533 last_offset = snapshot_offset;
534 }
535 commit(snapshot_offset)?;
536 next_offset = snapshot_offset + 1;
537 } else {
538 next_offset = earliest_offset;
541 last_offset = last_offset.max(earliest_offset.saturating_sub(1));
542 }
543 compacted = true;
544 break;
545 }
546 }
547 }
548
549 if stream_err {
550 next_offset = last_offset + 1;
553 sleeper.sleep(backoff).await;
554 backoff = next_backoff(backoff);
555 } else {
556 let _ = compacted;
559 backoff = BACKOFF_BASE;
560 }
561 }
562}
563
564fn next_backoff(cur: Duration) -> Duration {
565 let next = cur.saturating_mul(2);
566 if next > BACKOFF_MAX {
567 BACKOFF_MAX
568 } else {
569 next
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
581 use std::convert::Infallible;
582 use std::sync::atomic::{AtomicUsize, Ordering};
583 use std::sync::{Arc, Mutex as StdMutex};
584
585 fn change(offset: u64, key: &[u8], value: &[u8]) -> FeedItem {
588 FeedItem::Change(CommittedChange {
589 offset,
590 term: 1,
591 seq_in_entry: 0,
592 cf: 0,
593 key: key.to_vec(),
594 op: "put".to_string(),
595 value: value.to_vec(),
596 is_snapshot: false,
597 })
598 }
599
600 enum Step {
602 Item(FeedItem),
603 Err,
604 }
605
606 struct FakeStream {
607 script: Vec<Step>,
608 pos: usize,
609 }
610
611 #[tonic::async_trait]
612 impl FeedStream for FakeStream {
613 async fn recv(&mut self) -> Result<Option<FeedItem>, tonic::Status> {
614 if self.pos >= self.script.len() {
615 return Ok(None); }
617 let step = &self.script[self.pos];
618 self.pos += 1;
619 match step {
620 Step::Item(it) => Ok(Some(it.clone())),
621 Step::Err => Err(tonic::Status::unavailable("disconnect")),
622 }
623 }
624 }
625
626 type ScanPage = (Vec<(Vec<u8>, Vec<u8>)>, Option<Vec<u8>>);
627
628 struct FakeTransport {
629 streams: Vec<Vec<Step>>,
630 scan_pages: Vec<ScanPage>,
631 requests: Arc<StdMutex<Vec<u64>>>, scan_calls: Arc<StdMutex<usize>>,
633 }
634
635 #[tonic::async_trait]
636 impl FeedTransport for FakeTransport {
637 type Stream = FakeStream;
638
639 async fn open_feed(
640 &mut self,
641 _shard_id: u64,
642 from_offset: u64,
643 _cf: u32,
644 _key_prefix: &[u8],
645 _include_values: bool,
646 ) -> Result<Self::Stream, tonic::Status> {
647 self.requests.lock().unwrap().push(from_offset);
648 let script = if self.streams.is_empty() {
649 vec![Step::Err]
652 } else {
653 self.streams.remove(0)
654 };
655 Ok(FakeStream { script, pos: 0 })
656 }
657
658 async fn scan_page(
659 &mut self,
660 _prefix: &[u8],
661 _cursor: Option<&[u8]>,
662 _limit: u32,
663 _cf: u32,
664 ) -> Result<ScanPage, tonic::Status> {
665 *self.scan_calls.lock().unwrap() += 1;
666 if self.scan_pages.is_empty() {
667 Ok((vec![], None))
668 } else {
669 Ok(self.scan_pages.remove(0))
670 }
671 }
672 }
673
674 struct NoopSleeper {
676 calls: Arc<AtomicUsize>,
677 }
678
679 #[tonic::async_trait]
680 impl Sleeper for NoopSleeper {
681 async fn sleep(&mut self, _dur: Duration) {
682 self.calls.fetch_add(1, Ordering::Relaxed);
683 }
684 }
685
686 async fn collect_n(
688 streams: Vec<Vec<Step>>,
689 scan_pages: Vec<ScanPage>,
690 opts_builder: impl for<'a> FnOnce(&'a dyn CheckpointStore) -> SubscribeCommittedOptions<'a>,
691 checkpoint: &dyn CheckpointStore,
692 n: usize,
693 ) -> (Vec<CommittedChange>, Vec<u64>, usize) {
694 let requests = Arc::new(StdMutex::new(Vec::new()));
695 let scan_calls = Arc::new(StdMutex::new(0usize));
696 let mut transport = FakeTransport {
697 streams,
698 scan_pages,
699 requests: requests.clone(),
700 scan_calls: scan_calls.clone(),
701 };
702 let mut sleeper = NoopSleeper {
703 calls: Arc::new(AtomicUsize::new(0)),
704 };
705 let collected = Arc::new(StdMutex::new(Vec::new()));
706 let collected2 = collected.clone();
707 let opts = opts_builder(checkpoint);
708 let res: Result<(), ConsumeError<Infallible>> =
709 run_consumer(&mut transport, &mut sleeper, opts, 0, move |ch| {
710 let mut c = collected2.lock().unwrap();
711 c.push(ch);
712 Ok(c.len() < n)
713 })
714 .await;
715 res.expect("consumer should stop cleanly");
716 let out = collected.lock().unwrap().clone();
717 let reqs = requests.lock().unwrap().clone();
718 let scans = *scan_calls.lock().unwrap();
719 (out, reqs, scans)
720 }
721
722 fn tmp_ckpt(name: &str) -> (FileCheckpointStore, std::path::PathBuf) {
725 let mut dir = std::env::temp_dir();
726 dir.push(format!("statelet-cdc-test-{}-{}", std::process::id(), name));
727 let _ = std::fs::remove_dir_all(&dir);
728 std::fs::create_dir_all(&dir).unwrap();
729 let p = dir.join("ck.json");
730 (FileCheckpointStore::open(&p).unwrap(), p)
731 }
732
733 #[test]
736 fn file_checkpoint_roundtrip() {
737 let (store, p) = tmp_ckpt("roundtrip");
738 assert_eq!(store.load("sub-a").unwrap(), None);
739 store.commit("sub-a", 7).unwrap();
740 store.commit("sub-b", 11).unwrap();
741 assert_eq!(store.load("sub-a").unwrap(), Some(7));
742 assert_eq!(store.load("sub-b").unwrap(), Some(11));
743 let store2 = FileCheckpointStore::open(&p).unwrap();
745 assert_eq!(store2.load("sub-a").unwrap(), Some(7));
746 assert_eq!(store2.load("sub-b").unwrap(), Some(11));
747 let _ = std::fs::remove_file(&p);
748 }
749
750 #[test]
751 fn file_checkpoint_atomic_no_tmp_leftover() {
752 let (store, p) = tmp_ckpt("atomic");
753 store.commit("s", 3).unwrap();
754 store.commit("s", 4).unwrap();
755 let dir = p.parent().unwrap();
756 let leftovers: Vec<_> = std::fs::read_dir(dir)
757 .unwrap()
758 .filter_map(|e| e.ok())
759 .filter(|e| e.file_name().to_string_lossy().starts_with(".ckpt-"))
760 .collect();
761 assert!(leftovers.is_empty(), "leftover temp files: {leftovers:?}");
762 assert_eq!(
763 FileCheckpointStore::open(&p).unwrap().load("s").unwrap(),
764 Some(4)
765 );
766 let _ = std::fs::remove_file(&p);
767 }
768
769 #[test]
770 fn file_checkpoint_special_chars_in_subscription_id() {
771 let (store, p) = tmp_ckpt("special-chars");
775 let weird = r#"tenant:42,topic="orders\path""#;
776 store.commit(weird, 9).unwrap();
777 store.commit("plain", 3).unwrap();
778 let reopened = FileCheckpointStore::open(&p).unwrap();
779 assert_eq!(reopened.load(weird).unwrap(), Some(9));
780 assert_eq!(reopened.load("plain").unwrap(), Some(3));
781 let _ = std::fs::remove_file(&p);
782 }
783
784 #[test]
785 fn file_checkpoint_corrupt_file_is_empty() {
786 let (_unused, p) = tmp_ckpt("corrupt");
787 std::fs::write(&p, b"{ this is not json").unwrap();
788 let store = FileCheckpointStore::open(&p).unwrap();
789 assert_eq!(store.load("s").unwrap(), None);
790 }
791
792 #[tokio::test]
795 async fn start_from_checkpoint_plus_one() {
796 let (ck, p) = tmp_ckpt("ckpt-plus-one");
797 ck.commit("sub", 41).unwrap();
798 let (_out, reqs, _) = collect_n(
799 vec![vec![Step::Item(change(42, b"k", b""))]],
800 vec![],
801 |c| SubscribeCommittedOptions {
802 subscription_id: Some("sub".to_string()),
803 checkpoint: Some(c),
804 ..Default::default()
805 },
806 &ck,
807 1,
808 )
809 .await;
810 assert_eq!(reqs[0], 42, "from_offset should be checkpoint(41)+1");
811 let _ = std::fs::remove_file(&p);
812 }
813
814 #[tokio::test]
815 async fn start_from_offset_when_no_checkpoint() {
816 let (ck, p) = tmp_ckpt("from-offset");
817 let (_out, reqs, _) = collect_n(
818 vec![vec![Step::Item(change(100, b"k", b""))]],
819 vec![],
820 |c| SubscribeCommittedOptions {
821 from_offset: 100,
822 subscription_id: Some("sub".to_string()),
823 checkpoint: Some(c),
824 ..Default::default()
825 },
826 &ck,
827 1,
828 )
829 .await;
830 assert_eq!(reqs[0], 100);
831 let _ = std::fs::remove_file(&p);
832 }
833
834 #[tokio::test]
837 async fn change_delivered_and_committed_after_handler() {
838 let (ck, p) = tmp_ckpt("delivered");
839 let (out, _reqs, _) = collect_n(
840 vec![vec![
841 Step::Item(change(5, b"a", b"v")),
842 Step::Item(change(6, b"b", b"")),
843 ]],
844 vec![],
845 |c| SubscribeCommittedOptions {
846 subscription_id: Some("sub".to_string()),
847 checkpoint: Some(c),
848 ..Default::default()
849 },
850 &ck,
851 2,
852 )
853 .await;
854 assert_eq!(out.len(), 2);
855 assert_eq!(out[0].offset, 5);
856 assert_eq!(out[0].key, b"a");
857 assert_eq!(out[0].op, "put");
858 assert_eq!(out[0].value, b"v");
859 assert!(!out[0].is_snapshot);
860 assert_eq!(ck.load("sub").unwrap(), Some(5));
862 let _ = std::fs::remove_file(&p);
863 }
864
865 #[tokio::test]
866 async fn no_commit_when_auto_commit_false() {
867 let (ck, p) = tmp_ckpt("no-commit");
868 collect_n(
869 vec![vec![
870 Step::Item(change(5, b"a", b"")),
871 Step::Item(change(6, b"b", b"")),
872 ]],
873 vec![],
874 |c| SubscribeCommittedOptions {
875 subscription_id: Some("sub".to_string()),
876 checkpoint: Some(c),
877 auto_commit: false,
878 ..Default::default()
879 },
880 &ck,
881 2,
882 )
883 .await;
884 assert_eq!(ck.load("sub").unwrap(), None);
885 let _ = std::fs::remove_file(&p);
886 }
887
888 #[tokio::test]
891 async fn heartbeat_advances_and_commits_without_delivery() {
892 let (ck, p) = tmp_ckpt("heartbeat");
893 let (out, _reqs, _) = collect_n(
894 vec![vec![
895 Step::Item(FeedItem::Heartbeat(50)),
896 Step::Item(change(51, b"k", b"")),
897 ]],
898 vec![],
899 |c| SubscribeCommittedOptions {
900 subscription_id: Some("sub".to_string()),
901 checkpoint: Some(c),
902 ..Default::default()
903 },
904 &ck,
905 1,
906 )
907 .await;
908 assert_eq!(out[0].offset, 51, "heartbeat is not delivered");
909 assert_eq!(ck.load("sub").unwrap(), Some(50));
911 let _ = std::fs::remove_file(&p);
912 }
913
914 #[tokio::test]
917 async fn compacted_triggers_bootstrap_scan() {
918 let (ck, p) = tmp_ckpt("compacted");
919 let (out, reqs, scans) = collect_n(
920 vec![
921 vec![Step::Item(FeedItem::Compacted {
922 earliest_offset: 10,
923 snapshot_offset: 20,
924 })],
925 vec![Step::Item(change(21, b"live", b""))],
926 ],
927 vec![
928 (
929 vec![(b"k1".to_vec(), b"v1".to_vec())],
930 Some(b"cursor1".to_vec()),
931 ),
932 (vec![(b"k2".to_vec(), b"v2".to_vec())], None),
933 ],
934 |c| SubscribeCommittedOptions {
935 subscription_id: Some("sub".to_string()),
936 checkpoint: Some(c),
937 key_prefix: b"k".to_vec(),
938 cf: Some(0),
939 ..Default::default()
940 },
941 &ck,
942 3,
943 )
944 .await;
945 assert!(out[0].is_snapshot && out[0].op == "put" && out[0].offset == 20);
947 assert_eq!(
948 (out[0].key.as_slice(), out[0].value.as_slice()),
949 (b"k1".as_ref(), b"v1".as_ref())
950 );
951 assert!(out[1].is_snapshot && out[1].offset == 20 && out[1].key == b"k2");
952 assert!(out[2].offset == 21 && !out[2].is_snapshot);
953 assert_eq!(
954 ck.load("sub").unwrap(),
955 Some(20),
956 "snapshot_offset committed"
957 );
958 assert_eq!(reqs[1], 21, "resume from snapshot_offset+1");
959 assert_eq!(scans, 2, "two scan pages");
960 let _ = std::fs::remove_file(&p);
961 }
962
963 #[tokio::test]
964 async fn compacted_old_server_resumes_at_earliest() {
965 let (ck, p) = tmp_ckpt("old-server");
966 let (out, reqs, scans) = collect_n(
967 vec![
968 vec![Step::Item(FeedItem::Compacted {
969 earliest_offset: 99,
970 snapshot_offset: 0,
971 })],
972 vec![Step::Item(change(99, b"k", b""))],
973 ],
974 vec![],
975 |c| SubscribeCommittedOptions {
976 subscription_id: Some("sub".to_string()),
977 checkpoint: Some(c),
978 ..Default::default()
979 },
980 &ck,
981 1,
982 )
983 .await;
984 assert_eq!(out[0].offset, 99);
985 assert_eq!(scans, 0, "old-server path must not bootstrap scan");
986 assert_eq!(reqs[1], 99, "resume at earliest_offset");
987 let _ = std::fs::remove_file(&p);
988 }
989
990 #[tokio::test]
993 async fn stream_error_reconnects_from_last_plus_one() {
994 let (ck, p) = tmp_ckpt("reconnect");
995 let (out, reqs, _) = collect_n(
996 vec![
997 vec![Step::Item(change(5, b"a", b"")), Step::Err],
998 vec![Step::Item(change(6, b"b", b""))],
999 ],
1000 vec![],
1001 |c| SubscribeCommittedOptions {
1002 subscription_id: Some("sub".to_string()),
1003 checkpoint: Some(c),
1004 ..Default::default()
1005 },
1006 &ck,
1007 2,
1008 )
1009 .await;
1010 assert_eq!(out[0].offset, 5);
1011 assert_eq!(out[1].offset, 6);
1012 assert_eq!(reqs[1], 6, "reconnect from last+1");
1013 let _ = std::fs::remove_file(&p);
1014 }
1015}