1mod 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#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
18pub enum DatabaseStartupState {
19 Ready,
21 Recovering,
23}
24
25#[doc(hidden)]
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum GeneratedStartupDriverStep {
29 Terminal,
31 Recovering,
33 ApplyGeneratedSchema,
35}
36
37#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
39pub enum StartupFailureKind {
40 DatabaseControl,
42 JournalRecovery,
44 SchemaReconciliation,
46}
47
48#[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 #[must_use]
76 pub const fn kind(&self) -> StartupFailureKind {
77 self.kind
78 }
79
80 #[must_use]
82 pub const fn diagnostic(&self) -> &Diagnostic {
83 &self.diagnostic
84 }
85
86 #[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
141pub 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#[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#[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#[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, current_commit_memory_allocation,
184 database_incarnation_id, finish_commit, mark_startup_recovery_complete_for_tests,
185 persist_raw_commit_marker_for_tests, select_commit_memory_allocation,
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_schema_control_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_memory::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 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
219 Ok(232)
220 }
221 const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_fresh.commit.v1";
222 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
223 Ok(233)
224 }
225 const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_fresh.control.v1";
226 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
227 Ok(234)
228 }
229 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str = "icydb.test.startup_fresh.integrity.v1";
230 }
231
232 thread_local! {
233 static FRESH_STORES: StoreRegistry = StoreRegistry::new();
234 static CURRENT_STORES: StoreRegistry = StoreRegistry::new();
235 }
236
237 struct CurrentCanister;
238
239 impl Path for CurrentCanister {
240 const PATH: &'static str = "startup_tests::CurrentCanister";
241 }
242
243 impl CanisterKind for CurrentCanister {
244 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
245 Ok(228)
246 }
247 const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_current.commit.v1";
248 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
249 Ok(229)
250 }
251 const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_current.control.v1";
252 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
253 Ok(230)
254 }
255 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
256 "icydb.test.startup_current.integrity.v1";
257 }
258
259 struct CorruptCanister;
260
261 impl Path for CorruptCanister {
262 const PATH: &'static str = "startup_tests::CorruptCanister";
263 }
264
265 impl CanisterKind for CorruptCanister {
266 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
267 Ok(224)
268 }
269 const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_corrupt.commit.v1";
270 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
271 Ok(225)
272 }
273 const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_corrupt.control.v1";
274 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
275 Ok(226)
276 }
277 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
278 "icydb.test.startup_corrupt.integrity.v1";
279 }
280
281 thread_local! {
282 static CORRUPT_STORES: StoreRegistry = StoreRegistry::new();
283 }
284
285 struct DriverCanister;
286
287 impl Path for DriverCanister {
288 const PATH: &'static str = "startup_tests::DriverCanister";
289 }
290
291 impl CanisterKind for DriverCanister {
292 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
293 Ok(248)
294 }
295 const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup_driver.commit.v1";
296 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
297 Ok(249)
298 }
299 const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup_driver.control.v1";
300 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
301 Ok(250)
302 }
303 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
304 "icydb.test.startup_driver.integrity.v1";
305 }
306
307 thread_local! {
308 static DRIVER_STORES: StoreRegistry = StoreRegistry::new();
309 }
310
311 struct CardinalityDriverCanister;
312
313 impl Path for CardinalityDriverCanister {
314 const PATH: &'static str = "startup_tests::CardinalityDriverCanister";
315 }
316
317 impl CanisterKind for CardinalityDriverCanister {
318 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
321 Ok(217)
322 }
323 const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup.cardinality.driver.commit.v1";
324 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
325 Ok(218)
326 }
327 const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup.cardinality.driver.control.v1";
328 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
329 Ok(219)
330 }
331 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
332 "icydb.test.startup.cardinality.driver.integrity.v1";
333 }
334
335 thread_local! {
336 static CARDINALITY_DRIVER_DATA: RefCell<DataStore> =
337 RefCell::new(DataStore::init_journaled(test_memory(210)));
338 static CARDINALITY_DRIVER_INDEX: RefCell<IndexStore> =
339 RefCell::new(IndexStore::init_journaled(test_memory(211)));
340 static CARDINALITY_DRIVER_SCHEMA: RefCell<SchemaStore> =
341 RefCell::new(SchemaStore::init_journaled(test_memory(212)));
342 static CARDINALITY_DRIVER_TAIL: RefCell<JournalTailStore> =
343 RefCell::new(JournalTailStore::init(test_memory(213)));
344 static CARDINALITY_DRIVER_STORES: StoreRegistry = {
345 let mut registry = StoreRegistry::new();
346 registry.register_journaled_store(
347 "startup_tests::CardinalityDriverStore",
348 &CARDINALITY_DRIVER_DATA,
349 &CARDINALITY_DRIVER_INDEX,
350 &CARDINALITY_DRIVER_SCHEMA,
351 &CARDINALITY_DRIVER_TAIL,
352 StoreAllocationIdentities::new_journaled(
353 StoreAllocationIdentity::new(210, "icydb.test.cardinality.driver.data.v1"),
354 StoreAllocationIdentity::new(211, "icydb.test.cardinality.driver.index.v1"),
355 StoreAllocationIdentity::new(212, "icydb.test.cardinality.driver.schema.v1"),
356 StoreAllocationIdentity::new(213, "icydb.test.cardinality.driver.journal.v1"),
357 ),
358 StoreRuntimeStorageCapabilities::journaled(),
359 ).expect("cardinality driver store should register");
360 registry
361 };
362 }
363
364 struct HeapRecoveryFailureCanister;
365
366 impl Path for HeapRecoveryFailureCanister {
367 const PATH: &'static str = "startup_tests::HeapRecoveryFailureCanister";
368 }
369
370 impl CanisterKind for HeapRecoveryFailureCanister {
371 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
372 Ok(251)
373 }
374 const COMMIT_STABLE_KEY: &'static str =
375 "icydb.test.startup.heap.recovery.failure.commit.v1";
376 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
377 Ok(245)
378 }
379 const STARTUP_STABLE_KEY: &'static str =
380 "icydb.test.startup.heap.recovery.failure.control.v1";
381 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
382 Ok(246)
383 }
384 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
385 "icydb.test.startup.heap.recovery.failure.integrity.v1";
386 }
387
388 thread_local! {
389 static HEAP_RECOVERY_FAILURE_STORES: StoreRegistry = StoreRegistry::new();
390 }
391
392 struct HeapCheckpointFailureCanister;
393
394 impl Path for HeapCheckpointFailureCanister {
395 const PATH: &'static str = "startup_tests::HeapCheckpointFailureCanister";
396 }
397
398 impl CanisterKind for HeapCheckpointFailureCanister {
399 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
400 Ok(254)
401 }
402 const COMMIT_STABLE_KEY: &'static str =
403 "icydb.test.startup.heap.checkpoint.failure.commit.v1";
404 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
405 Ok(221)
406 }
407 const STARTUP_STABLE_KEY: &'static str =
408 "icydb.test.startup.heap.checkpoint.failure.control.v1";
409 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
410 Ok(222)
411 }
412 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
413 "icydb.test.startup.heap.checkpoint.failure.integrity.v1";
414 }
415
416 thread_local! {
417 static HEAP_CHECKPOINT_FAILURE_DATA: RefCell<DataStore> =
418 const { RefCell::new(DataStore::init_heap()) };
419 static HEAP_CHECKPOINT_FAILURE_INDEX: RefCell<IndexStore> =
420 const { RefCell::new(IndexStore::init_heap()) };
421 static HEAP_CHECKPOINT_FAILURE_SCHEMA: RefCell<SchemaStore> =
422 const { RefCell::new(SchemaStore::init_heap()) };
423 static HEAP_CHECKPOINT_FAILURE_STORES: StoreRegistry = {
424 let mut registry = StoreRegistry::new();
425 registry.register_store(
426 "startup_tests::HeapCheckpointFailureStore",
427 &HEAP_CHECKPOINT_FAILURE_DATA,
428 &HEAP_CHECKPOINT_FAILURE_INDEX,
429 &HEAP_CHECKPOINT_FAILURE_SCHEMA,
430 StoreAllocationIdentities::absent(),
431 StoreRuntimeStorageCapabilities::heap(),
432 ).expect("heap checkpoint failure store should register");
433 registry
434 };
435 }
436
437 const JOURNAL_RECOVERY_FAILURE_STORE_PATH: &str = "startup_tests::JournalRecoveryFailureStore";
438
439 struct JournalRecoveryFailureCanister;
440
441 impl Path for JournalRecoveryFailureCanister {
442 const PATH: &'static str = "startup_tests::JournalRecoveryFailureCanister";
443 }
444
445 impl CanisterKind for JournalRecoveryFailureCanister {
446 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
447 Ok(185)
448 }
449 const COMMIT_STABLE_KEY: &'static str =
450 "icydb.test.startup.journal.recovery.failure.commit.v1";
451 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
452 Ok(186)
453 }
454 const STARTUP_STABLE_KEY: &'static str =
455 "icydb.test.startup.journal.recovery.failure.control.v1";
456 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
457 Ok(187)
458 }
459 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
460 "icydb.test.startup.journal.recovery.failure.integrity.v1";
461 }
462
463 thread_local! {
464 static JOURNAL_RECOVERY_FAILURE_DATA: RefCell<DataStore> =
465 RefCell::new(DataStore::init_journaled(test_memory(181)));
466 static JOURNAL_RECOVERY_FAILURE_INDEX: RefCell<IndexStore> =
467 RefCell::new(IndexStore::init_journaled(test_memory(182)));
468 static JOURNAL_RECOVERY_FAILURE_SCHEMA: RefCell<SchemaStore> =
469 RefCell::new(SchemaStore::init_journaled(test_memory(183)));
470 static JOURNAL_RECOVERY_FAILURE_TAIL: RefCell<JournalTailStore> =
471 RefCell::new(JournalTailStore::init(test_memory(184)));
472 static JOURNAL_RECOVERY_FAILURE_STORES: StoreRegistry = {
473 let mut registry = StoreRegistry::new();
474 registry.register_journaled_store(
475 JOURNAL_RECOVERY_FAILURE_STORE_PATH,
476 &JOURNAL_RECOVERY_FAILURE_DATA,
477 &JOURNAL_RECOVERY_FAILURE_INDEX,
478 &JOURNAL_RECOVERY_FAILURE_SCHEMA,
479 &JOURNAL_RECOVERY_FAILURE_TAIL,
480 StoreAllocationIdentities::new_journaled(
481 StoreAllocationIdentity::new(
482 181,
483 "icydb.test.startup.journal.recovery.failure.data.v1",
484 ),
485 StoreAllocationIdentity::new(
486 182,
487 "icydb.test.startup.journal.recovery.failure.index.v1",
488 ),
489 StoreAllocationIdentity::new(
490 183,
491 "icydb.test.startup.journal.recovery.failure.schema.v1",
492 ),
493 StoreAllocationIdentity::new(
494 184,
495 "icydb.test.startup.journal.recovery.failure.journal.v1",
496 ),
497 ),
498 StoreRuntimeStorageCapabilities::journaled(),
499 ).expect("journal recovery failure store should register");
500 registry
501 };
502 }
503
504 #[test]
505 fn fresh_observation_is_recovering_and_performs_no_stable_write() {
506 assert_eq!(
507 observe_generated_startup_state::<FreshCanister>(
508 &FRESH_STORES,
509 "generated/0123456789abcdef",
510 ),
511 Ok(DatabaseStartupState::Recovering)
512 );
513 let commit = commit_memory_handle(
514 current_commit_memory_allocation().expect("commit allocation should configure"),
515 )
516 .expect("commit memory should reopen");
517 let startup =
518 receipt::startup_memory::<FreshCanister>().expect("startup memory should reopen");
519 assert_eq!(commit.size(), 0);
520 assert_eq!(startup.size(), 0);
521 }
522
523 #[test]
524 fn terminal_classification_is_typed_and_pending_or_internal_failures_remain_retryable() {
525 let corruption = InternalError::store_corruption();
526 assert!(
527 classify_terminal_failure(StartupFailureKind::JournalRecovery, &corruption).is_some()
528 );
529 let pending = InternalError::recovery_pending();
530 assert!(classify_terminal_failure(StartupFailureKind::JournalRecovery, &pending).is_none());
531 let transient = InternalError::recovery_database_format_control_unavailable();
532 assert!(
533 classify_terminal_failure(StartupFailureKind::DatabaseControl, &transient).is_none()
534 );
535 let migration_pending = InternalError::schema_migration(
536 icydb_diagnostic_code::SchemaMigrationCode::MigrationInProgress,
537 );
538 assert!(
539 classify_terminal_failure(StartupFailureKind::SchemaReconciliation, &migration_pending)
540 .is_none()
541 );
542 assert!(
543 classify_terminal_failure(
544 StartupFailureKind::SchemaReconciliation,
545 &InternalError::store_unsupported(),
546 )
547 .is_some(),
548 "genuinely unsupported schema application remains terminal",
549 );
550 }
551
552 #[test]
553 fn non_current_fixed_boot_magic_surfaces_directly_without_a_failure_receipt() {
554 select_commit_memory_allocation(
555 CorruptCanister::commit_memory_id().expect("test allocation"),
556 CorruptCanister::COMMIT_STABLE_KEY,
557 );
558 let memory = commit_memory_handle(
559 current_commit_memory_allocation().expect("commit allocation should resolve"),
560 )
561 .expect("commit memory should open");
562 assert_eq!(memory.grow(1), 0);
563 memory.write(0, b"NOTICYDBCONTROL");
564
565 let failure = observe_generated_startup_state::<CorruptCanister>(
566 &CORRUPT_STORES,
567 "generated/0123456789abcdef",
568 )
569 .expect_err("non-current boot magic must fail directly");
570 assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
571 assert_eq!(
572 failure.diagnostic().class(),
573 icydb_diagnostic_code::ErrorClass::IncompatiblePersistedFormat,
574 );
575 assert_eq!(
576 receipt::startup_memory::<CorruptCanister>()
577 .expect("startup memory should open")
578 .size(),
579 0,
580 );
581 }
582
583 #[test]
584 fn heap_only_malformed_marker_becomes_a_durable_database_control_failure() {
585 const SUBMISSION: &str = "generated/89abcdef01234567";
586
587 select_commit_memory_allocation(
588 HeapRecoveryFailureCanister::commit_memory_id().expect("test allocation"),
589 HeapRecoveryFailureCanister::COMMIT_STABLE_KEY,
590 );
591 let memory = commit_memory_handle(
592 current_commit_memory_allocation().expect("commit allocation should resolve"),
593 )
594 .expect("commit memory should open");
595 initialize_current_database_control_for_tests(&memory);
596 persist_raw_commit_marker_for_tests(vec![0xff])
597 .expect("malformed marker payload should persist inside valid control authority");
598
599 assert_eq!(
600 observe_generated_startup_state::<HeapRecoveryFailureCanister>(
601 &HEAP_RECOVERY_FAILURE_STORES,
602 SUBMISSION,
603 ),
604 Ok(DatabaseStartupState::Recovering),
605 "bounded observation must not decode marker payloads",
606 );
607
608 let request_root = RequestExecutionRoot::__new_runtime_root();
609 let session = crate::db::DbSession::<HeapRecoveryFailureCanister>::new(
610 &HEAP_RECOVERY_FAILURE_STORES,
611 &request_root,
612 );
613 assert_eq!(
614 drive_generated_startup_recovery_page(
615 &session,
616 &HEAP_RECOVERY_FAILURE_STORES,
617 SUBMISSION,
618 )
619 .expect("terminal corruption should publish one durable receipt"),
620 GeneratedStartupDriverStep::Terminal,
621 );
622
623 let failure = observe_generated_startup_state::<HeapRecoveryFailureCanister>(
624 &HEAP_RECOVERY_FAILURE_STORES,
625 SUBMISSION,
626 )
627 .expect_err("the durable database-control failure should replace blind recovery retries");
628 assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
629 assert_eq!(
630 failure.diagnostic().error_code(),
631 ErrorCode::RUNTIME_CORRUPTION
632 );
633 assert_eq!(
634 drive_generated_startup_recovery_page(
635 &session,
636 &HEAP_RECOVERY_FAILURE_STORES,
637 SUBMISSION,
638 )
639 .expect("exact terminal replay should not attempt recovery again"),
640 GeneratedStartupDriverStep::Terminal,
641 );
642 }
643
644 #[test]
645 fn heap_only_schema_control_corruption_becomes_a_durable_database_control_failure() {
646 const SUBMISSION: &str = "generated/76543210fedcba98";
647
648 select_commit_memory_allocation(
649 HeapCheckpointFailureCanister::commit_memory_id().expect("test allocation"),
650 HeapCheckpointFailureCanister::COMMIT_STABLE_KEY,
651 );
652 let memory = commit_memory_handle(
653 current_commit_memory_allocation().expect("commit allocation should resolve"),
654 )
655 .expect("commit memory should open");
656 initialize_current_database_control_for_tests(&memory);
657 corrupt_schema_control_header_for_tests()
658 .expect("schema-control authority should admit focused corruption");
659
660 let request_root = RequestExecutionRoot::__new_runtime_root();
661 let session = crate::db::DbSession::<HeapCheckpointFailureCanister>::new(
662 &HEAP_CHECKPOINT_FAILURE_STORES,
663 &request_root,
664 );
665 assert_eq!(
666 drive_generated_startup_recovery_page(
667 &session,
668 &HEAP_CHECKPOINT_FAILURE_STORES,
669 SUBMISSION,
670 )
671 .expect("checkpoint corruption should publish one durable receipt"),
672 GeneratedStartupDriverStep::Terminal,
673 );
674
675 let failure = observe_generated_startup_state::<HeapCheckpointFailureCanister>(
676 &HEAP_CHECKPOINT_FAILURE_STORES,
677 SUBMISSION,
678 )
679 .expect_err("the checkpoint failure should remain visible after the timer returns");
680 assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
681 assert_eq!(
682 failure.diagnostic().error_code(),
683 ErrorCode::RUNTIME_CORRUPTION
684 );
685 }
686
687 #[test]
688 fn persisted_journal_record_corruption_becomes_a_durable_journal_failure() {
689 const SUBMISSION: &str = "generated/2280bad0bad0bad0";
690
691 select_commit_memory_allocation(
692 JournalRecoveryFailureCanister::commit_memory_id().expect("test allocation"),
693 JournalRecoveryFailureCanister::COMMIT_STABLE_KEY,
694 );
695 let memory = commit_memory_handle(
696 current_commit_memory_allocation().expect("commit allocation should resolve"),
697 )
698 .expect("commit memory should open");
699 initialize_current_database_control_for_tests(&memory);
700 let format_root = RequestExecutionRoot::__new_runtime_root();
701 let format_database = crate::db::Db::<JournalRecoveryFailureCanister>::new(
702 &JOURNAL_RECOVERY_FAILURE_STORES,
703 format_root.scope(),
704 );
705 ensure_database_format_admitted(&format_database)
706 .expect("current journal registry should initialize before corruption injection");
707
708 let record = JournalRecord::schema_put(JOURNAL_RECOVERY_FAILURE_STORE_PATH, vec![0xff; 8])
709 .expect("syntactically bounded schema record should build");
710 let batch = JournalBatch::new(
711 [0x22; 16],
712 [0x28; 16],
713 JournalSequence::new(1),
714 vec![record],
715 )
716 .expect("syntactically current journal batch should build");
717 JOURNAL_RECOVERY_FAILURE_TAIL.with(|tail| {
718 tail.borrow_mut()
719 .append_batch(&batch)
720 .expect("persisted semantic-corruption fixture should insert");
721 });
722
723 assert_eq!(
724 observe_generated_startup_state::<JournalRecoveryFailureCanister>(
725 &JOURNAL_RECOVERY_FAILURE_STORES,
726 SUBMISSION,
727 ),
728 Ok(DatabaseStartupState::Recovering),
729 );
730 let request_root = RequestExecutionRoot::__new_runtime_root();
731 let session = crate::db::DbSession::<JournalRecoveryFailureCanister>::new(
732 &JOURNAL_RECOVERY_FAILURE_STORES,
733 &request_root,
734 );
735 let drive = || {
736 drive_generated_startup_recovery_page(
737 &session,
738 &JOURNAL_RECOVERY_FAILURE_STORES,
739 SUBMISSION,
740 )
741 .expect("the driver should progress or persist a durable failure")
742 };
743 assert_eq!(drive(), GeneratedStartupDriverStep::Recovering);
744 assert_eq!(drive(), GeneratedStartupDriverStep::Terminal);
745
746 let failure = observe_generated_startup_state::<JournalRecoveryFailureCanister>(
747 &JOURNAL_RECOVERY_FAILURE_STORES,
748 SUBMISSION,
749 )
750 .expect_err("the durable journal failure should replace blind recovery retries");
751 assert_eq!(failure.kind(), StartupFailureKind::JournalRecovery);
752 assert_eq!(
753 failure.diagnostic().error_code(),
754 ErrorCode::STORE_CORRUPTION,
755 );
756 assert_eq!(drive(), GeneratedStartupDriverStep::Terminal);
757
758 let changed_record =
759 JournalRecord::schema_put(JOURNAL_RECOVERY_FAILURE_STORE_PATH, vec![0xfe; 8])
760 .expect("changed semantic-corruption record should build");
761 let changed_batch = JournalBatch::new(
762 [0x23; 16],
763 [0x29; 16],
764 JournalSequence::new(2),
765 vec![changed_record],
766 )
767 .expect("changed journal batch should build");
768 let changed_encoded =
769 encode_journal_batch(&changed_batch).expect("changed journal batch should encode");
770 JOURNAL_RECOVERY_FAILURE_TAIL.with(|tail| {
771 tail.borrow_mut()
772 .insert_raw_batch_for_tests(JournalSequence::new(2), changed_encoded)
773 .expect("changed journal authority should insert");
774 });
775 assert_eq!(
776 observe_generated_startup_state::<JournalRecoveryFailureCanister>(
777 &JOURNAL_RECOVERY_FAILURE_STORES,
778 SUBMISSION,
779 ),
780 Ok(DatabaseStartupState::Recovering),
781 "a receipt bound to the predecessor tail proof must become stale",
782 );
783 }
784
785 #[test]
786 #[expect(
787 clippy::too_many_lines,
788 reason = "one lifecycle test keeps pending, ready, marker, and receipt precedence in one scenario"
789 )]
790 fn completed_recovery_stays_recovering_until_exact_generated_schema_receipt_then_is_ready() {
791 const SUBMISSION: &str = "generated/0123456789abcdef";
792
793 select_commit_memory_allocation(
794 CurrentCanister::commit_memory_id().expect("test allocation"),
795 CurrentCanister::COMMIT_STABLE_KEY,
796 );
797 let memory = commit_memory_handle(
798 current_commit_memory_allocation().expect("commit allocation should resolve"),
799 )
800 .expect("commit memory should open");
801 initialize_current_database_control_for_tests(&memory);
802 let incarnation = database_incarnation_id().expect("control should initialize");
803 mark_startup_recovery_complete_for_tests(&CURRENT_STORES)
804 .expect("recovery witness should publish");
805
806 assert_eq!(
807 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
808 Ok(DatabaseStartupState::Recovering),
809 );
810
811 let (database_identity, accepted_head) =
812 generated_schema_authority(&CURRENT_STORES, incarnation)
813 .expect("schema authority should resolve");
814 let submission_key =
815 SchemaSubmissionKey::try_new(SUBMISSION).expect("submission should admit");
816 let receipt = SchemaChangeReceipt::new(
817 database_identity,
818 submission_key.clone(),
819 SchemaProposalDigest::from_bytes([1; 32]),
820 accepted_head.clone(),
821 SchemaChangeOutcome::NoOp {
822 accepted_head: accepted_head.clone(),
823 },
824 )
825 .expect("terminal schema receipt should admit");
826 let record = SchemaApplicationRecord::new(receipt, Vec::new())
827 .expect("terminal schema record should admit");
828 apply_schema_application_record_op(
829 &SchemaApplicationRecordOp::insert(&record)
830 .expect("schema record operation should admit"),
831 )
832 .expect("schema record should publish");
833 let before = load_schema_application_record_read_only(database_identity, &submission_key)
834 .expect("record should load");
835
836 assert_eq!(
837 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
838 Ok(DatabaseStartupState::Ready),
839 );
840 assert_eq!(
841 load_schema_application_record_read_only(database_identity, &submission_key)
842 .expect("record should reload"),
843 before,
844 "pure readiness observation must not rewrite schema application state",
845 );
846 assert_eq!(
847 receipt::startup_memory::<CurrentCanister>()
848 .expect("startup memory should open")
849 .size(),
850 0,
851 "readiness without a failure must not allocate the receipt cell",
852 );
853
854 let marker = CommitMarker::from_parts([0x5a; 16], Vec::new())
855 .expect("empty marker should admit for control observation");
856 let interrupted = begin_commit(&marker).expect("marker should persist");
857 assert_eq!(
858 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
859 Ok(DatabaseStartupState::Recovering),
860 "a marker must take precedence over a completed volatile witness",
861 );
862 finish_commit(interrupted, |_| Ok(())).expect("empty marker should clear");
863 assert_eq!(
864 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
865 Ok(DatabaseStartupState::Ready),
866 );
867
868 let accepted_head_binding = match accepted_head {
869 icydb_schema::ExpectedAcceptedHead::Empty => receipt::AcceptedHeadBinding::Empty,
870 icydb_schema::ExpectedAcceptedHead::Exact {
871 revision,
872 fingerprint,
873 } => receipt::AcceptedHeadBinding::Exact {
874 revision,
875 fingerprint: fingerprint.to_bytes(),
876 },
877 };
878 let terminal_failure = StartupFailure::new(
879 StartupFailureKind::SchemaReconciliation,
880 ErrorCode::RUNTIME_CONFLICT.diagnostic(DiagnosticOrigin::Recovery),
881 Vec::new(),
882 );
883 let memoized = receipt::StartupFailureReceipt::new(
884 terminal_failure.clone(),
885 receipt::StartupFailureBinding::SchemaReconciliation {
886 incarnation,
887 submission_key: SUBMISSION.to_string(),
888 accepted_head: accepted_head_binding,
889 },
890 )
891 .expect("memoized schema failure should admit");
892 assert!(
893 receipt::publish::<CurrentCanister>(&memoized)
894 .expect("memoized failure should publish")
895 );
896 assert_eq!(
897 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
898 Err(terminal_failure),
899 "one exact matching failure receipt has priority over Ready evidence",
900 );
901 assert_eq!(
902 observe_generated_startup_state::<CurrentCanister>(
903 &CURRENT_STORES,
904 "generated/fedcba9876543210",
905 ),
906 Ok(DatabaseStartupState::Recovering),
907 "a receipt bound to another generated submission must be stale",
908 );
909 assert!(receipt::clear::<CurrentCanister>().expect("test receipt should clear"));
910 }
911
912 #[test]
913 fn driver_completes_one_recovery_page_then_memoizes_only_terminal_schema_failure() {
914 const SUBMISSION: &str = "generated/0011223344556677";
915
916 select_commit_memory_allocation(
917 DriverCanister::commit_memory_id().expect("test allocation"),
918 DriverCanister::COMMIT_STABLE_KEY,
919 );
920 let memory = commit_memory_handle(
921 current_commit_memory_allocation().expect("commit allocation should resolve"),
922 )
923 .expect("commit memory should open");
924 initialize_current_database_control_for_tests(&memory);
925 let request_root = RequestExecutionRoot::__new_runtime_root();
926 let session = crate::db::DbSession::<DriverCanister>::new(&DRIVER_STORES, &request_root);
927
928 assert_eq!(
929 drive_generated_startup_recovery_page(&session, &DRIVER_STORES, SUBMISSION)
930 .expect("empty recovery page should complete"),
931 GeneratedStartupDriverStep::ApplyGeneratedSchema,
932 );
933 assert_eq!(
934 observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION),
935 Ok(DatabaseStartupState::Recovering),
936 "recovery completion alone must not claim generated reconciliation",
937 );
938
939 let retryable = InternalError::recovery_pending();
940 assert!(
941 !record_generated_schema_startup_failure::<DriverCanister>(
942 &DRIVER_STORES,
943 SUBMISSION,
944 retryable.diagnostic(),
945 retryable.diagnostic_facts(),
946 )
947 .expect("retryable classification should complete without publication")
948 );
949 assert_eq!(
950 receipt::startup_memory::<DriverCanister>()
951 .expect("startup memory should open")
952 .size(),
953 0,
954 "retryable failure must not allocate the receipt cell",
955 );
956
957 let terminal = InternalError::store_corruption();
958 let marker = CommitMarker::from_parts([0x7b; 16], Vec::new())
959 .expect("empty marker should admit for receipt priority");
960 let interrupted = begin_commit(&marker).expect("marker should persist");
961 assert!(
962 record_generated_schema_startup_failure::<DriverCanister>(
963 &DRIVER_STORES,
964 SUBMISSION,
965 terminal.diagnostic(),
966 terminal.diagnostic_facts(),
967 )
968 .expect("terminal failure should publish")
969 );
970 let observed =
971 observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION)
972 .expect_err("matching terminal receipt should surface");
973 assert_eq!(observed.kind(), StartupFailureKind::SchemaReconciliation);
974 assert_eq!(
975 observed.diagnostic().error_code(),
976 ErrorCode::STORE_CORRUPTION
977 );
978 finish_commit(interrupted, |_| Ok(())).expect("test marker should clear");
979 assert!(
980 clear_generated_startup_failure::<DriverCanister>()
981 .expect("authoritative correction should clear the receipt")
982 );
983 }
984
985 #[test]
986 fn ready_startup_driver_publishes_empty_cardinality_then_quiesces() {
987 const SUBMISSION: &str = "generated/cardinality-driver";
988
989 select_commit_memory_allocation(
990 CardinalityDriverCanister::commit_memory_id().expect("test allocation"),
991 CardinalityDriverCanister::COMMIT_STABLE_KEY,
992 );
993 let memory = commit_memory_handle(
994 current_commit_memory_allocation().expect("commit allocation should resolve"),
995 )
996 .expect("commit memory should open");
997 initialize_current_database_control_for_tests(&memory);
998 let request_root = RequestExecutionRoot::__new_runtime_root();
999 let database = crate::db::Db::<CardinalityDriverCanister>::new(
1000 &CARDINALITY_DRIVER_STORES,
1001 request_root.scope(),
1002 );
1003 ensure_database_format_admitted(&database)
1004 .expect("current store registry should initialize");
1005 mark_startup_recovery_complete_for_tests(&CARDINALITY_DRIVER_STORES)
1006 .expect("recovery witness should publish");
1007 let incarnation = database_incarnation_id().expect("incarnation should resolve");
1008 let (database_identity, accepted_head) =
1009 generated_schema_authority(&CARDINALITY_DRIVER_STORES, incarnation)
1010 .expect("empty generated authority should resolve");
1011 let submission_key =
1012 SchemaSubmissionKey::try_new(SUBMISSION).expect("submission should admit");
1013 let receipt = SchemaChangeReceipt::new(
1014 database_identity,
1015 submission_key,
1016 SchemaProposalDigest::from_bytes([2; 32]),
1017 accepted_head.clone(),
1018 SchemaChangeOutcome::NoOp { accepted_head },
1019 )
1020 .expect("terminal schema receipt should admit");
1021 let record = SchemaApplicationRecord::new(receipt, Vec::new())
1022 .expect("terminal schema record should admit");
1023 apply_schema_application_record_op(
1024 &SchemaApplicationRecordOp::insert(&record)
1025 .expect("schema record operation should admit"),
1026 )
1027 .expect("schema receipt should publish");
1028 assert_eq!(
1029 observe_generated_startup_state::<CardinalityDriverCanister>(
1030 &CARDINALITY_DRIVER_STORES,
1031 SUBMISSION,
1032 ),
1033 Ok(DatabaseStartupState::Ready),
1034 );
1035
1036 let session = crate::db::DbSession::<CardinalityDriverCanister>::new(
1037 &CARDINALITY_DRIVER_STORES,
1038 &request_root,
1039 );
1040 assert_eq!(
1041 drive_generated_startup_recovery_page(
1042 &session,
1043 &CARDINALITY_DRIVER_STORES,
1044 SUBMISSION,
1045 )
1046 .expect("empty cardinality publication should use the existing driver"),
1047 GeneratedStartupDriverStep::Recovering,
1048 );
1049 CARDINALITY_DRIVER_SCHEMA.with_borrow(|schema| {
1050 let header = schema
1051 .cardinality_generation_header()
1052 .expect("cardinality header should decode")
1053 .expect("cardinality header should publish");
1054 assert_eq!(
1055 header.state(),
1056 crate::db::schema::cardinality_generation::CardinalityGenerationState::Ready,
1057 );
1058 });
1059 assert_eq!(
1060 drive_generated_startup_recovery_page(
1061 &session,
1062 &CARDINALITY_DRIVER_STORES,
1063 SUBMISSION,
1064 )
1065 .expect("current cardinality evidence should quiesce"),
1066 GeneratedStartupDriverStep::Terminal,
1067 );
1068 }
1069}