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::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}