1use std::fs;
2use std::io;
3use std::path::Path;
4
5use powdb_storage::catalog::Catalog;
6use powdb_storage::create_data_dir_secure;
7use powdb_storage::wal::{WalRecord, WalRecordType};
8use serde::{Deserialize, Serialize};
9
10use crate::metadata::{atomic_replace_json, now_unix_secs, read_identity, sync_state_dir};
11use crate::segment::{
12 read_units_since, validate_retained_tail_available, RetainedTailAvailability, RetainedUnit,
13 SegmentIdentity,
14};
15
16const APPLY_STATE_FILE: &str = "apply-state.json";
17const APPLY_STATE_FORMAT_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct RetainedTailApplySummary {
21 pub from_lsn: u64,
22 pub through_lsn: u64,
23 pub units_applied: usize,
24 pub first_lsn: Option<u64>,
25 pub last_lsn: Option<u64>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30enum ApplyStatus {
31 InProgress,
32 Complete,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36struct ApplyStateFile {
37 format_version: u32,
38 database_id: [u8; 16],
39 primary_generation: u64,
40 wal_format_version: u16,
41 catalog_version: u16,
42 from_lsn: u64,
43 through_lsn: u64,
44 applied_lsn: u64,
45 status: ApplyStatus,
46 started_unix_secs: u64,
47 updated_unix_secs: u64,
48}
49
50pub fn apply_retained_tail(
51 catalog: &mut Catalog,
52 retained_dir: &Path,
53 expected_identity: SegmentIdentity,
54 since_lsn: u64,
55 through_lsn: u64,
56) -> io::Result<RetainedTailApplySummary> {
57 expected_identity.validate()?;
58 let local_identity = read_identity(catalog.data_dir())?;
59 if !local_identity
60 .segment_identity()
61 .lineage_matches(expected_identity)
62 {
63 return Err(invalid_input(
64 "replica sync identity does not match retained tail history",
65 ));
66 }
67 catalog.ensure_no_pending_wal_records()?;
68 if through_lsn < since_lsn {
69 return Err(invalid_input(format!(
70 "retained tail target LSN {through_lsn} is behind start LSN {since_lsn}"
71 )));
72 }
73 let resume_lsn = reconcile_apply_state(catalog, expected_identity, since_lsn, through_lsn)?;
74 if through_lsn == since_lsn {
75 if resume_lsn == since_lsn {
76 write_complete_apply_state(
77 catalog.data_dir(),
78 expected_identity,
79 since_lsn,
80 through_lsn,
81 )?;
82 return Ok(noop_summary(since_lsn, through_lsn));
83 }
84 return Err(invalid_input(format!(
85 "replica apply resume LSN {resume_lsn} does not match retained tail start LSN {since_lsn}"
86 )));
87 }
88
89 if resume_lsn == through_lsn {
90 write_complete_apply_state(
91 catalog.data_dir(),
92 expected_identity,
93 since_lsn,
94 through_lsn,
95 )?;
96 return Ok(noop_summary(since_lsn, through_lsn));
97 }
98 if resume_lsn < since_lsn || resume_lsn > through_lsn {
99 return Err(invalid_input(format!(
100 "replica apply resume LSN {resume_lsn} does not match retained tail start LSN {since_lsn}"
101 )));
102 }
103
104 let availability =
105 validate_retained_tail_available(retained_dir, expected_identity, resume_lsn, through_lsn)?;
106 let max_units = usize::try_from(through_lsn - resume_lsn)
107 .map_err(|_| invalid_input("retained tail range is too large to apply in one batch"))?;
108 let units = read_units_since(retained_dir, expected_identity, resume_lsn, max_units)?;
109 if units.len() != availability.units_available
110 || units.last().map(|unit| unit.lsn) != Some(through_lsn)
111 {
112 return Err(io::Error::new(
113 io::ErrorKind::InvalidData,
114 "retained tail did not yield the complete requested LSN range",
115 ));
116 }
117 validate_v1_retained_units_applyable(&units)?;
118
119 let first_lsn = units.first().map(|unit| unit.lsn);
120 let last_lsn = units.last().map(|unit| unit.lsn);
121 let records = units
122 .into_iter()
123 .map(wal_record_from_retained_unit)
124 .collect::<io::Result<Vec<_>>>()?;
125 write_in_progress_apply_state(
126 catalog.data_dir(),
127 expected_identity,
128 since_lsn,
129 through_lsn,
130 resume_lsn,
131 )?;
132 catalog.apply_wal_records(&records)?;
133 write_complete_apply_state(
134 catalog.data_dir(),
135 expected_identity,
136 since_lsn,
137 through_lsn,
138 )?;
139
140 Ok(RetainedTailApplySummary {
141 from_lsn: since_lsn,
142 through_lsn,
143 units_applied: records.len(),
144 first_lsn,
145 last_lsn,
146 })
147}
148
149pub fn seed_retained_apply_boundary(
155 data_dir: &Path,
156 expected_identity: SegmentIdentity,
157 safe_lsn: u64,
158) -> io::Result<()> {
159 expected_identity.validate()?;
160 let local_identity = read_identity(data_dir)?;
161 if !local_identity
162 .segment_identity()
163 .lineage_matches(expected_identity)
164 {
165 return Err(invalid_input(
166 "replica sync identity does not match retained tail history",
167 ));
168 }
169 write_complete_apply_state(data_dir, expected_identity, safe_lsn, safe_lsn)
170}
171
172pub fn apply_retained_units_chunk(
181 catalog: &mut Catalog,
182 expected_identity: SegmentIdentity,
183 since_lsn: u64,
184 units: &[RetainedUnit],
185) -> io::Result<RetainedTailApplySummary> {
186 expected_identity.validate()?;
187 let local_identity = read_identity(catalog.data_dir())?;
188 if !local_identity
189 .segment_identity()
190 .lineage_matches(expected_identity)
191 {
192 return Err(invalid_input(
193 "replica sync identity does not match retained tail history",
194 ));
195 }
196 catalog.ensure_no_pending_wal_records()?;
197
198 let through_lsn = validate_retained_chunk_lsn_range(since_lsn, units)?;
199 let resume_lsn = reconcile_apply_state(catalog, expected_identity, since_lsn, through_lsn)?;
200 if units.is_empty() {
201 if resume_lsn == since_lsn {
202 ensure_retained_chunk_start_boundary(catalog, expected_identity, since_lsn)?;
203 write_complete_apply_state(
204 catalog.data_dir(),
205 expected_identity,
206 since_lsn,
207 through_lsn,
208 )?;
209 return Ok(noop_summary(since_lsn, through_lsn));
210 }
211 return Err(invalid_input(format!(
212 "replica apply resume LSN {resume_lsn} does not match retained chunk start LSN {since_lsn}"
213 )));
214 }
215 if resume_lsn == through_lsn {
216 ensure_retained_chunk_target_provenance(
217 catalog,
218 expected_identity,
219 since_lsn,
220 through_lsn,
221 )?;
222 write_complete_apply_state(
223 catalog.data_dir(),
224 expected_identity,
225 since_lsn,
226 through_lsn,
227 )?;
228 return Ok(noop_summary(since_lsn, through_lsn));
229 }
230 if resume_lsn != since_lsn {
231 return Err(invalid_data(format!(
232 "replica apply resume LSN {resume_lsn} is inside retained chunk {since_lsn}..{through_lsn}; repair required",
233 )));
234 }
235 ensure_retained_chunk_start_boundary(catalog, expected_identity, since_lsn)?;
236
237 validate_v1_retained_units_applyable(units)?;
238
239 let first_lsn = units.first().map(|unit| unit.lsn);
240 let last_lsn = units.last().map(|unit| unit.lsn);
241 let records = units
242 .iter()
243 .cloned()
244 .map(wal_record_from_retained_unit)
245 .collect::<io::Result<Vec<_>>>()?;
246 write_in_progress_apply_state(
247 catalog.data_dir(),
248 expected_identity,
249 since_lsn,
250 through_lsn,
251 since_lsn,
252 )?;
253 catalog.apply_wal_records(&records)?;
254 write_complete_apply_state(
255 catalog.data_dir(),
256 expected_identity,
257 since_lsn,
258 through_lsn,
259 )?;
260
261 Ok(RetainedTailApplySummary {
262 from_lsn: since_lsn,
263 through_lsn,
264 units_applied: records.len(),
265 first_lsn,
266 last_lsn,
267 })
268}
269
270pub fn validate_v1_retained_tail_applyable(
276 retained_dir: &Path,
277 expected_identity: SegmentIdentity,
278 since_lsn: u64,
279 through_lsn: u64,
280) -> io::Result<RetainedTailAvailability> {
281 expected_identity.validate()?;
282 let availability =
283 validate_retained_tail_available(retained_dir, expected_identity, since_lsn, through_lsn)?;
284 if availability.units_available == 0 {
285 return Ok(availability);
286 }
287 let max_units = usize::try_from(through_lsn - since_lsn)
288 .map_err(|_| invalid_input("retained tail range is too large to validate"))?;
289 let units = read_units_since(retained_dir, expected_identity, since_lsn, max_units)?;
290 if units.len() != availability.units_available
291 || units.last().map(|unit| unit.lsn) != Some(through_lsn)
292 {
293 return Err(invalid_data(
294 "retained tail did not yield the complete requested LSN range",
295 ));
296 }
297 validate_v1_retained_units_applyable(&units)?;
298 Ok(availability)
299}
300
301fn validate_retained_chunk_lsn_range(since_lsn: u64, units: &[RetainedUnit]) -> io::Result<u64> {
302 let Some(mut expected_lsn) = since_lsn.checked_add(1) else {
303 if units.is_empty() {
304 return Ok(since_lsn);
305 }
306 return Err(invalid_input("retained chunk start LSN overflow"));
307 };
308 for unit in units {
309 if unit.lsn != expected_lsn {
310 return Err(invalid_input(format!(
311 "retained chunk is not contiguous after LSN {since_lsn}: expected LSN {expected_lsn}, found {}",
312 unit.lsn
313 )));
314 }
315 expected_lsn = expected_lsn
316 .checked_add(1)
317 .ok_or_else(|| invalid_input("retained chunk LSN overflow"))?;
318 }
319 Ok(units.last().map(|unit| unit.lsn).unwrap_or(since_lsn))
320}
321
322fn reconcile_apply_state(
323 catalog: &Catalog,
324 expected_identity: SegmentIdentity,
325 since_lsn: u64,
326 through_lsn: u64,
327) -> io::Result<u64> {
328 let current_lsn = catalog.max_lsn();
329 let Some(state) = read_apply_state(catalog.data_dir())? else {
330 if current_lsn == since_lsn || current_lsn == through_lsn {
331 return Ok(current_lsn);
332 }
333 return Err(invalid_input(format!(
334 "replica applied LSN {current_lsn} does not match retained tail start LSN {since_lsn}"
335 )));
336 };
337
338 state.validate()?;
339 if !state.identity().lineage_matches(expected_identity) {
340 return Err(invalid_data(
341 "local retained-tail apply state belongs to a different database history",
342 ));
343 }
344 if matches!(state.status, ApplyStatus::Complete) {
345 if state.applied_lsn > current_lsn {
346 return Err(invalid_data(
347 "local retained-tail apply state is ahead of the catalog LSN",
348 ));
349 }
350 if current_lsn == since_lsn || current_lsn == through_lsn {
351 return Ok(current_lsn);
352 }
353 return Err(invalid_input(format!(
354 "replica applied LSN {current_lsn} does not match retained tail start LSN {since_lsn}"
355 )));
356 }
357
358 if state.from_lsn != since_lsn || state.through_lsn != through_lsn {
359 return Err(invalid_data(
360 "another retained-tail apply is in progress for this replica",
361 ));
362 }
363 if current_lsn == state.through_lsn {
364 return Ok(current_lsn);
365 }
366 if current_lsn != state.applied_lsn {
367 return Err(invalid_data(
368 "local retained-tail apply state requires repair before retry",
369 ));
370 }
371 write_in_progress_apply_state(
372 catalog.data_dir(),
373 expected_identity,
374 state.from_lsn,
375 state.through_lsn,
376 state.applied_lsn,
377 )?;
378 Ok(state.applied_lsn)
379}
380
381fn ensure_retained_chunk_target_provenance(
382 catalog: &Catalog,
383 expected_identity: SegmentIdentity,
384 since_lsn: u64,
385 through_lsn: u64,
386) -> io::Result<()> {
387 let Some(state) = read_apply_state(catalog.data_dir())? else {
388 return Err(invalid_data(format!(
389 "retained chunk target LSN {through_lsn} has no trusted local apply provenance"
390 )));
391 };
392 state.validate()?;
393 if !state.identity().lineage_matches(expected_identity) {
394 return Err(invalid_data(
395 "retained chunk target provenance belongs to a different database history",
396 ));
397 }
398 if state.from_lsn != since_lsn || state.through_lsn != through_lsn {
399 return Err(invalid_data(
400 "retained chunk target provenance belongs to a different apply range",
401 ));
402 }
403 if catalog.max_lsn() != through_lsn {
404 return Err(invalid_data(format!(
405 "catalog LSN {} does not match retained chunk target boundary {through_lsn}",
406 catalog.max_lsn()
407 )));
408 }
409 match state.status {
410 ApplyStatus::Complete if state.applied_lsn == through_lsn => Ok(()),
411 ApplyStatus::InProgress if state.applied_lsn == since_lsn => Ok(()),
412 _ => Err(invalid_data(
413 "retained chunk target is not backed by completed or replayed apply state",
414 )),
415 }
416}
417
418fn ensure_retained_chunk_start_boundary(
419 catalog: &Catalog,
420 expected_identity: SegmentIdentity,
421 since_lsn: u64,
422) -> io::Result<()> {
423 let Some(state) = read_apply_state(catalog.data_dir())? else {
424 return Err(invalid_data(format!(
425 "retained chunk start LSN {since_lsn} has no trusted local apply boundary"
426 )));
427 };
428 state.validate()?;
429 if !state.identity().lineage_matches(expected_identity) {
430 return Err(invalid_data(
431 "trusted retained chunk boundary belongs to a different database history",
432 ));
433 }
434 if !matches!(state.status, ApplyStatus::Complete) || state.applied_lsn != since_lsn {
435 return Err(invalid_data(format!(
436 "retained chunk start LSN {since_lsn} is not a trusted completed apply boundary"
437 )));
438 }
439 if catalog.max_lsn() != since_lsn {
440 return Err(invalid_data(format!(
441 "catalog LSN {} does not match retained chunk start boundary {since_lsn}",
442 catalog.max_lsn()
443 )));
444 }
445 Ok(())
446}
447
448fn read_apply_state(data_dir: &Path) -> io::Result<Option<ApplyStateFile>> {
449 let path = sync_state_dir(data_dir).join(APPLY_STATE_FILE);
450 let bytes = match fs::read(path) {
451 Ok(bytes) => bytes,
452 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
453 Err(err) => return Err(err),
454 };
455 let state: ApplyStateFile = serde_json::from_slice(&bytes).map_err(invalid_data)?;
456 state.validate()?;
457 Ok(Some(state))
458}
459
460fn write_in_progress_apply_state(
461 data_dir: &Path,
462 identity: SegmentIdentity,
463 from_lsn: u64,
464 through_lsn: u64,
465 applied_lsn: u64,
466) -> io::Result<()> {
467 write_apply_state(
468 data_dir,
469 identity,
470 from_lsn,
471 through_lsn,
472 applied_lsn,
473 ApplyStatus::InProgress,
474 )
475}
476
477fn write_complete_apply_state(
478 data_dir: &Path,
479 identity: SegmentIdentity,
480 from_lsn: u64,
481 through_lsn: u64,
482) -> io::Result<()> {
483 write_apply_state(
484 data_dir,
485 identity,
486 from_lsn,
487 through_lsn,
488 through_lsn,
489 ApplyStatus::Complete,
490 )
491}
492
493fn write_apply_state(
494 data_dir: &Path,
495 identity: SegmentIdentity,
496 from_lsn: u64,
497 through_lsn: u64,
498 applied_lsn: u64,
499 status: ApplyStatus,
500) -> io::Result<()> {
501 let state_dir = sync_state_dir(data_dir);
502 create_data_dir_secure(&state_dir)?;
503 let existing_started = read_apply_state(data_dir)?
504 .filter(|existing| {
505 existing.identity().lineage_matches(identity)
506 && existing.from_lsn == from_lsn
507 && existing.through_lsn == through_lsn
508 })
509 .map(|existing| existing.started_unix_secs);
510 let now = now_unix_secs();
511 let state = ApplyStateFile {
512 format_version: APPLY_STATE_FORMAT_VERSION,
513 database_id: identity.database_id,
514 primary_generation: identity.primary_generation,
515 wal_format_version: identity.wal_format_version,
516 catalog_version: identity.catalog_version,
517 from_lsn,
518 through_lsn,
519 applied_lsn,
520 status,
521 started_unix_secs: existing_started.unwrap_or(now),
522 updated_unix_secs: now,
523 };
524 state.validate()?;
525 atomic_replace_json(&state_dir, APPLY_STATE_FILE, &state)
526}
527
528impl ApplyStateFile {
529 fn identity(&self) -> SegmentIdentity {
530 SegmentIdentity {
531 database_id: self.database_id,
532 primary_generation: self.primary_generation,
533 wal_format_version: self.wal_format_version,
534 catalog_version: self.catalog_version,
535 }
536 }
537
538 fn validate(&self) -> io::Result<()> {
539 if self.format_version != APPLY_STATE_FORMAT_VERSION {
540 return Err(invalid_data(format!(
541 "unsupported retained-tail apply state format {}",
542 self.format_version
543 )));
544 }
545 self.identity().validate()?;
546 if self.through_lsn < self.from_lsn {
547 return Err(invalid_data(
548 "retained-tail apply state target is behind its start LSN",
549 ));
550 }
551 if self.applied_lsn < self.from_lsn || self.applied_lsn > self.through_lsn {
552 return Err(invalid_data(
553 "retained-tail apply state applied LSN is outside the apply range",
554 ));
555 }
556 if matches!(self.status, ApplyStatus::Complete) && self.applied_lsn != self.through_lsn {
557 return Err(invalid_data(
558 "completed retained-tail apply state must be applied through its target LSN",
559 ));
560 }
561 Ok(())
562 }
563}
564
565fn wal_record_from_retained_unit(unit: RetainedUnit) -> io::Result<WalRecord> {
566 let record_type = WalRecordType::from_u8(unit.record_type).ok_or_else(|| {
567 io::Error::new(
568 io::ErrorKind::InvalidData,
569 "retained unit contains an unknown WAL record type",
570 )
571 })?;
572 reject_unsupported_v1_record_type(record_type)?;
573 Ok(WalRecord {
574 tx_id: unit.tx_id,
575 record_type,
576 lsn: unit.lsn,
577 data: unit.data,
578 })
579}
580
581pub fn validate_v1_retained_units_applyable(units: &[RetainedUnit]) -> io::Result<()> {
587 let mut pending_tx_spans = Vec::new();
588 for unit in units {
589 let record_type = WalRecordType::from_u8(unit.record_type).ok_or_else(|| {
590 io::Error::new(
591 io::ErrorKind::InvalidData,
592 "retained unit contains an unknown WAL record type",
593 )
594 })?;
595 reject_unsupported_v1_record_type(record_type)?;
596 match record_type {
597 WalRecordType::Begin if unit.tx_id != 0 => {
598 pending_tx_spans.push(unit.tx_id);
599 }
600 WalRecordType::Insert | WalRecordType::Update | WalRecordType::Delete
601 if unit.tx_id != 0 && !pending_tx_spans.contains(&unit.tx_id) =>
602 {
603 pending_tx_spans.push(unit.tx_id);
604 }
605 WalRecordType::Commit | WalRecordType::Rollback if unit.tx_id != 0 => {
606 if let Some(index) = pending_tx_spans
607 .iter()
608 .rposition(|pending_tx_id| *pending_tx_id == unit.tx_id)
609 {
610 pending_tx_spans.remove(index);
611 }
612 }
613 _ => {}
614 }
615 }
616 if let Some(tx_id) = pending_tx_spans.first() {
617 return Err(invalid_input(format!(
618 "retained tail cuts through transaction {tx_id}; retry with a range through its commit or rollback boundary",
619 )));
620 }
621 Ok(())
622}
623
624fn reject_unsupported_v1_record_type(record_type: WalRecordType) -> io::Result<()> {
625 if matches!(
626 record_type,
627 WalRecordType::DdlCreateTable
628 | WalRecordType::DdlDropTable
629 | WalRecordType::DdlAddColumn
630 | WalRecordType::DdlDropColumn
631 ) {
632 return Err(invalid_input(
633 "DDL retained units are not supported by V1 embedded sync; rebootstrap or upgrade required",
634 ));
635 }
636 Ok(())
637}
638
639fn noop_summary(since_lsn: u64, through_lsn: u64) -> RetainedTailApplySummary {
640 RetainedTailApplySummary {
641 from_lsn: since_lsn,
642 through_lsn,
643 units_applied: 0,
644 first_lsn: None,
645 last_lsn: None,
646 }
647}
648
649fn invalid_input(message: impl Into<String>) -> io::Error {
650 io::Error::new(io::ErrorKind::InvalidInput, message.into())
651}
652
653fn invalid_data(message: impl ToString) -> io::Error {
654 io::Error::new(io::ErrorKind::InvalidData, message.to_string())
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use crate::{
661 checkpoint_with_retained_segments, open_or_create_identity,
662 open_preserving_retained_segments, read_identity_snapshot, read_units_through,
663 retained_segments_dir, write_identity_snapshot,
664 };
665 use crate::{write_segment_atomic, RetainedSegment};
666 use powdb_storage::types::{ColumnDef, Row, Schema, TypeId, Value};
667 use std::path::{Path, PathBuf};
668 use std::sync::atomic::{AtomicU64, Ordering};
669
670 fn tmp(tag: &str) -> PathBuf {
671 static CTR: AtomicU64 = AtomicU64::new(0);
672 let uniq = CTR.fetch_add(1, Ordering::Relaxed);
673 let path = std::env::temp_dir().join(format!(
674 "powdb_sync_apply_state_{tag}_{}_{}_{}",
675 std::process::id(),
676 now_unix_secs(),
677 uniq
678 ));
679 let _ = fs::remove_dir_all(&path);
680 path
681 }
682
683 fn schema_users() -> Schema {
684 Schema {
685 table_name: "User".into(),
686 columns: vec![
687 ColumnDef {
688 name: "id".into(),
689 type_id: TypeId::Int,
690 required: true,
691 position: 0,
692 },
693 ColumnDef {
694 name: "email".into(),
695 type_id: TypeId::Str,
696 required: false,
697 position: 1,
698 },
699 ],
700 }
701 }
702
703 fn user_row(id: i64) -> Row {
704 vec![Value::Int(id), Value::Str(format!("user{id}@example.com"))]
705 }
706
707 fn insert_range(catalog: &mut Catalog, start: i64, end: i64) {
708 for id in start..end {
709 catalog.insert("User", &user_row(id)).unwrap();
710 }
711 catalog.commit_autocommit().unwrap();
712 catalog.sync_wal().unwrap();
713 }
714
715 fn retained_unit(tx_id: u64, record_type: WalRecordType, lsn: u64) -> RetainedUnit {
716 RetainedUnit {
717 tx_id,
718 record_type: record_type as u8,
719 lsn,
720 data: Vec::new(),
721 }
722 }
723
724 fn rows(catalog: &Catalog) -> Vec<(i64, String)> {
725 let mut rows: Vec<_> = catalog
726 .scan("User")
727 .unwrap()
728 .map(|(_, row)| {
729 let id = match &row[0] {
730 Value::Int(id) => *id,
731 other => panic!("expected int id, got {other:?}"),
732 };
733 let email = match &row[1] {
734 Value::Str(email) => email.clone(),
735 other => panic!("expected email string, got {other:?}"),
736 };
737 (id, email)
738 })
739 .collect();
740 rows.sort_by_key(|(id, _)| *id);
741 rows
742 }
743
744 #[test]
745 fn v1_applyability_rejects_transaction_split_before_commit() {
746 let dir = tmp("split_tx_segments");
747 let identity = SegmentIdentity::current(*b"apply-split-tx!!", 1);
748 let segment = RetainedSegment::new(
749 identity,
750 vec![
751 retained_unit(7, WalRecordType::Begin, 1),
752 retained_unit(7, WalRecordType::Insert, 2),
753 ],
754 )
755 .unwrap();
756 write_segment_atomic(&dir, &segment).unwrap();
757
758 let err = validate_v1_retained_tail_applyable(&dir, identity, 0, 2).unwrap_err();
759 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
760 assert!(
761 err.to_string().contains("cuts through transaction 7"),
762 "split transaction tail must fail before storage replay, got: {err}"
763 );
764 }
765
766 #[test]
767 fn v1_applyability_rejects_begin_only_transaction_range() {
768 let units = vec![retained_unit(7, WalRecordType::Begin, 1)];
769 let err = validate_v1_retained_units_applyable(&units).unwrap_err();
770 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
771 assert!(
772 err.to_string().contains("cuts through transaction 7"),
773 "begin-only transaction range must fail before storage replay, got: {err}"
774 );
775 }
776
777 #[test]
778 fn v1_applyability_rejects_row_only_nonzero_transaction_range() {
779 let units = vec![retained_unit(9, WalRecordType::Insert, 1)];
780 let err = validate_v1_retained_units_applyable(&units).unwrap_err();
781 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
782 assert!(
783 err.to_string().contains("cuts through transaction 9"),
784 "row-only transaction range must fail before storage replay, got: {err}"
785 );
786 }
787
788 #[test]
789 fn v1_applyability_rejects_reused_tx_id_with_later_incomplete_span() {
790 let units = vec![
791 retained_unit(1, WalRecordType::Begin, 1),
792 retained_unit(1, WalRecordType::Insert, 2),
793 retained_unit(1, WalRecordType::Commit, 3),
794 retained_unit(1, WalRecordType::Begin, 4),
795 retained_unit(1, WalRecordType::Insert, 5),
796 ];
797 let err = validate_v1_retained_units_applyable(&units).unwrap_err();
798 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
799 assert!(
800 err.to_string().contains("cuts through transaction 1"),
801 "later incomplete span with reused tx id must fail, got: {err}"
802 );
803 }
804
805 #[test]
806 fn v1_applyability_accepts_transaction_closed_by_commit_or_rollback() {
807 let commit_units = vec![
808 retained_unit(7, WalRecordType::Begin, 1),
809 retained_unit(7, WalRecordType::Insert, 2),
810 retained_unit(7, WalRecordType::Commit, 3),
811 ];
812 validate_v1_retained_units_applyable(&commit_units).unwrap();
813
814 let rollback_units = vec![
815 retained_unit(8, WalRecordType::Begin, 1),
816 retained_unit(8, WalRecordType::Insert, 2),
817 retained_unit(8, WalRecordType::Rollback, 3),
818 ];
819 validate_v1_retained_units_applyable(&rollback_units).unwrap();
820 }
821
822 #[test]
823 fn retained_units_chunk_rejects_non_contiguous_lsn_range() {
824 let units = vec![
825 retained_unit(0, WalRecordType::Commit, 6),
826 retained_unit(0, WalRecordType::Commit, 8),
827 ];
828 let err = validate_retained_chunk_lsn_range(5, &units).unwrap_err();
829 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
830 assert!(
831 err.to_string().contains("expected LSN 7"),
832 "non-contiguous retained chunks must fail before replay, got: {err}"
833 );
834 }
835
836 #[test]
837 fn apply_retained_units_chunk_requires_trusted_start_boundary() {
838 let replica = tmp("chunk_no_boundary_replica");
839 let mut replica_cat = Catalog::create(&replica).unwrap();
840 replica_cat.create_table(schema_users()).unwrap();
841 insert_range(&mut replica_cat, 0, 1);
842 let identity = open_or_create_identity(&replica).unwrap();
843 checkpoint_with_retained_segments(&mut replica_cat).unwrap();
844 let since_lsn = replica_cat.max_lsn();
845 let units = vec![retained_unit(0, WalRecordType::Commit, since_lsn + 1)];
846
847 let err = apply_retained_units_chunk(
848 &mut replica_cat,
849 identity.segment_identity(),
850 since_lsn,
851 &units,
852 )
853 .unwrap_err();
854 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
855 assert!(
856 err.to_string().contains("no trusted local apply boundary"),
857 "unseeded retained chunks must fail before replay, got: {err}"
858 );
859 assert_eq!(
860 replica_cat.max_lsn(),
861 since_lsn,
862 "failed chunk apply must not advance catalog LSN"
863 );
864 }
865
866 #[test]
867 fn apply_retained_units_chunk_rejects_catalog_only_target_noop_provenance() {
868 let replica = tmp("chunk_catalog_only_target_replica");
869 let mut replica_cat = Catalog::create(&replica).unwrap();
870 replica_cat.create_table(schema_users()).unwrap();
871 insert_range(&mut replica_cat, 0, 2);
872 let identity = open_or_create_identity(&replica).unwrap();
873 checkpoint_with_retained_segments(&mut replica_cat).unwrap();
874 let through_lsn = replica_cat.max_lsn();
875 let since_lsn = through_lsn - 1;
876 let units = vec![retained_unit(0, WalRecordType::Commit, through_lsn)];
877
878 let err = apply_retained_units_chunk(
879 &mut replica_cat,
880 identity.segment_identity(),
881 since_lsn,
882 &units,
883 )
884 .unwrap_err();
885 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
886 assert!(
887 err.to_string()
888 .contains("no trusted local apply provenance"),
889 "catalog-only target no-op must not mint a trusted boundary, got: {err}"
890 );
891 assert!(
892 read_apply_state(&replica).unwrap().is_none(),
893 "failed catalog-only target no-op must not write apply-state"
894 );
895 }
896
897 #[test]
898 fn apply_retained_units_chunk_rejects_transaction_cut_without_advancing_lsn() {
899 let replica = tmp("chunk_cut_replica");
900 let mut replica_cat = Catalog::create(&replica).unwrap();
901 replica_cat.create_table(schema_users()).unwrap();
902 insert_range(&mut replica_cat, 0, 1);
903 let identity = open_or_create_identity(&replica).unwrap();
904 checkpoint_with_retained_segments(&mut replica_cat).unwrap();
905 let since_lsn = replica_cat.max_lsn();
906 seed_retained_apply_boundary(&replica, identity.segment_identity(), since_lsn).unwrap();
907 let cut_units = vec![
908 retained_unit(7, WalRecordType::Begin, since_lsn + 1),
909 retained_unit(7, WalRecordType::Insert, since_lsn + 2),
910 ];
911
912 let err = apply_retained_units_chunk(
913 &mut replica_cat,
914 identity.segment_identity(),
915 since_lsn,
916 &cut_units,
917 )
918 .unwrap_err();
919 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
920 assert!(
921 err.to_string().contains("cuts through transaction 7"),
922 "transaction-cut retained chunks must fail before replay, got: {err}"
923 );
924 assert_eq!(
925 replica_cat.max_lsn(),
926 since_lsn,
927 "failed chunk apply must not advance catalog LSN"
928 );
929 }
930
931 fn copy_snapshot_files(src: &Path, dest: &Path) {
932 fs::create_dir_all(dest).unwrap();
933 for entry in fs::read_dir(src).unwrap() {
934 let entry = entry.unwrap();
935 let name = entry.file_name().to_string_lossy().to_string();
936 if name == "catalog.bin"
937 || name == powdb_storage::catalog::CATALOG_LSN_FILE
938 || name.ends_with(".heap")
939 || name.ends_with(".idx")
940 {
941 fs::copy(entry.path(), dest.join(name)).unwrap();
942 }
943 }
944 }
945
946 #[test]
947 fn in_progress_apply_state_replays_from_recorded_safe_lsn_when_catalog_matches() {
948 let primary = tmp("primary");
949 let mut primary_cat = Catalog::create(&primary).unwrap();
950 primary_cat.create_table(schema_users()).unwrap();
951 insert_range(&mut primary_cat, 0, 3);
952 let identity = open_or_create_identity(&primary).unwrap();
953 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
954 let snapshot_lsn = primary_cat.max_lsn();
955
956 let replica = tmp("replica");
957 copy_snapshot_files(&primary, &replica);
958 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
959 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
960
961 insert_range(&mut primary_cat, 3, 8);
962 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
963 let through_lsn = primary_cat.max_lsn();
964 assert!(through_lsn > snapshot_lsn);
965
966 let retained_dir = retained_segments_dir(&primary);
967 let replica_cat = open_preserving_retained_segments(&replica).unwrap();
968 write_in_progress_apply_state(
969 &replica,
970 identity.segment_identity(),
971 snapshot_lsn,
972 through_lsn,
973 snapshot_lsn,
974 )
975 .unwrap();
976 drop(replica_cat);
977
978 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
979 let summary = apply_retained_tail(
980 &mut reopened,
981 &retained_dir,
982 identity.segment_identity(),
983 snapshot_lsn,
984 through_lsn,
985 )
986 .unwrap();
987
988 assert_eq!(summary.from_lsn, snapshot_lsn);
989 assert_eq!(summary.first_lsn, Some(snapshot_lsn + 1));
990 assert_eq!(summary.last_lsn, Some(through_lsn));
991 assert_eq!(rows(&reopened), rows(&primary_cat));
992 assert!(matches!(
993 read_apply_state(&replica).unwrap().unwrap().status,
994 ApplyStatus::Complete
995 ));
996 }
997
998 #[test]
999 fn in_progress_apply_state_fails_closed_when_catalog_lsn_advanced() {
1000 let primary = tmp("advanced_primary");
1001 let mut primary_cat = Catalog::create(&primary).unwrap();
1002 primary_cat.create_table(schema_users()).unwrap();
1003 insert_range(&mut primary_cat, 0, 3);
1004 let identity = open_or_create_identity(&primary).unwrap();
1005 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1006 let snapshot_lsn = primary_cat.max_lsn();
1007
1008 let replica = tmp("advanced_replica");
1009 copy_snapshot_files(&primary, &replica);
1010 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
1011 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
1012
1013 insert_range(&mut primary_cat, 3, 8);
1014 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1015 let through_lsn = primary_cat.max_lsn();
1016 let retained_dir = retained_segments_dir(&primary);
1017 let units = read_units_since(
1018 &retained_dir,
1019 identity.segment_identity(),
1020 snapshot_lsn,
1021 100,
1022 )
1023 .unwrap();
1024 assert!(units.len() > 1, "test needs a multi-unit retained tail");
1025 let partial_records = units[..1]
1026 .iter()
1027 .cloned()
1028 .map(wal_record_from_retained_unit)
1029 .collect::<io::Result<Vec<_>>>()
1030 .unwrap();
1031
1032 let mut replica_cat = open_preserving_retained_segments(&replica).unwrap();
1033 write_in_progress_apply_state(
1034 &replica,
1035 identity.segment_identity(),
1036 snapshot_lsn,
1037 through_lsn,
1038 snapshot_lsn,
1039 )
1040 .unwrap();
1041 replica_cat.apply_wal_records(&partial_records).unwrap();
1042 assert!(replica_cat.max_lsn() > snapshot_lsn);
1043 drop(replica_cat);
1044
1045 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1046 let err = apply_retained_tail(
1047 &mut reopened,
1048 &retained_dir,
1049 identity.segment_identity(),
1050 snapshot_lsn,
1051 through_lsn,
1052 )
1053 .unwrap_err();
1054 assert!(
1055 err.to_string().contains("requires repair before retry"),
1056 "advanced catalog LSN without complete apply-state must fail closed, got: {err}"
1057 );
1058 }
1059
1060 #[test]
1061 fn in_progress_apply_state_marks_complete_when_catalog_reached_target() {
1062 let primary = tmp("complete_window_primary");
1063 let mut primary_cat = Catalog::create(&primary).unwrap();
1064 primary_cat.create_table(schema_users()).unwrap();
1065 insert_range(&mut primary_cat, 0, 3);
1066 let identity = open_or_create_identity(&primary).unwrap();
1067 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1068 let snapshot_lsn = primary_cat.max_lsn();
1069
1070 let replica = tmp("complete_window_replica");
1071 copy_snapshot_files(&primary, &replica);
1072 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
1073 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
1074
1075 insert_range(&mut primary_cat, 3, 8);
1076 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1077 let through_lsn = primary_cat.max_lsn();
1078 let retained_dir = retained_segments_dir(&primary);
1079 let records = read_units_since(
1080 &retained_dir,
1081 identity.segment_identity(),
1082 snapshot_lsn,
1083 100,
1084 )
1085 .unwrap()
1086 .into_iter()
1087 .map(wal_record_from_retained_unit)
1088 .collect::<io::Result<Vec<_>>>()
1089 .unwrap();
1090
1091 let mut replica_cat = open_preserving_retained_segments(&replica).unwrap();
1092 write_in_progress_apply_state(
1093 &replica,
1094 identity.segment_identity(),
1095 snapshot_lsn,
1096 through_lsn,
1097 snapshot_lsn,
1098 )
1099 .unwrap();
1100 replica_cat.apply_wal_records(&records).unwrap();
1101 assert_eq!(replica_cat.max_lsn(), through_lsn);
1102 drop(replica_cat);
1103
1104 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1105 let summary = apply_retained_tail(
1106 &mut reopened,
1107 &retained_dir,
1108 identity.segment_identity(),
1109 snapshot_lsn,
1110 through_lsn,
1111 )
1112 .unwrap();
1113
1114 assert_eq!(summary.units_applied, 0);
1115 assert_eq!(rows(&reopened), rows(&primary_cat));
1116 assert!(matches!(
1117 read_apply_state(&replica).unwrap().unwrap().status,
1118 ApplyStatus::Complete
1119 ));
1120 }
1121
1122 #[test]
1123 fn chunk_apply_marks_complete_when_in_progress_replay_reached_target() {
1124 let primary = tmp("chunk_complete_window_primary");
1125 let mut primary_cat = Catalog::create(&primary).unwrap();
1126 primary_cat.create_table(schema_users()).unwrap();
1127 insert_range(&mut primary_cat, 0, 3);
1128 let identity = open_or_create_identity(&primary).unwrap();
1129 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1130 let snapshot_lsn = primary_cat.max_lsn();
1131
1132 let replica = tmp("chunk_complete_window_replica");
1133 copy_snapshot_files(&primary, &replica);
1134 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
1135 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
1136
1137 insert_range(&mut primary_cat, 3, 8);
1138 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1139 let through_lsn = primary_cat.max_lsn();
1140 let units = read_units_through(
1141 &retained_segments_dir(&primary),
1142 identity.segment_identity(),
1143 snapshot_lsn,
1144 through_lsn,
1145 100,
1146 )
1147 .unwrap();
1148 let records = units
1149 .iter()
1150 .cloned()
1151 .map(wal_record_from_retained_unit)
1152 .collect::<io::Result<Vec<_>>>()
1153 .unwrap();
1154
1155 let mut replica_cat = open_preserving_retained_segments(&replica).unwrap();
1156 write_in_progress_apply_state(
1157 &replica,
1158 identity.segment_identity(),
1159 snapshot_lsn,
1160 through_lsn,
1161 snapshot_lsn,
1162 )
1163 .unwrap();
1164 replica_cat.apply_wal_records(&records).unwrap();
1165 assert_eq!(replica_cat.max_lsn(), through_lsn);
1166 drop(replica_cat);
1167
1168 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1169 let summary = apply_retained_units_chunk(
1170 &mut reopened,
1171 identity.segment_identity(),
1172 snapshot_lsn,
1173 &units,
1174 )
1175 .unwrap();
1176
1177 assert_eq!(summary.units_applied, 0);
1178 assert_eq!(rows(&reopened), rows(&primary_cat));
1179 assert!(matches!(
1180 read_apply_state(&replica).unwrap().unwrap().status,
1181 ApplyStatus::Complete
1182 ));
1183 }
1184
1185 #[test]
1186 fn ddl_retained_units_fail_closed_in_v1_apply() {
1187 let err = wal_record_from_retained_unit(RetainedUnit {
1188 tx_id: 0,
1189 record_type: WalRecordType::DdlCreateTable as u8,
1190 lsn: 42,
1191 data: Vec::new(),
1192 })
1193 .unwrap_err();
1194 assert!(
1195 err.to_string()
1196 .contains("DDL retained units are not supported"),
1197 "DDL retained units must fail closed, got: {err}"
1198 );
1199 }
1200
1201 #[test]
1202 fn different_in_progress_apply_range_fails_closed() {
1203 let primary = tmp("blocked_primary");
1204 let mut primary_cat = Catalog::create(&primary).unwrap();
1205 primary_cat.create_table(schema_users()).unwrap();
1206 insert_range(&mut primary_cat, 0, 1);
1207 let identity = open_or_create_identity(&primary).unwrap();
1208
1209 write_in_progress_apply_state(&primary, identity.segment_identity(), 1, 10, 1).unwrap();
1210 let err =
1211 reconcile_apply_state(&primary_cat, identity.segment_identity(), 1, 11).unwrap_err();
1212 assert!(
1213 err.to_string().contains("another retained-tail apply"),
1214 "mismatched in-progress range must fail closed, got: {err}"
1215 );
1216 }
1217}