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