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