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 remains pending after exactly one bounded page attempt.
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
93#[cfg_attr(
94    not(test),
95    allow(
96        dead_code,
97        reason = "the classifier is consumed by the next driver slice"
98    )
99)]
100pub(in crate::db) fn classify_terminal_failure(
101    kind: StartupFailureKind,
102    error: &InternalError,
103) -> Option<StartupFailure> {
104    terminal_code_for_kind(kind, error.diagnostic().error_code())
105        .then(|| StartupFailure::from_internal(kind, error))
106}
107
108pub(in crate::db) fn classify_terminal_failure_parts(
109    kind: StartupFailureKind,
110    diagnostic: Diagnostic,
111    facts: Vec<(DiagnosticFactTag, u64)>,
112) -> Option<StartupFailure> {
113    terminal_code_for_kind(kind, diagnostic.error_code())
114        .then(|| StartupFailure::new(kind, diagnostic, facts))
115}
116
117fn terminal_code_for_kind(
118    kind: StartupFailureKind,
119    code: icydb_diagnostic_code::ErrorCode,
120) -> bool {
121    use icydb_diagnostic_code::ErrorCode;
122
123    let persisted_failure = code == ErrorCode::STORE_CORRUPTION
124        || code == ErrorCode::STORE_INVARIANT_VIOLATION
125        || code == ErrorCode::RUNTIME_CORRUPTION
126        || code == ErrorCode::RUNTIME_INCOMPATIBLE_PERSISTED_FORMAT
127        || code == ErrorCode::RUNTIME_INVARIANT_VIOLATION
128        || code == ErrorCode::RUNTIME_BOUNDARY_PERSISTED_ROW_LAYOUT_OUTSIDE_ACCEPTED_WINDOW
129        || code == ErrorCode::RUNTIME_BOUNDARY_PERSISTED_ROW_SLOT_COUNT_MISMATCH
130        || code == ErrorCode::RUNTIME_BOUNDARY_ACCEPTED_ROW_CONSTRAINT_PROGRAM_CORRUPT;
131    persisted_failure
132        || match kind {
133            StartupFailureKind::DatabaseControl => false,
134            StartupFailureKind::JournalRecovery => {
135                code == ErrorCode::RUNTIME_BOUNDARY_JOURNAL_MUTATION_REVISION_EXHAUSTED
136            }
137            StartupFailureKind::SchemaReconciliation => {
138                code == ErrorCode::SCHEMA_DDL_ADMISSION
139                    || code == ErrorCode::RUNTIME_CONFLICT
140                    || code == ErrorCode::RUNTIME_UNSUPPORTED
141                    || code == ErrorCode::RUNTIME_BOUNDARY_GENERATED_FIELD_AFTER_DDL_FIELD
142                    || code == ErrorCode::RUNTIME_BOUNDARY_CONSTRAINT_VIOLATION
143                    || code == ErrorCode::RUNTIME_BOUNDARY_GENERATED_CONSTRAINT_ACTIVATION_STALE
144            }
145        }
146}
147
148/// Observe one generated database without opening a request or advancing recovery.
149pub fn observe_generated_startup_state<C: CanisterKind>(
150    stores: &'static std::thread::LocalKey<StoreRegistry>,
151    submission_key: &str,
152) -> Result<DatabaseStartupState, StartupFailure> {
153    observe::observe::<C>(stores, submission_key)
154}
155
156/// Run at most one bounded recovery page without admitting ordinary work.
157#[doc(hidden)]
158pub fn drive_generated_startup_recovery_page<C: CanisterKind>(
159    session: &crate::db::DbSession<C>,
160    stores: &'static std::thread::LocalKey<StoreRegistry>,
161    submission_key: &str,
162) -> Result<GeneratedStartupDriverStep, InternalError> {
163    driver::drive_recovery_page(session, stores, submission_key)
164}
165
166/// Persist one deterministic generated-schema failure against fresh authority.
167#[doc(hidden)]
168pub fn record_generated_schema_startup_failure<C: CanisterKind>(
169    stores: &'static std::thread::LocalKey<StoreRegistry>,
170    submission_key: &str,
171    diagnostic: Diagnostic,
172    facts: Vec<(DiagnosticFactTag, u64)>,
173) -> Result<bool, InternalError> {
174    driver::record_schema_failure::<C>(stores, submission_key, diagnostic, facts)
175}
176
177/// Clear a stale startup failure after an authoritative successful handoff.
178#[doc(hidden)]
179pub fn clear_generated_startup_failure<C: CanisterKind>() -> Result<bool, InternalError> {
180    receipt::clear::<C>()
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::{
187        db::{
188            DataStore, IndexStore, StoreAllocationIdentities, StoreRuntimeStorageCapabilities,
189            commit::{
190                CommitMarker, begin_commit, commit_memory_handle, configure_commit_memory_id,
191                current_commit_memory_allocation, database_incarnation_id, finish_commit,
192                mark_startup_recovery_complete_for_tests, persist_raw_commit_marker_for_tests,
193            },
194            database_format::{
195                ensure_database_format_admitted, initialize_current_database_control_for_tests,
196            },
197            journal::{
198                JournalBatch, JournalRecord, JournalSequence, JournalTailStore,
199                encode_journal_batch,
200            },
201            registry::StoreAllocationIdentity,
202            schema::{
203                SchemaApplicationRecord, SchemaApplicationRecordOp, SchemaChangeOutcome,
204                SchemaChangeReceipt, SchemaStore, apply_schema_application_record_op,
205                corrupt_live_schema_checkpoint_header_for_tests, generated_schema_authority,
206                load_schema_application_record_read_only,
207            },
208            session::RequestExecutionRoot,
209        },
210        testing::test_memory,
211        traits::Path,
212    };
213    use ic_stable_structures::Memory;
214    use icydb_diagnostic_code::{ErrorCode, ErrorOrigin as DiagnosticOrigin};
215    use icydb_schema::{SchemaProposalDigest, SchemaSubmissionKey};
216    use std::cell::RefCell;
217
218    struct FreshCanister;
219
220    impl Path for FreshCanister {
221        const PATH: &'static str = "startup_tests::FreshCanister";
222    }
223
224    impl CanisterKind for FreshCanister {
225        const COMMIT_MEMORY_ID: u8 = 232;
226        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_fresh.commit.v1";
227        const STARTUP_MEMORY_ID: u8 = 233;
228        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_fresh.control.v1";
229        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 234;
230        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str = "icydb.test.startup_fresh.integrity.v1";
231    }
232
233    thread_local! {
234        static FRESH_STORES: StoreRegistry = StoreRegistry::new();
235        static CURRENT_STORES: StoreRegistry = StoreRegistry::new();
236    }
237
238    struct CurrentCanister;
239
240    impl Path for CurrentCanister {
241        const PATH: &'static str = "startup_tests::CurrentCanister";
242    }
243
244    impl CanisterKind for CurrentCanister {
245        const COMMIT_MEMORY_ID: u8 = 228;
246        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_current.commit.v1";
247        const STARTUP_MEMORY_ID: u8 = 229;
248        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_current.control.v1";
249        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 230;
250        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
251            "icydb.test.startup_current.integrity.v1";
252    }
253
254    struct CorruptCanister;
255
256    impl Path for CorruptCanister {
257        const PATH: &'static str = "startup_tests::CorruptCanister";
258    }
259
260    impl CanisterKind for CorruptCanister {
261        const COMMIT_MEMORY_ID: u8 = 224;
262        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_corrupt.commit.v1";
263        const STARTUP_MEMORY_ID: u8 = 225;
264        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_corrupt.control.v1";
265        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 226;
266        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
267            "icydb.test.startup_corrupt.integrity.v1";
268    }
269
270    thread_local! {
271        static CORRUPT_STORES: StoreRegistry = StoreRegistry::new();
272    }
273
274    struct DriverCanister;
275
276    impl Path for DriverCanister {
277        const PATH: &'static str = "startup_tests::DriverCanister";
278    }
279
280    impl CanisterKind for DriverCanister {
281        const COMMIT_MEMORY_ID: u8 = 248;
282        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_driver.commit.v1";
283        const STARTUP_MEMORY_ID: u8 = 249;
284        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_driver.control.v1";
285        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 250;
286        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
287            "icydb.test.startup_driver.integrity.v1";
288    }
289
290    thread_local! {
291        static DRIVER_STORES: StoreRegistry = StoreRegistry::new();
292    }
293
294    struct HeapRecoveryFailureCanister;
295
296    impl Path for HeapRecoveryFailureCanister {
297        const PATH: &'static str = "startup_tests::HeapRecoveryFailureCanister";
298    }
299
300    impl CanisterKind for HeapRecoveryFailureCanister {
301        const COMMIT_MEMORY_ID: u8 = 251;
302        const COMMIT_STABLE_KEY: &'static str =
303            "icydb.test.startup.heap.recovery.failure.commit.v1";
304        const STARTUP_MEMORY_ID: u8 = 245;
305        const STARTUP_STABLE_KEY: &'static str =
306            "icydb.test.startup.heap.recovery.failure.control.v1";
307        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 246;
308        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
309            "icydb.test.startup.heap.recovery.failure.integrity.v1";
310    }
311
312    thread_local! {
313        static HEAP_RECOVERY_FAILURE_STORES: StoreRegistry = StoreRegistry::new();
314    }
315
316    struct HeapCheckpointFailureCanister;
317
318    impl Path for HeapCheckpointFailureCanister {
319        const PATH: &'static str = "startup_tests::HeapCheckpointFailureCanister";
320    }
321
322    impl CanisterKind for HeapCheckpointFailureCanister {
323        const COMMIT_MEMORY_ID: u8 = 254;
324        const COMMIT_STABLE_KEY: &'static str =
325            "icydb.test.startup.heap.checkpoint.failure.commit.v1";
326        const STARTUP_MEMORY_ID: u8 = 221;
327        const STARTUP_STABLE_KEY: &'static str =
328            "icydb.test.startup.heap.checkpoint.failure.control.v1";
329        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 222;
330        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
331            "icydb.test.startup.heap.checkpoint.failure.integrity.v1";
332    }
333
334    thread_local! {
335        static HEAP_CHECKPOINT_FAILURE_DATA: RefCell<DataStore> =
336            const { RefCell::new(DataStore::init_heap()) };
337        static HEAP_CHECKPOINT_FAILURE_INDEX: RefCell<IndexStore> =
338            const { RefCell::new(IndexStore::init_heap()) };
339        static HEAP_CHECKPOINT_FAILURE_SCHEMA: RefCell<SchemaStore> =
340            const { RefCell::new(SchemaStore::init_heap()) };
341        static HEAP_CHECKPOINT_FAILURE_STORES: StoreRegistry = {
342            let mut registry = StoreRegistry::new();
343            registry.register_store(
344                "startup_tests::HeapCheckpointFailureStore",
345                &HEAP_CHECKPOINT_FAILURE_DATA,
346                &HEAP_CHECKPOINT_FAILURE_INDEX,
347                &HEAP_CHECKPOINT_FAILURE_SCHEMA,
348                StoreAllocationIdentities::absent(),
349                StoreRuntimeStorageCapabilities::heap(),
350            ).expect("heap checkpoint failure store should register");
351            registry
352        };
353    }
354
355    const JOURNAL_RECOVERY_FAILURE_STORE_PATH: &str = "startup_tests::JournalRecoveryFailureStore";
356
357    struct JournalRecoveryFailureCanister;
358
359    impl Path for JournalRecoveryFailureCanister {
360        const PATH: &'static str = "startup_tests::JournalRecoveryFailureCanister";
361    }
362
363    impl CanisterKind for JournalRecoveryFailureCanister {
364        const COMMIT_MEMORY_ID: u8 = 185;
365        const COMMIT_STABLE_KEY: &'static str =
366            "icydb.test.startup.journal.recovery.failure.commit.v1";
367        const STARTUP_MEMORY_ID: u8 = 186;
368        const STARTUP_STABLE_KEY: &'static str =
369            "icydb.test.startup.journal.recovery.failure.control.v1";
370        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 187;
371        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
372            "icydb.test.startup.journal.recovery.failure.integrity.v1";
373    }
374
375    thread_local! {
376        static JOURNAL_RECOVERY_FAILURE_DATA: RefCell<DataStore> =
377            RefCell::new(DataStore::init_journaled(test_memory(181)));
378        static JOURNAL_RECOVERY_FAILURE_INDEX: RefCell<IndexStore> =
379            RefCell::new(IndexStore::init_journaled(test_memory(182)));
380        static JOURNAL_RECOVERY_FAILURE_SCHEMA: RefCell<SchemaStore> =
381            RefCell::new(SchemaStore::init_journaled(test_memory(183)));
382        static JOURNAL_RECOVERY_FAILURE_TAIL: RefCell<JournalTailStore> =
383            RefCell::new(JournalTailStore::init(test_memory(184)));
384        static JOURNAL_RECOVERY_FAILURE_STORES: StoreRegistry = {
385            let mut registry = StoreRegistry::new();
386            registry.register_journaled_store(
387                JOURNAL_RECOVERY_FAILURE_STORE_PATH,
388                &JOURNAL_RECOVERY_FAILURE_DATA,
389                &JOURNAL_RECOVERY_FAILURE_INDEX,
390                &JOURNAL_RECOVERY_FAILURE_SCHEMA,
391                &JOURNAL_RECOVERY_FAILURE_TAIL,
392                StoreAllocationIdentities::new_journaled(
393                    StoreAllocationIdentity::new(
394                        181,
395                        "icydb.test.startup.journal.recovery.failure.data.v1",
396                    ),
397                    StoreAllocationIdentity::new(
398                        182,
399                        "icydb.test.startup.journal.recovery.failure.index.v1",
400                    ),
401                    StoreAllocationIdentity::new(
402                        183,
403                        "icydb.test.startup.journal.recovery.failure.schema.v1",
404                    ),
405                    StoreAllocationIdentity::new(
406                        184,
407                        "icydb.test.startup.journal.recovery.failure.journal.v1",
408                    ),
409                ),
410                StoreRuntimeStorageCapabilities::journaled(),
411            ).expect("journal recovery failure store should register");
412            registry
413        };
414    }
415
416    #[test]
417    fn fresh_observation_is_recovering_and_performs_no_stable_write() {
418        assert_eq!(
419            observe_generated_startup_state::<FreshCanister>(
420                &FRESH_STORES,
421                "generated/0123456789abcdef",
422            ),
423            Ok(DatabaseStartupState::Recovering)
424        );
425        let commit = commit_memory_handle(
426            current_commit_memory_allocation().expect("commit allocation should configure"),
427        )
428        .expect("commit memory should reopen");
429        let startup =
430            receipt::startup_memory::<FreshCanister>().expect("startup memory should reopen");
431        assert_eq!(commit.size(), 0);
432        assert_eq!(startup.size(), 0);
433    }
434
435    #[test]
436    fn terminal_classification_is_typed_and_pending_or_internal_failures_remain_retryable() {
437        let corruption = InternalError::store_corruption();
438        assert!(
439            classify_terminal_failure(StartupFailureKind::JournalRecovery, &corruption).is_some()
440        );
441        let pending = InternalError::recovery_pending();
442        assert!(classify_terminal_failure(StartupFailureKind::JournalRecovery, &pending).is_none());
443        let transient = InternalError::recovery_database_format_control_unavailable();
444        assert!(
445            classify_terminal_failure(StartupFailureKind::DatabaseControl, &transient).is_none()
446        );
447    }
448
449    #[test]
450    fn malformed_fixed_boot_control_surfaces_directly_without_a_failure_receipt() {
451        configure_commit_memory_id(
452            CorruptCanister::COMMIT_MEMORY_ID,
453            CorruptCanister::COMMIT_STABLE_KEY,
454        )
455        .expect("commit allocation should configure");
456        let memory = commit_memory_handle(
457            current_commit_memory_allocation().expect("commit allocation should resolve"),
458        )
459        .expect("commit memory should open");
460        assert_eq!(memory.grow(1), 0);
461        memory.write(0, b"NOTICYDBCONTROL");
462
463        let failure = observe_generated_startup_state::<CorruptCanister>(
464            &CORRUPT_STORES,
465            "generated/0123456789abcdef",
466        )
467        .expect_err("malformed boot control must fail directly");
468        assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
469        assert_eq!(
470            failure.diagnostic().class(),
471            icydb_diagnostic_code::ErrorClass::Corruption,
472        );
473        assert_eq!(
474            receipt::startup_memory::<CorruptCanister>()
475                .expect("startup memory should open")
476                .size(),
477            0,
478        );
479    }
480
481    #[test]
482    fn heap_only_malformed_marker_becomes_a_durable_database_control_failure() {
483        const SUBMISSION: &str = "generated/89abcdef01234567";
484
485        configure_commit_memory_id(
486            HeapRecoveryFailureCanister::COMMIT_MEMORY_ID,
487            HeapRecoveryFailureCanister::COMMIT_STABLE_KEY,
488        )
489        .expect("commit allocation should configure");
490        let memory = commit_memory_handle(
491            current_commit_memory_allocation().expect("commit allocation should resolve"),
492        )
493        .expect("commit memory should open");
494        initialize_current_database_control_for_tests(&memory);
495        persist_raw_commit_marker_for_tests(vec![0xff])
496            .expect("malformed marker payload should persist inside valid control authority");
497
498        assert_eq!(
499            observe_generated_startup_state::<HeapRecoveryFailureCanister>(
500                &HEAP_RECOVERY_FAILURE_STORES,
501                SUBMISSION,
502            ),
503            Ok(DatabaseStartupState::Recovering),
504            "bounded observation must not decode marker payloads",
505        );
506
507        let request_root = RequestExecutionRoot::__new_runtime_root();
508        let session = crate::db::DbSession::<HeapRecoveryFailureCanister>::new(
509            &HEAP_RECOVERY_FAILURE_STORES,
510            &request_root,
511        );
512        assert_eq!(
513            drive_generated_startup_recovery_page(
514                &session,
515                &HEAP_RECOVERY_FAILURE_STORES,
516                SUBMISSION,
517            )
518            .expect("terminal corruption should publish one durable receipt"),
519            GeneratedStartupDriverStep::Terminal,
520        );
521
522        let failure = observe_generated_startup_state::<HeapRecoveryFailureCanister>(
523            &HEAP_RECOVERY_FAILURE_STORES,
524            SUBMISSION,
525        )
526        .expect_err("the durable database-control failure should replace blind recovery retries");
527        assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
528        assert_eq!(
529            failure.diagnostic().error_code(),
530            ErrorCode::RUNTIME_CORRUPTION
531        );
532        assert_eq!(
533            drive_generated_startup_recovery_page(
534                &session,
535                &HEAP_RECOVERY_FAILURE_STORES,
536                SUBMISSION,
537            )
538            .expect("exact terminal replay should not attempt recovery again"),
539            GeneratedStartupDriverStep::Terminal,
540        );
541    }
542
543    #[test]
544    fn heap_only_checkpoint_corruption_becomes_a_durable_database_control_failure() {
545        const SUBMISSION: &str = "generated/76543210fedcba98";
546
547        configure_commit_memory_id(
548            HeapCheckpointFailureCanister::COMMIT_MEMORY_ID,
549            HeapCheckpointFailureCanister::COMMIT_STABLE_KEY,
550        )
551        .expect("commit allocation should configure");
552        let memory = commit_memory_handle(
553            current_commit_memory_allocation().expect("commit allocation should resolve"),
554        )
555        .expect("commit memory should open");
556        initialize_current_database_control_for_tests(&memory);
557        corrupt_live_schema_checkpoint_header_for_tests()
558            .expect("checkpoint authority should admit focused corruption");
559
560        let request_root = RequestExecutionRoot::__new_runtime_root();
561        let session = crate::db::DbSession::<HeapCheckpointFailureCanister>::new(
562            &HEAP_CHECKPOINT_FAILURE_STORES,
563            &request_root,
564        );
565        assert_eq!(
566            drive_generated_startup_recovery_page(
567                &session,
568                &HEAP_CHECKPOINT_FAILURE_STORES,
569                SUBMISSION,
570            )
571            .expect("checkpoint corruption should publish one durable receipt"),
572            GeneratedStartupDriverStep::Terminal,
573        );
574
575        let failure = observe_generated_startup_state::<HeapCheckpointFailureCanister>(
576            &HEAP_CHECKPOINT_FAILURE_STORES,
577            SUBMISSION,
578        )
579        .expect_err("the checkpoint failure should remain visible after the timer returns");
580        assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
581        assert_eq!(
582            failure.diagnostic().error_code(),
583            ErrorCode::RUNTIME_CORRUPTION
584        );
585    }
586
587    #[test]
588    fn persisted_journal_record_corruption_becomes_a_durable_journal_failure() {
589        const SUBMISSION: &str = "generated/2280bad0bad0bad0";
590
591        configure_commit_memory_id(
592            JournalRecoveryFailureCanister::COMMIT_MEMORY_ID,
593            JournalRecoveryFailureCanister::COMMIT_STABLE_KEY,
594        )
595        .expect("commit allocation should configure");
596        let memory = commit_memory_handle(
597            current_commit_memory_allocation().expect("commit allocation should resolve"),
598        )
599        .expect("commit memory should open");
600        initialize_current_database_control_for_tests(&memory);
601        let format_root = RequestExecutionRoot::__new_runtime_root();
602        let format_database = crate::db::Db::<JournalRecoveryFailureCanister>::new(
603            &JOURNAL_RECOVERY_FAILURE_STORES,
604            format_root.scope(),
605        );
606        ensure_database_format_admitted(&format_database)
607            .expect("current journal registry should initialize before corruption injection");
608
609        let record = JournalRecord::schema_put(JOURNAL_RECOVERY_FAILURE_STORE_PATH, vec![0xff; 8])
610            .expect("syntactically bounded schema record should build");
611        let batch = JournalBatch::new(
612            [0x22; 16],
613            [0x28; 16],
614            JournalSequence::new(1),
615            vec![record],
616        )
617        .expect("syntactically current journal batch should build");
618        JOURNAL_RECOVERY_FAILURE_TAIL.with(|tail| {
619            tail.borrow_mut()
620                .append_batch(&batch)
621                .expect("persisted semantic-corruption fixture should insert");
622        });
623
624        assert_eq!(
625            observe_generated_startup_state::<JournalRecoveryFailureCanister>(
626                &JOURNAL_RECOVERY_FAILURE_STORES,
627                SUBMISSION,
628            ),
629            Ok(DatabaseStartupState::Recovering),
630        );
631        let request_root = RequestExecutionRoot::__new_runtime_root();
632        let session = crate::db::DbSession::<JournalRecoveryFailureCanister>::new(
633            &JOURNAL_RECOVERY_FAILURE_STORES,
634            &request_root,
635        );
636        assert_eq!(
637            drive_generated_startup_recovery_page(
638                &session,
639                &JOURNAL_RECOVERY_FAILURE_STORES,
640                SUBMISSION,
641            )
642            .expect("journal corruption should publish one durable receipt"),
643            GeneratedStartupDriverStep::Terminal,
644        );
645
646        let failure = observe_generated_startup_state::<JournalRecoveryFailureCanister>(
647            &JOURNAL_RECOVERY_FAILURE_STORES,
648            SUBMISSION,
649        )
650        .expect_err("the durable journal failure should replace blind recovery retries");
651        assert_eq!(failure.kind(), StartupFailureKind::JournalRecovery);
652        assert_eq!(
653            failure.diagnostic().error_code(),
654            ErrorCode::STORE_CORRUPTION,
655        );
656        assert_eq!(
657            drive_generated_startup_recovery_page(
658                &session,
659                &JOURNAL_RECOVERY_FAILURE_STORES,
660                SUBMISSION,
661            )
662            .expect("exact terminal replay should stop without retrying recovery"),
663            GeneratedStartupDriverStep::Terminal,
664        );
665
666        let changed_record =
667            JournalRecord::schema_put(JOURNAL_RECOVERY_FAILURE_STORE_PATH, vec![0xfe; 8])
668                .expect("changed semantic-corruption record should build");
669        let changed_batch = JournalBatch::new(
670            [0x23; 16],
671            [0x29; 16],
672            JournalSequence::new(2),
673            vec![changed_record],
674        )
675        .expect("changed journal batch should build");
676        let changed_encoded =
677            encode_journal_batch(&changed_batch).expect("changed journal batch should encode");
678        JOURNAL_RECOVERY_FAILURE_TAIL.with(|tail| {
679            tail.borrow_mut()
680                .insert_raw_batch_for_tests(JournalSequence::new(2), changed_encoded)
681                .expect("changed journal authority should insert");
682        });
683        assert_eq!(
684            observe_generated_startup_state::<JournalRecoveryFailureCanister>(
685                &JOURNAL_RECOVERY_FAILURE_STORES,
686                SUBMISSION,
687            ),
688            Ok(DatabaseStartupState::Recovering),
689            "a receipt bound to the predecessor tail proof must become stale",
690        );
691    }
692
693    #[test]
694    #[expect(
695        clippy::too_many_lines,
696        reason = "one lifecycle test keeps pending, ready, marker, and receipt precedence in one scenario"
697    )]
698    fn completed_recovery_stays_recovering_until_exact_generated_schema_receipt_then_is_ready() {
699        const SUBMISSION: &str = "generated/0123456789abcdef";
700
701        configure_commit_memory_id(
702            CurrentCanister::COMMIT_MEMORY_ID,
703            CurrentCanister::COMMIT_STABLE_KEY,
704        )
705        .expect("commit allocation should configure");
706        let memory = commit_memory_handle(
707            current_commit_memory_allocation().expect("commit allocation should resolve"),
708        )
709        .expect("commit memory should open");
710        initialize_current_database_control_for_tests(&memory);
711        let incarnation = database_incarnation_id().expect("control should initialize");
712        mark_startup_recovery_complete_for_tests(&CURRENT_STORES)
713            .expect("recovery witness should publish");
714
715        assert_eq!(
716            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
717            Ok(DatabaseStartupState::Recovering),
718        );
719
720        let (database_identity, accepted_head) =
721            generated_schema_authority(&CURRENT_STORES, incarnation)
722                .expect("schema authority should resolve");
723        let submission_key =
724            SchemaSubmissionKey::try_new(SUBMISSION).expect("submission should admit");
725        let receipt = SchemaChangeReceipt::new(
726            database_identity,
727            submission_key.clone(),
728            SchemaProposalDigest::from_bytes([1; 32]),
729            accepted_head.clone(),
730            SchemaChangeOutcome::NoOp {
731                accepted_head: accepted_head.clone(),
732            },
733        )
734        .expect("terminal schema receipt should admit");
735        let record = SchemaApplicationRecord::new(receipt, Vec::new())
736            .expect("terminal schema record should admit");
737        apply_schema_application_record_op(
738            &SchemaApplicationRecordOp::insert(&record)
739                .expect("schema record operation should admit"),
740        )
741        .expect("schema record should publish");
742        let before = load_schema_application_record_read_only(database_identity, &submission_key)
743            .expect("record should load");
744
745        assert_eq!(
746            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
747            Ok(DatabaseStartupState::Ready),
748        );
749        assert_eq!(
750            load_schema_application_record_read_only(database_identity, &submission_key)
751                .expect("record should reload"),
752            before,
753            "pure readiness observation must not rewrite schema application state",
754        );
755        assert_eq!(
756            receipt::startup_memory::<CurrentCanister>()
757                .expect("startup memory should open")
758                .size(),
759            0,
760            "readiness without a failure must not allocate the receipt cell",
761        );
762
763        let marker = CommitMarker::from_parts([0x5a; 16], Vec::new())
764            .expect("empty marker should admit for control observation");
765        let interrupted = begin_commit(marker).expect("marker should persist");
766        assert_eq!(
767            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
768            Ok(DatabaseStartupState::Recovering),
769            "a marker must take precedence over a completed volatile witness",
770        );
771        finish_commit(interrupted, |_| Ok(())).expect("empty marker should clear");
772        assert_eq!(
773            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
774            Ok(DatabaseStartupState::Ready),
775        );
776
777        let accepted_head_binding = match accepted_head {
778            icydb_schema::ExpectedAcceptedHead::Empty => receipt::AcceptedHeadBinding::Empty,
779            icydb_schema::ExpectedAcceptedHead::Exact {
780                revision,
781                fingerprint,
782            } => receipt::AcceptedHeadBinding::Exact {
783                revision,
784                fingerprint: fingerprint.to_bytes(),
785            },
786        };
787        let terminal_failure = StartupFailure::new(
788            StartupFailureKind::SchemaReconciliation,
789            ErrorCode::RUNTIME_CONFLICT.diagnostic(DiagnosticOrigin::Recovery),
790            Vec::new(),
791        );
792        let memoized = receipt::StartupFailureReceipt::new(
793            terminal_failure.clone(),
794            receipt::StartupFailureBinding::SchemaReconciliation {
795                incarnation,
796                submission_key: SUBMISSION.to_string(),
797                accepted_head: accepted_head_binding,
798            },
799        )
800        .expect("memoized schema failure should admit");
801        assert!(
802            receipt::publish::<CurrentCanister>(&memoized)
803                .expect("memoized failure should publish")
804        );
805        assert_eq!(
806            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
807            Err(terminal_failure),
808            "one exact matching failure receipt has priority over Ready evidence",
809        );
810        assert_eq!(
811            observe_generated_startup_state::<CurrentCanister>(
812                &CURRENT_STORES,
813                "generated/fedcba9876543210",
814            ),
815            Ok(DatabaseStartupState::Recovering),
816            "a receipt bound to another generated submission must be stale",
817        );
818        assert!(receipt::clear::<CurrentCanister>().expect("test receipt should clear"));
819    }
820
821    #[test]
822    fn driver_completes_one_recovery_page_then_memoizes_only_terminal_schema_failure() {
823        const SUBMISSION: &str = "generated/0011223344556677";
824
825        configure_commit_memory_id(
826            DriverCanister::COMMIT_MEMORY_ID,
827            DriverCanister::COMMIT_STABLE_KEY,
828        )
829        .expect("commit allocation should configure");
830        let memory = commit_memory_handle(
831            current_commit_memory_allocation().expect("commit allocation should resolve"),
832        )
833        .expect("commit memory should open");
834        initialize_current_database_control_for_tests(&memory);
835        let request_root = RequestExecutionRoot::__new_runtime_root();
836        let session = crate::db::DbSession::<DriverCanister>::new(&DRIVER_STORES, &request_root);
837
838        assert_eq!(
839            drive_generated_startup_recovery_page(&session, &DRIVER_STORES, SUBMISSION)
840                .expect("empty recovery page should complete"),
841            GeneratedStartupDriverStep::ApplyGeneratedSchema,
842        );
843        assert_eq!(
844            observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION),
845            Ok(DatabaseStartupState::Recovering),
846            "recovery completion alone must not claim generated reconciliation",
847        );
848
849        let retryable = InternalError::recovery_pending();
850        assert!(
851            !record_generated_schema_startup_failure::<DriverCanister>(
852                &DRIVER_STORES,
853                SUBMISSION,
854                retryable.diagnostic(),
855                retryable.diagnostic_facts(),
856            )
857            .expect("retryable classification should complete without publication")
858        );
859        assert_eq!(
860            receipt::startup_memory::<DriverCanister>()
861                .expect("startup memory should open")
862                .size(),
863            0,
864            "retryable failure must not allocate the receipt cell",
865        );
866
867        let terminal = InternalError::store_corruption();
868        let marker = CommitMarker::from_parts([0x7b; 16], Vec::new())
869            .expect("empty marker should admit for receipt priority");
870        let interrupted = begin_commit(marker).expect("marker should persist");
871        assert!(
872            record_generated_schema_startup_failure::<DriverCanister>(
873                &DRIVER_STORES,
874                SUBMISSION,
875                terminal.diagnostic(),
876                terminal.diagnostic_facts(),
877            )
878            .expect("terminal failure should publish")
879        );
880        let observed =
881            observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION)
882                .expect_err("matching terminal receipt should surface");
883        assert_eq!(observed.kind(), StartupFailureKind::SchemaReconciliation);
884        assert_eq!(
885            observed.diagnostic().error_code(),
886            ErrorCode::STORE_CORRUPTION
887        );
888        finish_commit(interrupted, |_| Ok(())).expect("test marker should clear");
889        assert!(
890            clear_generated_startup_failure::<DriverCanister>()
891                .expect("authoritative correction should clear the receipt")
892        );
893    }
894}