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
44pub 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
57pub 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
67pub 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}