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