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