Skip to main content

powdb_sync/
apply.rs

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
150/// Record a trusted local retained-apply boundary.
151///
152/// Backup bootstrap uses this after restoring a verified same-lineage snapshot.
153/// Chunked apply then requires each chunk to start at this boundary and promotes
154/// the boundary only after the whole chunk has been replayed and marked complete.
155pub 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
174/// Apply one already-pulled retained-unit chunk to a local replica.
175///
176/// This is the V1 chunked-apply primitive used by embedded replicas after a
177/// sync pull. It intentionally applies only a complete, contiguous chunk that
178/// starts from a trusted local apply boundary: no gaps after `since_lsn`, no
179/// unsupported DDL records, and no transaction-cut ranges. Callers can run
180/// local reads between successful chunk calls; the catalog is only advanced
181/// after the whole chunk is replayed.
182pub 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
273/// Validate that a retained tail is safe for the V1 embedded-sync applier.
274///
275/// V1 applies complete, committed WAL-record ranges. It intentionally rejects
276/// schema-changing records and ranges that end before every included explicit
277/// transaction reaches a commit or rollback boundary.
278pub 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        // A crash can strand an InProgress intent for a range this call is
363        // not asking about. The catalog LSN (recovered on reopen) pins
364        // exactly what was durably applied, so any crash landing inside the
365        // intent's window is safe to supersede when the caller resumes
366        // exactly at that recovered frontier:
367        //  * nothing of the intent applied: the catalog stands at the
368        //    boundary the intent's `applied_lsn` carried forward;
369        //  * all of the intent applied, but the crash landed before the
370        //    state flipped Complete: the catalog stands at the intent's
371        //    target;
372        //  * killed mid-apply: records redo into pages one at a time, so
373        //    the frontier can sit strictly inside the range. Per-page LSN
374        //    redo makes reapplying from the frontier idempotent and
375        //    completes any partially-applied transaction.
376        // In all three, `since_lsn == current_lsn` is a trusted boundary
377        // and the stale intent is void. A catalog LSN outside the intent's
378        // [applied, through] window (state regressed, or advanced past the
379        // target it never reported reaching) and a caller not starting at
380        // the recovered frontier stay fail-closed below.
381        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    // An InProgress record whose `applied_lsn` matches is the crash shape
472    // where the intent never touched the catalog: `applied_lsn` carries the
473    // Complete boundary it overwrote, and the catalog-LSN check below proves
474    // the chunk rolled back. Refusing it wedged crashed replicas permanently
475    // (found by the replica kill-9 test).
476    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
618/// Validate an already-read retained-unit slice for V1 embedded-sync apply.
619///
620/// The slice must contain only V1-supported record types and must not end
621/// inside an explicit transaction. Server pull/ack paths use this to avoid
622/// advertising or accepting transaction-cut LSN boundaries.
623pub 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    /// SIGKILL between writing the InProgress intent and applying any unit:
1107    /// WAL replay recovers the catalog at exactly the boundary the intent's
1108    /// `applied_lsn` records. The stale intent is void — a host restarting
1109    /// with a DIFFERENT target range (the natural whole-tail resume) must
1110    /// not be wedged behind "another retained-tail apply is in progress".
1111    #[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        // Crash shape: intent written for some mid-range target, nothing
1132        // applied (the catalog is still at the snapshot boundary).
1133        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    /// SIGKILL in the middle of `apply_wal_records`: records redo into
1157    /// mmap'd pages one at a time, so the surviving catalog LSN can land
1158    /// STRICTLY INSIDE the stranded intent's range (the ASan CI run of the
1159    /// process-level kill-9 test landed exactly here). The catalog still
1160    /// pins the durable prefix — per-page LSN redo makes reapplication
1161    /// idempotent and completes any partially-applied transaction — so a
1162    /// resume that starts exactly at the recovered frontier must be
1163    /// accepted, not wedged behind "another retained-tail apply is in
1164    /// progress" with no recovery path.
1165    #[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    /// Same crash shape, but the host retries its EXACT interrupted chunk
1232    /// (the `apply_retained_units_chunk` contract). The rolled-back intent
1233    /// must count as a trusted start boundary: `applied_lsn` carries the
1234    /// Complete boundary it overwrote forward, and the catalog LSN proves
1235    /// the chunk never touched the catalog.
1236    #[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    /// SIGKILL after the chunk's records were durably applied but before the
1285    /// state flipped to Complete: the catalog stands exactly at the intent's
1286    /// target. Continuing from there with a new range must work — everything
1287    /// up to `state.through_lsn` is provably applied.
1288    #[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        // Apply the first chunk's records, then "crash" before the state
1312        // flips: the intent stays InProgress while the catalog stands at
1313        // its target.
1314        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}