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