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 schema::{
196 SchemaApplicationRecord, SchemaApplicationRecordOp, SchemaChangeOutcome,
197 SchemaChangeReceipt, SchemaStore, apply_schema_application_record_op,
198 corrupt_live_schema_checkpoint_header_for_tests, generated_schema_authority,
199 load_schema_application_record_read_only,
200 },
201 session::RequestExecutionRoot,
202 },
203 traits::Path,
204 };
205 use ic_stable_structures::Memory;
206 use icydb_diagnostic_code::{ErrorCode, ErrorOrigin as DiagnosticOrigin};
207 use icydb_schema::{SchemaProposalDigest, SchemaSubmissionKey};
208 use std::cell::RefCell;
209
210 struct FreshCanister;
211
212 impl Path for FreshCanister {
213 const PATH: &'static str = "startup_tests::FreshCanister";
214 }
215
216 impl CanisterKind for FreshCanister {
217 const COMMIT_MEMORY_ID: u8 = 232;
218 const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup-fresh.commit.v1";
219 const STARTUP_MEMORY_ID: u8 = 233;
220 const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup-fresh.control.v1";
221 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 234;
222 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str = "icydb.test.startup-fresh.integrity.v1";
223 }
224
225 thread_local! {
226 static FRESH_STORES: StoreRegistry = StoreRegistry::new();
227 static CURRENT_STORES: StoreRegistry = StoreRegistry::new();
228 }
229
230 struct CurrentCanister;
231
232 impl Path for CurrentCanister {
233 const PATH: &'static str = "startup_tests::CurrentCanister";
234 }
235
236 impl CanisterKind for CurrentCanister {
237 const COMMIT_MEMORY_ID: u8 = 228;
238 const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup-current.commit.v1";
239 const STARTUP_MEMORY_ID: u8 = 229;
240 const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup-current.control.v1";
241 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 230;
242 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
243 "icydb.test.startup-current.integrity.v1";
244 }
245
246 struct CorruptCanister;
247
248 impl Path for CorruptCanister {
249 const PATH: &'static str = "startup_tests::CorruptCanister";
250 }
251
252 impl CanisterKind for CorruptCanister {
253 const COMMIT_MEMORY_ID: u8 = 224;
254 const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup-corrupt.commit.v1";
255 const STARTUP_MEMORY_ID: u8 = 225;
256 const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup-corrupt.control.v1";
257 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 226;
258 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
259 "icydb.test.startup-corrupt.integrity.v1";
260 }
261
262 thread_local! {
263 static CORRUPT_STORES: StoreRegistry = StoreRegistry::new();
264 }
265
266 struct DriverCanister;
267
268 impl Path for DriverCanister {
269 const PATH: &'static str = "startup_tests::DriverCanister";
270 }
271
272 impl CanisterKind for DriverCanister {
273 const COMMIT_MEMORY_ID: u8 = 248;
274 const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup-driver.commit.v1";
275 const STARTUP_MEMORY_ID: u8 = 249;
276 const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup-driver.control.v1";
277 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 250;
278 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
279 "icydb.test.startup-driver.integrity.v1";
280 }
281
282 thread_local! {
283 static DRIVER_STORES: StoreRegistry = StoreRegistry::new();
284 }
285
286 struct HeapRecoveryFailureCanister;
287
288 impl Path for HeapRecoveryFailureCanister {
289 const PATH: &'static str = "startup_tests::HeapRecoveryFailureCanister";
290 }
291
292 impl CanisterKind for HeapRecoveryFailureCanister {
293 const COMMIT_MEMORY_ID: u8 = 251;
294 const COMMIT_STABLE_KEY: &'static str =
295 "icydb.test.startup.heap.recovery.failure.commit.v1";
296 const STARTUP_MEMORY_ID: u8 = 245;
297 const STARTUP_STABLE_KEY: &'static str =
298 "icydb.test.startup.heap.recovery.failure.control.v1";
299 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 246;
300 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
301 "icydb.test.startup.heap.recovery.failure.integrity.v1";
302 }
303
304 thread_local! {
305 static HEAP_RECOVERY_FAILURE_STORES: StoreRegistry = StoreRegistry::new();
306 }
307
308 struct HeapCheckpointFailureCanister;
309
310 impl Path for HeapCheckpointFailureCanister {
311 const PATH: &'static str = "startup_tests::HeapCheckpointFailureCanister";
312 }
313
314 impl CanisterKind for HeapCheckpointFailureCanister {
315 const COMMIT_MEMORY_ID: u8 = 254;
316 const COMMIT_STABLE_KEY: &'static str =
317 "icydb.test.startup.heap.checkpoint.failure.commit.v1";
318 const STARTUP_MEMORY_ID: u8 = 221;
319 const STARTUP_STABLE_KEY: &'static str =
320 "icydb.test.startup.heap.checkpoint.failure.control.v1";
321 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 222;
322 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
323 "icydb.test.startup.heap.checkpoint.failure.integrity.v1";
324 }
325
326 thread_local! {
327 static HEAP_CHECKPOINT_FAILURE_DATA: RefCell<DataStore> =
328 const { RefCell::new(DataStore::init_heap()) };
329 static HEAP_CHECKPOINT_FAILURE_INDEX: RefCell<IndexStore> =
330 const { RefCell::new(IndexStore::init_heap()) };
331 static HEAP_CHECKPOINT_FAILURE_SCHEMA: RefCell<SchemaStore> =
332 const { RefCell::new(SchemaStore::init_heap()) };
333 static HEAP_CHECKPOINT_FAILURE_STORES: StoreRegistry = {
334 let mut registry = StoreRegistry::new();
335 registry.register_store(
336 "startup_tests::HeapCheckpointFailureStore",
337 &HEAP_CHECKPOINT_FAILURE_DATA,
338 &HEAP_CHECKPOINT_FAILURE_INDEX,
339 &HEAP_CHECKPOINT_FAILURE_SCHEMA,
340 StoreAllocationIdentities::absent(),
341 StoreRuntimeStorageCapabilities::heap(),
342 ).expect("heap checkpoint failure store should register");
343 registry
344 };
345 }
346
347 #[test]
348 fn fresh_observation_is_recovering_and_performs_no_stable_write() {
349 assert_eq!(
350 observe_generated_startup_state::<FreshCanister>(
351 &FRESH_STORES,
352 "generated/0123456789abcdef",
353 ),
354 Ok(DatabaseStartupState::Recovering)
355 );
356 let commit = commit_memory_handle(
357 current_commit_memory_allocation().expect("commit allocation should configure"),
358 )
359 .expect("commit memory should reopen");
360 let startup =
361 receipt::startup_memory::<FreshCanister>().expect("startup memory should reopen");
362 assert_eq!(commit.size(), 0);
363 assert_eq!(startup.size(), 0);
364 }
365
366 #[test]
367 fn terminal_classification_is_typed_and_pending_or_internal_failures_remain_retryable() {
368 let corruption = InternalError::store_corruption();
369 assert!(
370 classify_terminal_failure(StartupFailureKind::JournalRecovery, &corruption).is_some()
371 );
372 let pending = InternalError::recovery_pending();
373 assert!(classify_terminal_failure(StartupFailureKind::JournalRecovery, &pending).is_none());
374 let transient = InternalError::recovery_database_format_control_unavailable();
375 assert!(
376 classify_terminal_failure(StartupFailureKind::DatabaseControl, &transient).is_none()
377 );
378 }
379
380 #[test]
381 fn malformed_fixed_boot_control_surfaces_directly_without_a_failure_receipt() {
382 configure_commit_memory_id(
383 CorruptCanister::COMMIT_MEMORY_ID,
384 CorruptCanister::COMMIT_STABLE_KEY,
385 )
386 .expect("commit allocation should configure");
387 let memory = commit_memory_handle(
388 current_commit_memory_allocation().expect("commit allocation should resolve"),
389 )
390 .expect("commit memory should open");
391 assert_eq!(memory.grow(1), 0);
392 memory.write(0, b"NOTICYDBCONTROL");
393
394 let failure = observe_generated_startup_state::<CorruptCanister>(
395 &CORRUPT_STORES,
396 "generated/0123456789abcdef",
397 )
398 .expect_err("malformed boot control must fail directly");
399 assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
400 assert_eq!(
401 failure.diagnostic().class(),
402 icydb_diagnostic_code::ErrorClass::Corruption,
403 );
404 assert_eq!(
405 receipt::startup_memory::<CorruptCanister>()
406 .expect("startup memory should open")
407 .size(),
408 0,
409 );
410 }
411
412 #[test]
413 fn heap_only_malformed_marker_becomes_a_durable_database_control_failure() {
414 const SUBMISSION: &str = "generated/89abcdef01234567";
415
416 configure_commit_memory_id(
417 HeapRecoveryFailureCanister::COMMIT_MEMORY_ID,
418 HeapRecoveryFailureCanister::COMMIT_STABLE_KEY,
419 )
420 .expect("commit allocation should configure");
421 let memory = commit_memory_handle(
422 current_commit_memory_allocation().expect("commit allocation should resolve"),
423 )
424 .expect("commit memory should open");
425 initialize_current_database_control_for_tests(&memory);
426 persist_raw_commit_marker_for_tests(vec![0xff])
427 .expect("malformed marker payload should persist inside valid control authority");
428
429 assert_eq!(
430 observe_generated_startup_state::<HeapRecoveryFailureCanister>(
431 &HEAP_RECOVERY_FAILURE_STORES,
432 SUBMISSION,
433 ),
434 Ok(DatabaseStartupState::Recovering),
435 "bounded observation must not decode marker payloads",
436 );
437
438 let request_root = RequestExecutionRoot::__new_runtime_root();
439 let session = crate::db::DbSession::<HeapRecoveryFailureCanister>::new(
440 &HEAP_RECOVERY_FAILURE_STORES,
441 &request_root,
442 );
443 assert_eq!(
444 drive_generated_startup_recovery_page(
445 &session,
446 &HEAP_RECOVERY_FAILURE_STORES,
447 SUBMISSION,
448 )
449 .expect("terminal corruption should publish one durable receipt"),
450 GeneratedStartupDriverStep::Terminal,
451 );
452
453 let failure = observe_generated_startup_state::<HeapRecoveryFailureCanister>(
454 &HEAP_RECOVERY_FAILURE_STORES,
455 SUBMISSION,
456 )
457 .expect_err("the durable database-control failure should replace blind recovery retries");
458 assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
459 assert_eq!(
460 failure.diagnostic().error_code(),
461 ErrorCode::RUNTIME_CORRUPTION
462 );
463 assert_eq!(
464 drive_generated_startup_recovery_page(
465 &session,
466 &HEAP_RECOVERY_FAILURE_STORES,
467 SUBMISSION,
468 )
469 .expect("exact terminal replay should not attempt recovery again"),
470 GeneratedStartupDriverStep::Terminal,
471 );
472 }
473
474 #[test]
475 fn heap_only_checkpoint_corruption_becomes_a_durable_database_control_failure() {
476 const SUBMISSION: &str = "generated/76543210fedcba98";
477
478 configure_commit_memory_id(
479 HeapCheckpointFailureCanister::COMMIT_MEMORY_ID,
480 HeapCheckpointFailureCanister::COMMIT_STABLE_KEY,
481 )
482 .expect("commit allocation should configure");
483 let memory = commit_memory_handle(
484 current_commit_memory_allocation().expect("commit allocation should resolve"),
485 )
486 .expect("commit memory should open");
487 initialize_current_database_control_for_tests(&memory);
488 corrupt_live_schema_checkpoint_header_for_tests()
489 .expect("checkpoint authority should admit focused corruption");
490
491 let request_root = RequestExecutionRoot::__new_runtime_root();
492 let session = crate::db::DbSession::<HeapCheckpointFailureCanister>::new(
493 &HEAP_CHECKPOINT_FAILURE_STORES,
494 &request_root,
495 );
496 assert_eq!(
497 drive_generated_startup_recovery_page(
498 &session,
499 &HEAP_CHECKPOINT_FAILURE_STORES,
500 SUBMISSION,
501 )
502 .expect("checkpoint corruption should publish one durable receipt"),
503 GeneratedStartupDriverStep::Terminal,
504 );
505
506 let failure = observe_generated_startup_state::<HeapCheckpointFailureCanister>(
507 &HEAP_CHECKPOINT_FAILURE_STORES,
508 SUBMISSION,
509 )
510 .expect_err("the checkpoint failure should remain visible after the timer returns");
511 assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
512 assert_eq!(
513 failure.diagnostic().error_code(),
514 ErrorCode::RUNTIME_CORRUPTION
515 );
516 }
517
518 #[test]
519 #[expect(
520 clippy::too_many_lines,
521 reason = "one lifecycle test keeps pending, ready, marker, and receipt precedence in one scenario"
522 )]
523 fn completed_recovery_stays_recovering_until_exact_generated_schema_receipt_then_is_ready() {
524 const SUBMISSION: &str = "generated/0123456789abcdef";
525
526 configure_commit_memory_id(
527 CurrentCanister::COMMIT_MEMORY_ID,
528 CurrentCanister::COMMIT_STABLE_KEY,
529 )
530 .expect("commit allocation should configure");
531 let memory = commit_memory_handle(
532 current_commit_memory_allocation().expect("commit allocation should resolve"),
533 )
534 .expect("commit memory should open");
535 initialize_current_database_control_for_tests(&memory);
536 let incarnation = database_incarnation_id().expect("control should initialize");
537 mark_startup_recovery_complete_for_tests(&CURRENT_STORES)
538 .expect("recovery witness should publish");
539
540 assert_eq!(
541 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
542 Ok(DatabaseStartupState::Recovering),
543 );
544
545 let (database_identity, accepted_head) =
546 generated_schema_authority(&CURRENT_STORES, incarnation)
547 .expect("schema authority should resolve");
548 let submission_key =
549 SchemaSubmissionKey::try_new(SUBMISSION).expect("submission should admit");
550 let receipt = SchemaChangeReceipt::new(
551 database_identity,
552 submission_key.clone(),
553 SchemaProposalDigest::from_bytes([1; 32]),
554 accepted_head.clone(),
555 SchemaChangeOutcome::NoOp {
556 accepted_head: accepted_head.clone(),
557 },
558 )
559 .expect("terminal schema receipt should admit");
560 let record = SchemaApplicationRecord::new(receipt, Vec::new())
561 .expect("terminal schema record should admit");
562 apply_schema_application_record_op(
563 &SchemaApplicationRecordOp::insert(&record)
564 .expect("schema record operation should admit"),
565 )
566 .expect("schema record should publish");
567 let before = load_schema_application_record_read_only(database_identity, &submission_key)
568 .expect("record should load");
569
570 assert_eq!(
571 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
572 Ok(DatabaseStartupState::Ready),
573 );
574 assert_eq!(
575 load_schema_application_record_read_only(database_identity, &submission_key)
576 .expect("record should reload"),
577 before,
578 "pure readiness observation must not rewrite schema application state",
579 );
580 assert_eq!(
581 receipt::startup_memory::<CurrentCanister>()
582 .expect("startup memory should open")
583 .size(),
584 0,
585 "readiness without a failure must not allocate the receipt cell",
586 );
587
588 let marker = CommitMarker::from_parts([0x5a; 16], Vec::new())
589 .expect("empty marker should admit for control observation");
590 let interrupted = begin_commit(marker).expect("marker should persist");
591 assert_eq!(
592 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
593 Ok(DatabaseStartupState::Recovering),
594 "a marker must take precedence over a completed volatile witness",
595 );
596 finish_commit(interrupted, |_| Ok(())).expect("empty marker should clear");
597 assert_eq!(
598 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
599 Ok(DatabaseStartupState::Ready),
600 );
601
602 let accepted_head_binding = match accepted_head {
603 icydb_schema::ExpectedAcceptedHead::Empty => receipt::AcceptedHeadBinding::Empty,
604 icydb_schema::ExpectedAcceptedHead::Exact {
605 revision,
606 fingerprint,
607 } => receipt::AcceptedHeadBinding::Exact {
608 revision,
609 fingerprint: fingerprint.to_bytes(),
610 },
611 };
612 let terminal_failure = StartupFailure::new(
613 StartupFailureKind::SchemaReconciliation,
614 ErrorCode::RUNTIME_CONFLICT.diagnostic(DiagnosticOrigin::Recovery),
615 Vec::new(),
616 );
617 let memoized = receipt::StartupFailureReceipt::new(
618 terminal_failure.clone(),
619 receipt::StartupFailureBinding::SchemaReconciliation {
620 incarnation,
621 submission_key: SUBMISSION.to_string(),
622 accepted_head: accepted_head_binding,
623 },
624 )
625 .expect("memoized schema failure should admit");
626 assert!(
627 receipt::publish::<CurrentCanister>(&memoized)
628 .expect("memoized failure should publish")
629 );
630 assert_eq!(
631 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
632 Err(terminal_failure),
633 "one exact matching failure receipt has priority over Ready evidence",
634 );
635 assert_eq!(
636 observe_generated_startup_state::<CurrentCanister>(
637 &CURRENT_STORES,
638 "generated/fedcba9876543210",
639 ),
640 Ok(DatabaseStartupState::Recovering),
641 "a receipt bound to another generated submission must be stale",
642 );
643 assert!(receipt::clear::<CurrentCanister>().expect("test receipt should clear"));
644 }
645
646 #[test]
647 fn driver_completes_one_recovery_page_then_memoizes_only_terminal_schema_failure() {
648 const SUBMISSION: &str = "generated/0011223344556677";
649
650 configure_commit_memory_id(
651 DriverCanister::COMMIT_MEMORY_ID,
652 DriverCanister::COMMIT_STABLE_KEY,
653 )
654 .expect("commit allocation should configure");
655 let memory = commit_memory_handle(
656 current_commit_memory_allocation().expect("commit allocation should resolve"),
657 )
658 .expect("commit memory should open");
659 initialize_current_database_control_for_tests(&memory);
660 let request_root = RequestExecutionRoot::__new_runtime_root();
661 let session = crate::db::DbSession::<DriverCanister>::new(&DRIVER_STORES, &request_root);
662
663 assert_eq!(
664 drive_generated_startup_recovery_page(&session, &DRIVER_STORES, SUBMISSION)
665 .expect("empty recovery page should complete"),
666 GeneratedStartupDriverStep::ApplyGeneratedSchema,
667 );
668 assert_eq!(
669 observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION),
670 Ok(DatabaseStartupState::Recovering),
671 "recovery completion alone must not claim generated reconciliation",
672 );
673
674 let retryable = InternalError::recovery_pending();
675 assert!(
676 !record_generated_schema_startup_failure::<DriverCanister>(
677 &DRIVER_STORES,
678 SUBMISSION,
679 retryable.diagnostic(),
680 retryable.diagnostic_facts(),
681 )
682 .expect("retryable classification should complete without publication")
683 );
684 assert_eq!(
685 receipt::startup_memory::<DriverCanister>()
686 .expect("startup memory should open")
687 .size(),
688 0,
689 "retryable failure must not allocate the receipt cell",
690 );
691
692 let terminal = InternalError::store_corruption();
693 let marker = CommitMarker::from_parts([0x7b; 16], Vec::new())
694 .expect("empty marker should admit for receipt priority");
695 let interrupted = begin_commit(marker).expect("marker should persist");
696 assert!(
697 record_generated_schema_startup_failure::<DriverCanister>(
698 &DRIVER_STORES,
699 SUBMISSION,
700 terminal.diagnostic(),
701 terminal.diagnostic_facts(),
702 )
703 .expect("terminal failure should publish")
704 );
705 let observed =
706 observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION)
707 .expect_err("matching terminal receipt should surface");
708 assert_eq!(observed.kind(), StartupFailureKind::SchemaReconciliation);
709 assert_eq!(
710 observed.diagnostic().error_code(),
711 ErrorCode::STORE_CORRUPTION
712 );
713 finish_commit(interrupted, |_| Ok(())).expect("test marker should clear");
714 assert!(
715 clear_generated_startup_failure::<DriverCanister>()
716 .expect("authoritative correction should clear the receipt")
717 );
718 }
719}