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
45pub 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
58pub 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
68pub 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 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}