1use crate::channel;
11use crate::checkpoint::Checkpoint;
12use crate::config::ResourcesConfig;
13use crate::error::{Error, ErrorKind, Result};
14use crate::event::SourceEvent;
15use crate::lsn::Lsn;
16use crate::metrics::Metrics;
17use crate::progress::{ProgressHandle, TxnSummary};
18use crate::retry::Backoff;
19use crate::state::JobState;
20use crate::traits::{EventStream, Feedback, ReplicationSink, SinkFactory, StreamFactory};
21use std::sync::Arc;
22use std::time::Duration;
23use tokio::sync::watch;
24use tokio_util::sync::CancellationToken;
25use tracing::{debug, error, info, warn};
26
27#[derive(Debug, Clone, Copy, Default)]
29pub struct AckPosition {
30 pub acked: Lsn,
31}
32
33const READER_STOP_TIMEOUT: Duration = Duration::from_secs(5);
35
36pub struct ApplyOutcome {
37 pub checkpoint: Checkpoint,
38}
39
40pub async fn run_apply_loop<S, K>(
45 stream: S,
46 mut sink: K,
47 mut cp: Checkpoint,
48 catchup_target: Lsn,
49 resources: &ResourcesConfig,
50 metrics: Arc<Metrics>,
51 cancel: CancellationToken,
52 progress: ProgressHandle,
53) -> Result<ApplyOutcome>
54where
55 S: EventStream + 'static,
56 K: ReplicationSink,
57{
58 cp.check_invariants().map_err(Error::internal)?;
59 let (tx, mut rx) = channel::bounded::<SourceEvent>(
60 resources.channel_capacity,
61 resources.max_inflight_bytes as usize,
62 );
63 let (ack_tx, ack_rx) = watch::channel(AckPosition {
64 acked: cp.acked_lsn,
65 });
66 let reader_cancel = cancel.child_token();
67 let reader = tokio::spawn(reader_task(
68 stream,
69 tx,
70 ack_rx,
71 resources.feedback_interval,
72 metrics.clone(),
73 reader_cancel.clone(),
74 ));
75
76 metrics.set_state(cp.state);
77 metrics.set_lsn(&metrics.committed_lsn, cp.committed_lsn);
78 metrics.set_lsn(&metrics.acked_lsn, cp.acked_lsn);
79
80 let result = applier_loop(
81 &mut rx,
82 &mut sink,
83 &mut cp,
84 catchup_target,
85 &ack_tx,
86 &metrics,
87 &progress,
88 )
89 .await;
90
91 reader_cancel.cancel();
95 rx.close();
96 let mut reader = reader;
97 let reader_result = match tokio::time::timeout(READER_STOP_TIMEOUT, &mut reader).await {
98 Ok(Ok(r)) => r,
99 Ok(Err(e)) => Err(Error::internal(format!("reader task panicked: {e}"))),
100 Err(_) => {
101 reader.abort();
102 Err(Error::transient(format!(
103 "reader did not stop within {READER_STOP_TIMEOUT:?}; dropping the source connection"
104 )))
105 }
106 };
107
108 let applier_err = result.err();
109 let reader_err = reader_result.err();
110 if applier_err.is_some() || reader_err.is_some() {
111 let _ = sink.rollback().await;
112 }
113 match (applier_err, reader_err) {
114 (None, None) => Ok(ApplyOutcome { checkpoint: cp }),
115 (Some(e), _) if e.kind() != ErrorKind::Cancelled => Err(e),
117 (_, Some(e)) => Err(e),
119 (Some(_), None) => Ok(ApplyOutcome { checkpoint: cp }),
121 }
122}
123
124struct OpenTxn {
125 final_lsn: Lsn,
126 xid: u32,
127 skipping: bool,
128}
129
130async fn applier_loop<K: ReplicationSink>(
131 rx: &mut channel::Receiver<SourceEvent>,
132 sink: &mut K,
133 cp: &mut Checkpoint,
134 catchup_target: Lsn,
135 ack_tx: &watch::Sender<AckPosition>,
136 metrics: &Metrics,
137 progress: &ProgressHandle,
138) -> Result<()> {
139 let mut open: Option<OpenTxn> = None;
140 let mut rows_in_txn: u64 = 0;
141 let mut names: std::collections::HashMap<u32, (String, Vec<usize>, Vec<String>)> =
142 Default::default();
143 let mut summary = TxnSummary::default();
144 let mut txn_started = std::time::Instant::now();
145 let wants_keys = progress.wants_keys();
146
147 while let Some(envelope) = rx.recv().await {
148 let event = envelope.into_inner();
149 metrics
150 .inflight_items
151 .store(0, std::sync::atomic::Ordering::Relaxed);
152 match event {
153 SourceEvent::Begin { final_lsn, xid, .. } => {
154 if open.is_some() {
155 return Err(Error::internal(format!(
156 "BEGIN for xid {xid} while transaction {} is open",
157 open.as_ref().map(|o| o.xid).unwrap_or(0)
158 )));
159 }
160 let skipping = cp.already_applied(final_lsn);
161 if skipping {
162 debug!(xid, %final_lsn, committed = %cp.commit_lsn, "skipping already applied transaction");
163 metrics.add(&metrics.txns_skipped, 1);
164 } else {
165 sink.begin().await?;
166 }
167 rows_in_txn = 0;
168 summary = TxnSummary {
169 xid,
170 ..Default::default()
171 };
172 txn_started = std::time::Instant::now();
173 open = Some(OpenTxn {
174 final_lsn,
175 xid,
176 skipping,
177 });
178 }
179 SourceEvent::Relation(rel) => {
180 let keys: Vec<usize> = rel.key_columns().map(|(i, _)| i).collect();
181 let key_names: Vec<String> =
182 rel.key_columns().map(|(_, c)| c.name.clone()).collect();
183 names.insert(rel.id, (rel.qualified_name(), keys, key_names));
184 sink.relation(rel).await?;
185 }
186 SourceEvent::Op(op) => {
187 let Some(txn) = open.as_ref() else {
188 return Err(Error::internal(format!(
189 "{} outside of a transaction",
190 op.kind()
191 )));
192 };
193 if !txn.skipping {
194 rows_in_txn += 1;
195 summarise(&mut summary, &names, &op, wants_keys);
196 sink.apply(op).await?;
197 }
198 }
199 SourceEvent::Commit {
200 commit_lsn,
201 end_lsn,
202 commit_ts,
203 } => {
204 let Some(txn) = open.take() else {
205 return Err(Error::internal("COMMIT outside of a transaction"));
206 };
207 if txn.final_lsn != commit_lsn {
208 return Err(Error::internal(format!(
209 "COMMIT lsn {commit_lsn} does not match BEGIN final_lsn {}",
210 txn.final_lsn
211 )));
212 }
213 if txn.skipping {
214 progress.skipped(txn.xid, end_lsn);
215 if cp.record_keepalive(end_lsn) {
217 sink.save_checkpoint(cp).await?;
218 }
219 } else {
220 cp.record_commit(commit_lsn, end_lsn, txn.xid, commit_ts);
221 if cp.state == JobState::CatchingUp && cp.acked_lsn >= catchup_target {
222 cp.state = JobState::Streaming;
223 info!(lsn = %cp.committed_lsn, "caught up; now streaming");
224 }
225 sink.commit(cp).await?;
226 summary.end_lsn = end_lsn;
227 summary.apply_time = txn_started.elapsed();
228 progress.txn(&summary);
229 metrics.add(&metrics.txns_applied, 1);
230 metrics.add(&metrics.rows_applied, rows_in_txn);
231 metrics.mark_applied_now();
232 metrics.set_lsn(&metrics.committed_lsn, cp.committed_lsn);
233 metrics.set_state(cp.state);
234 }
235 metrics.set_lsn(&metrics.acked_lsn, cp.acked_lsn);
236 ack_tx.send_replace(AckPosition {
237 acked: cp.acked_lsn,
238 });
239 }
240 SourceEvent::Keepalive { wal_end } => {
241 if open.is_some() {
242 continue;
243 }
244 let mut changed = cp.record_keepalive(wal_end);
245 if cp.state == JobState::CatchingUp && cp.acked_lsn >= catchup_target {
246 cp.state = JobState::Streaming;
247 info!(lsn = %cp.acked_lsn, "caught up; now streaming");
248 changed = true;
249 }
250 if changed {
251 sink.save_checkpoint(cp).await?;
252 metrics.set_lsn(&metrics.acked_lsn, cp.acked_lsn);
253 metrics.set_state(cp.state);
254 ack_tx.send_replace(AckPosition {
255 acked: cp.acked_lsn,
256 });
257 }
258 }
259 }
260 }
261 if let Some(txn) = open {
262 if !txn.skipping {
263 warn!(xid = txn.xid, "stream ended mid-transaction; rolling back");
264 }
265 return Err(Error::cancelled());
266 }
267 Ok(())
268}
269
270fn summarise(
271 summary: &mut TxnSummary,
272 names: &std::collections::HashMap<u32, (String, Vec<usize>, Vec<String>)>,
273 op: &crate::event::Op,
274 wants_keys: bool,
275) {
276 use crate::event::{Op, Tuple, Value};
277 let key_sample = |table: &str, keys: &[usize], key_names: &[String], t: &Tuple| -> String {
278 let parts: Vec<String> = keys
279 .iter()
280 .zip(key_names)
281 .map(|(i, n)| match t.values.get(*i) {
282 Some(Value::Text(b)) => format!("{n}={}", String::from_utf8_lossy(b)),
283 _ => format!("{n}=?"),
284 })
285 .collect();
286 format!("{table} {}", parts.join(","))
287 };
288 match op {
289 Op::Truncate { relations, .. } => {
290 for r in relations {
291 let table = names.get(r).map(|n| n.0.as_str()).unwrap_or("?");
292 summary.record(table, "truncate");
293 }
294 }
295 _ => {
296 let id = op.relation_ids()[0];
297 let Some((table, keys, key_names)) = names.get(&id) else {
298 summary.record("?", op.kind());
299 return;
300 };
301 summary.record(table, op.kind());
302 if wants_keys && summary.keys.len() < crate::progress::MAX_KEYS {
303 let tuple = match op {
304 Op::Insert { new, .. } => Some(new),
305 Op::Update { key, new, .. } => Some(key.as_ref().unwrap_or(new)),
306 Op::Delete { key, old, .. } => key.as_ref().or(old.as_ref()),
307 Op::Truncate { .. } => None,
308 };
309 if let Some(t) = tuple {
310 summary.record_key(key_sample(table, keys, key_names, t));
311 }
312 }
313 }
314 }
315}
316
317async fn reader_task<S: EventStream>(
318 mut stream: S,
319 tx: channel::Sender<SourceEvent>,
320 ack_rx: watch::Receiver<AckPosition>,
321 feedback_interval: Duration,
322 metrics: Arc<Metrics>,
323 cancel: CancellationToken,
324) -> Result<()> {
325 let mut ticker = tokio::time::interval(feedback_interval);
326 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
327 ticker.tick().await; let mut last_received = Lsn::ZERO;
329 let mut last_sent_ack = Lsn::ZERO;
334
335 let feedback = |ack: &watch::Receiver<AckPosition>, last_received: Lsn, reply: bool| {
336 let acked = ack.borrow().acked;
337 Feedback {
338 written: last_received.max(acked),
339 flushed: acked,
340 applied: acked,
341 reply_requested: reply,
342 }
343 };
344
345 loop {
346 let event = tokio::select! {
347 biased;
348 _ = cancel.cancelled() => {
349 let _ = stream.send_feedback(feedback(&ack_rx, last_received, false)).await;
351 return Ok(());
352 }
353 _ = ticker.tick() => {
354 let fb = feedback(&ack_rx, last_received, false);
355 if fb.flushed != last_sent_ack {
356 stream.send_feedback(fb).await?;
357 last_sent_ack = fb.flushed;
358 metrics.add(&metrics.feedback_sent, 1);
359 }
360 continue;
361 }
362 r = stream.next_event() => r?,
363 };
364
365 metrics.add(&metrics.events_received, 1);
366 metrics.add(&metrics.bytes_received, event.byte_len() as u64);
367 match &event {
368 SourceEvent::Commit { end_lsn, .. } => {
369 last_received = last_received.max(*end_lsn);
370 metrics.set_lsn(&metrics.last_received_lsn, *end_lsn);
371 }
372 SourceEvent::Keepalive { wal_end } => {
373 last_received = last_received.max(*wal_end);
374 metrics.set_lsn(&metrics.server_wal_end, *wal_end);
375 let fb = feedback(&ack_rx, last_received, false);
378 stream.send_feedback(fb).await?;
379 last_sent_ack = fb.flushed;
380 metrics.add(&metrics.feedback_sent, 1);
381 }
382 _ => {}
383 }
384
385 let bytes = event.byte_len();
386 let permit = loop {
387 tokio::select! {
388 biased;
389 _ = cancel.cancelled() => {
390 let _ = stream.send_feedback(feedback(&ack_rx, last_received, false)).await;
391 return Ok(());
392 }
393 _ = ticker.tick() => {
394 let fb = feedback(&ack_rx, last_received, false);
398 stream.send_feedback(fb).await?;
399 last_sent_ack = fb.flushed;
400 metrics.add(&metrics.feedback_sent, 1);
401 }
402 p = tx.reserve(bytes) => match p {
403 Ok(p) => break p,
404 Err(_) => return Ok(()), },
406 }
407 };
408 metrics
409 .inflight_items
410 .store(tx.inflight_items(), std::sync::atomic::Ordering::Relaxed);
411 permit.send(event);
412 }
413}
414
415pub async fn run_replication<SF, KF>(
418 job_id: &str,
419 mut streams: SF,
420 mut sinks: KF,
421 resources: &ResourcesConfig,
422 metrics: Arc<Metrics>,
423 cancel: CancellationToken,
424 progress: ProgressHandle,
425) -> Result<Checkpoint>
426where
427 SF: StreamFactory,
428 SF::Stream: 'static,
429 KF: SinkFactory,
430{
431 let mut backoff = Backoff::new(&resources.retry_backoff);
432 loop {
433 if cancel.is_cancelled() {
434 return Err(Error::cancelled());
435 }
436 let attempt = async {
437 let mut sink = sinks.connect().await?;
438 let cp = sink
439 .load_checkpoint(job_id)
440 .await?
441 .ok_or_else(|| Error::config(format!("job {job_id:?} has no checkpoint; run `pgsync bootstrap` first")))?;
442 if !cp.state.is_resumable() {
443 return Err(Error::new(
444 if cp.state == JobState::ResyncRequired { ErrorKind::ResyncRequired } else { ErrorKind::Preflight },
445 format!("job {job_id:?} is in state {} and cannot be resumed", cp.state),
446 ));
447 }
448 let mut cp = cp;
449 if matches!(cp.state, JobState::Error | JobState::Paused) {
450 cp.state = JobState::CatchingUp;
451 cp.last_error = None;
452 }
453 let (stream, wal_end) = streams.connect(&cp).await?;
454 metrics.set_lsn(&metrics.server_wal_end, wal_end);
455 metrics.set_connection(crate::metrics::ConnectionState::Connected);
456 info!(from = %cp.committed_lsn, acked = %cp.acked_lsn, server_wal_end = %wal_end, state = %cp.state, "replication stream opened");
457 progress.streaming(cp.committed_lsn);
458 let outcome = run_apply_loop(
459 stream,
460 sink,
461 cp,
462 wal_end,
463 resources,
464 metrics.clone(),
465 cancel.clone(),
466 progress.clone(),
467 )
468 .await?;
469 Ok::<_, Error>(outcome.checkpoint)
470 }
471 .await;
472
473 match attempt {
474 Ok(cp) => {
475 metrics.set_connection(crate::metrics::ConnectionState::Disconnected);
476 return Ok(cp);
477 }
478 Err(e) if e.is_retryable() && !cancel.is_cancelled() => {
479 metrics.set_connection(crate::metrics::ConnectionState::Reconnecting);
480 let Some(delay) = backoff.next_delay() else {
481 error!(error = %e, "retry attempts exhausted");
482 return Err(e);
483 };
484 metrics.add(&metrics.reconnects, 1);
485 warn!(error = %e, attempt = backoff.attempt(), delay_ms = delay.as_millis() as u64, "transient failure; reconnecting");
486 tokio::select! {
487 _ = cancel.cancelled() => return Err(Error::cancelled()),
488 _ = tokio::time::sleep(delay) => {}
489 }
490 }
491 Err(e) => {
492 metrics.set_connection(crate::metrics::ConnectionState::Disconnected);
493 if let Ok(mut sink) = sinks.connect().await
495 && let Ok(Some(mut cp)) = sink.load_checkpoint(job_id).await
496 {
497 cp.state = match e.kind() {
498 ErrorKind::ResyncRequired => JobState::ResyncRequired,
499 ErrorKind::Cancelled => cp.state,
500 _ => JobState::Error,
501 };
502 if e.kind() != ErrorKind::Cancelled {
503 cp.last_error = Some(truncate(&e.to_string(), 1000));
504 }
505 let _ = sink.save_checkpoint(&cp).await;
506 }
507 return Err(e);
508 }
509 }
510 }
511}
512
513fn truncate(s: &str, max: usize) -> String {
514 if s.len() <= max {
515 s.to_owned()
516 } else {
517 format!("{}…", &s[..max])
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use crate::event::{Op, Relation, ReplicaIdentity, Tuple, Value};
525 use std::collections::VecDeque;
526 use std::sync::Mutex;
527
528 #[derive(Clone)]
531 enum Script {
532 Event(SourceEvent),
533 Fail(ErrorKind),
534 }
535
536 struct FakeStream {
537 script: VecDeque<Script>,
538 feedback: Arc<Mutex<Vec<Feedback>>>,
539 fail_at: Option<tokio::time::Instant>,
540 }
541
542 impl FakeStream {
543 fn new(script: Vec<Script>, feedback: Arc<Mutex<Vec<Feedback>>>) -> Self {
544 FakeStream {
545 script: script.into(),
546 feedback,
547 fail_at: None,
548 }
549 }
550 }
551
552 impl EventStream for FakeStream {
553 async fn next_event(&mut self) -> Result<SourceEvent> {
555 match self.script.front().cloned() {
556 Some(Script::Event(e)) => {
557 self.script.pop_front();
558 Ok(e)
559 }
560 Some(Script::Fail(kind)) => {
561 let at = *self.fail_at.get_or_insert_with(|| {
563 tokio::time::Instant::now() + Duration::from_millis(60)
564 });
565 tokio::time::sleep_until(at).await;
566 self.script.pop_front();
567 self.fail_at = None;
568 Err(Error::new(kind, "scripted failure"))
569 }
570 None => std::future::pending().await,
571 }
572 }
573 async fn send_feedback(&mut self, fb: Feedback) -> Result<()> {
574 self.feedback.lock().unwrap().push(fb);
575 Ok(())
576 }
577 }
578
579 #[derive(Default)]
580 struct SinkLog {
581 calls: Vec<String>,
582 committed: Vec<Checkpoint>,
584 saved: Vec<Checkpoint>,
585 fail_commit_at: Option<usize>,
586 fail_commit_kind: Option<ErrorKind>,
587 durable: Option<Checkpoint>,
588 }
589
590 #[derive(Clone)]
591 struct FakeSink(Arc<Mutex<SinkLog>>);
592
593 impl ReplicationSink for FakeSink {
594 async fn load_checkpoint(&mut self, _job: &str) -> Result<Option<Checkpoint>> {
595 Ok(self.0.lock().unwrap().durable.clone())
596 }
597 async fn save_checkpoint(&mut self, cp: &Checkpoint) -> Result<()> {
598 let mut l = self.0.lock().unwrap();
599 l.calls.push(format!("save {}", cp.acked_lsn.0));
600 l.saved.push(cp.clone());
601 l.durable = Some(cp.clone());
602 Ok(())
603 }
604 async fn begin(&mut self) -> Result<()> {
605 self.0.lock().unwrap().calls.push("begin".into());
606 Ok(())
607 }
608 async fn relation(&mut self, rel: Arc<Relation>) -> Result<()> {
609 self.0
610 .lock()
611 .unwrap()
612 .calls
613 .push(format!("relation {}", rel.id));
614 Ok(())
615 }
616 async fn apply(&mut self, op: Op) -> Result<()> {
617 self.0
618 .lock()
619 .unwrap()
620 .calls
621 .push(format!("apply {}", op.kind()));
622 Ok(())
623 }
624 async fn commit(&mut self, cp: &Checkpoint) -> Result<()> {
625 let mut l = self.0.lock().unwrap();
626 let n = l.committed.len();
627 if l.fail_commit_at == Some(n) {
628 l.calls.push("commit FAILED".into());
629 return Err(Error::new(
630 l.fail_commit_kind.unwrap_or(ErrorKind::Transient),
631 "commit failed",
632 ));
633 }
634 l.calls.push(format!("commit {}", cp.committed_lsn.0));
635 l.committed.push(cp.clone());
636 l.durable = Some(cp.clone());
637 Ok(())
638 }
639 async fn rollback(&mut self) -> Result<()> {
640 self.0.lock().unwrap().calls.push("rollback".into());
641 Ok(())
642 }
643 }
644
645 fn begin(lsn: u64, xid: u32) -> Script {
646 Script::Event(SourceEvent::Begin {
647 final_lsn: Lsn(lsn),
648 commit_ts: 1,
649 xid,
650 })
651 }
652 fn commit(lsn: u64) -> Script {
653 Script::Event(SourceEvent::Commit {
654 commit_lsn: Lsn(lsn),
655 end_lsn: Lsn(lsn + 8),
656 commit_ts: 1,
657 })
658 }
659 fn insert() -> Script {
660 Script::Event(SourceEvent::Op(Op::Insert {
661 relation: 1,
662 new: Tuple {
663 values: vec![Value::text("1")],
664 },
665 }))
666 }
667 fn keepalive(lsn: u64) -> Script {
668 Script::Event(SourceEvent::Keepalive { wal_end: Lsn(lsn) })
669 }
670 fn relation() -> Script {
671 Script::Event(SourceEvent::Relation(Arc::new(Relation {
672 id: 1,
673 schema: "public".into(),
674 name: "t".into(),
675 replica_identity: ReplicaIdentity::Default,
676 columns: vec![],
677 })))
678 }
679
680 fn resources() -> ResourcesConfig {
681 ResourcesConfig {
682 feedback_interval: Duration::from_millis(20),
683 channel_capacity: 4,
684 max_inflight_bytes: 1 << 20,
685 ..Default::default()
686 }
687 }
688
689 fn checkpoint(state: JobState) -> Checkpoint {
690 let mut cp = Checkpoint::new_job("job", "sys", "db", "slot", "pub");
691 cp.state = state;
692 cp
693 }
694
695 async fn run(
696 script: Vec<Script>,
697 cp: Checkpoint,
698 sink: FakeSink,
699 ) -> (Result<ApplyOutcome>, Arc<Mutex<Vec<Feedback>>>) {
700 let feedback = Arc::new(Mutex::new(vec![]));
701 let stream = FakeStream::new(script, feedback.clone());
702 let cancel = CancellationToken::new();
703 let metrics = Arc::new(Metrics::new());
704 let res = tokio::time::timeout(
705 Duration::from_secs(5),
706 run_apply_loop(
707 stream,
708 sink,
709 cp,
710 Lsn(1000),
711 &resources(),
712 metrics,
713 cancel,
714 crate::progress::none(),
715 ),
716 )
717 .await
718 .expect("apply loop should finish");
719 (res, feedback)
720 }
721
722 #[tokio::test]
725 async fn applies_transactions_in_order_and_acks_after_commit() {
726 let sink = FakeSink(Default::default());
727 let script = vec![
728 relation(),
729 begin(100, 1),
730 insert(),
731 insert(),
732 commit(100),
733 begin(200, 2),
734 insert(),
735 commit(200),
736 Script::Fail(ErrorKind::Transient),
737 ];
738 let (res, feedback) = run(script, checkpoint(JobState::Streaming), sink.clone()).await;
739 assert_eq!(res.err().map(|e| e.kind()), Some(ErrorKind::Transient));
740 let log = sink.0.lock().unwrap();
741 assert_eq!(
742 log.calls,
743 vec![
744 "relation 1",
745 "begin",
746 "apply insert",
747 "apply insert",
748 "commit 108",
749 "begin",
750 "apply insert",
751 "commit 208",
752 "rollback",
753 ]
754 );
755 assert_eq!(log.committed[0].commit_lsn, Lsn(100));
756 assert_eq!(log.committed[1].committed_lsn, Lsn(208));
757 assert_eq!(log.committed[1].acked_lsn, Lsn(208));
758 assert_eq!(log.committed[1].txns_applied, 2);
759 let fb = feedback.lock().unwrap();
761 assert!(fb.iter().all(|f| f.flushed <= Lsn(208)));
762 assert_eq!(fb.last().unwrap().flushed, Lsn(208));
763 }
764
765 #[tokio::test]
766 async fn skips_redelivered_transactions_without_touching_sink() {
767 let sink = FakeSink(Default::default());
768 let mut cp = checkpoint(JobState::Streaming);
769 cp.record_commit(Lsn(200), Lsn(208), 2, 1);
770 let script = vec![
771 begin(100, 1),
772 insert(),
773 commit(100), begin(200, 2),
775 insert(),
776 commit(200), begin(300, 3),
778 insert(),
779 commit(300), Script::Fail(ErrorKind::Transient),
781 ];
782 let (_, _) = run(script, cp, sink.clone()).await;
783 let log = sink.0.lock().unwrap();
784 assert_eq!(
785 log.calls,
786 vec!["begin", "apply insert", "commit 308", "rollback"]
787 );
788 assert_eq!(log.committed[0].txns_applied, 2);
789 }
790
791 #[tokio::test]
792 async fn keepalive_between_transactions_persists_and_advances_ack() {
793 let sink = FakeSink(Default::default());
794 let script = vec![
795 begin(100, 1),
796 insert(),
797 keepalive(104),
798 commit(100), keepalive(500), keepalive(400), Script::Fail(ErrorKind::Transient),
802 ];
803 let (_, feedback) = run(script, checkpoint(JobState::Streaming), sink.clone()).await;
804 let log = sink.0.lock().unwrap();
805 assert_eq!(
806 log.calls,
807 vec![
808 "begin",
809 "apply insert",
810 "commit 108",
811 "save 500",
812 "rollback"
813 ]
814 );
815 assert_eq!(log.saved[0].acked_lsn, Lsn(500));
816 assert_eq!(log.saved[0].committed_lsn, Lsn(108));
817 assert_eq!(feedback.lock().unwrap().last().unwrap().flushed, Lsn(500));
818 }
819
820 #[tokio::test]
821 async fn catching_up_becomes_streaming_once_target_reached() {
822 let sink = FakeSink(Default::default());
823 let script = vec![
824 begin(100, 1),
825 commit(100),
826 begin(2000, 2),
827 commit(2000),
828 Script::Fail(ErrorKind::Transient),
829 ];
830 let (_, _) = run(script, checkpoint(JobState::CatchingUp), sink.clone()).await;
831 let log = sink.0.lock().unwrap();
832 assert_eq!(log.committed[0].state, JobState::CatchingUp);
833 assert_eq!(log.committed[1].state, JobState::Streaming);
834 }
835
836 #[tokio::test]
837 async fn commit_failure_rolls_back_and_does_not_ack() {
838 let sink = FakeSink(Default::default());
839 sink.0.lock().unwrap().fail_commit_at = Some(0);
840 sink.0.lock().unwrap().fail_commit_kind = Some(ErrorKind::Unsupported);
841 let script = vec![
842 begin(100, 1),
843 insert(),
844 commit(100),
845 begin(200, 2),
846 commit(200),
847 ];
848 let (res, feedback) = run(script, checkpoint(JobState::Streaming), sink.clone()).await;
849 assert_eq!(res.err().map(|e| e.kind()), Some(ErrorKind::Unsupported));
850 let log = sink.0.lock().unwrap();
851 assert_eq!(
852 log.calls,
853 vec!["begin", "apply insert", "commit FAILED", "rollback"]
854 );
855 assert!(log.committed.is_empty());
856 assert!(
857 feedback
858 .lock()
859 .unwrap()
860 .iter()
861 .all(|f| f.flushed == Lsn::ZERO)
862 );
863 }
864
865 #[tokio::test]
866 async fn protocol_violations_are_internal_errors() {
867 let sink = FakeSink(Default::default());
868 let (res, _) = run(
869 vec![insert()],
870 checkpoint(JobState::Streaming),
871 sink.clone(),
872 )
873 .await;
874 assert_eq!(res.err().map(|e| e.kind()), Some(ErrorKind::Internal));
875 let (res, _) = run(
876 vec![begin(1, 1), begin(2, 2)],
877 checkpoint(JobState::Streaming),
878 sink.clone(),
879 )
880 .await;
881 assert_eq!(res.err().map(|e| e.kind()), Some(ErrorKind::Internal));
882 let (res, _) = run(
883 vec![begin(100, 1), commit(101)],
884 checkpoint(JobState::Streaming),
885 sink,
886 )
887 .await;
888 assert_eq!(res.err().map(|e| e.kind()), Some(ErrorKind::Internal));
889 }
890
891 #[tokio::test]
892 async fn idle_reader_reports_only_changed_positions() {
893 let sink = FakeSink(Default::default());
896 let feedback = Arc::new(Mutex::new(vec![]));
897 let stream = FakeStream::new(vec![keepalive(500)], feedback.clone());
898 let cancel = CancellationToken::new();
899 let handle = tokio::spawn(run_apply_loop(
900 stream,
901 sink,
902 checkpoint(JobState::Streaming),
903 Lsn(1000),
904 &*Box::leak(Box::new(resources())),
905 Arc::new(Metrics::new()),
906 cancel.clone(),
907 crate::progress::none(),
908 ));
909 tokio::time::sleep(Duration::from_millis(200)).await; cancel.cancel();
911 let _ = handle.await;
912 let fb = feedback.lock().unwrap();
913 assert!(fb.len() <= 3, "unsolicited feedback every tick: {fb:?}");
916 assert_eq!(fb.last().unwrap().flushed, Lsn(500));
917 }
918
919 #[tokio::test]
920 async fn feedback_keeps_flowing_while_applier_is_blocked() {
921 struct BlockingSink(FakeSink);
924 impl ReplicationSink for BlockingSink {
925 async fn load_checkpoint(&mut self, j: &str) -> Result<Option<Checkpoint>> {
926 self.0.load_checkpoint(j).await
927 }
928 async fn save_checkpoint(&mut self, cp: &Checkpoint) -> Result<()> {
929 self.0.save_checkpoint(cp).await
930 }
931 async fn begin(&mut self) -> Result<()> {
932 self.0.begin().await
933 }
934 async fn relation(&mut self, r: Arc<Relation>) -> Result<()> {
935 self.0.relation(r).await
936 }
937 async fn apply(&mut self, _op: Op) -> Result<()> {
938 std::future::pending().await
939 }
940 async fn commit(&mut self, cp: &Checkpoint) -> Result<()> {
941 self.0.commit(cp).await
942 }
943 async fn rollback(&mut self) -> Result<()> {
944 self.0.rollback().await
945 }
946 }
947 let feedback = Arc::new(Mutex::new(vec![]));
948 let mut script: Vec<Script> = vec![begin(100, 1)];
949 script.extend(std::iter::repeat_n(insert(), 50));
950 let stream = FakeStream::new(script, feedback.clone());
951 let cancel = CancellationToken::new();
952 let metrics = Arc::new(Metrics::new());
953 let mut cp = checkpoint(JobState::Streaming);
954 cp.record_commit(Lsn(50), Lsn(58), 1, 1);
955 let handle = tokio::spawn(run_apply_loop(
956 stream,
957 BlockingSink(FakeSink(Default::default())),
958 cp,
959 Lsn(1000),
960 &*Box::leak(Box::new(resources())),
961 metrics.clone(),
962 cancel.clone(),
963 crate::progress::none(),
964 ));
965 tokio::time::sleep(Duration::from_millis(150)).await;
966 let sent = feedback.lock().unwrap().len();
967 assert!(
968 sent >= 3,
969 "expected periodic feedback under backpressure, got {sent}"
970 );
971 assert!(
972 feedback
973 .lock()
974 .unwrap()
975 .iter()
976 .all(|f| f.flushed == Lsn(58))
977 );
978 assert!(
979 metrics.snapshot().inflight_items >= 3,
980 "channel should be backed up"
981 );
982 cancel.cancel();
983 handle.abort();
987 }
988
989 struct ScriptedStreams {
992 rounds: VecDeque<Vec<Script>>,
993 feedback: Arc<Mutex<Vec<Feedback>>>,
994 connects: Vec<Lsn>,
995 }
996 impl StreamFactory for ScriptedStreams {
997 type Stream = FakeStream;
998 async fn connect(&mut self, cp: &Checkpoint) -> Result<(FakeStream, Lsn)> {
999 self.connects.push(cp.committed_lsn);
1000 let script = self
1001 .rounds
1002 .pop_front()
1003 .unwrap_or_else(|| vec![Script::Fail(ErrorKind::Config)]);
1004 Ok((FakeStream::new(script, self.feedback.clone()), Lsn(1000)))
1005 }
1006 }
1007 struct Sinks(FakeSink);
1008 impl SinkFactory for Sinks {
1009 type Sink = FakeSink;
1010 async fn connect(&mut self) -> Result<FakeSink> {
1011 Ok(self.0.clone())
1012 }
1013 }
1014
1015 #[tokio::test]
1016 async fn reconnects_after_transient_error_and_resumes_from_durable_checkpoint() {
1017 let sink = FakeSink(Default::default());
1018 let mut cp = checkpoint(JobState::Streaming);
1019 cp.record_commit(Lsn(10), Lsn(18), 0, 0);
1020 sink.0.lock().unwrap().durable = Some(cp);
1021 let streams = ScriptedStreams {
1022 rounds: vec![
1023 vec![
1025 begin(100, 1),
1026 insert(),
1027 commit(100),
1028 begin(200, 2),
1029 insert(),
1030 Script::Fail(ErrorKind::Transient),
1031 ],
1032 vec![
1034 begin(100, 1),
1035 insert(),
1036 commit(100),
1037 begin(200, 2),
1038 insert(),
1039 commit(200),
1040 Script::Fail(ErrorKind::Config),
1041 ],
1042 ]
1043 .into(),
1044 feedback: Default::default(),
1045 connects: vec![],
1046 };
1047 let resources = ResourcesConfig {
1048 retry_backoff: crate::config::RetryBackoff {
1049 initial: Duration::from_millis(5),
1050 max: Duration::from_millis(5),
1051 multiplier: 1.0,
1052 max_attempts: 0,
1053 },
1054 ..resources()
1055 };
1056 let metrics = Arc::new(Metrics::new());
1057 let res = run_replication(
1058 "job",
1059 streams,
1060 Sinks(sink.clone()),
1061 &resources,
1062 metrics.clone(),
1063 CancellationToken::new(),
1064 crate::progress::none(),
1065 )
1066 .await;
1067 assert_eq!(res.err().map(|e| e.kind()), Some(ErrorKind::Config));
1068 let log = sink.0.lock().unwrap();
1069 assert_eq!(
1070 log.calls,
1071 vec![
1072 "begin",
1073 "apply insert",
1074 "commit 108",
1075 "begin",
1076 "apply insert",
1077 "rollback",
1078 "begin",
1079 "apply insert",
1080 "commit 208",
1081 "rollback",
1082 "save 208", ]
1084 );
1085 assert_eq!(log.durable.as_ref().unwrap().state, JobState::Error);
1086 assert!(log.durable.as_ref().unwrap().last_error.is_some());
1087 assert_eq!(metrics.snapshot().reconnects, 1);
1088 assert_eq!(metrics.snapshot().txns_skipped, 1);
1089 }
1090
1091 #[tokio::test]
1092 async fn refuses_to_run_without_checkpoint_or_in_resync_state() {
1093 let sink = FakeSink(Default::default());
1094 let streams = ScriptedStreams {
1095 rounds: VecDeque::new(),
1096 feedback: Default::default(),
1097 connects: vec![],
1098 };
1099 let res = run_replication(
1100 "job",
1101 streams,
1102 Sinks(sink.clone()),
1103 &resources(),
1104 Arc::new(Metrics::new()),
1105 CancellationToken::new(),
1106 crate::progress::none(),
1107 )
1108 .await;
1109 assert_eq!(res.err().map(|e| e.kind()), Some(ErrorKind::Config));
1110
1111 sink.0.lock().unwrap().durable = Some(checkpoint(JobState::ResyncRequired));
1112 let streams = ScriptedStreams {
1113 rounds: VecDeque::new(),
1114 feedback: Default::default(),
1115 connects: vec![],
1116 };
1117 let res = run_replication(
1118 "job",
1119 streams,
1120 Sinks(sink),
1121 &resources(),
1122 Arc::new(Metrics::new()),
1123 CancellationToken::new(),
1124 crate::progress::none(),
1125 )
1126 .await;
1127 assert_eq!(res.err().map(|e| e.kind()), Some(ErrorKind::ResyncRequired));
1128 }
1129}