Skip to main content

datum_cdc/
checkpoint.rs

1use std::{
2    collections::BTreeMap,
3    fs,
4    path::{Path, PathBuf},
5    sync::{Arc, Mutex},
6};
7
8use serde::{Deserialize, Serialize};
9use tokio::sync::mpsc;
10
11use crate::{CdcError, CdcOffset, CdcResult, PgLsn};
12
13pub(crate) enum FeedbackCommand {
14    Checkpoint(CdcOffset),
15    Stop,
16    DropSlot {
17        force: bool,
18        reply: tokio::sync::oneshot::Sender<CdcResult<()>>,
19    },
20}
21
22/// Durable checkpoint store used to resume a source after process restart.
23pub trait CdcCheckpointStore: Send + Sync + 'static {
24    fn load(&self, slot: &str) -> CdcResult<Option<CdcOffset>>;
25    fn store(&self, offset: &CdcOffset) -> CdcResult<()>;
26}
27
28/// File-backed checkpoint store.
29///
30/// If the path is a directory, one JSON file per slot is written under it. If
31/// the path is a file, that exact file is used.
32#[derive(Debug, Clone)]
33pub struct FileCheckpointStore {
34    path: Arc<PathBuf>,
35}
36
37impl FileCheckpointStore {
38    #[must_use]
39    pub fn new(path: impl Into<PathBuf>) -> Self {
40        Self {
41            path: Arc::new(path.into()),
42        }
43    }
44
45    fn slot_path(&self, slot: &str) -> PathBuf {
46        if self.path.extension().is_some() {
47            return (*self.path).clone();
48        }
49        self.path.join(format!("{slot}.json"))
50    }
51}
52
53impl CdcCheckpointStore for FileCheckpointStore {
54    fn load(&self, slot: &str) -> CdcResult<Option<CdcOffset>> {
55        let path = self.slot_path(slot);
56        if !path.exists() {
57            return Ok(None);
58        }
59        let bytes = fs::read(&path)?;
60        let record: CheckpointRecord = serde_json::from_slice(&bytes)?;
61        Ok(Some(record.offset))
62    }
63
64    fn store(&self, offset: &CdcOffset) -> CdcResult<()> {
65        let path = self.slot_path(&offset.slot);
66        if let Some(parent) = path.parent() {
67            fs::create_dir_all(parent)?;
68        }
69        let tmp = path.with_extension("json.tmp");
70        let bytes = serde_json::to_vec_pretty(&CheckpointRecord {
71            offset: offset.clone(),
72        })?;
73        fs::write(&tmp, bytes)?;
74        fs::rename(tmp, path)?;
75        Ok(())
76    }
77}
78
79#[derive(Debug, Serialize, Deserialize)]
80struct CheckpointRecord {
81    offset: CdcOffset,
82}
83
84/// In-memory checkpoint store useful for tests and embedded ephemeral readers.
85#[derive(Debug, Default)]
86pub struct MemoryCheckpointStore {
87    offsets: Mutex<BTreeMap<String, CdcOffset>>,
88}
89
90impl MemoryCheckpointStore {
91    #[must_use]
92    pub fn new() -> Self {
93        Self::default()
94    }
95}
96
97impl CdcCheckpointStore for MemoryCheckpointStore {
98    fn load(&self, slot: &str) -> CdcResult<Option<CdcOffset>> {
99        Ok(self
100            .offsets
101            .lock()
102            .expect("checkpoint store poisoned")
103            .get(slot)
104            .cloned())
105    }
106
107    fn store(&self, offset: &CdcOffset) -> CdcResult<()> {
108        self.offsets
109            .lock()
110            .expect("checkpoint store poisoned")
111            .insert(offset.slot.clone(), offset.clone());
112        Ok(())
113    }
114}
115
116/// Handle used by downstream code after it has durably accepted an event.
117#[derive(Clone)]
118pub struct CdcCheckpointHandle {
119    slot: String,
120    store: Option<Arc<dyn CdcCheckpointStore>>,
121    commands: mpsc::UnboundedSender<FeedbackCommand>,
122}
123
124impl CdcCheckpointHandle {
125    pub(crate) fn new(
126        slot: String,
127        store: Option<Arc<dyn CdcCheckpointStore>>,
128        commands: mpsc::UnboundedSender<FeedbackCommand>,
129    ) -> Self {
130        Self {
131            slot,
132            store,
133            commands,
134        }
135    }
136
137    /// Persist and announce a downstream checkpoint.
138    ///
139    /// The replication task will advance PostgreSQL feedback only after all
140    /// events in the next contiguous transaction have been checkpointed.
141    pub fn checkpoint(&self, offset: CdcOffset) -> CdcResult<()> {
142        if offset.slot != self.slot {
143            return Err(CdcError::Checkpoint(format!(
144                "checkpoint for slot {:?} cannot be applied to slot {:?}",
145                offset.slot, self.slot
146            )));
147        }
148        if let Some(store) = &self.store {
149            store.store(&offset)?;
150        }
151        self.commands
152            .send(FeedbackCommand::Checkpoint(offset))
153            .map_err(|_| CdcError::ChannelClosed)
154    }
155
156    pub async fn checkpoint_async(&self, offset: CdcOffset) -> CdcResult<()> {
157        self.checkpoint(offset)
158    }
159}
160
161#[derive(Debug)]
162pub(crate) struct FeedbackCoordinator {
163    slot: String,
164    watermark: PgLsn,
165    pending: BTreeMap<PgLsn, PendingTx>,
166}
167
168#[derive(Debug)]
169struct PendingTx {
170    seen: Vec<bool>,
171    seen_count: usize,
172}
173
174impl FeedbackCoordinator {
175    pub(crate) fn new(slot: String, watermark: PgLsn) -> Self {
176        Self {
177            slot,
178            watermark,
179            pending: BTreeMap::new(),
180        }
181    }
182
183    pub(crate) fn register_tx(&mut self, tx_end_lsn: PgLsn, event_count: u32) -> CdcResult<()> {
184        if event_count == 0 || tx_end_lsn <= self.watermark {
185            return Ok(());
186        }
187        let event_count = usize::try_from(event_count).map_err(|_| {
188            CdcError::Checkpoint("transaction event count does not fit usize".into())
189        })?;
190        self.pending
191            .entry(tx_end_lsn)
192            .or_insert_with(|| PendingTx::new(event_count));
193        Ok(())
194    }
195
196    pub(crate) fn checkpoint(&mut self, offset: &CdcOffset) -> CdcResult<Option<PgLsn>> {
197        if offset.slot != self.slot || offset.tx_end_lsn <= self.watermark {
198            return Ok(None);
199        }
200        self.register_tx(offset.tx_end_lsn, offset.event_count)?;
201        let Some(tx) = self.pending.get_mut(&offset.tx_end_lsn) else {
202            return Ok(None);
203        };
204        tx.mark(offset.event_index)?;
205        Ok(self.advance_watermark())
206    }
207
208    /// Record a committed transaction that decoded to zero published changes.
209    ///
210    /// An empty transaction has nothing to checkpoint downstream, but its
211    /// `tx_end_lsn` must not advance PostgreSQL feedback ahead of an earlier
212    /// transaction that downstream has not confirmed yet. It is registered as an
213    /// immediately-complete entry, so feedback advances only through the
214    /// contiguous complete prefix, preserving at-least-once delivery.
215    pub(crate) fn note_empty_tx(&mut self, tx_end_lsn: PgLsn) -> CdcResult<Option<PgLsn>> {
216        if tx_end_lsn <= self.watermark {
217            return Ok(None);
218        }
219        self.pending
220            .entry(tx_end_lsn)
221            .or_insert_with(|| PendingTx::new(0));
222        Ok(self.advance_watermark())
223    }
224
225    /// Advance the watermark through the contiguous prefix of complete
226    /// transactions and return the highest LSN it reached, if any.
227    fn advance_watermark(&mut self) -> Option<PgLsn> {
228        let mut advanced = None;
229        while let Some((lsn, tx)) = self.pending.first_key_value() {
230            if !tx.is_complete() {
231                break;
232            }
233            let lsn = *lsn;
234            self.watermark = lsn;
235            advanced = Some(lsn);
236            self.pending.remove(&lsn);
237        }
238        advanced
239    }
240
241    #[cfg(test)]
242    #[must_use]
243    pub(crate) fn watermark(&self) -> PgLsn {
244        self.watermark
245    }
246}
247
248impl PendingTx {
249    fn new(event_count: usize) -> Self {
250        Self {
251            seen: vec![false; event_count],
252            seen_count: 0,
253        }
254    }
255
256    fn mark(&mut self, event_index: u32) -> CdcResult<()> {
257        let event_index = usize::try_from(event_index)
258            .map_err(|_| CdcError::Checkpoint("event index does not fit usize".into()))?;
259        let Some(seen) = self.seen.get_mut(event_index) else {
260            return Err(CdcError::Checkpoint(format!(
261                "event index {event_index} outside transaction event count {}",
262                self.seen.len()
263            )));
264        };
265        if !*seen {
266            *seen = true;
267            self.seen_count += 1;
268        }
269        Ok(())
270    }
271
272    fn is_complete(&self) -> bool {
273        self.seen_count == self.seen.len()
274    }
275}
276
277#[allow(dead_code)]
278fn _assert_path_send_sync(_: &Path) {}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn offset(slot: &str, tx: u64, index: u32, count: u32) -> CdcOffset {
285        CdcOffset {
286            slot: slot.to_owned(),
287            tx_end_lsn: PgLsn::from_u64(tx),
288            commit_lsn: PgLsn::from_u64(tx - 1),
289            xid: 7,
290            event_index: index,
291            event_count: count,
292        }
293    }
294
295    #[test]
296    fn feedback_waits_for_complete_contiguous_transactions() {
297        let mut feedback = FeedbackCoordinator::new("slot".to_owned(), PgLsn::ZERO);
298        feedback
299            .register_tx(PgLsn::from_u64(10), 2)
300            .expect("register tx 10");
301        feedback
302            .register_tx(PgLsn::from_u64(20), 1)
303            .expect("register tx 20");
304
305        assert_eq!(
306            feedback.checkpoint(&offset("slot", 20, 0, 1)).unwrap(),
307            None
308        );
309        assert_eq!(
310            feedback.checkpoint(&offset("slot", 10, 0, 2)).unwrap(),
311            None
312        );
313        assert_eq!(
314            feedback.checkpoint(&offset("slot", 10, 1, 2)).unwrap(),
315            Some(PgLsn::from_u64(20))
316        );
317        assert_eq!(feedback.watermark(), PgLsn::from_u64(20));
318    }
319
320    #[test]
321    fn empty_transaction_does_not_advance_past_unconfirmed_earlier_transaction() {
322        let mut feedback = FeedbackCoordinator::new("slot".to_owned(), PgLsn::ZERO);
323        // Earlier real transaction with two events, registered but not yet
324        // checkpointed downstream.
325        feedback
326            .register_tx(PgLsn::from_u64(10), 2)
327            .expect("register tx 10");
328
329        // An empty transaction commits after it. Feedback must NOT advance,
330        // because tx 10 has not been confirmed downstream yet — advancing would
331        // let PostgreSQL discard tx 10's WAL before it is durably accepted.
332        assert_eq!(feedback.note_empty_tx(PgLsn::from_u64(20)).unwrap(), None);
333        assert_eq!(feedback.watermark(), PgLsn::ZERO);
334
335        // Confirm the earlier transaction. Feedback now advances contiguously
336        // through both the real transaction and the trailing empty one.
337        assert_eq!(
338            feedback.checkpoint(&offset("slot", 10, 0, 2)).unwrap(),
339            None
340        );
341        assert_eq!(
342            feedback.checkpoint(&offset("slot", 10, 1, 2)).unwrap(),
343            Some(PgLsn::from_u64(20))
344        );
345        assert_eq!(feedback.watermark(), PgLsn::from_u64(20));
346    }
347
348    #[test]
349    fn empty_transaction_advances_when_no_earlier_transaction_pending() {
350        let mut feedback = FeedbackCoordinator::new("slot".to_owned(), PgLsn::ZERO);
351        // With nothing pending, an empty transaction is safe to release
352        // immediately, so the slot keeps advancing on empty-only churn.
353        assert_eq!(
354            feedback.note_empty_tx(PgLsn::from_u64(30)).unwrap(),
355            Some(PgLsn::from_u64(30))
356        );
357        assert_eq!(feedback.watermark(), PgLsn::from_u64(30));
358        // A stale empty transaction at or below the watermark is a no-op.
359        assert_eq!(feedback.note_empty_tx(PgLsn::from_u64(30)).unwrap(), None);
360        assert_eq!(feedback.watermark(), PgLsn::from_u64(30));
361    }
362}