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