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