Skip to main content

powdb_sync/
checkpoint.rs

1use std::io;
2use std::ops::{Deref, DerefMut};
3use std::path::{Path, PathBuf};
4
5use powdb_storage::catalog::Catalog;
6use powdb_storage::wal::WalRecord;
7
8use crate::metadata::{open_or_create_identity, read_identity};
9use crate::segment::{
10    read_segment_file, segment_file_name, write_segment_atomic, RetainedSegment, RetainedUnit,
11};
12use crate::DatabaseIdentity;
13
14pub const RETAINED_SEGMENTS_DIR: &str = "retained";
15
16pub struct SyncCatalog {
17    catalog: Catalog,
18}
19
20impl Deref for SyncCatalog {
21    type Target = Catalog;
22
23    fn deref(&self) -> &Self::Target {
24        &self.catalog
25    }
26}
27
28impl DerefMut for SyncCatalog {
29    fn deref_mut(&mut self) -> &mut Self::Target {
30        &mut self.catalog
31    }
32}
33
34impl Drop for SyncCatalog {
35    fn drop(&mut self) {
36        let _ = checkpoint_preserving_retained_segments_if_enabled(&mut self.catalog);
37    }
38}
39
40pub fn retained_segments_dir(data_dir: &Path) -> PathBuf {
41    crate::sync_state_dir(data_dir).join(RETAINED_SEGMENTS_DIR)
42}
43
44/// Open a catalog and, if sync identity exists, archive replayed WAL records
45/// before recovery truncates `wal.log`.
46pub fn open_preserving_retained_segments(data_dir: &Path) -> io::Result<SyncCatalog> {
47    let catalog = match read_identity(data_dir) {
48        Ok(identity) => Catalog::open_with_wal_archive(data_dir, move |dir, records| {
49            archive_wal_records_for_identity(dir, identity, records)
50        }),
51        Err(err) if err.kind() == io::ErrorKind::NotFound => Catalog::open(data_dir),
52        Err(err) => Err(err),
53    }?;
54    Ok(SyncCatalog { catalog })
55}
56
57/// Checkpoint a catalog with sync enabled, creating database identity if this
58/// is the first sync-aware operation.
59pub fn checkpoint_with_retained_segments(catalog: &mut Catalog) -> io::Result<()> {
60    let data_dir = catalog.data_dir().to_path_buf();
61    let identity = open_or_create_identity(&data_dir)?;
62    catalog.checkpoint_with_wal_archive(move |dir, records| {
63        archive_wal_records_for_identity(dir, identity, records)
64    })
65}
66
67/// Checkpoint normally unless sync identity already exists, in which case WAL
68/// records are archived into retained segments before truncation.
69pub fn checkpoint_preserving_retained_segments_if_enabled(catalog: &mut Catalog) -> io::Result<()> {
70    let data_dir = catalog.data_dir().to_path_buf();
71    match read_identity(&data_dir) {
72        Ok(identity) => catalog.checkpoint_with_wal_archive(move |dir, records| {
73            archive_wal_records_for_identity(dir, identity, records)
74        }),
75        Err(err) if err.kind() == io::ErrorKind::NotFound => catalog.checkpoint(),
76        Err(err) => Err(err),
77    }
78}
79
80pub fn archive_wal_records_for_identity(
81    data_dir: &Path,
82    identity: DatabaseIdentity,
83    records: &[WalRecord],
84) -> io::Result<()> {
85    if records.is_empty() {
86        return Ok(());
87    }
88    let units: Vec<RetainedUnit> = records.iter().map(RetainedUnit::from).collect();
89    let segment = RetainedSegment::new(identity.segment_identity(), units)?;
90    write_segment_idempotent(&retained_segments_dir(data_dir), &segment).map(|_| ())
91}
92
93fn write_segment_idempotent(dir: &Path, segment: &RetainedSegment) -> io::Result<PathBuf> {
94    match write_segment_atomic(dir, segment) {
95        Ok(path) => Ok(path),
96        Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
97            let path = dir.join(segment_file_name(segment.start_lsn, segment.end_lsn));
98            let existing = read_segment_file(&path)?;
99            if existing == *segment {
100                Ok(path)
101            } else {
102                Err(io::Error::new(
103                    io::ErrorKind::AlreadyExists,
104                    "retained segment already exists with different contents",
105                ))
106            }
107        }
108        Err(err) => Err(err),
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use powdb_storage::types::{ColumnDef, Schema, TypeId, Value};
116    use powdb_storage::wal::WalRecordType;
117
118    fn schema_t() -> Schema {
119        Schema {
120            table_name: "T".into(),
121            columns: vec![ColumnDef {
122                name: "id".into(),
123                type_id: TypeId::Int,
124                required: true,
125                position: 0,
126            }],
127        }
128    }
129
130    #[test]
131    fn sync_checkpoint_archives_wal_before_truncate() {
132        let dir = tempfile::tempdir().unwrap();
133        let mut catalog = Catalog::create(dir.path()).unwrap();
134        catalog.create_table(schema_t()).unwrap();
135        catalog.insert("T", &vec![Value::Int(1)]).unwrap();
136        catalog.sync_wal().unwrap();
137
138        let identity = open_or_create_identity(dir.path()).unwrap();
139        checkpoint_with_retained_segments(&mut catalog).unwrap();
140
141        let units = crate::read_units_since(
142            &retained_segments_dir(dir.path()),
143            identity.segment_identity(),
144            0,
145            100,
146        )
147        .unwrap();
148        assert!(
149            units.iter().any(|unit| unit.lsn == catalog.max_lsn()),
150            "retained segment should include the checkpointed high-water LSN"
151        );
152        assert!(
153            units
154                .iter()
155                .any(|unit| unit.record_type == WalRecordType::Commit as u8),
156            "sync-aware checkpoint should archive autocommit boundary markers"
157        );
158        assert!(
159            Catalog::open(dir.path()).is_ok(),
160            "plain open is safe after sync-aware checkpoint has emptied the WAL"
161        );
162    }
163
164    #[test]
165    fn plain_checkpoint_refuses_to_truncate_sync_enabled_wal() {
166        let dir = tempfile::tempdir().unwrap();
167        let mut catalog = Catalog::create(dir.path()).unwrap();
168        catalog.create_table(schema_t()).unwrap();
169        catalog.insert("T", &vec![Value::Int(1)]).unwrap();
170        catalog.sync_wal().unwrap();
171        open_or_create_identity(dir.path()).unwrap();
172
173        let err = catalog.checkpoint().unwrap_err();
174        assert!(
175            format!("{err}").contains("without a WAL archive hook"),
176            "plain checkpoint must fail closed for sync-enabled history, got: {err}"
177        );
178
179        checkpoint_with_retained_segments(&mut catalog).unwrap();
180    }
181
182    #[test]
183    fn sync_open_archives_replayed_wal_before_recovery_truncate() {
184        let dir = tempfile::tempdir().unwrap();
185        let identity = {
186            let mut catalog = Catalog::create(dir.path()).unwrap();
187            catalog.create_table(schema_t()).unwrap();
188            catalog.insert("T", &vec![Value::Int(1)]).unwrap();
189            catalog.sync_wal().unwrap();
190            open_or_create_identity(dir.path()).unwrap()
191        };
192
193        let mut catalog = open_preserving_retained_segments(dir.path()).unwrap();
194        assert_eq!(catalog.scan("T").unwrap().count(), 1);
195
196        let units = crate::read_units_since(
197            &retained_segments_dir(dir.path()),
198            identity.segment_identity(),
199            0,
200            100,
201        )
202        .unwrap();
203        assert!(!units.is_empty());
204
205        catalog.insert("T", &vec![Value::Int(2)]).unwrap();
206        catalog.sync_wal().unwrap();
207        checkpoint_preserving_retained_segments_if_enabled(&mut catalog).unwrap();
208    }
209
210    #[test]
211    fn sync_open_drop_archives_later_writes_before_plain_reopen() {
212        let dir = tempfile::tempdir().unwrap();
213        let identity = {
214            let mut catalog = Catalog::create(dir.path()).unwrap();
215            catalog.create_table(schema_t()).unwrap();
216            catalog.insert("T", &vec![Value::Int(1)]).unwrap();
217            catalog.sync_wal().unwrap();
218            open_or_create_identity(dir.path()).unwrap()
219        };
220
221        {
222            let mut catalog = open_preserving_retained_segments(dir.path()).unwrap();
223            assert_eq!(catalog.scan("T").unwrap().count(), 1);
224            catalog.insert("T", &vec![Value::Int(2)]).unwrap();
225            catalog.sync_wal().unwrap();
226        }
227
228        let catalog = Catalog::open(dir.path()).unwrap();
229        assert_eq!(catalog.scan("T").unwrap().count(), 2);
230        drop(catalog);
231
232        let units = crate::read_units_since(
233            &retained_segments_dir(dir.path()),
234            identity.segment_identity(),
235            0,
236            100,
237        )
238        .unwrap();
239        assert!(
240            units.iter().any(|unit| unit.lsn >= 2),
241            "sync-aware drop must archive writes made after sync-aware open"
242        );
243    }
244
245    #[test]
246    fn plain_open_refuses_sync_enabled_wal_without_truncating() {
247        let dir = tempfile::tempdir().unwrap();
248        let identity = {
249            let mut catalog = Catalog::create(dir.path()).unwrap();
250            catalog.create_table(schema_t()).unwrap();
251            catalog.insert("T", &vec![Value::Int(1)]).unwrap();
252            catalog.sync_wal().unwrap();
253            open_or_create_identity(dir.path()).unwrap()
254        };
255
256        let err = match Catalog::open(dir.path()) {
257            Ok(_) => panic!("plain recovery must fail closed for sync-enabled WAL history"),
258            Err(err) => err,
259        };
260        assert!(
261            format!("{err}").contains("without a WAL archive hook"),
262            "plain recovery must fail closed for sync-enabled WAL history, got: {err}"
263        );
264
265        let mut catalog = open_preserving_retained_segments(dir.path()).unwrap();
266        assert_eq!(catalog.scan("T").unwrap().count(), 1);
267        let units = crate::read_units_since(
268            &retained_segments_dir(dir.path()),
269            identity.segment_identity(),
270            0,
271            100,
272        )
273        .unwrap();
274        assert!(
275            !units.is_empty(),
276            "plain recovery failure must leave WAL history available for sync-aware open"
277        );
278
279        catalog.insert("T", &vec![Value::Int(2)]).unwrap();
280        catalog.sync_wal().unwrap();
281        checkpoint_preserving_retained_segments_if_enabled(&mut catalog).unwrap();
282    }
283}