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
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
148pub 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#[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#[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#[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}