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        let mut advanced = None;
206        while let Some((lsn, tx)) = self.pending.first_key_value() {
207            if !tx.is_complete() {
208                break;
209            }
210            let lsn = *lsn;
211            self.watermark = lsn;
212            advanced = Some(lsn);
213            self.pending.remove(&lsn);
214        }
215        Ok(advanced)
216    }
217
218    #[cfg(test)]
219    #[must_use]
220    pub(crate) fn watermark(&self) -> PgLsn {
221        self.watermark
222    }
223}
224
225impl PendingTx {
226    fn new(event_count: usize) -> Self {
227        Self {
228            seen: vec![false; event_count],
229            seen_count: 0,
230        }
231    }
232
233    fn mark(&mut self, event_index: u32) -> CdcResult<()> {
234        let event_index = usize::try_from(event_index)
235            .map_err(|_| CdcError::Checkpoint("event index does not fit usize".into()))?;
236        let Some(seen) = self.seen.get_mut(event_index) else {
237            return Err(CdcError::Checkpoint(format!(
238                "event index {event_index} outside transaction event count {}",
239                self.seen.len()
240            )));
241        };
242        if !*seen {
243            *seen = true;
244            self.seen_count += 1;
245        }
246        Ok(())
247    }
248
249    fn is_complete(&self) -> bool {
250        self.seen_count == self.seen.len()
251    }
252}
253
254#[allow(dead_code)]
255fn _assert_path_send_sync(_: &Path) {}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn offset(slot: &str, tx: u64, index: u32, count: u32) -> CdcOffset {
262        CdcOffset {
263            slot: slot.to_owned(),
264            tx_end_lsn: PgLsn::from_u64(tx),
265            commit_lsn: PgLsn::from_u64(tx - 1),
266            xid: 7,
267            event_index: index,
268            event_count: count,
269        }
270    }
271
272    #[test]
273    fn feedback_waits_for_complete_contiguous_transactions() {
274        let mut feedback = FeedbackCoordinator::new("slot".to_owned(), PgLsn::ZERO);
275        feedback
276            .register_tx(PgLsn::from_u64(10), 2)
277            .expect("register tx 10");
278        feedback
279            .register_tx(PgLsn::from_u64(20), 1)
280            .expect("register tx 20");
281
282        assert_eq!(
283            feedback.checkpoint(&offset("slot", 20, 0, 1)).unwrap(),
284            None
285        );
286        assert_eq!(
287            feedback.checkpoint(&offset("slot", 10, 0, 2)).unwrap(),
288            None
289        );
290        assert_eq!(
291            feedback.checkpoint(&offset("slot", 10, 1, 2)).unwrap(),
292            Some(PgLsn::from_u64(20))
293        );
294        assert_eq!(feedback.watermark(), PgLsn::from_u64(20));
295    }
296}