Skip to main content

icydb_core/db/startup/
mod.rs

1//! Module: db::startup
2//! Responsibility: derive bounded generated-database startup readiness.
3//! Does not own: recovery execution, watchdog registration, or ordinary-operation admission.
4//! Boundary: fixed durable controls plus runtime recovery witness -> readiness or typed failure.
5
6mod driver;
7mod observe;
8pub(in crate::db) mod receipt;
9
10use icydb_diagnostic_code::{
11    Diagnostic, DiagnosticCode, DiagnosticDetail, DiagnosticFactTag, ErrorCode,
12    MAX_PUBLIC_DIAGNOSTIC_FACTS, SchemaMigrationCode,
13};
14use serde::Deserialize;
15
16use crate::{db::StoreRegistry, error::InternalError, traits::CanisterKind};
17
18/// Current generated-database startup readiness.
19#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
20pub enum DatabaseStartupState {
21    /// Recovery controls are complete and the generated schema is reconciled.
22    Ready,
23    /// Dedicated replicated startup work remains.
24    Recovering,
25}
26
27/// One bounded outcome from the hidden replicated startup coordinator.
28#[doc(hidden)]
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum GeneratedStartupDriverStep {
31    /// Startup is already ready or has one durably observable terminal failure.
32    Terminal,
33    /// Recovery or optional derived-evidence work remains after one bounded page.
34    Recovering,
35    /// Recovery is complete and generated schema reconciliation must run now.
36    ApplyGeneratedSchema,
37}
38
39/// Closed owner of one terminal startup failure.
40#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
41pub enum StartupFailureKind {
42    /// Database boot, incarnation, or commit-control failure.
43    DatabaseControl,
44    /// Journal-tail or fold-continuation recovery failure.
45    JournalRecovery,
46    /// Accepted-schema reconciliation or runtime preparation failure.
47    SchemaReconciliation,
48}
49
50/// Internal bounded startup failure projected by the public facade.
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct StartupFailure {
53    kind: StartupFailureKind,
54    diagnostic: Diagnostic,
55    facts: Vec<(DiagnosticFactTag, u64)>,
56}
57
58impl StartupFailure {
59    pub(in crate::db) fn from_internal(kind: StartupFailureKind, error: &InternalError) -> Self {
60        Self::new(kind, error.diagnostic(), error.diagnostic_facts())
61    }
62
63    pub(in crate::db) fn new(
64        kind: StartupFailureKind,
65        diagnostic: Diagnostic,
66        facts: Vec<(DiagnosticFactTag, u64)>,
67    ) -> Self {
68        debug_assert!(facts.len() <= MAX_PUBLIC_DIAGNOSTIC_FACTS);
69        Self {
70            kind,
71            diagnostic,
72            facts,
73        }
74    }
75
76    /// Return the subsystem that owns this terminal startup failure.
77    #[must_use]
78    pub const fn kind(&self) -> StartupFailureKind {
79        self.kind
80    }
81
82    /// Return its compact diagnostic identity.
83    #[must_use]
84    pub const fn diagnostic(&self) -> &Diagnostic {
85        &self.diagnostic
86    }
87
88    /// Borrow its bounded numeric diagnostic facts.
89    #[must_use]
90    pub const fn facts(&self) -> &[(DiagnosticFactTag, u64)] {
91        self.facts.as_slice()
92    }
93}
94
95pub(in crate::db) fn classify_terminal_failure(
96    kind: StartupFailureKind,
97    error: &InternalError,
98) -> Option<StartupFailure> {
99    terminal_code_for_kind(kind, error.diagnostic().error_code())
100        .then(|| StartupFailure::from_internal(kind, error))
101}
102
103pub(in crate::db) fn classify_terminal_failure_parts(
104    kind: StartupFailureKind,
105    diagnostic: Diagnostic,
106    facts: Vec<(DiagnosticFactTag, u64)>,
107) -> Option<StartupFailure> {
108    terminal_code_for_kind(kind, diagnostic.error_code())
109        .then(|| StartupFailure::new(kind, diagnostic, facts))
110}
111
112fn terminal_code_for_kind(
113    kind: StartupFailureKind,
114    code: icydb_diagnostic_code::ErrorCode,
115) -> bool {
116    let persisted_failure = code == ErrorCode::STORE_CORRUPTION
117        || code == ErrorCode::STORE_INVARIANT_VIOLATION
118        || code == ErrorCode::RUNTIME_CORRUPTION
119        || code == ErrorCode::RUNTIME_INCOMPATIBLE_PERSISTED_FORMAT
120        || code == ErrorCode::RUNTIME_INVARIANT_VIOLATION
121        || code == ErrorCode::RUNTIME_BOUNDARY_PERSISTED_ROW_LAYOUT_OUTSIDE_ACCEPTED_WINDOW
122        || code == ErrorCode::RUNTIME_BOUNDARY_PERSISTED_ROW_SLOT_COUNT_MISMATCH
123        || code == ErrorCode::RUNTIME_BOUNDARY_ACCEPTED_ROW_CONSTRAINT_PROGRAM_CORRUPT;
124    persisted_failure
125        || match kind {
126            StartupFailureKind::DatabaseControl => false,
127            StartupFailureKind::JournalRecovery => {
128                code == ErrorCode::RUNTIME_BOUNDARY_JOURNAL_MUTATION_REVISION_EXHAUSTED
129            }
130            StartupFailureKind::SchemaReconciliation => {
131                // The diagnostic registry owns migration categories. Planning
132                // refusals and corrupt authority cannot progress by retrying;
133                // leave controller lifecycle/publication conflicts unchanged.
134                let migration_planning_failure = matches!(
135                    code.diagnostic_detail(),
136                    Some(DiagnosticDetail::SchemaMigration { reason })
137                        if matches!(
138                            reason.diagnostic_code(),
139                            DiagnosticCode::RuntimeUnsupported | DiagnosticCode::RuntimeCorruption
140                        ) || reason == SchemaMigrationCode::IdentityConflict
141                );
142                migration_planning_failure
143                    || code == ErrorCode::SCHEMA_DDL_ADMISSION
144                    || code == ErrorCode::RUNTIME_CONFLICT
145                    || code == ErrorCode::RUNTIME_UNSUPPORTED
146                    || code == ErrorCode::RUNTIME_BOUNDARY_GENERATED_FIELD_AFTER_DDL_FIELD
147                    || code == ErrorCode::RUNTIME_BOUNDARY_CONSTRAINT_VIOLATION
148                    || code == ErrorCode::RUNTIME_BOUNDARY_GENERATED_CONSTRAINT_ACTIVATION_STALE
149            }
150        }
151}
152
153/// Observe one generated database without opening a request or advancing recovery.
154pub fn observe_generated_startup_state<C: CanisterKind>(
155    stores: &'static std::thread::LocalKey<StoreRegistry>,
156    submission_key: &str,
157) -> Result<DatabaseStartupState, StartupFailure> {
158    observe::observe::<C>(stores, submission_key)
159}
160
161/// Run at most one bounded recovery page without admitting ordinary work.
162#[doc(hidden)]
163pub fn drive_generated_startup_recovery_page<C: CanisterKind>(
164    session: &crate::db::DbSession<C>,
165    stores: &'static std::thread::LocalKey<StoreRegistry>,
166    submission_key: &str,
167) -> Result<GeneratedStartupDriverStep, InternalError> {
168    driver::drive_recovery_page(session, stores, submission_key)
169}
170
171/// Persist one deterministic generated-schema failure against fresh authority.
172#[doc(hidden)]
173pub fn record_generated_schema_startup_failure<C: CanisterKind>(
174    stores: &'static std::thread::LocalKey<StoreRegistry>,
175    submission_key: &str,
176    diagnostic: Diagnostic,
177    facts: Vec<(DiagnosticFactTag, u64)>,
178) -> Result<bool, InternalError> {
179    driver::record_schema_failure::<C>(stores, submission_key, diagnostic, facts)
180}
181
182/// Clear a stale startup failure after an authoritative successful handoff.
183#[doc(hidden)]
184pub fn clear_generated_startup_failure<C: CanisterKind>() -> Result<bool, InternalError> {
185    receipt::clear::<C>()
186}
187
188#[cfg(test)]
189mod tests {
190    mod migration_failures;
191
192    use super::*;
193    use crate::{
194        db::{
195            DataStore, IndexStore, StoreAllocationIdentities, StoreRuntimeStorageCapabilities,
196            commit::{
197                CommitMarker, begin_commit, commit_memory_handle, current_commit_memory_allocation,
198                database_incarnation_id, finish_commit, mark_startup_recovery_complete_for_tests,
199                persist_raw_commit_marker_for_tests, select_commit_memory_allocation,
200            },
201            database_format::{
202                ensure_database_format_admitted, initialize_current_database_control_for_tests,
203            },
204            journal::{
205                JournalBatch, JournalRecord, JournalSequence, JournalTailStore,
206                encode_journal_batch,
207            },
208            registry::StoreAllocationIdentity,
209            resource::request::RequestExecutionRoot,
210            schema::{
211                ConstraintId, SchemaApplicationRecord, SchemaApplicationRecordOp,
212                SchemaChangeOutcome, SchemaChangeReceipt, SchemaStore,
213                apply_schema_application_record_op, corrupt_schema_control_header_for_tests,
214                generated_schema_authority, load_schema_application_record_read_only,
215            },
216        },
217        testing::test_memory,
218        traits::Path,
219        types::EntityTag,
220    };
221    use ic_memory::Memory;
222    use icydb_diagnostic_code::{ErrorCode, ErrorOrigin as DiagnosticOrigin};
223    use icydb_schema::{SchemaProposalDigest, SchemaSubmissionKey};
224    use std::cell::RefCell;
225
226    struct FreshCanister;
227
228    impl Path for FreshCanister {
229        const PATH: &'static str = "startup_tests::FreshCanister";
230    }
231
232    impl CanisterKind for FreshCanister {
233        fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
234            Ok(232)
235        }
236        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_fresh.commit.v1";
237        fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
238            Ok(233)
239        }
240        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_fresh.control.v1";
241        fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
242            Ok(234)
243        }
244        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str = "icydb.test.startup_fresh.integrity.v1";
245    }
246
247    thread_local! {
248        static FRESH_STORES: StoreRegistry = StoreRegistry::new();
249        static CURRENT_STORES: StoreRegistry = StoreRegistry::new();
250    }
251
252    struct CurrentCanister;
253
254    impl Path for CurrentCanister {
255        const PATH: &'static str = "startup_tests::CurrentCanister";
256    }
257
258    impl CanisterKind for CurrentCanister {
259        fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
260            Ok(228)
261        }
262        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_current.commit.v1";
263        fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
264            Ok(229)
265        }
266        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_current.control.v1";
267        fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
268            Ok(230)
269        }
270        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
271            "icydb.test.startup_current.integrity.v1";
272    }
273
274    struct CorruptCanister;
275
276    impl Path for CorruptCanister {
277        const PATH: &'static str = "startup_tests::CorruptCanister";
278    }
279
280    impl CanisterKind for CorruptCanister {
281        fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
282            Ok(224)
283        }
284        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_corrupt.commit.v1";
285        fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
286            Ok(225)
287        }
288        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_corrupt.control.v1";
289        fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
290            Ok(226)
291        }
292        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
293            "icydb.test.startup_corrupt.integrity.v1";
294    }
295
296    thread_local! {
297        static CORRUPT_STORES: StoreRegistry = StoreRegistry::new();
298    }
299
300    struct DriverCanister;
301
302    impl Path for DriverCanister {
303        const PATH: &'static str = "startup_tests::DriverCanister";
304    }
305
306    impl CanisterKind for DriverCanister {
307        fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
308            Ok(248)
309        }
310        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_driver.commit.v1";
311        fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
312            Ok(249)
313        }
314        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_driver.control.v1";
315        fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
316            Ok(250)
317        }
318        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
319            "icydb.test.startup_driver.integrity.v1";
320    }
321
322    thread_local! {
323        static DRIVER_STORES: StoreRegistry = StoreRegistry::new();
324    }
325
326    struct CardinalityDriverCanister;
327
328    impl Path for CardinalityDriverCanister {
329        const PATH: &'static str = "startup_tests::CardinalityDriverCanister";
330    }
331
332    impl CanisterKind for CardinalityDriverCanister {
333        // Keep this test-only control triplet distinct from the migration
334        // fixtures that coexist in the all-feature libtest process.
335        fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
336            Ok(217)
337        }
338        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup.cardinality.driver.commit.v1";
339        fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
340            Ok(218)
341        }
342        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup.cardinality.driver.control.v1";
343        fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
344            Ok(219)
345        }
346        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
347            "icydb.test.startup.cardinality.driver.integrity.v1";
348    }
349
350    thread_local! {
351        static CARDINALITY_DRIVER_DATA: RefCell<DataStore> =
352            RefCell::new(DataStore::init_journaled(test_memory(210)));
353        static CARDINALITY_DRIVER_INDEX: RefCell<IndexStore> =
354            RefCell::new(IndexStore::init_journaled(test_memory(211)));
355        static CARDINALITY_DRIVER_SCHEMA: RefCell<SchemaStore> =
356            RefCell::new(SchemaStore::init_journaled(test_memory(212)));
357        static CARDINALITY_DRIVER_TAIL: RefCell<JournalTailStore> =
358            RefCell::new(JournalTailStore::init(test_memory(213)));
359        static CARDINALITY_DRIVER_STORES: StoreRegistry = {
360            let mut registry = StoreRegistry::new();
361            registry.register_journaled_store(
362                "startup_tests::CardinalityDriverStore",
363                &CARDINALITY_DRIVER_DATA,
364                &CARDINALITY_DRIVER_INDEX,
365                &CARDINALITY_DRIVER_SCHEMA,
366                &CARDINALITY_DRIVER_TAIL,
367                StoreAllocationIdentities::new_journaled(
368                    StoreAllocationIdentity::new(210, "icydb.test.cardinality.driver.data.v1"),
369                    StoreAllocationIdentity::new(211, "icydb.test.cardinality.driver.index.v1"),
370                    StoreAllocationIdentity::new(212, "icydb.test.cardinality.driver.schema.v1"),
371                    StoreAllocationIdentity::new(213, "icydb.test.cardinality.driver.journal.v1"),
372                ),
373                StoreRuntimeStorageCapabilities::journaled(),
374            ).expect("cardinality driver store should register");
375            registry
376        };
377    }
378
379    struct HeapRecoveryFailureCanister;
380
381    impl Path for HeapRecoveryFailureCanister {
382        const PATH: &'static str = "startup_tests::HeapRecoveryFailureCanister";
383    }
384
385    impl CanisterKind for HeapRecoveryFailureCanister {
386        fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
387            Ok(251)
388        }
389        const COMMIT_STABLE_KEY: &'static str =
390            "icydb.test.startup.heap.recovery.failure.commit.v1";
391        fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
392            Ok(245)
393        }
394        const STARTUP_STABLE_KEY: &'static str =
395            "icydb.test.startup.heap.recovery.failure.control.v1";
396        fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
397            Ok(246)
398        }
399        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
400            "icydb.test.startup.heap.recovery.failure.integrity.v1";
401    }
402
403    thread_local! {
404        static HEAP_RECOVERY_FAILURE_STORES: StoreRegistry = StoreRegistry::new();
405    }
406
407    struct HeapCheckpointFailureCanister;
408
409    impl Path for HeapCheckpointFailureCanister {
410        const PATH: &'static str = "startup_tests::HeapCheckpointFailureCanister";
411    }
412
413    impl CanisterKind for HeapCheckpointFailureCanister {
414        fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
415            Ok(254)
416        }
417        const COMMIT_STABLE_KEY: &'static str =
418            "icydb.test.startup.heap.checkpoint.failure.commit.v1";
419        fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
420            Ok(221)
421        }
422        const STARTUP_STABLE_KEY: &'static str =
423            "icydb.test.startup.heap.checkpoint.failure.control.v1";
424        fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
425            Ok(222)
426        }
427        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
428            "icydb.test.startup.heap.checkpoint.failure.integrity.v1";
429    }
430
431    thread_local! {
432        static HEAP_CHECKPOINT_FAILURE_DATA: RefCell<DataStore> =
433            const { RefCell::new(DataStore::init_heap()) };
434        static HEAP_CHECKPOINT_FAILURE_INDEX: RefCell<IndexStore> =
435            const { RefCell::new(IndexStore::init_heap()) };
436        static HEAP_CHECKPOINT_FAILURE_SCHEMA: RefCell<SchemaStore> =
437            const { RefCell::new(SchemaStore::init_heap()) };
438        static HEAP_CHECKPOINT_FAILURE_STORES: StoreRegistry = {
439            let mut registry = StoreRegistry::new();
440            registry.register_store(
441                "startup_tests::HeapCheckpointFailureStore",
442                &HEAP_CHECKPOINT_FAILURE_DATA,
443                &HEAP_CHECKPOINT_FAILURE_INDEX,
444                &HEAP_CHECKPOINT_FAILURE_SCHEMA,
445                StoreAllocationIdentities::absent(),
446                StoreRuntimeStorageCapabilities::heap(),
447            ).expect("heap checkpoint failure store should register");
448            registry
449        };
450    }
451
452    const JOURNAL_RECOVERY_FAILURE_STORE_PATH: &str = "startup_tests::JournalRecoveryFailureStore";
453
454    struct JournalRecoveryFailureCanister;
455
456    impl Path for JournalRecoveryFailureCanister {
457        const PATH: &'static str = "startup_tests::JournalRecoveryFailureCanister";
458    }
459
460    impl CanisterKind for JournalRecoveryFailureCanister {
461        fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
462            Ok(185)
463        }
464        const COMMIT_STABLE_KEY: &'static str =
465            "icydb.test.startup.journal.recovery.failure.commit.v1";
466        fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
467            Ok(186)
468        }
469        const STARTUP_STABLE_KEY: &'static str =
470            "icydb.test.startup.journal.recovery.failure.control.v1";
471        fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
472            Ok(187)
473        }
474        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
475            "icydb.test.startup.journal.recovery.failure.integrity.v1";
476    }
477
478    thread_local! {
479        static JOURNAL_RECOVERY_FAILURE_DATA: RefCell<DataStore> =
480            RefCell::new(DataStore::init_journaled(test_memory(181)));
481        static JOURNAL_RECOVERY_FAILURE_INDEX: RefCell<IndexStore> =
482            RefCell::new(IndexStore::init_journaled(test_memory(182)));
483        static JOURNAL_RECOVERY_FAILURE_SCHEMA: RefCell<SchemaStore> =
484            RefCell::new(SchemaStore::init_journaled(test_memory(183)));
485        static JOURNAL_RECOVERY_FAILURE_TAIL: RefCell<JournalTailStore> =
486            RefCell::new(JournalTailStore::init(test_memory(184)));
487        static JOURNAL_RECOVERY_FAILURE_STORES: StoreRegistry = {
488            let mut registry = StoreRegistry::new();
489            registry.register_journaled_store(
490                JOURNAL_RECOVERY_FAILURE_STORE_PATH,
491                &JOURNAL_RECOVERY_FAILURE_DATA,
492                &JOURNAL_RECOVERY_FAILURE_INDEX,
493                &JOURNAL_RECOVERY_FAILURE_SCHEMA,
494                &JOURNAL_RECOVERY_FAILURE_TAIL,
495                StoreAllocationIdentities::new_journaled(
496                    StoreAllocationIdentity::new(
497                        181,
498                        "icydb.test.startup.journal.recovery.failure.data.v1",
499                    ),
500                    StoreAllocationIdentity::new(
501                        182,
502                        "icydb.test.startup.journal.recovery.failure.index.v1",
503                    ),
504                    StoreAllocationIdentity::new(
505                        183,
506                        "icydb.test.startup.journal.recovery.failure.schema.v1",
507                    ),
508                    StoreAllocationIdentity::new(
509                        184,
510                        "icydb.test.startup.journal.recovery.failure.journal.v1",
511                    ),
512                ),
513                StoreRuntimeStorageCapabilities::journaled(),
514            ).expect("journal recovery failure store should register");
515            registry
516        };
517    }
518
519    #[test]
520    fn fresh_observation_is_recovering_and_performs_no_stable_write() {
521        assert_eq!(
522            observe_generated_startup_state::<FreshCanister>(
523                &FRESH_STORES,
524                "generated/0123456789abcdef",
525            ),
526            Ok(DatabaseStartupState::Recovering)
527        );
528        let commit = commit_memory_handle(
529            current_commit_memory_allocation().expect("commit allocation should configure"),
530        )
531        .expect("commit memory should reopen");
532        let startup =
533            receipt::startup_memory::<FreshCanister>().expect("startup memory should reopen");
534        assert_eq!(commit.size(), 0);
535        assert_eq!(startup.size(), 0);
536    }
537
538    #[test]
539    fn terminal_classification_is_typed_and_pending_or_internal_failures_remain_retryable() {
540        let corruption = InternalError::store_corruption();
541        assert!(
542            classify_terminal_failure(StartupFailureKind::JournalRecovery, &corruption).is_some()
543        );
544        let pending = InternalError::recovery_pending();
545        assert!(classify_terminal_failure(StartupFailureKind::JournalRecovery, &pending).is_none());
546        let transient = InternalError::recovery_database_format_control_unavailable();
547        assert!(
548            classify_terminal_failure(StartupFailureKind::DatabaseControl, &transient).is_none()
549        );
550        let migration_pending = InternalError::schema_migration(
551            icydb_diagnostic_code::SchemaMigrationCode::MigrationInProgress,
552        );
553        assert!(
554            classify_terminal_failure(StartupFailureKind::SchemaReconciliation, &migration_pending)
555                .is_none()
556        );
557        assert!(
558            classify_terminal_failure(
559                StartupFailureKind::SchemaReconciliation,
560                &InternalError::store_unsupported(),
561            )
562            .is_some(),
563            "genuinely unsupported schema application remains terminal",
564        );
565    }
566
567    #[test]
568    fn non_current_fixed_boot_magic_surfaces_directly_without_a_failure_receipt() {
569        select_commit_memory_allocation(
570            CorruptCanister::commit_memory_id().expect("test allocation"),
571            CorruptCanister::COMMIT_STABLE_KEY,
572        );
573        let memory = commit_memory_handle(
574            current_commit_memory_allocation().expect("commit allocation should resolve"),
575        )
576        .expect("commit memory should open");
577        assert_eq!(memory.as_ref().grow(1), Ok(0));
578        memory.write(0, b"NOTICYDBCONTROL");
579
580        let failure = observe_generated_startup_state::<CorruptCanister>(
581            &CORRUPT_STORES,
582            "generated/0123456789abcdef",
583        )
584        .expect_err("non-current boot magic must fail directly");
585        assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
586        assert_eq!(
587            failure.diagnostic().class(),
588            icydb_diagnostic_code::ErrorClass::IncompatiblePersistedFormat,
589        );
590        assert_eq!(
591            receipt::startup_memory::<CorruptCanister>()
592                .expect("startup memory should open")
593                .size(),
594            0,
595        );
596    }
597
598    #[test]
599    fn non_virgin_control_page_fails_observation_without_writes_or_retry() {
600        const SUBMISSION: &str = "generated/0123456789abcdef";
601        select_commit_memory_allocation(
602            CorruptCanister::commit_memory_id().unwrap(),
603            CorruptCanister::COMMIT_STABLE_KEY,
604        );
605        let memory = commit_memory_handle(current_commit_memory_allocation().unwrap()).unwrap();
606        assert_eq!(memory.as_ref().grow(1), Ok(0));
607        assert_eq!(
608            observe_generated_startup_state::<CorruptCanister>(&CORRUPT_STORES, SUBMISSION),
609            Ok(DatabaseStartupState::Recovering),
610        );
611        for (offset, bytes) in [
612            (0, &b"SCL"[..]),
613            (
614                crate::db::database_format::DATABASE_BOOT_RECORD_BYTES as u64,
615                &[1][..],
616            ),
617            (65_535, &[1][..]),
618        ] {
619            memory.write(offset, bytes);
620            let mut before = vec![0; 65_536];
621            memory.read(0, &mut before);
622            for _ in 0..2 {
623                let failure =
624                    observe_generated_startup_state::<CorruptCanister>(&CORRUPT_STORES, SUBMISSION)
625                        .expect_err(
626                            "non-virgin control memory must fail before recovery is scheduled",
627                        );
628                assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
629                assert_eq!(
630                    failure.diagnostic().class(),
631                    icydb_diagnostic_code::ErrorClass::IncompatiblePersistedFormat
632                );
633            }
634            let mut after = vec![0; 65_536];
635            memory.read(0, &mut after);
636            assert_eq!(before, after);
637            assert_eq!(memory.size(), 1);
638            assert_eq!(
639                receipt::startup_memory::<CorruptCanister>().unwrap().size(),
640                0
641            );
642            memory.write(offset, &[0; 3][..bytes.len()]);
643        }
644    }
645
646    #[test]
647    fn heap_only_malformed_marker_becomes_a_durable_database_control_failure() {
648        const SUBMISSION: &str = "generated/89abcdef01234567";
649
650        select_commit_memory_allocation(
651            HeapRecoveryFailureCanister::commit_memory_id().expect("test allocation"),
652            HeapRecoveryFailureCanister::COMMIT_STABLE_KEY,
653        );
654        let memory = commit_memory_handle(
655            current_commit_memory_allocation().expect("commit allocation should resolve"),
656        )
657        .expect("commit memory should open");
658        initialize_current_database_control_for_tests(&memory);
659        persist_raw_commit_marker_for_tests(vec![0xff])
660            .expect("malformed marker payload should persist inside valid control authority");
661
662        assert_eq!(
663            observe_generated_startup_state::<HeapRecoveryFailureCanister>(
664                &HEAP_RECOVERY_FAILURE_STORES,
665                SUBMISSION,
666            ),
667            Ok(DatabaseStartupState::Recovering),
668            "bounded observation must not decode marker payloads",
669        );
670
671        let request_root = RequestExecutionRoot::__new_runtime_root();
672        let session = crate::db::DbSession::<HeapRecoveryFailureCanister>::new(
673            &HEAP_RECOVERY_FAILURE_STORES,
674            &request_root,
675        );
676        assert_eq!(
677            drive_generated_startup_recovery_page(
678                &session,
679                &HEAP_RECOVERY_FAILURE_STORES,
680                SUBMISSION,
681            )
682            .expect("terminal corruption should publish one durable receipt"),
683            GeneratedStartupDriverStep::Terminal,
684        );
685
686        let failure = observe_generated_startup_state::<HeapRecoveryFailureCanister>(
687            &HEAP_RECOVERY_FAILURE_STORES,
688            SUBMISSION,
689        )
690        .expect_err("the durable database-control failure should replace blind recovery retries");
691        assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
692        assert_eq!(
693            failure.diagnostic().error_code(),
694            ErrorCode::RUNTIME_CORRUPTION
695        );
696        assert_eq!(
697            drive_generated_startup_recovery_page(
698                &session,
699                &HEAP_RECOVERY_FAILURE_STORES,
700                SUBMISSION,
701            )
702            .expect("exact terminal replay should not attempt recovery again"),
703            GeneratedStartupDriverStep::Terminal,
704        );
705    }
706
707    #[test]
708    fn heap_only_schema_control_corruption_becomes_a_durable_database_control_failure() {
709        const SUBMISSION: &str = "generated/76543210fedcba98";
710
711        select_commit_memory_allocation(
712            HeapCheckpointFailureCanister::commit_memory_id().expect("test allocation"),
713            HeapCheckpointFailureCanister::COMMIT_STABLE_KEY,
714        );
715        let memory = commit_memory_handle(
716            current_commit_memory_allocation().expect("commit allocation should resolve"),
717        )
718        .expect("commit memory should open");
719        initialize_current_database_control_for_tests(&memory);
720        corrupt_schema_control_header_for_tests()
721            .expect("schema-control authority should admit focused corruption");
722
723        let request_root = RequestExecutionRoot::__new_runtime_root();
724        let session = crate::db::DbSession::<HeapCheckpointFailureCanister>::new(
725            &HEAP_CHECKPOINT_FAILURE_STORES,
726            &request_root,
727        );
728        assert_eq!(
729            drive_generated_startup_recovery_page(
730                &session,
731                &HEAP_CHECKPOINT_FAILURE_STORES,
732                SUBMISSION,
733            )
734            .expect("checkpoint corruption should publish one durable receipt"),
735            GeneratedStartupDriverStep::Terminal,
736        );
737
738        let failure = observe_generated_startup_state::<HeapCheckpointFailureCanister>(
739            &HEAP_CHECKPOINT_FAILURE_STORES,
740            SUBMISSION,
741        )
742        .expect_err("the checkpoint failure should remain visible after the timer returns");
743        assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
744        assert_eq!(
745            failure.diagnostic().error_code(),
746            ErrorCode::RUNTIME_CORRUPTION
747        );
748    }
749
750    #[test]
751    fn persisted_journal_record_corruption_becomes_a_durable_journal_failure() {
752        const SUBMISSION: &str = "generated/2280bad0bad0bad0";
753
754        select_commit_memory_allocation(
755            JournalRecoveryFailureCanister::commit_memory_id().expect("test allocation"),
756            JournalRecoveryFailureCanister::COMMIT_STABLE_KEY,
757        );
758        let memory = commit_memory_handle(
759            current_commit_memory_allocation().expect("commit allocation should resolve"),
760        )
761        .expect("commit memory should open");
762        initialize_current_database_control_for_tests(&memory);
763        let format_root = RequestExecutionRoot::__new_runtime_root();
764        let format_database = crate::db::Db::<JournalRecoveryFailureCanister>::new(
765            &JOURNAL_RECOVERY_FAILURE_STORES,
766            format_root.scope(),
767        );
768        ensure_database_format_admitted(&format_database)
769            .expect("current journal registry should initialize before corruption injection");
770
771        let record = JournalRecord::constraint_validation_job_delete(
772            JOURNAL_RECOVERY_FAILURE_STORE_PATH,
773            EntityTag::new(1),
774            ConstraintId::new(1).unwrap(),
775        )
776        .expect("syntactically bounded control record should build");
777        let batch = JournalBatch::new(
778            [0x22; 16],
779            [0x28; 16],
780            JournalSequence::new(1),
781            vec![record],
782        )
783        .expect("syntactically current journal batch should build");
784        JOURNAL_RECOVERY_FAILURE_TAIL.with(|tail| {
785            tail.borrow_mut()
786                .append_batch(&batch)
787                .expect("persisted semantic-corruption fixture should insert");
788        });
789
790        assert_eq!(
791            observe_generated_startup_state::<JournalRecoveryFailureCanister>(
792                &JOURNAL_RECOVERY_FAILURE_STORES,
793                SUBMISSION,
794            ),
795            Ok(DatabaseStartupState::Recovering),
796        );
797        let request_root = RequestExecutionRoot::__new_runtime_root();
798        let session = crate::db::DbSession::<JournalRecoveryFailureCanister>::new(
799            &JOURNAL_RECOVERY_FAILURE_STORES,
800            &request_root,
801        );
802        let drive = || {
803            drive_generated_startup_recovery_page(
804                &session,
805                &JOURNAL_RECOVERY_FAILURE_STORES,
806                SUBMISSION,
807            )
808            .expect("the driver should progress or persist a durable failure")
809        };
810        assert_eq!(drive(), GeneratedStartupDriverStep::Recovering);
811        assert_eq!(drive(), GeneratedStartupDriverStep::Terminal);
812
813        let failure = observe_generated_startup_state::<JournalRecoveryFailureCanister>(
814            &JOURNAL_RECOVERY_FAILURE_STORES,
815            SUBMISSION,
816        )
817        .expect_err("the durable journal failure should replace blind recovery retries");
818        assert_eq!(failure.kind(), StartupFailureKind::JournalRecovery);
819        assert_eq!(
820            failure.diagnostic().error_code(),
821            ErrorCode::STORE_CORRUPTION,
822        );
823        assert_eq!(drive(), GeneratedStartupDriverStep::Terminal);
824
825        let changed_record = JournalRecord::constraint_validation_job_delete(
826            JOURNAL_RECOVERY_FAILURE_STORE_PATH,
827            EntityTag::new(2),
828            ConstraintId::new(1).unwrap(),
829        )
830        .expect("changed semantic-corruption record should build");
831        let changed_batch = JournalBatch::new(
832            [0x23; 16],
833            [0x29; 16],
834            JournalSequence::new(2),
835            vec![changed_record],
836        )
837        .expect("changed journal batch should build");
838        let changed_encoded =
839            encode_journal_batch(&changed_batch).expect("changed journal batch should encode");
840        JOURNAL_RECOVERY_FAILURE_TAIL.with(|tail| {
841            tail.borrow_mut()
842                .insert_raw_batch_for_tests(JournalSequence::new(2), changed_encoded)
843                .expect("changed journal authority should insert");
844        });
845        assert_eq!(
846            observe_generated_startup_state::<JournalRecoveryFailureCanister>(
847                &JOURNAL_RECOVERY_FAILURE_STORES,
848                SUBMISSION,
849            ),
850            Ok(DatabaseStartupState::Recovering),
851            "a receipt bound to the predecessor tail proof must become stale",
852        );
853    }
854
855    #[test]
856    #[expect(
857        clippy::too_many_lines,
858        reason = "one lifecycle test keeps pending, ready, marker, and receipt precedence in one scenario"
859    )]
860    fn completed_recovery_stays_recovering_until_exact_generated_schema_receipt_then_is_ready() {
861        const SUBMISSION: &str = "generated/0123456789abcdef";
862
863        select_commit_memory_allocation(
864            CurrentCanister::commit_memory_id().expect("test allocation"),
865            CurrentCanister::COMMIT_STABLE_KEY,
866        );
867        let memory = commit_memory_handle(
868            current_commit_memory_allocation().expect("commit allocation should resolve"),
869        )
870        .expect("commit memory should open");
871        initialize_current_database_control_for_tests(&memory);
872        let incarnation = database_incarnation_id().expect("control should initialize");
873        mark_startup_recovery_complete_for_tests(&CURRENT_STORES)
874            .expect("recovery witness should publish");
875
876        assert_eq!(
877            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
878            Ok(DatabaseStartupState::Recovering),
879        );
880
881        let (database_identity, accepted_head) =
882            generated_schema_authority(&CURRENT_STORES, incarnation)
883                .expect("schema authority should resolve");
884        let submission_key =
885            SchemaSubmissionKey::try_new(SUBMISSION).expect("submission should admit");
886        let receipt = SchemaChangeReceipt::new(
887            database_identity,
888            submission_key.clone(),
889            SchemaProposalDigest::from_bytes([1; 32]),
890            accepted_head.clone(),
891            SchemaChangeOutcome::NoOp {
892                accepted_head: accepted_head.clone(),
893            },
894        )
895        .expect("terminal schema receipt should admit");
896        let record = SchemaApplicationRecord::new(receipt, Vec::new())
897            .expect("terminal schema record should admit");
898        apply_schema_application_record_op(
899            &SchemaApplicationRecordOp::insert(&record)
900                .expect("schema record operation should admit"),
901        )
902        .expect("schema record should publish");
903        let before = load_schema_application_record_read_only(database_identity, &submission_key)
904            .expect("record should load");
905
906        assert_eq!(
907            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
908            Ok(DatabaseStartupState::Ready),
909        );
910        assert_eq!(
911            load_schema_application_record_read_only(database_identity, &submission_key)
912                .expect("record should reload"),
913            before,
914            "pure readiness observation must not rewrite schema application state",
915        );
916        assert_eq!(
917            receipt::startup_memory::<CurrentCanister>()
918                .expect("startup memory should open")
919                .size(),
920            0,
921            "readiness without a failure must not allocate the receipt cell",
922        );
923
924        let marker = CommitMarker::from_parts([0x5a; 16], Vec::new())
925            .expect("empty marker should admit for control observation");
926        let interrupted = begin_commit(&marker).expect("marker should persist");
927        assert_eq!(
928            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
929            Ok(DatabaseStartupState::Recovering),
930            "a marker must take precedence over a completed volatile witness",
931        );
932        finish_commit(interrupted, |_| Ok(())).expect("empty marker should clear");
933        assert_eq!(
934            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
935            Ok(DatabaseStartupState::Ready),
936        );
937
938        let accepted_head_binding = match accepted_head {
939            icydb_schema::ExpectedAcceptedHead::Empty => receipt::AcceptedHeadBinding::Empty,
940            icydb_schema::ExpectedAcceptedHead::Exact {
941                revision,
942                fingerprint,
943            } => receipt::AcceptedHeadBinding::Exact {
944                revision,
945                fingerprint: fingerprint.to_bytes(),
946            },
947        };
948        let terminal_failure = StartupFailure::new(
949            StartupFailureKind::SchemaReconciliation,
950            ErrorCode::RUNTIME_CONFLICT.diagnostic(DiagnosticOrigin::Recovery),
951            Vec::new(),
952        );
953        let memoized = receipt::StartupFailureReceipt::new(
954            terminal_failure.clone(),
955            receipt::StartupFailureBinding::SchemaReconciliation {
956                incarnation,
957                submission_key: SUBMISSION.to_string(),
958                accepted_head: accepted_head_binding,
959            },
960        )
961        .expect("memoized schema failure should admit");
962        assert!(
963            receipt::publish::<CurrentCanister>(&memoized)
964                .expect("memoized failure should publish")
965        );
966        assert_eq!(
967            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
968            Err(terminal_failure),
969            "one exact matching failure receipt has priority over Ready evidence",
970        );
971        assert_eq!(
972            observe_generated_startup_state::<CurrentCanister>(
973                &CURRENT_STORES,
974                "generated/fedcba9876543210",
975            ),
976            Ok(DatabaseStartupState::Recovering),
977            "a receipt bound to another generated submission must be stale",
978        );
979        assert!(receipt::clear::<CurrentCanister>().expect("test receipt should clear"));
980    }
981
982    #[test]
983    fn driver_completes_one_recovery_page_then_memoizes_only_terminal_schema_failure() {
984        const SUBMISSION: &str = "generated/0011223344556677";
985
986        select_commit_memory_allocation(
987            DriverCanister::commit_memory_id().expect("test allocation"),
988            DriverCanister::COMMIT_STABLE_KEY,
989        );
990        let memory = commit_memory_handle(
991            current_commit_memory_allocation().expect("commit allocation should resolve"),
992        )
993        .expect("commit memory should open");
994        initialize_current_database_control_for_tests(&memory);
995        let request_root = RequestExecutionRoot::__new_runtime_root();
996        let session = crate::db::DbSession::<DriverCanister>::new(&DRIVER_STORES, &request_root);
997
998        assert_eq!(
999            drive_generated_startup_recovery_page(&session, &DRIVER_STORES, SUBMISSION)
1000                .expect("empty recovery page should complete"),
1001            GeneratedStartupDriverStep::ApplyGeneratedSchema,
1002        );
1003        assert_eq!(
1004            observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION),
1005            Ok(DatabaseStartupState::Recovering),
1006            "recovery completion alone must not claim generated reconciliation",
1007        );
1008
1009        let retryable = InternalError::recovery_pending();
1010        assert!(
1011            !record_generated_schema_startup_failure::<DriverCanister>(
1012                &DRIVER_STORES,
1013                SUBMISSION,
1014                retryable.diagnostic(),
1015                retryable.diagnostic_facts(),
1016            )
1017            .expect("retryable classification should complete without publication")
1018        );
1019        assert_eq!(
1020            receipt::startup_memory::<DriverCanister>()
1021                .expect("startup memory should open")
1022                .size(),
1023            0,
1024            "retryable failure must not allocate the receipt cell",
1025        );
1026
1027        let terminal = InternalError::store_corruption();
1028        let marker = CommitMarker::from_parts([0x7b; 16], Vec::new())
1029            .expect("empty marker should admit for receipt priority");
1030        let interrupted = begin_commit(&marker).expect("marker should persist");
1031        assert!(
1032            record_generated_schema_startup_failure::<DriverCanister>(
1033                &DRIVER_STORES,
1034                SUBMISSION,
1035                terminal.diagnostic(),
1036                terminal.diagnostic_facts(),
1037            )
1038            .expect("terminal failure should publish")
1039        );
1040        let observed =
1041            observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION)
1042                .expect_err("matching terminal receipt should surface");
1043        assert_eq!(observed.kind(), StartupFailureKind::SchemaReconciliation);
1044        assert_eq!(
1045            observed.diagnostic().error_code(),
1046            ErrorCode::STORE_CORRUPTION
1047        );
1048        finish_commit(interrupted, |_| Ok(())).expect("test marker should clear");
1049        assert!(
1050            clear_generated_startup_failure::<DriverCanister>()
1051                .expect("authoritative correction should clear the receipt")
1052        );
1053    }
1054
1055    #[test]
1056    fn ready_startup_driver_publishes_empty_cardinality_then_quiesces() {
1057        const SUBMISSION: &str = "generated/cardinality-driver";
1058
1059        select_commit_memory_allocation(
1060            CardinalityDriverCanister::commit_memory_id().expect("test allocation"),
1061            CardinalityDriverCanister::COMMIT_STABLE_KEY,
1062        );
1063        let memory = commit_memory_handle(
1064            current_commit_memory_allocation().expect("commit allocation should resolve"),
1065        )
1066        .expect("commit memory should open");
1067        initialize_current_database_control_for_tests(&memory);
1068        let request_root = RequestExecutionRoot::__new_runtime_root();
1069        let database = crate::db::Db::<CardinalityDriverCanister>::new(
1070            &CARDINALITY_DRIVER_STORES,
1071            request_root.scope(),
1072        );
1073        ensure_database_format_admitted(&database)
1074            .expect("current store registry should initialize");
1075        mark_startup_recovery_complete_for_tests(&CARDINALITY_DRIVER_STORES)
1076            .expect("recovery witness should publish");
1077        let incarnation = database_incarnation_id().expect("incarnation should resolve");
1078        let (database_identity, accepted_head) =
1079            generated_schema_authority(&CARDINALITY_DRIVER_STORES, incarnation)
1080                .expect("empty generated authority should resolve");
1081        let submission_key =
1082            SchemaSubmissionKey::try_new(SUBMISSION).expect("submission should admit");
1083        let receipt = SchemaChangeReceipt::new(
1084            database_identity,
1085            submission_key,
1086            SchemaProposalDigest::from_bytes([2; 32]),
1087            accepted_head.clone(),
1088            SchemaChangeOutcome::NoOp { accepted_head },
1089        )
1090        .expect("terminal schema receipt should admit");
1091        let record = SchemaApplicationRecord::new(receipt, Vec::new())
1092            .expect("terminal schema record should admit");
1093        apply_schema_application_record_op(
1094            &SchemaApplicationRecordOp::insert(&record)
1095                .expect("schema record operation should admit"),
1096        )
1097        .expect("schema receipt should publish");
1098        assert_eq!(
1099            observe_generated_startup_state::<CardinalityDriverCanister>(
1100                &CARDINALITY_DRIVER_STORES,
1101                SUBMISSION,
1102            ),
1103            Ok(DatabaseStartupState::Ready),
1104        );
1105
1106        let session = crate::db::DbSession::<CardinalityDriverCanister>::new(
1107            &CARDINALITY_DRIVER_STORES,
1108            &request_root,
1109        );
1110        assert_eq!(
1111            drive_generated_startup_recovery_page(
1112                &session,
1113                &CARDINALITY_DRIVER_STORES,
1114                SUBMISSION,
1115            )
1116            .expect("empty cardinality publication should use the existing driver"),
1117            GeneratedStartupDriverStep::Recovering,
1118        );
1119        CARDINALITY_DRIVER_SCHEMA.with_borrow(|schema| {
1120            let header = schema
1121                .cardinality_generation_header()
1122                .expect("cardinality header should decode")
1123                .expect("cardinality header should publish");
1124            assert_eq!(
1125                header.state(),
1126                crate::db::schema::cardinality_generation::CardinalityGenerationState::Ready,
1127            );
1128        });
1129        assert_eq!(
1130            drive_generated_startup_recovery_page(
1131                &session,
1132                &CARDINALITY_DRIVER_STORES,
1133                SUBMISSION,
1134            )
1135            .expect("current cardinality evidence should quiesce"),
1136            GeneratedStartupDriverStep::Terminal,
1137        );
1138    }
1139}