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(crate::SyncError::IdentityMismatch(
64 "replica sync identity does not match retained tail history".into(),
65 )
66 .into());
67 }
68 catalog.ensure_no_pending_wal_records()?;
69 if through_lsn < since_lsn {
70 return Err(invalid_input(format!(
71 "retained tail target LSN {through_lsn} is behind start LSN {since_lsn}"
72 )));
73 }
74 let resume_lsn = reconcile_apply_state(catalog, expected_identity, since_lsn, through_lsn)?;
75 if through_lsn == since_lsn {
76 if resume_lsn == since_lsn {
77 write_complete_apply_state(
78 catalog.data_dir(),
79 expected_identity,
80 since_lsn,
81 through_lsn,
82 )?;
83 return Ok(noop_summary(since_lsn, through_lsn));
84 }
85 return Err(invalid_input(format!(
86 "replica apply resume LSN {resume_lsn} does not match retained tail start LSN {since_lsn}"
87 )));
88 }
89
90 if resume_lsn == through_lsn {
91 write_complete_apply_state(
92 catalog.data_dir(),
93 expected_identity,
94 since_lsn,
95 through_lsn,
96 )?;
97 return Ok(noop_summary(since_lsn, through_lsn));
98 }
99 if resume_lsn < since_lsn || resume_lsn > through_lsn {
100 return Err(invalid_input(format!(
101 "replica apply resume LSN {resume_lsn} does not match retained tail start LSN {since_lsn}"
102 )));
103 }
104
105 let availability =
106 validate_retained_tail_available(retained_dir, expected_identity, resume_lsn, through_lsn)?;
107 let max_units = usize::try_from(through_lsn - resume_lsn)
108 .map_err(|_| invalid_input("retained tail range is too large to apply in one batch"))?;
109 let units = read_units_since(retained_dir, expected_identity, resume_lsn, max_units)?;
110 if units.len() != availability.units_available
111 || units.last().map(|unit| unit.lsn) != Some(through_lsn)
112 {
113 return Err(io::Error::new(
114 io::ErrorKind::InvalidData,
115 "retained tail did not yield the complete requested LSN range",
116 ));
117 }
118 validate_v1_retained_units_applyable(&units)?;
119
120 let first_lsn = units.first().map(|unit| unit.lsn);
121 let last_lsn = units.last().map(|unit| unit.lsn);
122 let records = units
123 .into_iter()
124 .map(wal_record_from_retained_unit)
125 .collect::<io::Result<Vec<_>>>()?;
126 write_in_progress_apply_state(
127 catalog.data_dir(),
128 expected_identity,
129 since_lsn,
130 through_lsn,
131 resume_lsn,
132 )?;
133 catalog.apply_wal_records(&records)?;
134 write_complete_apply_state(
135 catalog.data_dir(),
136 expected_identity,
137 since_lsn,
138 through_lsn,
139 )?;
140
141 Ok(RetainedTailApplySummary {
142 from_lsn: since_lsn,
143 through_lsn,
144 units_applied: records.len(),
145 first_lsn,
146 last_lsn,
147 })
148}
149
150pub fn seed_retained_apply_boundary(
156 data_dir: &Path,
157 expected_identity: SegmentIdentity,
158 safe_lsn: u64,
159) -> io::Result<()> {
160 expected_identity.validate()?;
161 let local_identity = read_identity(data_dir)?;
162 if !local_identity
163 .segment_identity()
164 .lineage_matches(expected_identity)
165 {
166 return Err(crate::SyncError::IdentityMismatch(
167 "replica sync identity does not match retained tail history".into(),
168 )
169 .into());
170 }
171 write_complete_apply_state(data_dir, expected_identity, safe_lsn, safe_lsn)
172}
173
174pub fn apply_retained_units_chunk(
183 catalog: &mut Catalog,
184 expected_identity: SegmentIdentity,
185 since_lsn: u64,
186 units: &[RetainedUnit],
187) -> io::Result<RetainedTailApplySummary> {
188 expected_identity.validate()?;
189 let local_identity = read_identity(catalog.data_dir())?;
190 if !local_identity
191 .segment_identity()
192 .lineage_matches(expected_identity)
193 {
194 return Err(crate::SyncError::IdentityMismatch(
195 "replica sync identity does not match retained tail history".into(),
196 )
197 .into());
198 }
199 catalog.ensure_no_pending_wal_records()?;
200
201 let through_lsn = validate_retained_chunk_lsn_range(since_lsn, units)?;
202 let resume_lsn = reconcile_apply_state(catalog, expected_identity, since_lsn, through_lsn)?;
203 if units.is_empty() {
204 if resume_lsn == since_lsn {
205 ensure_retained_chunk_start_boundary(catalog, expected_identity, since_lsn)?;
206 write_complete_apply_state(
207 catalog.data_dir(),
208 expected_identity,
209 since_lsn,
210 through_lsn,
211 )?;
212 return Ok(noop_summary(since_lsn, through_lsn));
213 }
214 return Err(invalid_input(format!(
215 "replica apply resume LSN {resume_lsn} does not match retained chunk start LSN {since_lsn}"
216 )));
217 }
218 if resume_lsn == through_lsn {
219 ensure_retained_chunk_target_provenance(
220 catalog,
221 expected_identity,
222 since_lsn,
223 through_lsn,
224 )?;
225 write_complete_apply_state(
226 catalog.data_dir(),
227 expected_identity,
228 since_lsn,
229 through_lsn,
230 )?;
231 return Ok(noop_summary(since_lsn, through_lsn));
232 }
233 if resume_lsn != since_lsn {
234 return Err(invalid_data(format!(
235 "replica apply resume LSN {resume_lsn} is inside retained chunk {since_lsn}..{through_lsn}; repair required",
236 )));
237 }
238 ensure_retained_chunk_start_boundary(catalog, expected_identity, since_lsn)?;
239
240 validate_v1_retained_units_applyable(units)?;
241
242 let first_lsn = units.first().map(|unit| unit.lsn);
243 let last_lsn = units.last().map(|unit| unit.lsn);
244 let records = units
245 .iter()
246 .cloned()
247 .map(wal_record_from_retained_unit)
248 .collect::<io::Result<Vec<_>>>()?;
249 write_in_progress_apply_state(
250 catalog.data_dir(),
251 expected_identity,
252 since_lsn,
253 through_lsn,
254 since_lsn,
255 )?;
256 catalog.apply_wal_records(&records)?;
257 write_complete_apply_state(
258 catalog.data_dir(),
259 expected_identity,
260 since_lsn,
261 through_lsn,
262 )?;
263
264 Ok(RetainedTailApplySummary {
265 from_lsn: since_lsn,
266 through_lsn,
267 units_applied: records.len(),
268 first_lsn,
269 last_lsn,
270 })
271}
272
273pub fn validate_v1_retained_tail_applyable(
279 retained_dir: &Path,
280 expected_identity: SegmentIdentity,
281 since_lsn: u64,
282 through_lsn: u64,
283) -> io::Result<RetainedTailAvailability> {
284 expected_identity.validate()?;
285 let availability =
286 validate_retained_tail_available(retained_dir, expected_identity, since_lsn, through_lsn)?;
287 if availability.units_available == 0 {
288 return Ok(availability);
289 }
290 let max_units = usize::try_from(through_lsn - since_lsn)
291 .map_err(|_| invalid_input("retained tail range is too large to validate"))?;
292 let units = read_units_since(retained_dir, expected_identity, since_lsn, max_units)?;
293 if units.len() != availability.units_available
294 || units.last().map(|unit| unit.lsn) != Some(through_lsn)
295 {
296 return Err(invalid_data(
297 "retained tail did not yield the complete requested LSN range",
298 ));
299 }
300 validate_v1_retained_units_applyable(&units)?;
301 Ok(availability)
302}
303
304fn validate_retained_chunk_lsn_range(since_lsn: u64, units: &[RetainedUnit]) -> io::Result<u64> {
305 let Some(mut expected_lsn) = since_lsn.checked_add(1) else {
306 if units.is_empty() {
307 return Ok(since_lsn);
308 }
309 return Err(invalid_input("retained chunk start LSN overflow"));
310 };
311 for unit in units {
312 if unit.lsn != expected_lsn {
313 return Err(invalid_input(format!(
314 "retained chunk is not contiguous after LSN {since_lsn}: expected LSN {expected_lsn}, found {}",
315 unit.lsn
316 )));
317 }
318 expected_lsn = expected_lsn
319 .checked_add(1)
320 .ok_or_else(|| invalid_input("retained chunk LSN overflow"))?;
321 }
322 Ok(units.last().map(|unit| unit.lsn).unwrap_or(since_lsn))
323}
324
325fn reconcile_apply_state(
326 catalog: &Catalog,
327 expected_identity: SegmentIdentity,
328 since_lsn: u64,
329 through_lsn: u64,
330) -> io::Result<u64> {
331 let current_lsn = catalog.max_lsn();
332 let Some(state) = read_apply_state(catalog.data_dir())? else {
333 if current_lsn == since_lsn || current_lsn == through_lsn {
334 return Ok(current_lsn);
335 }
336 return Err(invalid_input(format!(
337 "replica applied LSN {current_lsn} does not match retained tail start LSN {since_lsn}"
338 )));
339 };
340
341 state.validate()?;
342 if !state.identity().lineage_matches(expected_identity) {
343 return Err(invalid_data(
344 "local retained-tail apply state belongs to a different database history",
345 ));
346 }
347 if matches!(state.status, ApplyStatus::Complete) {
348 if state.applied_lsn > current_lsn {
349 return Err(invalid_data(
350 "local retained-tail apply state is ahead of the catalog LSN",
351 ));
352 }
353 if current_lsn == since_lsn || current_lsn == through_lsn {
354 return Ok(current_lsn);
355 }
356 return Err(invalid_input(format!(
357 "replica applied LSN {current_lsn} does not match retained tail start LSN {since_lsn}"
358 )));
359 }
360
361 if state.from_lsn != since_lsn || state.through_lsn != through_lsn {
362 if current_lsn == since_lsn
382 && current_lsn >= state.applied_lsn
383 && current_lsn <= state.through_lsn
384 {
385 return Ok(current_lsn);
386 }
387 return Err(crate::SyncError::ApplyInProgress(
388 "another retained-tail apply is in progress for this replica".into(),
389 )
390 .into());
391 }
392 if current_lsn == state.through_lsn {
393 return Ok(current_lsn);
394 }
395 if current_lsn != state.applied_lsn {
396 return Err(crate::SyncError::ApplyStateRequiresRepair(
397 "local retained-tail apply state requires repair before retry".into(),
398 )
399 .into());
400 }
401 write_in_progress_apply_state(
402 catalog.data_dir(),
403 expected_identity,
404 state.from_lsn,
405 state.through_lsn,
406 state.applied_lsn,
407 )?;
408 Ok(state.applied_lsn)
409}
410
411fn ensure_retained_chunk_target_provenance(
412 catalog: &Catalog,
413 expected_identity: SegmentIdentity,
414 since_lsn: u64,
415 through_lsn: u64,
416) -> io::Result<()> {
417 let Some(state) = read_apply_state(catalog.data_dir())? else {
418 return Err(invalid_data(format!(
419 "retained chunk target LSN {through_lsn} has no trusted local apply provenance"
420 )));
421 };
422 state.validate()?;
423 if !state.identity().lineage_matches(expected_identity) {
424 return Err(invalid_data(
425 "retained chunk target provenance belongs to a different database history",
426 ));
427 }
428 if state.from_lsn != since_lsn || state.through_lsn != through_lsn {
429 return Err(invalid_data(
430 "retained chunk target provenance belongs to a different apply range",
431 ));
432 }
433 if catalog.max_lsn() != through_lsn {
434 return Err(invalid_data(format!(
435 "catalog LSN {} does not match retained chunk target boundary {through_lsn}",
436 catalog.max_lsn()
437 )));
438 }
439 match state.status {
440 ApplyStatus::Complete if state.applied_lsn == through_lsn => Ok(()),
441 ApplyStatus::InProgress if state.applied_lsn == since_lsn => Ok(()),
442 _ => Err(invalid_data(
443 "retained chunk target is not backed by completed or replayed apply state",
444 )),
445 }
446}
447
448fn ensure_retained_chunk_start_boundary(
449 catalog: &Catalog,
450 expected_identity: SegmentIdentity,
451 since_lsn: u64,
452) -> io::Result<()> {
453 let Some(state) = read_apply_state(catalog.data_dir())? else {
454 return Err(crate::SyncError::UntrustedApplyBoundary(format!(
455 "retained chunk start LSN {since_lsn} has no trusted local apply boundary"
456 ))
457 .into());
458 };
459 state.validate()?;
460 if !state.identity().lineage_matches(expected_identity) {
461 return Err(invalid_data(
462 "trusted retained chunk boundary belongs to a different database history",
463 ));
464 }
465 if state.applied_lsn != since_lsn {
466 return Err(crate::SyncError::UntrustedApplyBoundary(format!(
467 "retained chunk start LSN {since_lsn} is not a trusted completed apply boundary"
468 ))
469 .into());
470 }
471 if catalog.max_lsn() != since_lsn {
477 return Err(invalid_data(format!(
478 "catalog LSN {} does not match retained chunk start boundary {since_lsn}",
479 catalog.max_lsn()
480 )));
481 }
482 Ok(())
483}
484
485fn read_apply_state(data_dir: &Path) -> io::Result<Option<ApplyStateFile>> {
486 let path = sync_state_dir(data_dir).join(APPLY_STATE_FILE);
487 let bytes = match fs::read(path) {
488 Ok(bytes) => bytes,
489 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
490 Err(err) => return Err(err),
491 };
492 let state: ApplyStateFile = serde_json::from_slice(&bytes).map_err(invalid_data)?;
493 state.validate()?;
494 Ok(Some(state))
495}
496
497fn write_in_progress_apply_state(
498 data_dir: &Path,
499 identity: SegmentIdentity,
500 from_lsn: u64,
501 through_lsn: u64,
502 applied_lsn: u64,
503) -> io::Result<()> {
504 write_apply_state(
505 data_dir,
506 identity,
507 from_lsn,
508 through_lsn,
509 applied_lsn,
510 ApplyStatus::InProgress,
511 )
512}
513
514fn write_complete_apply_state(
515 data_dir: &Path,
516 identity: SegmentIdentity,
517 from_lsn: u64,
518 through_lsn: u64,
519) -> io::Result<()> {
520 write_apply_state(
521 data_dir,
522 identity,
523 from_lsn,
524 through_lsn,
525 through_lsn,
526 ApplyStatus::Complete,
527 )
528}
529
530fn write_apply_state(
531 data_dir: &Path,
532 identity: SegmentIdentity,
533 from_lsn: u64,
534 through_lsn: u64,
535 applied_lsn: u64,
536 status: ApplyStatus,
537) -> io::Result<()> {
538 let state_dir = sync_state_dir(data_dir);
539 create_data_dir_secure(&state_dir)?;
540 let existing_started = read_apply_state(data_dir)?
541 .filter(|existing| {
542 existing.identity().lineage_matches(identity)
543 && existing.from_lsn == from_lsn
544 && existing.through_lsn == through_lsn
545 })
546 .map(|existing| existing.started_unix_secs);
547 let now = now_unix_secs();
548 let state = ApplyStateFile {
549 format_version: APPLY_STATE_FORMAT_VERSION,
550 database_id: identity.database_id,
551 primary_generation: identity.primary_generation,
552 wal_format_version: identity.wal_format_version,
553 catalog_version: identity.catalog_version,
554 from_lsn,
555 through_lsn,
556 applied_lsn,
557 status,
558 started_unix_secs: existing_started.unwrap_or(now),
559 updated_unix_secs: now,
560 };
561 state.validate()?;
562 atomic_replace_json(&state_dir, APPLY_STATE_FILE, &state)
563}
564
565impl ApplyStateFile {
566 fn identity(&self) -> SegmentIdentity {
567 SegmentIdentity {
568 database_id: self.database_id,
569 primary_generation: self.primary_generation,
570 wal_format_version: self.wal_format_version,
571 catalog_version: self.catalog_version,
572 }
573 }
574
575 fn validate(&self) -> io::Result<()> {
576 if self.format_version != APPLY_STATE_FORMAT_VERSION {
577 return Err(invalid_data(format!(
578 "unsupported retained-tail apply state format {}",
579 self.format_version
580 )));
581 }
582 self.identity().validate()?;
583 if self.through_lsn < self.from_lsn {
584 return Err(invalid_data(
585 "retained-tail apply state target is behind its start LSN",
586 ));
587 }
588 if self.applied_lsn < self.from_lsn || self.applied_lsn > self.through_lsn {
589 return Err(invalid_data(
590 "retained-tail apply state applied LSN is outside the apply range",
591 ));
592 }
593 if matches!(self.status, ApplyStatus::Complete) && self.applied_lsn != self.through_lsn {
594 return Err(invalid_data(
595 "completed retained-tail apply state must be applied through its target LSN",
596 ));
597 }
598 Ok(())
599 }
600}
601
602fn wal_record_from_retained_unit(unit: RetainedUnit) -> io::Result<WalRecord> {
603 let record_type = WalRecordType::from_u8(unit.record_type).ok_or_else(|| {
604 io::Error::new(
605 io::ErrorKind::InvalidData,
606 "retained unit contains an unknown WAL record type",
607 )
608 })?;
609 reject_unsupported_v1_record_type(record_type)?;
610 Ok(WalRecord {
611 tx_id: unit.tx_id,
612 record_type,
613 lsn: unit.lsn,
614 data: unit.data,
615 })
616}
617
618pub fn validate_v1_retained_units_applyable(units: &[RetainedUnit]) -> io::Result<()> {
624 let mut pending_tx_spans = Vec::new();
625 for unit in units {
626 let record_type = WalRecordType::from_u8(unit.record_type).ok_or_else(|| {
627 io::Error::new(
628 io::ErrorKind::InvalidData,
629 "retained unit contains an unknown WAL record type",
630 )
631 })?;
632 reject_unsupported_v1_record_type(record_type)?;
633 match record_type {
634 WalRecordType::Begin if unit.tx_id != 0 => {
635 pending_tx_spans.push(unit.tx_id);
636 }
637 WalRecordType::Insert | WalRecordType::Update | WalRecordType::Delete
638 if unit.tx_id != 0 && !pending_tx_spans.contains(&unit.tx_id) =>
639 {
640 pending_tx_spans.push(unit.tx_id);
641 }
642 WalRecordType::Commit | WalRecordType::Rollback if unit.tx_id != 0 => {
643 if let Some(index) = pending_tx_spans
644 .iter()
645 .rposition(|pending_tx_id| *pending_tx_id == unit.tx_id)
646 {
647 pending_tx_spans.remove(index);
648 }
649 }
650 _ => {}
651 }
652 }
653 if let Some(tx_id) = pending_tx_spans.first() {
654 return Err(invalid_input(format!(
655 "retained tail cuts through transaction {tx_id}; retry with a range through its commit or rollback boundary",
656 )));
657 }
658 Ok(())
659}
660
661fn reject_unsupported_v1_record_type(record_type: WalRecordType) -> io::Result<()> {
662 if matches!(
663 record_type,
664 WalRecordType::DdlCreateTable
665 | WalRecordType::DdlDropTable
666 | WalRecordType::DdlAddColumn
667 | WalRecordType::DdlDropColumn
668 ) {
669 return Err(invalid_input(
670 "DDL retained units are not supported by V1 embedded sync; rebootstrap or upgrade required",
671 ));
672 }
673 Ok(())
674}
675
676fn noop_summary(since_lsn: u64, through_lsn: u64) -> RetainedTailApplySummary {
677 RetainedTailApplySummary {
678 from_lsn: since_lsn,
679 through_lsn,
680 units_applied: 0,
681 first_lsn: None,
682 last_lsn: None,
683 }
684}
685
686fn invalid_input(message: impl Into<String>) -> io::Error {
687 crate::SyncError::InvalidRequest(message.into()).into()
688}
689
690fn invalid_data(message: impl ToString) -> io::Error {
691 crate::SyncError::CorruptState(message.to_string()).into()
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697 use crate::{
698 checkpoint_with_retained_segments, open_or_create_identity,
699 open_preserving_retained_segments, read_identity_snapshot, read_units_through,
700 retained_segments_dir, write_identity_snapshot,
701 };
702 use crate::{write_segment_atomic, RetainedSegment};
703 use powdb_storage::types::{ColumnDef, Row, Schema, TypeId, Value};
704 use std::path::{Path, PathBuf};
705 use std::sync::atomic::{AtomicU64, Ordering};
706
707 fn tmp(tag: &str) -> PathBuf {
708 static CTR: AtomicU64 = AtomicU64::new(0);
709 let uniq = CTR.fetch_add(1, Ordering::Relaxed);
710 let path = std::env::temp_dir().join(format!(
711 "powdb_sync_apply_state_{tag}_{}_{}_{}",
712 std::process::id(),
713 now_unix_secs(),
714 uniq
715 ));
716 let _ = fs::remove_dir_all(&path);
717 path
718 }
719
720 fn schema_users() -> Schema {
721 Schema {
722 table_name: "User".into(),
723 columns: vec![
724 ColumnDef {
725 name: "id".into(),
726 type_id: TypeId::Int,
727 required: true,
728 position: 0,
729 },
730 ColumnDef {
731 name: "email".into(),
732 type_id: TypeId::Str,
733 required: false,
734 position: 1,
735 },
736 ],
737 }
738 }
739
740 fn user_row(id: i64) -> Row {
741 vec![Value::Int(id), Value::Str(format!("user{id}@example.com"))]
742 }
743
744 fn insert_range(catalog: &mut Catalog, start: i64, end: i64) {
745 for id in start..end {
746 catalog.insert("User", &user_row(id)).unwrap();
747 }
748 catalog.commit_autocommit().unwrap();
749 catalog.sync_wal().unwrap();
750 }
751
752 fn retained_unit(tx_id: u64, record_type: WalRecordType, lsn: u64) -> RetainedUnit {
753 RetainedUnit {
754 tx_id,
755 record_type: record_type as u8,
756 lsn,
757 data: Vec::new(),
758 }
759 }
760
761 fn rows(catalog: &Catalog) -> Vec<(i64, String)> {
762 let mut rows: Vec<_> = catalog
763 .scan("User")
764 .unwrap()
765 .map(|item| {
766 let (_, row) = item.unwrap();
767 let id = match &row[0] {
768 Value::Int(id) => *id,
769 other => panic!("expected int id, got {other:?}"),
770 };
771 let email = match &row[1] {
772 Value::Str(email) => email.clone(),
773 other => panic!("expected email string, got {other:?}"),
774 };
775 (id, email)
776 })
777 .collect();
778 rows.sort_by_key(|(id, _)| *id);
779 rows
780 }
781
782 #[test]
783 fn v1_applyability_rejects_transaction_split_before_commit() {
784 let dir = tmp("split_tx_segments");
785 let identity = SegmentIdentity::current(*b"apply-split-tx!!", 1);
786 let segment = RetainedSegment::new(
787 identity,
788 vec![
789 retained_unit(7, WalRecordType::Begin, 1),
790 retained_unit(7, WalRecordType::Insert, 2),
791 ],
792 )
793 .unwrap();
794 write_segment_atomic(&dir, &segment).unwrap();
795
796 let err = validate_v1_retained_tail_applyable(&dir, identity, 0, 2).unwrap_err();
797 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
798 assert!(
799 err.to_string().contains("cuts through transaction 7"),
800 "split transaction tail must fail before storage replay, got: {err}"
801 );
802 }
803
804 #[test]
805 fn v1_applyability_rejects_begin_only_transaction_range() {
806 let units = vec![retained_unit(7, WalRecordType::Begin, 1)];
807 let err = validate_v1_retained_units_applyable(&units).unwrap_err();
808 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
809 assert!(
810 err.to_string().contains("cuts through transaction 7"),
811 "begin-only transaction range must fail before storage replay, got: {err}"
812 );
813 }
814
815 #[test]
816 fn v1_applyability_rejects_row_only_nonzero_transaction_range() {
817 let units = vec![retained_unit(9, WalRecordType::Insert, 1)];
818 let err = validate_v1_retained_units_applyable(&units).unwrap_err();
819 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
820 assert!(
821 err.to_string().contains("cuts through transaction 9"),
822 "row-only transaction range must fail before storage replay, got: {err}"
823 );
824 }
825
826 #[test]
827 fn v1_applyability_rejects_reused_tx_id_with_later_incomplete_span() {
828 let units = vec![
829 retained_unit(1, WalRecordType::Begin, 1),
830 retained_unit(1, WalRecordType::Insert, 2),
831 retained_unit(1, WalRecordType::Commit, 3),
832 retained_unit(1, WalRecordType::Begin, 4),
833 retained_unit(1, WalRecordType::Insert, 5),
834 ];
835 let err = validate_v1_retained_units_applyable(&units).unwrap_err();
836 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
837 assert!(
838 err.to_string().contains("cuts through transaction 1"),
839 "later incomplete span with reused tx id must fail, got: {err}"
840 );
841 }
842
843 #[test]
844 fn v1_applyability_accepts_transaction_closed_by_commit_or_rollback() {
845 let commit_units = vec![
846 retained_unit(7, WalRecordType::Begin, 1),
847 retained_unit(7, WalRecordType::Insert, 2),
848 retained_unit(7, WalRecordType::Commit, 3),
849 ];
850 validate_v1_retained_units_applyable(&commit_units).unwrap();
851
852 let rollback_units = vec![
853 retained_unit(8, WalRecordType::Begin, 1),
854 retained_unit(8, WalRecordType::Insert, 2),
855 retained_unit(8, WalRecordType::Rollback, 3),
856 ];
857 validate_v1_retained_units_applyable(&rollback_units).unwrap();
858 }
859
860 #[test]
861 fn retained_units_chunk_rejects_non_contiguous_lsn_range() {
862 let units = vec![
863 retained_unit(0, WalRecordType::Commit, 6),
864 retained_unit(0, WalRecordType::Commit, 8),
865 ];
866 let err = validate_retained_chunk_lsn_range(5, &units).unwrap_err();
867 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
868 assert!(
869 err.to_string().contains("expected LSN 7"),
870 "non-contiguous retained chunks must fail before replay, got: {err}"
871 );
872 }
873
874 #[test]
875 fn apply_retained_units_chunk_requires_trusted_start_boundary() {
876 let replica = tmp("chunk_no_boundary_replica");
877 let mut replica_cat = Catalog::create(&replica).unwrap();
878 replica_cat.create_table(schema_users()).unwrap();
879 insert_range(&mut replica_cat, 0, 1);
880 let identity = open_or_create_identity(&replica).unwrap();
881 checkpoint_with_retained_segments(&mut replica_cat).unwrap();
882 let since_lsn = replica_cat.max_lsn();
883 let units = vec![retained_unit(0, WalRecordType::Commit, since_lsn + 1)];
884
885 let err = apply_retained_units_chunk(
886 &mut replica_cat,
887 identity.segment_identity(),
888 since_lsn,
889 &units,
890 )
891 .unwrap_err();
892 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
893 assert!(
894 err.to_string().contains("no trusted local apply boundary"),
895 "unseeded retained chunks must fail before replay, got: {err}"
896 );
897 assert_eq!(
898 replica_cat.max_lsn(),
899 since_lsn,
900 "failed chunk apply must not advance catalog LSN"
901 );
902 }
903
904 #[test]
905 fn apply_retained_units_chunk_rejects_catalog_only_target_noop_provenance() {
906 let replica = tmp("chunk_catalog_only_target_replica");
907 let mut replica_cat = Catalog::create(&replica).unwrap();
908 replica_cat.create_table(schema_users()).unwrap();
909 insert_range(&mut replica_cat, 0, 2);
910 let identity = open_or_create_identity(&replica).unwrap();
911 checkpoint_with_retained_segments(&mut replica_cat).unwrap();
912 let through_lsn = replica_cat.max_lsn();
913 let since_lsn = through_lsn - 1;
914 let units = vec![retained_unit(0, WalRecordType::Commit, through_lsn)];
915
916 let err = apply_retained_units_chunk(
917 &mut replica_cat,
918 identity.segment_identity(),
919 since_lsn,
920 &units,
921 )
922 .unwrap_err();
923 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
924 assert!(
925 err.to_string()
926 .contains("no trusted local apply provenance"),
927 "catalog-only target no-op must not mint a trusted boundary, got: {err}"
928 );
929 assert!(
930 read_apply_state(&replica).unwrap().is_none(),
931 "failed catalog-only target no-op must not write apply-state"
932 );
933 }
934
935 #[test]
936 fn apply_retained_units_chunk_rejects_transaction_cut_without_advancing_lsn() {
937 let replica = tmp("chunk_cut_replica");
938 let mut replica_cat = Catalog::create(&replica).unwrap();
939 replica_cat.create_table(schema_users()).unwrap();
940 insert_range(&mut replica_cat, 0, 1);
941 let identity = open_or_create_identity(&replica).unwrap();
942 checkpoint_with_retained_segments(&mut replica_cat).unwrap();
943 let since_lsn = replica_cat.max_lsn();
944 seed_retained_apply_boundary(&replica, identity.segment_identity(), since_lsn).unwrap();
945 let cut_units = vec![
946 retained_unit(7, WalRecordType::Begin, since_lsn + 1),
947 retained_unit(7, WalRecordType::Insert, since_lsn + 2),
948 ];
949
950 let err = apply_retained_units_chunk(
951 &mut replica_cat,
952 identity.segment_identity(),
953 since_lsn,
954 &cut_units,
955 )
956 .unwrap_err();
957 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
958 assert!(
959 err.to_string().contains("cuts through transaction 7"),
960 "transaction-cut retained chunks must fail before replay, got: {err}"
961 );
962 assert_eq!(
963 replica_cat.max_lsn(),
964 since_lsn,
965 "failed chunk apply must not advance catalog LSN"
966 );
967 }
968
969 fn copy_snapshot_files(src: &Path, dest: &Path) {
970 fs::create_dir_all(dest).unwrap();
971 for entry in fs::read_dir(src).unwrap() {
972 let entry = entry.unwrap();
973 let name = entry.file_name().to_string_lossy().to_string();
974 if name == "catalog.bin"
975 || name == powdb_storage::catalog::CATALOG_LSN_FILE
976 || name.ends_with(".heap")
977 || name.ends_with(".idx")
978 {
979 fs::copy(entry.path(), dest.join(name)).unwrap();
980 }
981 }
982 }
983
984 #[test]
985 fn in_progress_apply_state_replays_from_recorded_safe_lsn_when_catalog_matches() {
986 let primary = tmp("primary");
987 let mut primary_cat = Catalog::create(&primary).unwrap();
988 primary_cat.create_table(schema_users()).unwrap();
989 insert_range(&mut primary_cat, 0, 3);
990 let identity = open_or_create_identity(&primary).unwrap();
991 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
992 let snapshot_lsn = primary_cat.max_lsn();
993
994 let replica = tmp("replica");
995 copy_snapshot_files(&primary, &replica);
996 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
997 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
998
999 insert_range(&mut primary_cat, 3, 8);
1000 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1001 let through_lsn = primary_cat.max_lsn();
1002 assert!(through_lsn > snapshot_lsn);
1003
1004 let retained_dir = retained_segments_dir(&primary);
1005 let replica_cat = open_preserving_retained_segments(&replica).unwrap();
1006 write_in_progress_apply_state(
1007 &replica,
1008 identity.segment_identity(),
1009 snapshot_lsn,
1010 through_lsn,
1011 snapshot_lsn,
1012 )
1013 .unwrap();
1014 drop(replica_cat);
1015
1016 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1017 let summary = apply_retained_tail(
1018 &mut reopened,
1019 &retained_dir,
1020 identity.segment_identity(),
1021 snapshot_lsn,
1022 through_lsn,
1023 )
1024 .unwrap();
1025
1026 assert_eq!(summary.from_lsn, snapshot_lsn);
1027 assert_eq!(summary.first_lsn, Some(snapshot_lsn + 1));
1028 assert_eq!(summary.last_lsn, Some(through_lsn));
1029 assert_eq!(rows(&reopened), rows(&primary_cat));
1030 assert!(matches!(
1031 read_apply_state(&replica).unwrap().unwrap().status,
1032 ApplyStatus::Complete
1033 ));
1034 }
1035
1036 #[test]
1037 fn in_progress_apply_state_fails_closed_when_catalog_lsn_advanced() {
1038 let primary = tmp("advanced_primary");
1039 let mut primary_cat = Catalog::create(&primary).unwrap();
1040 primary_cat.create_table(schema_users()).unwrap();
1041 insert_range(&mut primary_cat, 0, 3);
1042 let identity = open_or_create_identity(&primary).unwrap();
1043 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1044 let snapshot_lsn = primary_cat.max_lsn();
1045
1046 let replica = tmp("advanced_replica");
1047 copy_snapshot_files(&primary, &replica);
1048 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
1049 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
1050
1051 insert_range(&mut primary_cat, 3, 8);
1052 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1053 let through_lsn = primary_cat.max_lsn();
1054 let retained_dir = retained_segments_dir(&primary);
1055 let units = read_units_since(
1056 &retained_dir,
1057 identity.segment_identity(),
1058 snapshot_lsn,
1059 100,
1060 )
1061 .unwrap();
1062 assert!(units.len() > 1, "test needs a multi-unit retained tail");
1063 let partial_records = units[..1]
1064 .iter()
1065 .cloned()
1066 .map(wal_record_from_retained_unit)
1067 .collect::<io::Result<Vec<_>>>()
1068 .unwrap();
1069
1070 let mut replica_cat = open_preserving_retained_segments(&replica).unwrap();
1071 write_in_progress_apply_state(
1072 &replica,
1073 identity.segment_identity(),
1074 snapshot_lsn,
1075 through_lsn,
1076 snapshot_lsn,
1077 )
1078 .unwrap();
1079 replica_cat.apply_wal_records(&partial_records).unwrap();
1080 assert!(replica_cat.max_lsn() > snapshot_lsn);
1081 drop(replica_cat);
1082
1083 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1084 let err = apply_retained_tail(
1085 &mut reopened,
1086 &retained_dir,
1087 identity.segment_identity(),
1088 snapshot_lsn,
1089 through_lsn,
1090 )
1091 .unwrap_err();
1092 assert!(
1093 err.to_string().contains("requires repair before retry"),
1094 "advanced catalog LSN without complete apply-state must fail closed, got: {err}"
1095 );
1096 let typed = err
1097 .get_ref()
1098 .and_then(|e| e.downcast_ref::<crate::SyncError>());
1099 assert!(
1100 matches!(typed, Some(crate::SyncError::ApplyStateRequiresRepair(_))),
1101 "hosts must be able to branch on the refusal without matching \
1102 rendered text, got {typed:?}"
1103 );
1104 }
1105
1106 #[test]
1112 fn a_rolled_back_interrupted_apply_accepts_a_new_resume_range() {
1113 let primary = tmp("rolledback_primary");
1114 let mut primary_cat = Catalog::create(&primary).unwrap();
1115 primary_cat.create_table(schema_users()).unwrap();
1116 insert_range(&mut primary_cat, 0, 3);
1117 let identity = open_or_create_identity(&primary).unwrap();
1118 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1119 let snapshot_lsn = primary_cat.max_lsn();
1120
1121 let replica = tmp("rolledback_replica");
1122 copy_snapshot_files(&primary, &replica);
1123 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
1124 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
1125
1126 insert_range(&mut primary_cat, 3, 8);
1127 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1128 let through_lsn = primary_cat.max_lsn();
1129 let retained_dir = retained_segments_dir(&primary);
1130
1131 write_in_progress_apply_state(
1134 &replica,
1135 identity.segment_identity(),
1136 snapshot_lsn,
1137 snapshot_lsn + 2,
1138 snapshot_lsn,
1139 )
1140 .unwrap();
1141
1142 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1143 assert_eq!(reopened.max_lsn(), snapshot_lsn);
1144 let summary = apply_retained_tail(
1145 &mut reopened,
1146 &retained_dir,
1147 identity.segment_identity(),
1148 snapshot_lsn,
1149 through_lsn,
1150 )
1151 .unwrap();
1152 assert_eq!(summary.through_lsn, through_lsn);
1153 assert_eq!(rows(&reopened), rows(&primary_cat));
1154 }
1155
1156 #[test]
1166 fn a_mid_chunk_crash_resumes_from_the_recovered_frontier() {
1167 let primary = tmp("frontier_primary");
1168 let mut primary_cat = Catalog::create(&primary).unwrap();
1169 primary_cat.create_table(schema_users()).unwrap();
1170 insert_range(&mut primary_cat, 0, 3);
1171 let identity = open_or_create_identity(&primary).unwrap();
1172 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1173 let snapshot_lsn = primary_cat.max_lsn();
1174
1175 let replica = tmp("frontier_replica");
1176 copy_snapshot_files(&primary, &replica);
1177 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
1178 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
1179
1180 insert_range(&mut primary_cat, 3, 8);
1181 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1182 let through_lsn = primary_cat.max_lsn();
1183 let retained_dir = retained_segments_dir(&primary);
1184 let units = read_units_since(
1185 &retained_dir,
1186 identity.segment_identity(),
1187 snapshot_lsn,
1188 100,
1189 )
1190 .unwrap();
1191 assert!(units.len() > 1, "test needs a multi-unit retained tail");
1192 let partial_records = units[..1]
1193 .iter()
1194 .cloned()
1195 .map(wal_record_from_retained_unit)
1196 .collect::<io::Result<Vec<_>>>()
1197 .unwrap();
1198
1199 let mut replica_cat = open_preserving_retained_segments(&replica).unwrap();
1200 write_in_progress_apply_state(
1201 &replica,
1202 identity.segment_identity(),
1203 snapshot_lsn,
1204 through_lsn,
1205 snapshot_lsn,
1206 )
1207 .unwrap();
1208 replica_cat.apply_wal_records(&partial_records).unwrap();
1209 let frontier = replica_cat.max_lsn();
1210 assert!(
1211 frontier > snapshot_lsn && frontier < through_lsn,
1212 "frontier must be strictly inside the stranded range \
1213 ({snapshot_lsn}..{through_lsn}), got {frontier}"
1214 );
1215 drop(replica_cat);
1216
1217 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1218 assert_eq!(reopened.max_lsn(), frontier);
1219 let summary = apply_retained_tail(
1220 &mut reopened,
1221 &retained_dir,
1222 identity.segment_identity(),
1223 frontier,
1224 through_lsn,
1225 )
1226 .unwrap();
1227 assert_eq!(summary.through_lsn, through_lsn);
1228 assert_eq!(rows(&reopened), rows(&primary_cat));
1229 }
1230
1231 #[test]
1237 fn a_rolled_back_interrupted_chunk_can_be_retried_exactly() {
1238 let primary = tmp("chunkretry_primary");
1239 let mut primary_cat = Catalog::create(&primary).unwrap();
1240 primary_cat.create_table(schema_users()).unwrap();
1241 insert_range(&mut primary_cat, 0, 3);
1242 let identity = open_or_create_identity(&primary).unwrap();
1243 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1244 let snapshot_lsn = primary_cat.max_lsn();
1245
1246 let replica = tmp("chunkretry_replica");
1247 copy_snapshot_files(&primary, &replica);
1248 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
1249 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
1250
1251 insert_range(&mut primary_cat, 3, 8);
1252 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1253 let through_lsn = primary_cat.max_lsn();
1254 let retained_dir = retained_segments_dir(&primary);
1255 let units = read_units_since(
1256 &retained_dir,
1257 identity.segment_identity(),
1258 snapshot_lsn,
1259 100,
1260 )
1261 .unwrap();
1262
1263 write_in_progress_apply_state(
1264 &replica,
1265 identity.segment_identity(),
1266 snapshot_lsn,
1267 through_lsn,
1268 snapshot_lsn,
1269 )
1270 .unwrap();
1271
1272 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1273 let summary = apply_retained_units_chunk(
1274 &mut reopened,
1275 identity.segment_identity(),
1276 snapshot_lsn,
1277 &units,
1278 )
1279 .unwrap();
1280 assert_eq!(summary.through_lsn, through_lsn);
1281 assert_eq!(rows(&reopened), rows(&primary_cat));
1282 }
1283
1284 #[test]
1289 fn a_completed_but_unflipped_chunk_accepts_the_next_range() {
1290 let primary = tmp("unflipped_primary");
1291 let mut primary_cat = Catalog::create(&primary).unwrap();
1292 primary_cat.create_table(schema_users()).unwrap();
1293 insert_range(&mut primary_cat, 0, 3);
1294 let identity = open_or_create_identity(&primary).unwrap();
1295 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1296 let snapshot_lsn = primary_cat.max_lsn();
1297
1298 let replica = tmp("unflipped_replica");
1299 copy_snapshot_files(&primary, &replica);
1300 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
1301 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
1302
1303 insert_range(&mut primary_cat, 3, 5);
1304 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1305 let mid_lsn = primary_cat.max_lsn();
1306 insert_range(&mut primary_cat, 5, 8);
1307 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1308 let through_lsn = primary_cat.max_lsn();
1309 let retained_dir = retained_segments_dir(&primary);
1310
1311 let first_units = read_units_through(
1315 &retained_dir,
1316 identity.segment_identity(),
1317 snapshot_lsn,
1318 mid_lsn,
1319 usize::MAX,
1320 )
1321 .unwrap();
1322 let records = first_units
1323 .iter()
1324 .cloned()
1325 .map(wal_record_from_retained_unit)
1326 .collect::<io::Result<Vec<_>>>()
1327 .unwrap();
1328 let mut replica_cat = open_preserving_retained_segments(&replica).unwrap();
1329 write_in_progress_apply_state(
1330 &replica,
1331 identity.segment_identity(),
1332 snapshot_lsn,
1333 mid_lsn,
1334 snapshot_lsn,
1335 )
1336 .unwrap();
1337 replica_cat.apply_wal_records(&records).unwrap();
1338 assert_eq!(replica_cat.max_lsn(), mid_lsn);
1339 drop(replica_cat);
1340
1341 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1342 let summary = apply_retained_tail(
1343 &mut reopened,
1344 &retained_dir,
1345 identity.segment_identity(),
1346 mid_lsn,
1347 through_lsn,
1348 )
1349 .unwrap();
1350 assert_eq!(summary.through_lsn, through_lsn);
1351 assert_eq!(rows(&reopened), rows(&primary_cat));
1352 }
1353
1354 #[test]
1355 fn in_progress_apply_state_marks_complete_when_catalog_reached_target() {
1356 let primary = tmp("complete_window_primary");
1357 let mut primary_cat = Catalog::create(&primary).unwrap();
1358 primary_cat.create_table(schema_users()).unwrap();
1359 insert_range(&mut primary_cat, 0, 3);
1360 let identity = open_or_create_identity(&primary).unwrap();
1361 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1362 let snapshot_lsn = primary_cat.max_lsn();
1363
1364 let replica = tmp("complete_window_replica");
1365 copy_snapshot_files(&primary, &replica);
1366 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
1367 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
1368
1369 insert_range(&mut primary_cat, 3, 8);
1370 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1371 let through_lsn = primary_cat.max_lsn();
1372 let retained_dir = retained_segments_dir(&primary);
1373 let records = read_units_since(
1374 &retained_dir,
1375 identity.segment_identity(),
1376 snapshot_lsn,
1377 100,
1378 )
1379 .unwrap()
1380 .into_iter()
1381 .map(wal_record_from_retained_unit)
1382 .collect::<io::Result<Vec<_>>>()
1383 .unwrap();
1384
1385 let mut replica_cat = open_preserving_retained_segments(&replica).unwrap();
1386 write_in_progress_apply_state(
1387 &replica,
1388 identity.segment_identity(),
1389 snapshot_lsn,
1390 through_lsn,
1391 snapshot_lsn,
1392 )
1393 .unwrap();
1394 replica_cat.apply_wal_records(&records).unwrap();
1395 assert_eq!(replica_cat.max_lsn(), through_lsn);
1396 drop(replica_cat);
1397
1398 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1399 let summary = apply_retained_tail(
1400 &mut reopened,
1401 &retained_dir,
1402 identity.segment_identity(),
1403 snapshot_lsn,
1404 through_lsn,
1405 )
1406 .unwrap();
1407
1408 assert_eq!(summary.units_applied, 0);
1409 assert_eq!(rows(&reopened), rows(&primary_cat));
1410 assert!(matches!(
1411 read_apply_state(&replica).unwrap().unwrap().status,
1412 ApplyStatus::Complete
1413 ));
1414 }
1415
1416 #[test]
1417 fn chunk_apply_marks_complete_when_in_progress_replay_reached_target() {
1418 let primary = tmp("chunk_complete_window_primary");
1419 let mut primary_cat = Catalog::create(&primary).unwrap();
1420 primary_cat.create_table(schema_users()).unwrap();
1421 insert_range(&mut primary_cat, 0, 3);
1422 let identity = open_or_create_identity(&primary).unwrap();
1423 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1424 let snapshot_lsn = primary_cat.max_lsn();
1425
1426 let replica = tmp("chunk_complete_window_replica");
1427 copy_snapshot_files(&primary, &replica);
1428 let identity_snapshot = read_identity_snapshot(&primary).unwrap().unwrap();
1429 write_identity_snapshot(&replica, &identity_snapshot).unwrap();
1430
1431 insert_range(&mut primary_cat, 3, 8);
1432 checkpoint_with_retained_segments(&mut primary_cat).unwrap();
1433 let through_lsn = primary_cat.max_lsn();
1434 let units = read_units_through(
1435 &retained_segments_dir(&primary),
1436 identity.segment_identity(),
1437 snapshot_lsn,
1438 through_lsn,
1439 100,
1440 )
1441 .unwrap();
1442 let records = units
1443 .iter()
1444 .cloned()
1445 .map(wal_record_from_retained_unit)
1446 .collect::<io::Result<Vec<_>>>()
1447 .unwrap();
1448
1449 let mut replica_cat = open_preserving_retained_segments(&replica).unwrap();
1450 write_in_progress_apply_state(
1451 &replica,
1452 identity.segment_identity(),
1453 snapshot_lsn,
1454 through_lsn,
1455 snapshot_lsn,
1456 )
1457 .unwrap();
1458 replica_cat.apply_wal_records(&records).unwrap();
1459 assert_eq!(replica_cat.max_lsn(), through_lsn);
1460 drop(replica_cat);
1461
1462 let mut reopened = open_preserving_retained_segments(&replica).unwrap();
1463 let summary = apply_retained_units_chunk(
1464 &mut reopened,
1465 identity.segment_identity(),
1466 snapshot_lsn,
1467 &units,
1468 )
1469 .unwrap();
1470
1471 assert_eq!(summary.units_applied, 0);
1472 assert_eq!(rows(&reopened), rows(&primary_cat));
1473 assert!(matches!(
1474 read_apply_state(&replica).unwrap().unwrap().status,
1475 ApplyStatus::Complete
1476 ));
1477 }
1478
1479 #[test]
1480 fn ddl_retained_units_fail_closed_in_v1_apply() {
1481 let err = wal_record_from_retained_unit(RetainedUnit {
1482 tx_id: 0,
1483 record_type: WalRecordType::DdlCreateTable as u8,
1484 lsn: 42,
1485 data: Vec::new(),
1486 })
1487 .unwrap_err();
1488 assert!(
1489 err.to_string()
1490 .contains("DDL retained units are not supported"),
1491 "DDL retained units must fail closed, got: {err}"
1492 );
1493 }
1494
1495 #[test]
1496 fn different_in_progress_apply_range_fails_closed() {
1497 let primary = tmp("blocked_primary");
1498 let mut primary_cat = Catalog::create(&primary).unwrap();
1499 primary_cat.create_table(schema_users()).unwrap();
1500 insert_range(&mut primary_cat, 0, 1);
1501 let identity = open_or_create_identity(&primary).unwrap();
1502
1503 write_in_progress_apply_state(&primary, identity.segment_identity(), 1, 10, 1).unwrap();
1504 let err =
1505 reconcile_apply_state(&primary_cat, identity.segment_identity(), 1, 11).unwrap_err();
1506 assert!(
1507 err.to_string().contains("another retained-tail apply"),
1508 "mismatched in-progress range must fail closed, got: {err}"
1509 );
1510 }
1511}