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_schema_control_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_memory::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 non_current_fixed_boot_magic_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("non-current boot magic must fail directly");
508 assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
509 assert_eq!(
510 failure.diagnostic().class(),
511 icydb_diagnostic_code::ErrorClass::IncompatiblePersistedFormat,
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_schema_control_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_schema_control_header_for_tests()
598 .expect("schema-control 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 let drive = || {
677 drive_generated_startup_recovery_page(
678 &session,
679 &JOURNAL_RECOVERY_FAILURE_STORES,
680 SUBMISSION,
681 )
682 .expect("the driver should progress or persist a durable failure")
683 };
684 assert_eq!(drive(), GeneratedStartupDriverStep::Recovering);
685 assert_eq!(drive(), GeneratedStartupDriverStep::Terminal);
686
687 let failure = observe_generated_startup_state::<JournalRecoveryFailureCanister>(
688 &JOURNAL_RECOVERY_FAILURE_STORES,
689 SUBMISSION,
690 )
691 .expect_err("the durable journal failure should replace blind recovery retries");
692 assert_eq!(failure.kind(), StartupFailureKind::JournalRecovery);
693 assert_eq!(
694 failure.diagnostic().error_code(),
695 ErrorCode::STORE_CORRUPTION,
696 );
697 assert_eq!(drive(), GeneratedStartupDriverStep::Terminal);
698
699 let changed_record =
700 JournalRecord::schema_put(JOURNAL_RECOVERY_FAILURE_STORE_PATH, vec![0xfe; 8])
701 .expect("changed semantic-corruption record should build");
702 let changed_batch = JournalBatch::new(
703 [0x23; 16],
704 [0x29; 16],
705 JournalSequence::new(2),
706 vec![changed_record],
707 )
708 .expect("changed journal batch should build");
709 let changed_encoded =
710 encode_journal_batch(&changed_batch).expect("changed journal batch should encode");
711 JOURNAL_RECOVERY_FAILURE_TAIL.with(|tail| {
712 tail.borrow_mut()
713 .insert_raw_batch_for_tests(JournalSequence::new(2), changed_encoded)
714 .expect("changed journal authority should insert");
715 });
716 assert_eq!(
717 observe_generated_startup_state::<JournalRecoveryFailureCanister>(
718 &JOURNAL_RECOVERY_FAILURE_STORES,
719 SUBMISSION,
720 ),
721 Ok(DatabaseStartupState::Recovering),
722 "a receipt bound to the predecessor tail proof must become stale",
723 );
724 }
725
726 #[test]
727 #[expect(
728 clippy::too_many_lines,
729 reason = "one lifecycle test keeps pending, ready, marker, and receipt precedence in one scenario"
730 )]
731 fn completed_recovery_stays_recovering_until_exact_generated_schema_receipt_then_is_ready() {
732 const SUBMISSION: &str = "generated/0123456789abcdef";
733
734 configure_commit_memory_id(
735 CurrentCanister::COMMIT_MEMORY_ID,
736 CurrentCanister::COMMIT_STABLE_KEY,
737 )
738 .expect("commit allocation should configure");
739 let memory = commit_memory_handle(
740 current_commit_memory_allocation().expect("commit allocation should resolve"),
741 )
742 .expect("commit memory should open");
743 initialize_current_database_control_for_tests(&memory);
744 let incarnation = database_incarnation_id().expect("control should initialize");
745 mark_startup_recovery_complete_for_tests(&CURRENT_STORES)
746 .expect("recovery witness should publish");
747
748 assert_eq!(
749 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
750 Ok(DatabaseStartupState::Recovering),
751 );
752
753 let (database_identity, accepted_head) =
754 generated_schema_authority(&CURRENT_STORES, incarnation)
755 .expect("schema authority should resolve");
756 let submission_key =
757 SchemaSubmissionKey::try_new(SUBMISSION).expect("submission should admit");
758 let receipt = SchemaChangeReceipt::new(
759 database_identity,
760 submission_key.clone(),
761 SchemaProposalDigest::from_bytes([1; 32]),
762 accepted_head.clone(),
763 SchemaChangeOutcome::NoOp {
764 accepted_head: accepted_head.clone(),
765 },
766 )
767 .expect("terminal schema receipt should admit");
768 let record = SchemaApplicationRecord::new(receipt, Vec::new())
769 .expect("terminal schema record should admit");
770 apply_schema_application_record_op(
771 &SchemaApplicationRecordOp::insert(&record)
772 .expect("schema record operation should admit"),
773 )
774 .expect("schema record should publish");
775 let before = load_schema_application_record_read_only(database_identity, &submission_key)
776 .expect("record should load");
777
778 assert_eq!(
779 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
780 Ok(DatabaseStartupState::Ready),
781 );
782 assert_eq!(
783 load_schema_application_record_read_only(database_identity, &submission_key)
784 .expect("record should reload"),
785 before,
786 "pure readiness observation must not rewrite schema application state",
787 );
788 assert_eq!(
789 receipt::startup_memory::<CurrentCanister>()
790 .expect("startup memory should open")
791 .size(),
792 0,
793 "readiness without a failure must not allocate the receipt cell",
794 );
795
796 let marker = CommitMarker::from_parts([0x5a; 16], Vec::new())
797 .expect("empty marker should admit for control observation");
798 let interrupted = begin_commit(marker).expect("marker should persist");
799 assert_eq!(
800 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
801 Ok(DatabaseStartupState::Recovering),
802 "a marker must take precedence over a completed volatile witness",
803 );
804 finish_commit(interrupted, |_| Ok(())).expect("empty marker should clear");
805 assert_eq!(
806 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
807 Ok(DatabaseStartupState::Ready),
808 );
809
810 let accepted_head_binding = match accepted_head {
811 icydb_schema::ExpectedAcceptedHead::Empty => receipt::AcceptedHeadBinding::Empty,
812 icydb_schema::ExpectedAcceptedHead::Exact {
813 revision,
814 fingerprint,
815 } => receipt::AcceptedHeadBinding::Exact {
816 revision,
817 fingerprint: fingerprint.to_bytes(),
818 },
819 };
820 let terminal_failure = StartupFailure::new(
821 StartupFailureKind::SchemaReconciliation,
822 ErrorCode::RUNTIME_CONFLICT.diagnostic(DiagnosticOrigin::Recovery),
823 Vec::new(),
824 );
825 let memoized = receipt::StartupFailureReceipt::new(
826 terminal_failure.clone(),
827 receipt::StartupFailureBinding::SchemaReconciliation {
828 incarnation,
829 submission_key: SUBMISSION.to_string(),
830 accepted_head: accepted_head_binding,
831 },
832 )
833 .expect("memoized schema failure should admit");
834 assert!(
835 receipt::publish::<CurrentCanister>(&memoized)
836 .expect("memoized failure should publish")
837 );
838 assert_eq!(
839 observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
840 Err(terminal_failure),
841 "one exact matching failure receipt has priority over Ready evidence",
842 );
843 assert_eq!(
844 observe_generated_startup_state::<CurrentCanister>(
845 &CURRENT_STORES,
846 "generated/fedcba9876543210",
847 ),
848 Ok(DatabaseStartupState::Recovering),
849 "a receipt bound to another generated submission must be stale",
850 );
851 assert!(receipt::clear::<CurrentCanister>().expect("test receipt should clear"));
852 }
853
854 #[test]
855 fn driver_completes_one_recovery_page_then_memoizes_only_terminal_schema_failure() {
856 const SUBMISSION: &str = "generated/0011223344556677";
857
858 configure_commit_memory_id(
859 DriverCanister::COMMIT_MEMORY_ID,
860 DriverCanister::COMMIT_STABLE_KEY,
861 )
862 .expect("commit allocation should configure");
863 let memory = commit_memory_handle(
864 current_commit_memory_allocation().expect("commit allocation should resolve"),
865 )
866 .expect("commit memory should open");
867 initialize_current_database_control_for_tests(&memory);
868 let request_root = RequestExecutionRoot::__new_runtime_root();
869 let session = crate::db::DbSession::<DriverCanister>::new(&DRIVER_STORES, &request_root);
870
871 assert_eq!(
872 drive_generated_startup_recovery_page(&session, &DRIVER_STORES, SUBMISSION)
873 .expect("empty recovery page should complete"),
874 GeneratedStartupDriverStep::ApplyGeneratedSchema,
875 );
876 assert_eq!(
877 observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION),
878 Ok(DatabaseStartupState::Recovering),
879 "recovery completion alone must not claim generated reconciliation",
880 );
881
882 let retryable = InternalError::recovery_pending();
883 assert!(
884 !record_generated_schema_startup_failure::<DriverCanister>(
885 &DRIVER_STORES,
886 SUBMISSION,
887 retryable.diagnostic(),
888 retryable.diagnostic_facts(),
889 )
890 .expect("retryable classification should complete without publication")
891 );
892 assert_eq!(
893 receipt::startup_memory::<DriverCanister>()
894 .expect("startup memory should open")
895 .size(),
896 0,
897 "retryable failure must not allocate the receipt cell",
898 );
899
900 let terminal = InternalError::store_corruption();
901 let marker = CommitMarker::from_parts([0x7b; 16], Vec::new())
902 .expect("empty marker should admit for receipt priority");
903 let interrupted = begin_commit(marker).expect("marker should persist");
904 assert!(
905 record_generated_schema_startup_failure::<DriverCanister>(
906 &DRIVER_STORES,
907 SUBMISSION,
908 terminal.diagnostic(),
909 terminal.diagnostic_facts(),
910 )
911 .expect("terminal failure should publish")
912 );
913 let observed =
914 observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION)
915 .expect_err("matching terminal receipt should surface");
916 assert_eq!(observed.kind(), StartupFailureKind::SchemaReconciliation);
917 assert_eq!(
918 observed.diagnostic().error_code(),
919 ErrorCode::STORE_CORRUPTION
920 );
921 finish_commit(interrupted, |_| Ok(())).expect("test marker should clear");
922 assert!(
923 clear_generated_startup_failure::<DriverCanister>()
924 .expect("authoritative correction should clear the receipt")
925 );
926 }
927
928 #[test]
929 fn ready_startup_driver_publishes_empty_cardinality_then_quiesces() {
930 const SUBMISSION: &str = "generated/cardinality-driver";
931
932 configure_commit_memory_id(
933 CardinalityDriverCanister::COMMIT_MEMORY_ID,
934 CardinalityDriverCanister::COMMIT_STABLE_KEY,
935 )
936 .expect("commit allocation should configure");
937 let memory = commit_memory_handle(
938 current_commit_memory_allocation().expect("commit allocation should resolve"),
939 )
940 .expect("commit memory should open");
941 initialize_current_database_control_for_tests(&memory);
942 let request_root = RequestExecutionRoot::__new_runtime_root();
943 let database = crate::db::Db::<CardinalityDriverCanister>::new(
944 &CARDINALITY_DRIVER_STORES,
945 request_root.scope(),
946 );
947 ensure_database_format_admitted(&database)
948 .expect("current store registry should initialize");
949 mark_startup_recovery_complete_for_tests(&CARDINALITY_DRIVER_STORES)
950 .expect("recovery witness should publish");
951 let incarnation = database_incarnation_id().expect("incarnation should resolve");
952 let (database_identity, accepted_head) =
953 generated_schema_authority(&CARDINALITY_DRIVER_STORES, incarnation)
954 .expect("empty generated authority should resolve");
955 let submission_key =
956 SchemaSubmissionKey::try_new(SUBMISSION).expect("submission should admit");
957 let receipt = SchemaChangeReceipt::new(
958 database_identity,
959 submission_key,
960 SchemaProposalDigest::from_bytes([2; 32]),
961 accepted_head.clone(),
962 SchemaChangeOutcome::NoOp { accepted_head },
963 )
964 .expect("terminal schema receipt should admit");
965 let record = SchemaApplicationRecord::new(receipt, Vec::new())
966 .expect("terminal schema record should admit");
967 apply_schema_application_record_op(
968 &SchemaApplicationRecordOp::insert(&record)
969 .expect("schema record operation should admit"),
970 )
971 .expect("schema receipt should publish");
972 assert_eq!(
973 observe_generated_startup_state::<CardinalityDriverCanister>(
974 &CARDINALITY_DRIVER_STORES,
975 SUBMISSION,
976 ),
977 Ok(DatabaseStartupState::Ready),
978 );
979
980 let session = crate::db::DbSession::<CardinalityDriverCanister>::new(
981 &CARDINALITY_DRIVER_STORES,
982 &request_root,
983 );
984 assert_eq!(
985 drive_generated_startup_recovery_page(
986 &session,
987 &CARDINALITY_DRIVER_STORES,
988 SUBMISSION,
989 )
990 .expect("empty cardinality publication should use the existing driver"),
991 GeneratedStartupDriverStep::Recovering,
992 );
993 CARDINALITY_DRIVER_SCHEMA.with_borrow(|schema| {
994 let header = schema
995 .cardinality_generation_header()
996 .expect("cardinality header should decode")
997 .expect("cardinality header should publish");
998 assert_eq!(
999 header.state(),
1000 crate::db::schema::cardinality_generation::CardinalityGenerationState::Ready,
1001 );
1002 });
1003 assert_eq!(
1004 drive_generated_startup_recovery_page(
1005 &session,
1006 &CARDINALITY_DRIVER_STORES,
1007 SUBMISSION,
1008 )
1009 .expect("current cardinality evidence should quiesce"),
1010 GeneratedStartupDriverStep::Terminal,
1011 );
1012 }
1013}