Skip to main content

pgsync_core/
engine.rs

1//! The replication engine: reader task, applier loop, and reconnect policy.
2//!
3//! Guarantees:
4//! - Events are applied in the order the source produced them.
5//! - A source transaction is applied atomically together with its checkpoint.
6//! - WAL positions are acknowledged only after the corresponding target commit returned.
7//! - Redelivered transactions (after crash/reconnect) are skipped by LSN comparison.
8//! - Memory held by undelivered events is bounded by the channel limits.
9
10use 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/// Position the reader reports to the server. Only `acked` is ever reported as flushed.
28#[derive(Debug, Clone, Copy, Default)]
29pub struct AckPosition {
30    pub acked: Lsn,
31}
32
33/// How long shutdown waits for the reader task before aborting it.
34const READER_STOP_TIMEOUT: Duration = Duration::from_secs(5);
35
36pub struct ApplyOutcome {
37    pub checkpoint: Checkpoint,
38}
39
40/// Runs one connected stream until cancellation or error.
41///
42/// `catchup_target` is the server WAL end observed when the stream was opened; once the
43/// acknowledged position reaches it the job state becomes `STREAMING`.
44pub 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    // Stop the reader regardless of why we exited, then surface the most useful error.
92    // A reader stuck in a socket write to a dead peer must not block shutdown or the
93    // reconnect: bound the wait and abort it (the connection is dropped either way).
94    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        // A real applier failure (not a cancellation) is the root cause.
116        (Some(e), _) if e.kind() != ErrorKind::Cancelled => Err(e),
117        // Otherwise the reader's failure explains why the channel closed.
118        (_, Some(e)) => Err(e),
119        // Applier saw the channel close with a transaction open, because we were cancelled.
120        (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                    // Already durable. Make sure the ack position covers it.
216                    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; // first tick completes immediately; skip it
328    let mut last_received = Lsn::ZERO;
329    // Position last reported while reading. Unsolicited reports are sent only when it
330    // changed: a server that hears from us every tick never sends the keepalive that
331    // carries the position of WAL it skipped (unpublished tables, vacuum), and the slot
332    // would never advance over it while the publication is idle.
333    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                // Final progress report so the slot reflects everything durable.
350                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                // Answer keepalives promptly (the server may have requested a reply) with
376                // the last durable position.
377                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                    // Applier is slow (backpressure) and the socket is not being read, so
395                    // server pings cannot be answered: send an unconditional heartbeat with
396                    // the last durable position.
397                    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(()), // applier gone; it reports the reason
405                },
406            }
407        };
408        metrics
409            .inflight_items
410            .store(tx.inflight_items(), std::sync::atomic::Ordering::Relaxed);
411        permit.send(event);
412    }
413}
414
415/// Reconnecting driver: opens a stream and a sink, runs the apply loop, and on transient
416/// failures reloads the checkpoint from the sink (the source of truth) and reconnects.
417pub 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                // Record the failure in the target when we can; never advance positions.
494                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    // ---- fakes -------------------------------------------------------------------
529
530    #[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        // Cancel-safe: nothing is consumed until the result is actually returned.
554        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                    // Give the applier and the feedback ticker time to run first.
562                    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        /// Checkpoints as they were committed (durable).
583        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    // ---- tests -------------------------------------------------------------------
723
724    #[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        // Periodic feedback carries the durable position only.
760        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), // older: skip
774            begin(200, 2),
775            insert(),
776            commit(200), // equal: skip
777            begin(300, 3),
778            insert(),
779            commit(300), // new: apply
780            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),    // mid-txn keepalive ignored
799            keepalive(500), // idle: persisted
800            keepalive(400), // regress: ignored
801            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        // One keepalive, then silence: the position is reported when it changes, never on
894        // every tick, so the server will ping us with the skipped-WAL position.
895        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; // ~10 ticks at 20ms
910        cancel.cancel();
911        let _ = handle.await;
912        let fb = feedback.lock().unwrap();
913        // keepalive answer + one tick after the applier persisted 500 + final report on
914        // cancel; never one per tick.
915        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        // Sink that blocks on apply forever: the reader must still send periodic feedback
922        // with the last durable position while the channel is full.
923        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        // The applier is stuck in `apply`; cancellation is observed by the reader, which
984        // closes the channel. The blocked applier can only be unblocked by the sink itself,
985        // so we abort the task here as a process shutdown would.
986        handle.abort();
987    }
988
989    // ---- reconnect driver ----------------------------------------------------------
990
991    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                // Round 1: one txn applied, then connection drops mid second txn.
1024                vec![
1025                    begin(100, 1),
1026                    insert(),
1027                    commit(100),
1028                    begin(200, 2),
1029                    insert(),
1030                    Script::Fail(ErrorKind::Transient),
1031                ],
1032                // Round 2: server redelivers from its confirmed position.
1033                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", // final state recorded as ERROR
1083            ]
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}