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