1use crate::{
7 db::{
8 Db,
9 codec::{
10 finalize_hash_sha256, new_hash_sha256_prefixed, write_hash_len_u32, write_hash_str_u32,
11 write_hash_tag_u8, write_hash_u64,
12 },
13 commit::{
14 AcceptedSchemaPublication, DatabaseControlOp, database_incarnation_id,
15 ensure_recovery_admitted, publish_accepted_schema_candidates_with_application_record,
16 publish_accepted_schema_candidates_with_database_control,
17 publish_generated_row_local_abort_with_application_record,
18 },
19 data::{DataStore, RawDataStoreKey, StoreVisit},
20 index::{IndexState, IndexStore},
21 integrity::DatabaseIncarnationId,
22 key_taxonomy::RawDataStoreKeyRange,
23 registry::{
24 StoreAllocationIdentity, StoreAllocationIdentityCapability, StoreCommitParticipation,
25 StoreDurability, StoreHandle, StoreRecoveryCapability, StoreRelationSourceCapability,
26 StoreRelationTargetCapability, StoreRuntimeStorageMode, StoreSchemaMetadataCapability,
27 },
28 relation::prove_empty_reverse_relation_domain,
29 schema::ensure_schema_migration_ready_for_schema_changes,
30 schema::{
31 AcceptedSchemaRevision, AcceptedSchemaRevisionBundle, CandidateSchemaRevision,
32 ConstraintActivationKind, ConstraintActivationState, ConstraintId, ConstraintOrigin,
33 ConstraintValidationPhase, ConstraintValidationProgress, ExistingProposalStore,
34 MAX_IDENTITY_STATE_RECORDS_PER_DATABASE, ProposalStoreTarget, SchemaApplicationRecord,
35 SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob, SchemaChangeJobId,
36 SchemaChangeOutcome, SchemaChangeProgress, SchemaChangeProgressStatus,
37 SchemaChangeReceipt, SchemaChangeValidationPhase, StagedUserIndexDomainError,
38 UnpublishedRowLocalValidation, advance_accepted_row_local_constraint_activation,
39 constraint_validation_finding_output, derive_schema_change_job_id,
40 load_schema_application_record_read_only, lower_existing_schema_proposal,
41 lower_generated_existing_schema_proposal, lower_initial_schema_proposal,
42 prove_empty_user_index_domain, validate_unpublished_row_local_candidate_bounded,
43 with_schema_application_store,
44 },
45 },
46 error::InternalError,
47 metrics::{ExecutionMetricsPhase, ExecutionMetricsSpan},
48 traits::CanisterKind,
49 types::EntityTag,
50};
51
52use icydb_schema::{
53 ExpectedAcceptedHead, ExpectedSchemaFingerprint, SchemaProposal, SchemaProposalDigest,
54 SchemaSubmissionKey, TargetDatabaseIdentity, TargetStoreIdentity,
55};
56use serde::Deserialize;
57use sha2::Digest;
58#[cfg(feature = "migration")]
59use std::collections::BTreeMap;
60use std::{cell::Cell, ops::Bound};
61
62#[cfg(feature = "migration")]
63use crate::db::commit::{
64 RecoveryProgress, StartupRecoveryFailure, continue_recovery_with_failure_authority,
65};
66
67#[cfg(feature = "migration")]
68use crate::db::schema::{
69 PersistedSchemaMigrationEntity, PersistedSchemaMigrationFindingKind,
70 PersistedSchemaMigrationIndex, PersistedSchemaMigrationPhase,
71 PersistedSchemaMigrationTransition, SchemaMigrationCommand, SchemaMigrationEntityTransition,
72 SchemaMigrationFinding, SchemaMigrationFindingKind, SchemaMigrationPhase,
73 SchemaMigrationReceipt, SchemaMigrationRecord, SchemaMigrationRecordOp,
74 SchemaMigrationStatusPage, SchemaMigrationStatusRequest,
75 live_schema_checkpoint::{load_entity_source_lineage_catalog, load_schema_migration_record},
76 migration_execution::{
77 cleanup_migration_staging_page, final_validate_migration_page,
78 migration_derived_domain_count, publish_migration_rewrite_page, rewrite_migration_page,
79 },
80 migration_lineage::{
81 AcceptedEntitySourceLineage, AcceptedEntitySourceLineageCatalog,
82 AcceptedEntitySourceLineageState, EntitySourceLineageCatalogOp,
83 },
84 migration_planner::{
85 PlannedEntitySourceLineage, SchemaMigrationPlanningError, plan_entity_source_adoption,
86 plan_new_entity_source_lineage, plan_schema_migration,
87 },
88 migration_validation::{stage_migration_index_entries, validate_migration_page},
89};
90
91#[cfg(feature = "migration")]
92use icydb_diagnostic_code::SchemaMigrationCode;
93#[cfg(feature = "migration")]
94use icydb_schema::{EntitySourceKey, SchemaMigrationPlanDigest};
95
96const DATABASE_TARGET_FINGERPRINT_PROFILE: &[u8] = b"icydb.schema-target.database.v1";
97const STORE_TARGET_FINGERPRINT_PROFILE: &[u8] = b"icydb.schema-target.store.v1";
98const ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE: &[u8] = b"icydb.accepted-schema.database-head.v1";
99#[cfg(feature = "migration")]
100const SCHEMA_MIGRATION_SUBMISSION_PROFILE: &[u8] = b"icydb.schema-migration.submission.v1";
101
102#[derive(Clone, Copy)]
103struct GeneratedDatabaseIdentityCacheEntry {
104 registry: usize,
105 incarnation: DatabaseIncarnationId,
106 identity: TargetDatabaseIdentity,
107}
108
109thread_local! {
110 static GENERATED_DATABASE_IDENTITY: Cell<Option<GeneratedDatabaseIdentityCacheEntry>> =
114 const { Cell::new(None) };
115}
116
117#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
125pub struct SchemaApplicationStore {
126 path: String,
127 identity: TargetStoreIdentity,
128}
129
130impl SchemaApplicationStore {
131 #[must_use]
133 pub const fn path(&self) -> &str {
134 self.path.as_str()
135 }
136
137 #[must_use]
139 pub const fn identity(&self) -> TargetStoreIdentity {
140 self.identity
141 }
142}
143
144#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
153pub struct SchemaApplicationTarget {
154 database_identity: TargetDatabaseIdentity,
155 accepted_head: ExpectedAcceptedHead,
156 stores: Vec<SchemaApplicationStore>,
157}
158
159impl SchemaApplicationTarget {
160 #[must_use]
162 pub const fn database_identity(&self) -> TargetDatabaseIdentity {
163 self.database_identity
164 }
165
166 #[must_use]
168 pub const fn accepted_head(&self) -> &ExpectedAcceptedHead {
169 &self.accepted_head
170 }
171
172 #[must_use]
174 pub const fn stores(&self) -> &[SchemaApplicationStore] {
175 self.stores.as_slice()
176 }
177}
178
179#[derive(Clone, Copy)]
187struct StoreApplicationAuthority {
188 path: &'static str,
189 handle: StoreHandle,
190}
191
192struct PendingApplicationAbort {
194 authority: StoreApplicationAuthority,
195 current: AcceptedSchemaRevisionBundle,
196 entity_tag: EntityTag,
197 constraint_id: ConstraintId,
198 remove_validation_job: bool,
199}
200
201#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209struct AcceptedStoreHead {
210 revision: u64,
211 fingerprint: [u8; 32],
212}
213
214#[derive(Clone)]
216struct DirectGeneratedRowLocalProof {
217 candidate_index: usize,
218 store: StoreHandle,
219 store_path: &'static str,
220 entity_tag: crate::types::EntityTag,
221 entity_path: String,
222 constraint_id: ConstraintId,
223 has_rows: bool,
224}
225
226#[derive(Clone)]
228struct PendingGeneratedRowLocalConstraint {
229 proof: DirectGeneratedRowLocalProof,
230}
231
232struct LoweredApplication {
234 current_bundles: Vec<Option<AcceptedSchemaRevisionBundle>>,
235 candidates: Vec<CandidateSchemaRevision>,
236 pending: Option<PendingGeneratedRowLocalConstraint>,
237}
238
239pub(in crate::db) fn schema_application_target<C: CanisterKind>(
241 db: &Db<C>,
242) -> Result<SchemaApplicationTarget, InternalError> {
243 ensure_recovery_admitted(db)?;
244 let incarnation = database_incarnation_id()?;
245 let mut stores = db.with_store_registry(|registry| {
246 registry
247 .iter()
248 .map(|(path, handle)| StoreApplicationAuthority { path, handle })
249 .collect::<Vec<_>>()
250 });
251 icydb_schema::compact_sort_unstable_by(&mut stores, |left, right| left.path.cmp(right.path));
252
253 let database_identity = derive_database_identity(incarnation.to_bytes(), stores.as_slice());
254 let mut accepted_heads = Vec::with_capacity(stores.len());
255 let mut application_stores = Vec::with_capacity(stores.len());
256 for store in &stores {
257 let root = store
258 .handle
259 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
260 .map(|selection| AcceptedStoreHead {
261 revision: selection.root().revision().get(),
262 fingerprint: selection.root().fingerprint().as_bytes(),
263 });
264 accepted_heads.push((store.path, root));
265 application_stores.push(SchemaApplicationStore {
266 path: store.path.to_string(),
267 identity: derive_store_identity(database_identity, store),
268 });
269 }
270
271 Ok(SchemaApplicationTarget {
272 database_identity,
273 accepted_head: derive_accepted_head(accepted_heads.as_slice()),
274 stores: application_stores,
275 })
276}
277
278pub(in crate::db) fn schema_application_receipt<C: CanisterKind>(
281 db: &Db<C>,
282 database_identity: TargetDatabaseIdentity,
283 submission_key: &SchemaSubmissionKey,
284) -> Result<Option<SchemaChangeReceipt>, InternalError> {
285 ensure_recovery_admitted(db)?;
286 with_schema_application_store(|store| {
287 store
288 .load(database_identity, submission_key)
289 .map(|record| record.map(|record| record.receipt().clone()))
290 })
291}
292
293fn exact_schema_application_receipt(
294 proposal: &SchemaProposal,
295 proposal_digest: SchemaProposalDigest,
296) -> Result<Option<SchemaChangeReceipt>, InternalError> {
297 let Some(record) = with_schema_application_store(|store| {
298 store.load(proposal.target_database(), proposal.submission_key())
299 })?
300 else {
301 return Ok(None);
302 };
303 let receipt = record.receipt();
304 if !receipt.is_exact_submission(
305 proposal.target_database(),
306 proposal.submission_key(),
307 proposal_digest,
308 proposal.expected_head(),
309 ) {
310 return Err(InternalError::schema_application_conflict());
311 }
312 Ok(Some(receipt.clone()))
313}
314
315pub(in crate::db) fn continue_schema_application<C: CanisterKind>(
318 db: &Db<C>,
319 job_id: SchemaChangeJobId,
320 acknowledged_receipt: Option<u64>,
321) -> Result<SchemaChangeProgress, InternalError> {
322 ensure_recovery_admitted(db)?;
323 ensure_schema_migration_ready_for_schema_changes()?;
324 let record = with_schema_application_store(|store| store.load_job(job_id))?
325 .ok_or_else(InternalError::schema_application_conflict)?;
326 let target = schema_application_target(db)?;
327 if target.database_identity() != record.receipt().database_identity() {
328 return Err(InternalError::schema_application_conflict());
329 }
330 let candidate_head = match record.receipt().outcome() {
331 SchemaChangeOutcome::Pending {
332 job,
333 candidate_head,
334 } if job.id() == job_id => candidate_head,
335 SchemaChangeOutcome::Applied { .. } => {
336 return Ok(SchemaChangeProgress::new(
337 record.receipt().clone(),
338 SchemaChangeProgressStatus::Applied,
339 ));
340 }
341 SchemaChangeOutcome::Aborted { .. } => {
342 return Ok(SchemaChangeProgress::new(
343 record.receipt().clone(),
344 SchemaChangeProgressStatus::Aborted,
345 ));
346 }
347 _ => return Err(InternalError::store_corruption()),
348 };
349 let [activation] = record.activations() else {
350 return Err(InternalError::store_corruption());
351 };
352 let authorities = application_authorities(db);
353 let authority = authorities
354 .iter()
355 .find(|authority| {
356 derive_store_identity(target.database_identity(), authority) == activation.store()
357 })
358 .ok_or_else(InternalError::store_corruption)?;
359 let entity_tag = EntityTag::new(activation.entity_tag());
360 let constraint_id = ConstraintId::new(activation.constraint_id())
361 .ok_or_else(InternalError::store_corruption)?;
362 let bundle = authority
363 .handle
364 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
365 .ok_or_else(InternalError::store_corruption)?;
366 if bundle.store_path() != authority.path {
367 return Err(InternalError::store_corruption());
368 }
369 let snapshot = bundle
370 .entity_snapshots()
371 .get(&entity_tag)
372 .ok_or_else(InternalError::store_corruption)?;
373
374 let accepted = snapshot
375 .constraint_catalog()
376 .constraints()
377 .iter()
378 .any(|constraint| {
379 constraint.id() == constraint_id
380 && constraint.origin() == ConstraintOrigin::Generated
381 && matches!(
382 constraint.kind(),
383 crate::db::schema::AcceptedConstraintKind::Check { .. }
384 | crate::db::schema::AcceptedConstraintKind::TargetedRule { .. }
385 )
386 });
387 let pending = snapshot.constraint_catalog().activation(constraint_id);
388 if accepted && pending.is_none() {
389 return finalize_schema_application(
390 db,
391 &record,
392 candidate_head,
393 SchemaChangeProgressStatus::Applied,
394 );
395 }
396 let pending = pending.ok_or_else(InternalError::store_corruption)?;
397 if pending.origin() != ConstraintOrigin::Generated
398 || !matches!(
399 pending.kind(),
400 ConstraintActivationKind::Check { .. } | ConstraintActivationKind::TargetedRule { .. }
401 )
402 {
403 return Err(InternalError::store_corruption());
404 }
405 let entity_path = snapshot.entity_path().to_string();
406 let progress = advance_accepted_row_local_constraint_activation(
407 authority.handle,
408 authority.path,
409 entity_tag,
410 entity_path.as_str(),
411 constraint_id,
412 acknowledged_receipt,
413 )?;
414 let status = schema_change_progress_status(snapshot, entity_tag, constraint_id, progress)?;
415 if status == SchemaChangeProgressStatus::Applied {
416 finalize_schema_application(db, &record, candidate_head, status)
417 } else {
418 Ok(SchemaChangeProgress::new(record.receipt().clone(), status))
419 }
420}
421
422pub(in crate::db) fn abort_schema_application<C: CanisterKind>(
428 db: &Db<C>,
429 job_id: SchemaChangeJobId,
430 acknowledged_receipt: Option<u64>,
431) -> Result<SchemaChangeProgress, InternalError> {
432 ensure_recovery_admitted(db)?;
433 ensure_schema_migration_ready_for_schema_changes()?;
434 let record = with_schema_application_store(|store| store.load_job(job_id))?
435 .ok_or_else(InternalError::schema_application_conflict)?;
436 let target = schema_application_target(db)?;
437 if target.database_identity() != record.receipt().database_identity() {
438 return Err(InternalError::schema_application_conflict());
439 }
440 match record.receipt().outcome() {
441 SchemaChangeOutcome::Applied { .. } => {
442 return Ok(SchemaChangeProgress::new(
443 record.receipt().clone(),
444 SchemaChangeProgressStatus::Applied,
445 ));
446 }
447 SchemaChangeOutcome::Aborted { .. } => {
448 return Ok(SchemaChangeProgress::new(
449 record.receipt().clone(),
450 SchemaChangeProgressStatus::Aborted,
451 ));
452 }
453 SchemaChangeOutcome::Pending { job, .. } if job.id() == job_id => {}
454 SchemaChangeOutcome::NoOp { .. } | SchemaChangeOutcome::Pending { .. } => {
455 return Err(InternalError::store_corruption());
456 }
457 }
458
459 let authorities = application_authorities(db);
460 let abort = prepare_pending_application_abort(
461 target.database_identity(),
462 &record,
463 authorities.as_slice(),
464 acknowledged_receipt,
465 )?;
466 let candidate = aborted_generated_row_local_candidate(
467 &abort.current,
468 abort.entity_tag,
469 abort.constraint_id,
470 )?;
471 let accepted_head =
472 accepted_head_after_candidates(authorities.as_slice(), std::slice::from_ref(&candidate))?;
473 let receipt = SchemaChangeReceipt::new(
474 record.receipt().database_identity(),
475 record.receipt().submission_key().clone(),
476 record.receipt().proposal_digest(),
477 record.receipt().prior_head().clone(),
478 SchemaChangeOutcome::Aborted { accepted_head },
479 )?;
480 let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
481 let operation = SchemaApplicationRecordOp::replace(&record, &terminal)?;
482 if abort.remove_validation_job {
483 publish_generated_row_local_abort_with_application_record(
484 abort.authority.path,
485 abort.authority.handle,
486 abort.current.revision(),
487 &candidate,
488 abort.entity_tag,
489 abort.constraint_id,
490 operation,
491 )?;
492 } else {
493 publish_accepted_schema_candidates_with_application_record(
494 vec![AcceptedSchemaPublication::new(
495 abort.authority.path,
496 abort.authority.handle,
497 abort.current.revision(),
498 &candidate,
499 )],
500 operation,
501 )?;
502 }
503 Ok(SchemaChangeProgress::new(
504 receipt,
505 SchemaChangeProgressStatus::Aborted,
506 ))
507}
508
509fn prepare_pending_application_abort(
510 database_identity: TargetDatabaseIdentity,
511 record: &SchemaApplicationRecord,
512 authorities: &[StoreApplicationAuthority],
513 acknowledged_receipt: Option<u64>,
514) -> Result<PendingApplicationAbort, InternalError> {
515 let [activation] = record.activations() else {
516 return Err(InternalError::store_corruption());
517 };
518 let authority = authorities
519 .iter()
520 .copied()
521 .find(|authority| derive_store_identity(database_identity, authority) == activation.store())
522 .ok_or_else(InternalError::store_corruption)?;
523 let entity_tag = EntityTag::new(activation.entity_tag());
524 let constraint_id = ConstraintId::new(activation.constraint_id())
525 .ok_or_else(InternalError::store_corruption)?;
526 let current = authority
527 .handle
528 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
529 .ok_or_else(InternalError::store_corruption)?;
530 if current.store_path() != authority.path {
531 return Err(InternalError::store_corruption());
532 }
533 let pending = current
534 .entity_snapshots()
535 .get(&entity_tag)
536 .and_then(|snapshot| snapshot.constraint_catalog().activation(constraint_id))
537 .filter(|pending| {
538 pending.origin() == ConstraintOrigin::Generated
539 && matches!(
540 pending.kind(),
541 ConstraintActivationKind::Check { .. }
542 | ConstraintActivationKind::TargetedRule { .. }
543 )
544 })
545 .ok_or_else(InternalError::store_corruption)?;
546 let remove_validation_job = pending_generated_row_local_job_retirement(
547 authority,
548 entity_tag,
549 constraint_id,
550 pending.state(),
551 acknowledged_receipt,
552 )?;
553 Ok(PendingApplicationAbort {
554 authority,
555 current,
556 entity_tag,
557 constraint_id,
558 remove_validation_job,
559 })
560}
561
562fn pending_generated_row_local_job_retirement(
563 authority: StoreApplicationAuthority,
564 entity_tag: EntityTag,
565 constraint_id: ConstraintId,
566 state: ConstraintActivationState,
567 acknowledged_receipt: Option<u64>,
568) -> Result<bool, InternalError> {
569 let job = authority
570 .handle
571 .with_schema(|store| store.constraint_validation_job(entity_tag, constraint_id))?;
572 match state {
573 ConstraintActivationState::EnforcingNewWrites => {
574 if acknowledged_receipt.is_some() || job.is_some() {
575 return Err(InternalError::schema_application_conflict());
576 }
577 Ok(false)
578 }
579 ConstraintActivationState::Validating => {
580 let mut job = job.ok_or_else(InternalError::store_corruption)?;
581 if !job.acknowledge_receipt(acknowledged_receipt) {
582 return Err(InternalError::schema_application_conflict());
583 }
584 Ok(true)
585 }
586 }
587}
588
589fn aborted_generated_row_local_candidate(
590 current: &AcceptedSchemaRevisionBundle,
591 entity_tag: EntityTag,
592 constraint_id: ConstraintId,
593) -> Result<CandidateSchemaRevision, InternalError> {
594 let snapshot = current
595 .entity_snapshots()
596 .get(&entity_tag)
597 .cloned()
598 .ok_or_else(InternalError::store_corruption)?;
599 let _activation = snapshot
600 .constraint_catalog()
601 .activation(constraint_id)
602 .filter(|activation| {
603 activation.origin() == ConstraintOrigin::Generated
604 && matches!(
605 activation.kind(),
606 ConstraintActivationKind::Check { .. }
607 | ConstraintActivationKind::TargetedRule { .. }
608 )
609 })
610 .ok_or_else(InternalError::store_corruption)?;
611 let catalog = snapshot
612 .constraint_catalog()
613 .clone()
614 .with_aborted_activation(constraint_id)
615 .map_err(|_| InternalError::store_invariant())?;
616 let accepted_identity_remains = catalog
617 .constraints()
618 .iter()
619 .any(|constraint| constraint.id() == constraint_id);
620 let mut source_bindings = current.source_bindings().clone();
621 if !accepted_identity_remains {
622 source_bindings.remove_constraint_identity(entity_tag, constraint_id)?;
623 }
624 let revision = current
625 .revision()
626 .checked_next()
627 .ok_or_else(InternalError::store_unsupported)?;
628 CandidateSchemaRevision::from_entity_snapshot(
629 current,
630 revision,
631 entity_tag,
632 snapshot.with_constraint_catalog(catalog),
633 source_bindings,
634 )
635}
636
637pub(in crate::db) fn apply_schema<C: CanisterKind>(
640 db: &Db<C>,
641 proposal: &SchemaProposal,
642) -> Result<SchemaChangeReceipt, InternalError> {
643 apply_schema_with_contract::<C, true>(db, proposal)
644}
645
646pub(in crate::db) fn apply_generated_schema<C: CanisterKind>(
649 db: &Db<C>,
650 proposal: &SchemaProposal,
651) -> Result<SchemaChangeReceipt, InternalError> {
652 if !proposal.removals().is_empty() {
653 return Err(InternalError::store_invariant());
654 }
655 apply_schema_with_contract::<C, false>(db, proposal)
656}
657
658fn apply_schema_with_contract<C: CanisterKind, const ALLOW_REMOVALS: bool>(
659 db: &Db<C>,
660 proposal: &SchemaProposal,
661) -> Result<SchemaChangeReceipt, InternalError> {
662 ensure_recovery_admitted(db)?;
663 ensure_schema_migration_ready_for_schema_changes()?;
664 let proposal_digest = proposal
665 .digest()
666 .map_err(|_| InternalError::store_unsupported())?;
667 if let Some(receipt) = exact_schema_application_receipt(proposal, proposal_digest)? {
668 return Ok(receipt);
669 }
670
671 let target = schema_application_target(db)?;
672 if target.database_identity() != proposal.target_database()
673 || target.accepted_head() != proposal.expected_head()
674 {
675 return Err(InternalError::schema_application_conflict());
676 }
677
678 preflight_ordinary_source_application(db, proposal, &target)?;
679
680 let authorities = application_authorities(db);
681 let LoweredApplication {
682 current_bundles,
683 candidates,
684 pending,
685 } = lower_application_candidates::<ALLOW_REMOVALS>(&target, proposal, authorities.as_slice())?;
686 validate_database_identity_state_capacity(
687 authorities.as_slice(),
688 candidates.as_slice(),
689 database_incarnation_id()?,
690 )?;
691 let accepted_head = if let Some(pending) = pending.as_ref() {
692 let final_candidates =
693 final_candidates_for_pending_row_local_constraint(&candidates, pending)?;
694 accepted_head_after_candidates(authorities.as_slice(), final_candidates.as_slice())?
695 } else if candidates.is_empty() {
696 target.accepted_head().clone()
697 } else {
698 accepted_head_after_candidates(authorities.as_slice(), candidates.as_slice())?
699 };
700 #[cfg(feature = "migration")]
701 let outcome_head = accepted_head.clone();
702 #[cfg(not(feature = "migration"))]
703 let outcome_head = accepted_head;
704 let outcome = if pending.is_some() {
705 let job_id = derive_schema_change_job_id(
706 target.database_identity(),
707 proposal.submission_key(),
708 proposal_digest,
709 target.accepted_head(),
710 )?;
711 SchemaChangeOutcome::Pending {
712 job: SchemaChangeJob::new(job_id),
713 candidate_head: outcome_head,
714 }
715 } else if candidates.is_empty() {
716 SchemaChangeOutcome::NoOp {
717 accepted_head: outcome_head,
718 }
719 } else {
720 SchemaChangeOutcome::Applied {
721 accepted_head: outcome_head,
722 }
723 };
724 let receipt = SchemaChangeReceipt::new(
725 target.database_identity(),
726 proposal.submission_key().clone(),
727 proposal_digest,
728 target.accepted_head().clone(),
729 outcome,
730 )?;
731 let activations = match pending {
732 Some(pending) => {
733 let authority = authorities
734 .iter()
735 .find(|authority| authority.path == pending.proof.store_path)
736 .ok_or_else(InternalError::store_invariant)?;
737 vec![SchemaChangeActivation::new(
738 derive_store_identity(target.database_identity(), authority),
739 pending.proof.entity_tag.value(),
740 pending.proof.constraint_id.get(),
741 )?]
742 }
743 None => Vec::new(),
744 };
745 let record = SchemaApplicationRecord::new(receipt.clone(), activations)?;
746 let operation = SchemaApplicationRecordOp::insert(&record)?;
747 let _publication_span = ExecutionMetricsSpan::new(ExecutionMetricsPhase::Publication);
749 #[cfg(feature = "migration")]
750 let database_control = attach_ordinary_lineage_publication(
751 proposal,
752 target.accepted_head(),
753 &accepted_head,
754 authorities.as_slice(),
755 ¤t_bundles,
756 candidates.as_slice(),
757 operation,
758 )?;
759 #[cfg(not(feature = "migration"))]
760 let database_control = vec![DatabaseControlOp::SchemaApplication(operation)];
761 let publications =
762 application_publications(authorities.as_slice(), ¤t_bundles, &candidates)?;
763 publish_accepted_schema_candidates_with_database_control(publications, database_control)?;
764 Ok(receipt)
765}
766
767fn preflight_ordinary_source_application<C: CanisterKind>(
768 db: &Db<C>,
769 proposal: &SchemaProposal,
770 target: &SchemaApplicationTarget,
771) -> Result<(), InternalError> {
772 if proposal.migration().is_none()
773 || matches!(target.accepted_head(), ExpectedAcceptedHead::Empty)
774 {
775 return Ok(());
776 }
777 #[cfg(feature = "migration")]
778 {
779 if current_proposal_lineage_is_applied(db, proposal)? {
780 return Ok(());
781 }
782 preflight_unpublished_schema_migration(target, proposal, db)?;
783 Err(InternalError::schema_migration(
787 SchemaMigrationCode::MigrationInProgress,
788 ))
789 }
790 #[cfg(not(feature = "migration"))]
791 {
792 let _ = db;
793 Err(InternalError::store_unsupported())
794 }
795}
796
797#[cfg(feature = "migration")]
801pub(in crate::db) fn migrate_schema<C: CanisterKind>(
802 db: &Db<C>,
803 proposal: &SchemaProposal,
804 command: SchemaMigrationCommand,
805) -> Result<SchemaMigrationStatusPage, InternalError> {
806 ensure_recovery_admitted(db)?;
807 let expected_head = match &command {
810 SchemaMigrationCommand::Adopt { expected_head, .. }
811 | SchemaMigrationCommand::Advance { expected_head, .. }
812 | SchemaMigrationCommand::Abort { expected_head, .. } => expected_head,
813 };
814 if proposal.expected_head() != expected_head {
815 return Err(InternalError::schema_migration(
816 SchemaMigrationCode::StaleAcceptedHead,
817 ));
818 }
819 match command {
820 SchemaMigrationCommand::Adopt {
821 expected_database,
822 expected_head,
823 } => adopt_entity_source_lineage(db, proposal, expected_database, &expected_head),
824 SchemaMigrationCommand::Advance {
825 expected_database,
826 expected_head,
827 expected_plan,
828 acknowledged_finding_page,
829 } => advance_metadata_schema_migration(
830 db,
831 proposal,
832 expected_database,
833 &expected_head,
834 expected_plan,
835 acknowledged_finding_page,
836 ),
837 SchemaMigrationCommand::Abort {
838 expected_database,
839 expected_head,
840 expected_plan,
841 } => {
842 let plan = proposal.migration().ok_or_else(|| {
843 InternalError::schema_migration(SchemaMigrationCode::MissingMigration)
844 })?;
845 if proposal.target_database() != expected_database || plan.digest() != expected_plan {
846 return Err(InternalError::schema_migration(
847 SchemaMigrationCode::PlanChanged,
848 ));
849 }
850 if let Some(record) = exact_active_migration_record(
851 proposal,
852 expected_database,
853 &expected_head,
854 expected_plan,
855 )? {
856 let target = schema_application_target(db)?;
857 validate_active_migration_target(&record, &target)?;
858 if record.phase() == PersistedSchemaMigrationPhase::Applied
859 || record.phase() == PersistedSchemaMigrationPhase::Aborted
860 {
861 return active_migration_status(proposal, &target, &record);
862 }
863 if !record.phase().abortable() {
864 return Err(InternalError::schema_migration(
865 SchemaMigrationCode::AbortTooLate,
866 ));
867 }
868 let planned = recompile_active_physical_migration(db, proposal, &record)?;
869 let authorities = application_authorities(db);
870 let mut store_identities = BTreeMap::new();
871 for authority in &authorities {
872 store_identities.insert(
873 authority.path,
874 derive_store_identity(record.database_identity(), authority),
875 );
876 }
877 let (progress, exhausted) = cleanup_migration_staging_page(
878 db,
879 &planned,
880 record.progress(),
881 &store_identities,
882 )?;
883 let phase = if exhausted {
884 PersistedSchemaMigrationPhase::Aborted
885 } else {
886 record.phase()
887 };
888 let advanced = record.transition(phase, progress)?;
889 let operation = SchemaMigrationRecordOp::replace(&record, &advanced)?;
890 publish_accepted_schema_candidates_with_database_control(
891 Vec::new(),
892 vec![DatabaseControlOp::SchemaMigration(operation)],
893 )?;
894 return active_migration_status(proposal, &target, &advanced);
895 }
896 let target = exact_migration_target(db, expected_database, &expected_head)?;
897 let status = schema_migration_status_for_target(db, proposal, &target)?;
898 if status.phase() == SchemaMigrationPhase::Applied {
899 Ok(status)
900 } else {
901 Err(InternalError::schema_migration(
904 SchemaMigrationCode::MissingMigration,
905 ))
906 }
907 }
908 }
909}
910
911#[cfg(feature = "migration")]
913pub(in crate::db) fn schema_migration_status<C: CanisterKind>(
914 db: &Db<C>,
915 proposal: &SchemaProposal,
916 request: &SchemaMigrationStatusRequest,
917) -> Result<SchemaMigrationStatusPage, InternalError> {
918 ensure_recovery_admitted(db)?;
919 if !request.validate() || request.cursor().is_some() {
920 return Err(InternalError::cursor_invalid_continuation());
921 }
922 let target = schema_application_target(db)?;
923 schema_migration_status_for_target(db, proposal, &target)
924}
925
926#[cfg(feature = "migration")]
929pub(in crate::db) fn ensure_generated_schema_application_admitted<C: CanisterKind>(
930 db: &Db<C>,
931 proposal: &SchemaProposal,
932) -> Result<(), InternalError> {
933 ensure_recovery_admitted(db)?;
934 let Some(record) = load_schema_migration_record()? else {
935 return Ok(());
936 };
937 if matches!(
938 record.phase(),
939 PersistedSchemaMigrationPhase::Applied | PersistedSchemaMigrationPhase::Aborted
940 ) {
941 return Ok(());
942 }
943 validate_active_migration_deployment(proposal, &record)?;
944 let target = schema_application_target(db)?;
945 validate_active_migration_target(&record, &target)?;
946 Err(InternalError::schema_migration(
949 SchemaMigrationCode::MigrationInProgress,
950 ))
951}
952
953#[cfg(feature = "migration")]
954fn adopt_entity_source_lineage<C: CanisterKind>(
955 db: &Db<C>,
956 proposal: &SchemaProposal,
957 expected_database: TargetDatabaseIdentity,
958 expected_head: &ExpectedAcceptedHead,
959) -> Result<SchemaMigrationStatusPage, InternalError> {
960 if proposal.migration().is_some() || proposal.target_database() != expected_database {
961 return Err(InternalError::schema_migration(
962 SchemaMigrationCode::PlanChanged,
963 ));
964 }
965 let proposal_digest = proposal
966 .digest()
967 .map_err(|_| InternalError::store_unsupported())?;
968 let submission_key = migration_submission_key(None)?;
969 if let Some(record) = load_exact_migration_record(
970 expected_database,
971 &submission_key,
972 proposal_digest,
973 expected_head,
974 )? {
975 let replay_target = exact_migration_replay_target(db, expected_database, &record)?;
976 return schema_migration_status_for_target(db, proposal, &replay_target);
977 }
978 let target = exact_migration_target(db, expected_database, expected_head)?;
979
980 let authorities = application_authorities(db);
981 let current_bundles = load_current_application_bundles(authorities.as_slice())?;
982 let stores = existing_proposal_stores(
983 target.database_identity(),
984 authorities.as_slice(),
985 current_bundles.as_slice(),
986 );
987 let stored_before = load_entity_source_lineage_catalog()?;
988 let before = stored_before.clone().unwrap_or_default();
989 let planned = plan_entity_source_adoption(proposal, stores.as_slice(), &before)
990 .map_err(schema_migration_planning_error)?;
991 let after = lineage_after_planned(&before, planned.as_slice(), expected_head)?;
992 let receipt = SchemaChangeReceipt::new(
993 expected_database,
994 submission_key,
995 proposal_digest,
996 expected_head.clone(),
997 SchemaChangeOutcome::NoOp {
998 accepted_head: expected_head.clone(),
999 },
1000 )?;
1001 let record = SchemaApplicationRecord::new(receipt, Vec::new())?;
1002 let operation = SchemaApplicationRecordOp::insert(&record)?;
1003 let lineage = EntitySourceLineageCatalogOp::replace(stored_before.as_ref(), &after)?;
1004 publish_accepted_schema_candidates_with_database_control(
1005 Vec::new(),
1006 vec![
1007 DatabaseControlOp::SchemaApplication(operation),
1008 DatabaseControlOp::EntitySourceLineage(lineage),
1009 ],
1010 )?;
1011 schema_migration_status_for_target(db, proposal, &target)
1012}
1013
1014#[cfg(feature = "migration")]
1015#[expect(
1016 clippy::too_many_lines,
1017 reason = "one migration entry point keeps preparation and exact replay ordering visible"
1018)]
1019fn advance_metadata_schema_migration<C: CanisterKind>(
1020 db: &Db<C>,
1021 proposal: &SchemaProposal,
1022 expected_database: TargetDatabaseIdentity,
1023 expected_head: &ExpectedAcceptedHead,
1024 expected_plan: SchemaMigrationPlanDigest,
1025 acknowledged_finding_page: Option<u64>,
1026) -> Result<SchemaMigrationStatusPage, InternalError> {
1027 let plan = proposal
1028 .migration()
1029 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::MissingMigration))?;
1030 if proposal.target_database() != expected_database || plan.digest() != expected_plan {
1031 return Err(InternalError::schema_migration(
1032 SchemaMigrationCode::PlanChanged,
1033 ));
1034 }
1035 let proposal_digest = proposal
1036 .digest()
1037 .map_err(|_| InternalError::store_unsupported())?;
1038 if let Some(record) =
1039 exact_active_migration_record(proposal, expected_database, expected_head, expected_plan)?
1040 {
1041 let target = schema_application_target(db)?;
1042 validate_active_migration_target(&record, &target)?;
1043 return advance_active_schema_migration(
1044 db,
1045 proposal,
1046 &target,
1047 &record,
1048 acknowledged_finding_page,
1049 );
1050 }
1051 if acknowledged_finding_page.is_some() {
1052 return Err(InternalError::schema_migration(
1053 SchemaMigrationCode::CandidateMismatch,
1054 ));
1055 }
1056 let submission_key = migration_submission_key(Some(expected_plan))?;
1057 if let Some(record) = load_exact_migration_record(
1058 expected_database,
1059 &submission_key,
1060 proposal_digest,
1061 expected_head,
1062 )? {
1063 let replay_target = exact_migration_replay_target(db, expected_database, &record)?;
1064 return schema_migration_status_for_target(db, proposal, &replay_target);
1065 }
1066 exact_migration_target(db, expected_database, expected_head)?;
1067
1068 let authorities = application_authorities(db);
1069 let current_bundles = load_current_application_bundles(authorities.as_slice())?;
1070 let stores = existing_proposal_stores(
1071 expected_database,
1072 authorities.as_slice(),
1073 current_bundles.as_slice(),
1074 );
1075 let before = load_entity_source_lineage_catalog()?
1076 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::Unadopted))?;
1077 let planned = plan_schema_migration(proposal, stores.as_slice(), &before)
1078 .map_err(schema_migration_planning_error)?;
1079 let mut candidates = planned.candidates().to_vec();
1080 let pending = if planned.requires_physical_validation() {
1081 preflight_new_entity_domains(
1086 authorities.as_slice(),
1087 current_bundles.as_slice(),
1088 &candidates,
1089 )?;
1090 None
1091 } else {
1092 preflight_existing_application(
1093 authorities.as_slice(),
1094 current_bundles.as_slice(),
1095 &mut candidates,
1096 )?
1097 };
1098 if pending.is_some() {
1099 return Err(InternalError::schema_migration(
1100 SchemaMigrationCode::MigrationInProgress,
1101 ));
1102 }
1103 if candidates.is_empty() || planned.lineage().is_empty() {
1104 return Err(InternalError::schema_migration(
1105 SchemaMigrationCode::EmptyEntityVersionBump,
1106 ));
1107 }
1108 validate_database_identity_state_capacity(
1109 authorities.as_slice(),
1110 candidates.as_slice(),
1111 database_incarnation_id()?,
1112 )?;
1113 let accepted_head =
1114 accepted_head_after_candidates(authorities.as_slice(), candidates.as_slice())?;
1115 if planned.requires_physical_validation() {
1116 ensure_physical_migration_stores_are_journaled(db, &planned)?;
1117 let record = prepared_physical_schema_migration(
1118 proposal,
1119 &planned,
1120 candidates.as_slice(),
1121 expected_database,
1122 expected_head,
1123 &accepted_head,
1124 proposal_digest,
1125 expected_plan,
1126 stores.as_slice(),
1127 )?;
1128 let prior = load_schema_migration_record()?;
1132 if prior.is_some()
1133 && continue_recovery_with_failure_authority(db)
1134 .map_err(StartupRecoveryFailure::into_error)?
1135 == RecoveryProgress::Pending
1136 {
1137 let target = schema_application_target(db)?;
1138 return schema_migration_status_for_target(db, proposal, &target);
1139 }
1140 let operation = match prior.as_ref() {
1141 Some(prior) => SchemaMigrationRecordOp::replace(prior, &record)?,
1142 None => SchemaMigrationRecordOp::insert(&record)?,
1143 };
1144 publish_accepted_schema_candidates_with_database_control(
1145 Vec::new(),
1146 vec![DatabaseControlOp::SchemaMigration(operation)],
1147 )?;
1148 let target = schema_application_target(db)?;
1149 validate_active_migration_target(&record, &target)?;
1150 return active_migration_status(proposal, &target, &record);
1151 }
1152 let after = lineage_after_planned(&before, planned.lineage(), &accepted_head)?;
1153 let receipt = SchemaChangeReceipt::new(
1154 expected_database,
1155 submission_key,
1156 proposal_digest,
1157 expected_head.clone(),
1158 SchemaChangeOutcome::Applied { accepted_head },
1159 )?;
1160 let record = SchemaApplicationRecord::new(receipt, Vec::new())?;
1161 let operation = SchemaApplicationRecordOp::insert(&record)?;
1162 let lineage = EntitySourceLineageCatalogOp::replace(Some(&before), &after)?;
1163 let publications = application_publications(
1164 authorities.as_slice(),
1165 current_bundles.as_slice(),
1166 candidates.as_slice(),
1167 )?;
1168 publish_accepted_schema_candidates_with_database_control(
1169 publications,
1170 vec![
1171 DatabaseControlOp::SchemaApplication(operation),
1172 DatabaseControlOp::EntitySourceLineage(lineage),
1173 ],
1174 )?;
1175 let applied_target = schema_application_target(db)?;
1176 schema_migration_status_for_target(db, proposal, &applied_target)
1177}
1178
1179#[cfg(feature = "migration")]
1180fn ensure_physical_migration_stores_are_journaled<C: CanisterKind>(
1181 db: &Db<C>,
1182 planned: &crate::db::schema::migration_planner::PlannedSchemaMigration,
1183) -> Result<(), InternalError> {
1184 for program in planned.programs() {
1185 let store = db.store_handle(program.store_path())?;
1186 if store.storage_capabilities().recovery()
1187 != StoreRecoveryCapability::StableBasePlusJournalReplay
1188 {
1189 return Err(InternalError::schema_migration(
1190 SchemaMigrationCode::PhysicalRunnerMissing,
1191 ));
1192 }
1193 }
1194 Ok(())
1195}
1196
1197#[cfg(feature = "migration")]
1198#[expect(
1199 clippy::too_many_arguments,
1200 reason = "migration preparation binds every immutable deployment and candidate identity"
1201)]
1202fn prepared_physical_schema_migration(
1203 proposal: &SchemaProposal,
1204 planned: &crate::db::schema::migration_planner::PlannedSchemaMigration,
1205 candidates: &[CandidateSchemaRevision],
1206 database_identity: TargetDatabaseIdentity,
1207 accepted_before: &ExpectedAcceptedHead,
1208 candidate_head: &ExpectedAcceptedHead,
1209 submission_digest: SchemaProposalDigest,
1210 plan_digest: SchemaMigrationPlanDigest,
1211 stores: &[ExistingProposalStore<'_>],
1212) -> Result<SchemaMigrationRecord, InternalError> {
1213 let plan = proposal
1214 .migration()
1215 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::MissingMigration))?;
1216 let transitions = plan
1217 .transitions()
1218 .iter()
1219 .map(|transition| {
1220 PersistedSchemaMigrationTransition::try_new(
1221 transition.entity().clone(),
1222 transition.from().get(),
1223 transition
1224 .from()
1225 .get()
1226 .checked_add(1)
1227 .ok_or_else(InternalError::store_invariant)?,
1228 )
1229 })
1230 .collect::<Result<Vec<_>, InternalError>>()?;
1231 let entities = planned
1232 .lineage()
1233 .iter()
1234 .map(|entity| {
1235 PersistedSchemaMigrationEntity::try_new(
1236 entity.store(),
1237 entity.entity(),
1238 entity.digest(),
1239 )
1240 })
1241 .collect::<Result<Vec<_>, InternalError>>()?;
1242 let mut staged_indexes = Vec::new();
1243 for candidate in candidates {
1244 let store = stores
1245 .iter()
1246 .find(|store| store.path == candidate.store_path())
1247 .ok_or_else(InternalError::store_invariant)?;
1248 for (entity, snapshot) in candidate.bundle().entity_snapshots() {
1249 let Some(before) = store.bundle.entity_snapshots().get(entity) else {
1252 continue;
1253 };
1254 for index in snapshot
1255 .indexes()
1256 .iter()
1257 .filter(|index| {
1258 before
1259 .indexes()
1260 .iter()
1261 .find(|old| old.schema_id() == index.schema_id())
1262 .is_none_or(|old| old.physical_generation() != index.physical_generation())
1263 })
1264 .chain(snapshot.candidate_indexes())
1265 {
1266 staged_indexes.push(PersistedSchemaMigrationIndex::try_new(
1267 store.identity,
1268 *entity,
1269 u64::from(index.schema_id().get()),
1270 index.physical_generation(),
1271 )?);
1272 }
1273 }
1274 }
1275 icydb_schema::compact_sort_unstable_by(&mut staged_indexes, Ord::cmp);
1276 staged_indexes.dedup();
1277 SchemaMigrationRecord::prepared(
1278 database_identity,
1279 accepted_before.clone(),
1280 candidate_head.clone(),
1281 submission_digest,
1282 plan_digest,
1283 transitions,
1284 entities,
1285 staged_indexes,
1286 )
1287}
1288
1289#[cfg(feature = "migration")]
1290#[expect(
1291 clippy::too_many_lines,
1292 reason = "the closed phase match keeps every durable migration transition and publication boundary exhaustive"
1293)]
1294fn advance_active_schema_migration<C: CanisterKind>(
1295 db: &Db<C>,
1296 proposal: &SchemaProposal,
1297 target: &SchemaApplicationTarget,
1298 record: &SchemaMigrationRecord,
1299 acknowledged_finding_page: Option<u64>,
1300) -> Result<SchemaMigrationStatusPage, InternalError> {
1301 match record.phase() {
1302 PersistedSchemaMigrationPhase::Prepared => {
1303 if acknowledged_finding_page.is_some() {
1304 return Err(InternalError::schema_migration(
1305 SchemaMigrationCode::CandidateMismatch,
1306 ));
1307 }
1308 let validating = record.transition(
1309 PersistedSchemaMigrationPhase::Validating,
1310 record.progress().clone(),
1311 )?;
1312 publish_migration_record_replacement(record, &validating)?;
1313 active_migration_status(proposal, target, &validating)
1314 }
1315 PersistedSchemaMigrationPhase::Validating => {
1316 if acknowledged_finding_page.is_some() {
1317 return Err(InternalError::schema_migration(
1318 SchemaMigrationCode::CandidateMismatch,
1319 ));
1320 }
1321 let planned = recompile_active_physical_migration(db, proposal, record)?;
1322 let page =
1323 validate_migration_page(db, &planned, record.progress(), record.plan_digest())?;
1324 let (progress, staged_entries, exhausted) = page.into_parts();
1325 let phase = if progress.findings().is_empty() {
1326 if exhausted {
1327 PersistedSchemaMigrationPhase::ReadyToRewrite
1328 } else {
1329 PersistedSchemaMigrationPhase::Validating
1330 }
1331 } else {
1332 PersistedSchemaMigrationPhase::Rejected
1333 };
1334 if progress.findings().is_empty() {
1335 stage_migration_index_entries(staged_entries)?;
1339 }
1340 let advanced = record.transition(phase, progress)?;
1341 publish_migration_record_replacement(record, &advanced)?;
1342 active_migration_status(proposal, target, &advanced)
1343 }
1344 PersistedSchemaMigrationPhase::Rejected => {
1345 if acknowledged_finding_page.is_some()
1346 && acknowledged_finding_page != record.progress().finding_page()
1347 {
1348 return Err(InternalError::schema_migration(
1349 SchemaMigrationCode::CandidateMismatch,
1350 ));
1351 }
1352 active_migration_status(proposal, target, record)
1353 }
1354 PersistedSchemaMigrationPhase::ReadyToRewrite => {
1355 if acknowledged_finding_page.is_some() {
1356 return Err(InternalError::schema_migration(
1357 SchemaMigrationCode::CandidateMismatch,
1358 ));
1359 }
1360 let progress = record.progress().begin_row_phase()?;
1361 let rewriting =
1362 record.transition(PersistedSchemaMigrationPhase::RewritingRows, progress)?;
1363 publish_migration_record_replacement(record, &rewriting)?;
1364 active_migration_status(proposal, target, &rewriting)
1365 }
1366 PersistedSchemaMigrationPhase::RewritingRows => {
1367 if acknowledged_finding_page.is_some() {
1368 return Err(InternalError::schema_migration(
1369 SchemaMigrationCode::CandidateMismatch,
1370 ));
1371 }
1372 let planned = recompile_active_physical_migration(db, proposal, record)?;
1373 let page =
1374 rewrite_migration_page(db, &planned, record.progress(), record.plan_digest())?;
1375 let (progress, effects, exhausted) = page.into_parts();
1376 if progress.rows_rewritten() > progress.rows_validated()
1377 || (exhausted && progress.rows_rewritten() != progress.rows_validated())
1378 {
1379 return Err(InternalError::schema_migration(
1380 SchemaMigrationCode::ProgressCorrupt,
1381 ));
1382 }
1383 let phase = if exhausted {
1384 PersistedSchemaMigrationPhase::RebuildingIndexes
1385 } else {
1386 PersistedSchemaMigrationPhase::RewritingRows
1387 };
1388 let advanced = record.transition(phase, progress)?;
1389 let operation = SchemaMigrationRecordOp::replace(record, &advanced)?;
1390 publish_migration_rewrite_page(effects, operation)?;
1391 active_migration_status(proposal, target, &advanced)
1392 }
1393 PersistedSchemaMigrationPhase::RebuildingIndexes => {
1394 if acknowledged_finding_page.is_some() {
1395 return Err(InternalError::schema_migration(
1396 SchemaMigrationCode::CandidateMismatch,
1397 ));
1398 }
1399 let planned = recompile_active_physical_migration(db, proposal, record)?;
1400 let rebuilt = migration_derived_domain_count(db, &planned)?;
1401 let progress = record
1402 .progress()
1403 .begin_row_phase()?
1404 .with_index_progress(None, rebuilt)?;
1405 let validating =
1406 record.transition(PersistedSchemaMigrationPhase::FinalValidation, progress)?;
1407 publish_migration_record_replacement(record, &validating)?;
1408 active_migration_status(proposal, target, &validating)
1409 }
1410 PersistedSchemaMigrationPhase::FinalValidation => {
1411 if acknowledged_finding_page.is_some() {
1412 return Err(InternalError::schema_migration(
1413 SchemaMigrationCode::CandidateMismatch,
1414 ));
1415 }
1416 let planned = recompile_active_physical_migration(db, proposal, record)?;
1417 let page = final_validate_migration_page(db, &planned, record.progress())?;
1418 let (progress, exhausted) = page.into_parts();
1419 let phase = if exhausted {
1420 PersistedSchemaMigrationPhase::Publishing
1421 } else {
1422 PersistedSchemaMigrationPhase::FinalValidation
1423 };
1424 let advanced = record.transition(phase, progress)?;
1425 publish_migration_record_replacement(record, &advanced)?;
1426 active_migration_status(proposal, target, &advanced)
1427 }
1428 PersistedSchemaMigrationPhase::Publishing => {
1429 if acknowledged_finding_page.is_some() {
1430 return Err(InternalError::schema_migration(
1431 SchemaMigrationCode::CandidateMismatch,
1432 ));
1433 }
1434 publish_completed_physical_migration(db, proposal, record)
1435 }
1436 PersistedSchemaMigrationPhase::Applied | PersistedSchemaMigrationPhase::Aborted => {
1437 if acknowledged_finding_page.is_some() {
1438 return Err(InternalError::schema_migration(
1439 SchemaMigrationCode::CandidateMismatch,
1440 ));
1441 }
1442 active_migration_status(proposal, target, record)
1443 }
1444 }
1445}
1446
1447#[cfg(feature = "migration")]
1448fn recompile_active_physical_migration<C: CanisterKind>(
1449 db: &Db<C>,
1450 proposal: &SchemaProposal,
1451 record: &SchemaMigrationRecord,
1452) -> Result<crate::db::schema::migration_planner::PlannedSchemaMigration, InternalError> {
1453 let authorities = application_authorities(db);
1454 let current_bundles = load_current_application_bundles(authorities.as_slice())?;
1455 let stores = existing_proposal_stores(
1456 record.database_identity(),
1457 authorities.as_slice(),
1458 current_bundles.as_slice(),
1459 );
1460 let lineage = load_entity_source_lineage_catalog()?
1461 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::Unadopted))?;
1462 let planned = plan_schema_migration(proposal, stores.as_slice(), &lineage)
1463 .map_err(schema_migration_planning_error)?;
1464 if !planned.requires_physical_validation() {
1465 return Err(InternalError::schema_migration(
1466 SchemaMigrationCode::PlanChanged,
1467 ));
1468 }
1469 let candidate_head =
1470 accepted_head_after_candidates(authorities.as_slice(), planned.candidates())?;
1471 if &candidate_head != record.candidate_head() {
1472 return Err(InternalError::schema_migration(
1473 SchemaMigrationCode::CandidateMismatch,
1474 ));
1475 }
1476 Ok(planned)
1477}
1478
1479#[cfg(feature = "migration")]
1480fn publish_completed_physical_migration<C: CanisterKind>(
1481 db: &Db<C>,
1482 proposal: &SchemaProposal,
1483 record: &SchemaMigrationRecord,
1484) -> Result<SchemaMigrationStatusPage, InternalError> {
1485 let target = schema_application_target(db)?;
1486 validate_active_migration_target(record, &target)?;
1487 let planned = recompile_active_physical_migration(db, proposal, record)?;
1488 let authorities = application_authorities(db);
1489 let current_bundles = load_current_application_bundles(authorities.as_slice())?;
1490 let candidate_head =
1491 accepted_head_after_candidates(authorities.as_slice(), planned.candidates())?;
1492 if &candidate_head != record.candidate_head() {
1493 return Err(InternalError::schema_migration(
1494 SchemaMigrationCode::PublicationRaceLost,
1495 ));
1496 }
1497 let lineage_before = load_entity_source_lineage_catalog()?
1498 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::Unadopted))?;
1499 let lineage_after =
1500 lineage_after_planned(&lineage_before, planned.lineage(), record.candidate_head())?;
1501 let receipt = SchemaChangeReceipt::new(
1502 record.database_identity(),
1503 migration_submission_key(Some(record.plan_digest()))?,
1504 record.submission_digest(),
1505 record.accepted_before().clone(),
1506 SchemaChangeOutcome::Applied {
1507 accepted_head: record.candidate_head().clone(),
1508 },
1509 )?;
1510 let application = SchemaApplicationRecord::new(receipt, Vec::new())?;
1511 let application = SchemaApplicationRecordOp::insert(&application)?;
1512 let lineage = EntitySourceLineageCatalogOp::replace(Some(&lineage_before), &lineage_after)?;
1513 let applied = record.transition(
1514 PersistedSchemaMigrationPhase::Applied,
1515 record.progress().clone(),
1516 )?;
1517 let migration = SchemaMigrationRecordOp::replace(record, &applied)?;
1518 let publications = application_publications(
1519 authorities.as_slice(),
1520 current_bundles.as_slice(),
1521 planned.candidates(),
1522 )?;
1523 publish_accepted_schema_candidates_with_database_control(
1524 publications,
1525 vec![
1526 DatabaseControlOp::SchemaApplication(application),
1527 DatabaseControlOp::EntitySourceLineage(lineage),
1528 DatabaseControlOp::SchemaMigration(migration),
1529 ],
1530 )?;
1531 db.mark_all_registered_index_stores_ready()?;
1532 let applied_target = schema_application_target(db)?;
1533 active_migration_status(proposal, &applied_target, &applied)
1534}
1535
1536#[cfg(feature = "migration")]
1537fn publish_migration_record_replacement(
1538 before: &SchemaMigrationRecord,
1539 after: &SchemaMigrationRecord,
1540) -> Result<(), InternalError> {
1541 let operation = SchemaMigrationRecordOp::replace(before, after)?;
1542 publish_accepted_schema_candidates_with_database_control(
1543 Vec::new(),
1544 vec![DatabaseControlOp::SchemaMigration(operation)],
1545 )
1546}
1547
1548#[cfg(feature = "migration")]
1549fn attach_ordinary_lineage_publication(
1550 proposal: &SchemaProposal,
1551 prior_head: &ExpectedAcceptedHead,
1552 accepted_head: &ExpectedAcceptedHead,
1553 authorities: &[StoreApplicationAuthority],
1554 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
1555 candidates: &[CandidateSchemaRevision],
1556 operation: SchemaApplicationRecordOp,
1557) -> Result<Vec<DatabaseControlOp>, InternalError> {
1558 let mut operations = vec![DatabaseControlOp::SchemaApplication(operation)];
1559 let stored_before = load_entity_source_lineage_catalog()?;
1560 let planned = plan_new_entity_source_lineage(proposal, current_bundles, candidates)
1561 .map_err(schema_migration_planning_error)?;
1562 if !planned.is_empty() && !matches!(prior_head, ExpectedAcceptedHead::Empty) {
1563 let before = stored_before
1566 .as_ref()
1567 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::Unadopted))?;
1568 for bundle in current_bundles.iter().flatten() {
1569 let authority = authorities
1570 .iter()
1571 .find(|authority| authority.path == bundle.store_path())
1572 .ok_or_else(InternalError::store_invariant)?;
1573 let store = derive_store_identity(proposal.target_database(), authority);
1574 for tag in bundle.entity_snapshots().keys() {
1575 if !before.get(store, *tag).is_some_and(|entry| {
1576 matches!(
1577 entry.state(),
1578 AcceptedEntitySourceLineageState::Adopted { .. }
1579 )
1580 }) {
1581 return Err(InternalError::schema_migration(
1582 SchemaMigrationCode::Unadopted,
1583 ));
1584 }
1585 }
1586 }
1587 }
1588 if planned.is_empty() && (stored_before.is_none() || prior_head == accepted_head) {
1589 return Ok(operations);
1590 }
1591 let before = stored_before.clone().unwrap_or_default();
1592 let after = lineage_after_planned(&before, planned.as_slice(), accepted_head)?;
1593 if before == after {
1594 return Ok(operations);
1595 }
1596 operations.push(DatabaseControlOp::EntitySourceLineage(
1597 EntitySourceLineageCatalogOp::replace(stored_before.as_ref(), &after)?,
1598 ));
1599 Ok(operations)
1600}
1601
1602#[cfg(feature = "migration")]
1603fn exact_migration_target<C: CanisterKind>(
1604 db: &Db<C>,
1605 expected_database: TargetDatabaseIdentity,
1606 expected_head: &ExpectedAcceptedHead,
1607) -> Result<SchemaApplicationTarget, InternalError> {
1608 let target = schema_application_target(db)?;
1609 if target.database_identity() != expected_database || target.accepted_head() != expected_head {
1610 return Err(InternalError::schema_migration(
1611 SchemaMigrationCode::StaleAcceptedHead,
1612 ));
1613 }
1614 Ok(target)
1615}
1616
1617#[cfg(feature = "migration")]
1618fn exact_migration_replay_target<C: CanisterKind>(
1619 db: &Db<C>,
1620 expected_database: TargetDatabaseIdentity,
1621 record: &SchemaApplicationRecord,
1622) -> Result<SchemaApplicationTarget, InternalError> {
1623 let target = schema_application_target(db)?;
1624 if target.database_identity() != expected_database
1625 || target.accepted_head() != migration_record_accepted_head(record)?
1626 {
1627 return Err(InternalError::schema_migration(
1628 SchemaMigrationCode::PlanChanged,
1629 ));
1630 }
1631 Ok(target)
1632}
1633
1634#[cfg(feature = "migration")]
1635fn schema_migration_status_for_target<C: CanisterKind>(
1636 db: &Db<C>,
1637 proposal: &SchemaProposal,
1638 target: &SchemaApplicationTarget,
1639) -> Result<SchemaMigrationStatusPage, InternalError> {
1640 if let Some(record) = load_schema_migration_record()?
1641 && (!record.phase().terminal()
1642 || proposal
1643 .digest()
1644 .map_err(|_| InternalError::store_unsupported())?
1645 == record.submission_digest())
1646 {
1647 validate_active_migration_deployment(proposal, &record)?;
1648 validate_active_migration_target(&record, target)?;
1649 return active_migration_status(proposal, target, &record);
1650 }
1651 let lineage = load_entity_source_lineage_catalog()?.unwrap_or_default();
1652 let plan_digest = proposal
1653 .migration()
1654 .map(icydb_schema::SchemaMigrationPlan::digest);
1655 let transitions = migration_transitions(proposal)?;
1656 let submission_key = migration_submission_key(plan_digest)?;
1657 let terminal = load_migration_record_for_status(target.database_identity(), &submission_key)?
1658 .map(|record| public_migration_receipt(&record, plan_digest))
1659 .transpose()?;
1660 let unadopted = lineage.entries().is_empty()
1661 || lineage
1662 .entries()
1663 .values()
1664 .any(|entry| matches!(entry.state(), AcceptedEntitySourceLineageState::Unadopted));
1665 let applied = current_proposal_lineage_is_applied(db, proposal)?;
1666 let phase = if unadopted {
1667 SchemaMigrationPhase::Unadopted
1668 } else if proposal.migration().is_none() {
1669 SchemaMigrationPhase::Adopted
1670 } else if applied {
1671 SchemaMigrationPhase::Applied
1672 } else {
1673 SchemaMigrationPhase::Idle
1674 };
1675 let terminal = terminal.filter(|receipt| {
1676 receipt.accepted_head() == target.accepted_head()
1677 && receipt.plan_digest() == plan_digest
1678 && matches!(
1679 phase,
1680 SchemaMigrationPhase::Adopted | SchemaMigrationPhase::Applied
1681 )
1682 });
1683 Ok(SchemaMigrationStatusPage::new(
1684 target.database_identity(),
1685 target.accepted_head().clone(),
1686 plan_digest,
1687 phase,
1688 transitions,
1689 0,
1690 0,
1691 0,
1692 Vec::new(),
1693 None,
1694 terminal,
1695 ))
1696}
1697
1698#[cfg(feature = "migration")]
1699fn exact_active_migration_record(
1700 proposal: &SchemaProposal,
1701 expected_database: TargetDatabaseIdentity,
1702 expected_head: &ExpectedAcceptedHead,
1703 expected_plan: SchemaMigrationPlanDigest,
1704) -> Result<Option<SchemaMigrationRecord>, InternalError> {
1705 let Some(record) = load_schema_migration_record()? else {
1706 return Ok(None);
1707 };
1708 if record.phase().terminal()
1711 && (record.accepted_before() != expected_head
1712 || record.plan_digest() != expected_plan
1713 || proposal
1714 .digest()
1715 .map_err(|_| InternalError::store_unsupported())?
1716 != record.submission_digest())
1717 {
1718 return Ok(None);
1719 }
1720 if record.database_identity() != expected_database
1721 || record.accepted_before() != expected_head
1722 || record.plan_digest() != expected_plan
1723 {
1724 return Err(InternalError::schema_migration(
1725 SchemaMigrationCode::PlanChanged,
1726 ));
1727 }
1728 validate_active_migration_deployment(proposal, &record)?;
1729 Ok(Some(record))
1730}
1731
1732#[cfg(feature = "migration")]
1733fn validate_active_migration_deployment(
1734 proposal: &SchemaProposal,
1735 record: &SchemaMigrationRecord,
1736) -> Result<(), InternalError> {
1737 let plan = proposal
1738 .migration()
1739 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::PlanChanged))?;
1740 let proposal_digest = proposal
1741 .digest()
1742 .map_err(|_| InternalError::store_unsupported())?;
1743 if proposal.target_database() != record.database_identity()
1744 || plan.digest() != record.plan_digest()
1745 || proposal_digest != record.submission_digest()
1746 {
1747 return Err(InternalError::schema_migration(
1748 SchemaMigrationCode::PlanChanged,
1749 ));
1750 }
1751 Ok(())
1752}
1753
1754#[cfg(feature = "migration")]
1755fn validate_active_migration_target(
1756 record: &SchemaMigrationRecord,
1757 target: &SchemaApplicationTarget,
1758) -> Result<(), InternalError> {
1759 let expected_head = if record.phase() == PersistedSchemaMigrationPhase::Applied {
1760 record.candidate_head()
1761 } else {
1762 record.accepted_before()
1763 };
1764 if target.database_identity() != record.database_identity()
1765 || target.accepted_head() != expected_head
1766 {
1767 return Err(InternalError::schema_migration(
1768 SchemaMigrationCode::PlanChanged,
1769 ));
1770 }
1771 Ok(())
1772}
1773
1774#[cfg(feature = "migration")]
1775fn active_migration_status(
1776 proposal: &SchemaProposal,
1777 target: &SchemaApplicationTarget,
1778 record: &SchemaMigrationRecord,
1779) -> Result<SchemaMigrationStatusPage, InternalError> {
1780 validate_active_migration_deployment(proposal, record)?;
1781 validate_active_migration_target(record, target)?;
1782 let transitions = record
1783 .transitions()
1784 .iter()
1785 .map(|transition| {
1786 SchemaMigrationEntityTransition::new(
1787 transition.entity().clone(),
1788 Some(transition.predecessor_version()),
1789 transition.target_version(),
1790 )
1791 })
1792 .collect();
1793 let findings = record
1794 .progress()
1795 .findings()
1796 .iter()
1797 .map(|finding| {
1798 let kind = match finding.kind() {
1799 PersistedSchemaMigrationFindingKind::Transform => {
1800 SchemaMigrationFindingKind::Transform
1801 }
1802 PersistedSchemaMigrationFindingKind::UniqueIndex => {
1803 SchemaMigrationFindingKind::UniqueIndex
1804 }
1805 PersistedSchemaMigrationFindingKind::Relation => {
1806 SchemaMigrationFindingKind::Relation
1807 }
1808 PersistedSchemaMigrationFindingKind::Constraint => {
1809 SchemaMigrationFindingKind::Constraint
1810 }
1811 PersistedSchemaMigrationFindingKind::ResourceLimit => {
1812 SchemaMigrationFindingKind::ResourceLimit
1813 }
1814 };
1815 SchemaMigrationFinding::new(
1816 kind,
1817 finding.entity().value(),
1818 finding.primary_key().to_vec(),
1819 )
1820 })
1821 .collect();
1822 let phase = match record.phase() {
1823 PersistedSchemaMigrationPhase::Prepared => SchemaMigrationPhase::Prepared,
1824 PersistedSchemaMigrationPhase::Validating => SchemaMigrationPhase::Validating,
1825 PersistedSchemaMigrationPhase::ReadyToRewrite => SchemaMigrationPhase::ReadyToRewrite,
1826 PersistedSchemaMigrationPhase::RewritingRows => SchemaMigrationPhase::RewritingRows,
1827 PersistedSchemaMigrationPhase::RebuildingIndexes => SchemaMigrationPhase::RebuildingIndexes,
1828 PersistedSchemaMigrationPhase::FinalValidation => SchemaMigrationPhase::FinalValidation,
1829 PersistedSchemaMigrationPhase::Publishing => SchemaMigrationPhase::Publishing,
1830 PersistedSchemaMigrationPhase::Applied => SchemaMigrationPhase::Applied,
1831 PersistedSchemaMigrationPhase::Rejected => SchemaMigrationPhase::Rejected,
1832 PersistedSchemaMigrationPhase::Aborted => SchemaMigrationPhase::Aborted,
1833 };
1834 let terminal_receipt = (record.phase() == PersistedSchemaMigrationPhase::Applied).then(|| {
1835 SchemaMigrationReceipt::new(
1836 record.database_identity(),
1837 Some(record.plan_digest()),
1838 record.accepted_before().clone(),
1839 record.candidate_head().clone(),
1840 )
1841 });
1842 Ok(SchemaMigrationStatusPage::new(
1843 record.database_identity(),
1844 target.accepted_head().clone(),
1845 Some(record.plan_digest()),
1846 phase,
1847 transitions,
1848 record.progress().rows_validated(),
1849 record.progress().rows_rewritten(),
1850 record.progress().indexes_rebuilt(),
1851 findings,
1852 None,
1853 terminal_receipt,
1854 ))
1855}
1856
1857#[cfg(feature = "migration")]
1858fn load_current_application_bundles(
1859 authorities: &[StoreApplicationAuthority],
1860) -> Result<Vec<Option<AcceptedSchemaRevisionBundle>>, InternalError> {
1861 authorities
1862 .iter()
1863 .map(|authority| {
1864 authority
1865 .handle
1866 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
1867 })
1868 .collect()
1869}
1870
1871#[cfg(feature = "migration")]
1872fn existing_proposal_stores<'a>(
1873 database_identity: TargetDatabaseIdentity,
1874 authorities: &[StoreApplicationAuthority],
1875 bundles: &'a [Option<AcceptedSchemaRevisionBundle>],
1876) -> Vec<ExistingProposalStore<'a>> {
1877 authorities
1878 .iter()
1879 .zip(bundles)
1880 .filter_map(|(authority, bundle)| {
1881 bundle.as_ref().map(|bundle| ExistingProposalStore {
1882 path: authority.path,
1883 identity: derive_store_identity(database_identity, authority),
1884 bundle,
1885 })
1886 })
1887 .collect()
1888}
1889
1890#[cfg(feature = "migration")]
1891fn lineage_after_planned(
1892 before: &AcceptedEntitySourceLineageCatalog,
1893 planned: &[PlannedEntitySourceLineage],
1894 accepted_head: &ExpectedAcceptedHead,
1895) -> Result<AcceptedEntitySourceLineageCatalog, InternalError> {
1896 let mut entries = BTreeMap::new();
1897 for (key, entry) in before.entries() {
1898 let next = match entry.state() {
1899 AcceptedEntitySourceLineageState::Unadopted => {
1900 AcceptedEntitySourceLineage::unadopted(accepted_head.clone())?
1901 }
1902 AcceptedEntitySourceLineageState::Adopted {
1903 version,
1904 source_digest,
1905 } => AcceptedEntitySourceLineage::adopted(
1906 accepted_head.clone(),
1907 *version,
1908 *source_digest,
1909 )?,
1910 };
1911 entries.insert(*key, next);
1912 }
1913 for next in planned {
1914 entries.insert(
1915 (next.store(), next.entity()),
1916 AcceptedEntitySourceLineage::adopted(
1917 accepted_head.clone(),
1918 next.version(),
1919 next.digest(),
1920 )?,
1921 );
1922 }
1923 AcceptedEntitySourceLineageCatalog::try_new(entries)
1924}
1925
1926#[cfg(feature = "migration")]
1927fn schema_migration_planning_error(error: SchemaMigrationPlanningError) -> InternalError {
1928 let reason = match error {
1929 SchemaMigrationPlanningError::Preparation(error) => return error,
1930 SchemaMigrationPlanningError::Unadopted => SchemaMigrationCode::Unadopted,
1931 SchemaMigrationPlanningError::MissingMigration => SchemaMigrationCode::MissingMigration,
1932 SchemaMigrationPlanningError::VersionGap => SchemaMigrationCode::VersionGap,
1933 SchemaMigrationPlanningError::Downgrade => SchemaMigrationCode::Downgrade,
1934 SchemaMigrationPlanningError::EmptyEntityVersionBump => {
1935 SchemaMigrationCode::EmptyEntityVersionBump
1936 }
1937 SchemaMigrationPlanningError::UnknownFromObject => SchemaMigrationCode::UnknownFromObject,
1938 SchemaMigrationPlanningError::UnknownToObject => SchemaMigrationCode::UnknownToObject,
1939 SchemaMigrationPlanningError::KindMismatch => SchemaMigrationCode::KindMismatch,
1940 SchemaMigrationPlanningError::IdentityConflict => SchemaMigrationCode::IdentityConflict,
1941 SchemaMigrationPlanningError::UnexplainedSchemaDifference => {
1942 SchemaMigrationCode::UnexplainedSchemaDifference
1943 }
1944 SchemaMigrationPlanningError::UnsupportedTransform => {
1945 SchemaMigrationCode::UnsupportedTransform
1946 }
1947 SchemaMigrationPlanningError::RekeyedCatalogInvalid
1948 | SchemaMigrationPlanningError::CandidateMismatch => SchemaMigrationCode::CandidateMismatch,
1949 SchemaMigrationPlanningError::CorruptLineage => SchemaMigrationCode::ProgressCorrupt,
1950 };
1951 InternalError::schema_migration(reason)
1952}
1953
1954#[cfg(feature = "migration")]
1955fn migration_submission_key(
1956 plan_digest: Option<SchemaMigrationPlanDigest>,
1957) -> Result<SchemaSubmissionKey, InternalError> {
1958 let mut hasher = new_hash_sha256_prefixed(SCHEMA_MIGRATION_SUBMISSION_PROFILE);
1959 match plan_digest {
1960 None => write_hash_tag_u8(&mut hasher, 0),
1961 Some(digest) => {
1962 write_hash_tag_u8(&mut hasher, 1);
1963 hasher.update(digest.to_bytes());
1964 }
1965 }
1966 let digest = finalize_hash_sha256(hasher);
1967 let mut encoded = String::with_capacity(80);
1968 encoded.push_str("migration/");
1969 for byte in digest {
1970 use std::fmt::Write as _;
1971 write!(&mut encoded, "{byte:02x}").map_err(|_| InternalError::store_invariant())?;
1972 }
1973 SchemaSubmissionKey::try_new(encoded).map_err(|_| InternalError::store_invariant())
1974}
1975
1976#[cfg(feature = "migration")]
1977fn load_migration_record_for_status(
1978 database_identity: TargetDatabaseIdentity,
1979 submission_key: &SchemaSubmissionKey,
1980) -> Result<Option<SchemaApplicationRecord>, InternalError> {
1981 let record =
1982 with_schema_application_store(|store| store.load(database_identity, submission_key))?;
1983 if record
1984 .as_ref()
1985 .is_some_and(|record| record.receipt().database_identity() != database_identity)
1986 {
1987 return Err(InternalError::schema_migration(
1988 SchemaMigrationCode::ProgressCorrupt,
1989 ));
1990 }
1991 Ok(record)
1992}
1993
1994#[cfg(feature = "migration")]
1995fn load_exact_migration_record(
1996 database_identity: TargetDatabaseIdentity,
1997 submission_key: &SchemaSubmissionKey,
1998 proposal_digest: SchemaProposalDigest,
1999 prior_head: &ExpectedAcceptedHead,
2000) -> Result<Option<SchemaApplicationRecord>, InternalError> {
2001 let Some(record) =
2002 with_schema_application_store(|store| store.load(database_identity, submission_key))?
2003 else {
2004 return Ok(None);
2005 };
2006 if !record.receipt().is_exact_submission(
2007 database_identity,
2008 submission_key,
2009 proposal_digest,
2010 prior_head,
2011 ) {
2012 return Err(InternalError::schema_migration(
2013 SchemaMigrationCode::PlanChanged,
2014 ));
2015 }
2016 Ok(Some(record))
2017}
2018
2019#[cfg(feature = "migration")]
2020fn public_migration_receipt(
2021 record: &SchemaApplicationRecord,
2022 plan_digest: Option<SchemaMigrationPlanDigest>,
2023) -> Result<SchemaMigrationReceipt, InternalError> {
2024 let accepted_head = migration_record_accepted_head(record)?.clone();
2025 Ok(SchemaMigrationReceipt::new(
2026 record.receipt().database_identity(),
2027 plan_digest,
2028 record.receipt().prior_head().clone(),
2029 accepted_head,
2030 ))
2031}
2032
2033#[cfg(feature = "migration")]
2034fn migration_record_accepted_head(
2035 record: &SchemaApplicationRecord,
2036) -> Result<&ExpectedAcceptedHead, InternalError> {
2037 match record.receipt().outcome() {
2038 SchemaChangeOutcome::NoOp { accepted_head }
2039 | SchemaChangeOutcome::Applied { accepted_head } => Ok(accepted_head),
2040 SchemaChangeOutcome::Pending { .. } | SchemaChangeOutcome::Aborted { .. } => Err(
2041 InternalError::schema_migration(SchemaMigrationCode::ProgressCorrupt),
2042 ),
2043 }
2044}
2045
2046#[cfg(feature = "migration")]
2047fn migration_transitions(
2048 proposal: &SchemaProposal,
2049) -> Result<Vec<SchemaMigrationEntityTransition>, InternalError> {
2050 if let Some(plan) = proposal.migration() {
2051 return plan
2052 .transitions()
2053 .iter()
2054 .map(|transition| {
2055 let target = proposal_entity(proposal, transition.entity())?;
2056 Ok(SchemaMigrationEntityTransition::new(
2057 transition.entity().clone(),
2058 Some(transition.from().get()),
2059 target.version().get(),
2060 ))
2061 })
2062 .collect();
2063 }
2064 proposal
2065 .fragments()
2066 .iter()
2067 .flat_map(icydb_schema::SchemaFragment::entities)
2068 .map(|entity| {
2069 Ok(SchemaMigrationEntityTransition::new(
2070 entity.source_key().clone(),
2071 None,
2072 entity.version().get(),
2073 ))
2074 })
2075 .collect()
2076}
2077
2078#[cfg(feature = "migration")]
2079fn proposal_entity<'a>(
2080 proposal: &'a SchemaProposal,
2081 source: &EntitySourceKey,
2082) -> Result<&'a icydb_schema::EntityFragment, InternalError> {
2083 proposal
2084 .fragments()
2085 .iter()
2086 .flat_map(icydb_schema::SchemaFragment::entities)
2087 .find(|entity| entity.source_key() == source)
2088 .ok_or_else(InternalError::store_invariant)
2089}
2090
2091#[cfg(feature = "migration")]
2092fn current_proposal_lineage_is_applied<C: CanisterKind>(
2093 db: &Db<C>,
2094 proposal: &SchemaProposal,
2095) -> Result<bool, InternalError> {
2096 let Some(lineage) = load_entity_source_lineage_catalog()? else {
2097 return Ok(false);
2098 };
2099 let entities = proposal
2102 .fragments()
2103 .iter()
2104 .flat_map(icydb_schema::SchemaFragment::entities);
2105 let target = proposal.target_database();
2106 let authorities = application_authorities(db);
2107 for entity in entities {
2108 let source = entity.source_key();
2109 let digest = proposal
2110 .entity_source_digest(source)
2111 .map_err(|_| InternalError::store_invariant())?;
2112 let mut matched = false;
2113 for authority in &authorities {
2114 let store_identity = derive_store_identity(target, authority);
2115 let entity_tag = authority
2116 .handle
2117 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
2118 .and_then(|bundle| bundle.source_bindings().entity(source));
2119 let Some(entity_tag) = entity_tag else {
2120 continue;
2121 };
2122 let Some(entry) = lineage.get(store_identity, entity_tag) else {
2123 return Ok(false);
2124 };
2125 matched = matches!(
2126 entry.state(),
2127 AcceptedEntitySourceLineageState::Adopted { version, source_digest }
2128 if version.get() == entity.version().get() && *source_digest == digest
2129 );
2130 break;
2131 }
2132 if !matched {
2133 return Ok(false);
2134 }
2135 }
2136 Ok(true)
2137}
2138
2139#[cfg(feature = "migration")]
2140fn preflight_unpublished_schema_migration<C: CanisterKind>(
2141 target: &SchemaApplicationTarget,
2142 proposal: &SchemaProposal,
2143 db: &Db<C>,
2144) -> Result<(), InternalError> {
2145 let authorities = application_authorities(db);
2146 let current_bundles = authorities
2147 .iter()
2148 .map(|authority| {
2149 authority
2150 .handle
2151 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
2152 })
2153 .collect::<Result<Vec<_>, InternalError>>()?;
2154 let stores = authorities
2155 .iter()
2156 .zip(¤t_bundles)
2157 .filter_map(|(authority, bundle)| {
2158 bundle.as_ref().map(|bundle| ExistingProposalStore {
2159 path: authority.path,
2160 identity: derive_store_identity(target.database_identity(), authority),
2161 bundle,
2162 })
2163 })
2164 .collect::<Vec<_>>();
2165 let lineage = load_entity_source_lineage_catalog()?.unwrap_or_default();
2166 let planned = plan_schema_migration(proposal, stores.as_slice(), &lineage)
2167 .map_err(schema_migration_planning_error)?;
2168 if planned.candidates().is_empty() || planned.lineage().is_empty() {
2169 return Err(InternalError::store_invariant());
2170 }
2171 for next in planned.lineage() {
2172 let Some(current) = lineage.get(next.store(), next.entity()) else {
2173 if next.version().get() != 1
2176 || stores.iter().any(|store| {
2177 store.identity == next.store()
2178 && store.bundle.entity_snapshots().contains_key(&next.entity())
2179 })
2180 {
2181 return Err(InternalError::store_invariant());
2182 }
2183 continue;
2184 };
2185 let AcceptedEntitySourceLineageState::Adopted {
2186 version,
2187 source_digest,
2188 } = current.state()
2189 else {
2190 return Err(InternalError::store_invariant());
2191 };
2192 let expected_version = version
2193 .get()
2194 .checked_add(1)
2195 .ok_or_else(InternalError::store_invariant)?;
2196 if next.version().get() != expected_version || next.digest() == *source_digest {
2197 return Err(InternalError::store_invariant());
2198 }
2199 }
2200 Ok(())
2201}
2202
2203fn lower_application_candidates<const ALLOW_REMOVALS: bool>(
2204 target: &SchemaApplicationTarget,
2205 proposal: &SchemaProposal,
2206 authorities: &[StoreApplicationAuthority],
2207) -> Result<LoweredApplication, InternalError> {
2208 let _span = ExecutionMetricsSpan::new(ExecutionMetricsPhase::Lowering);
2209 let current_bundles = authorities
2210 .iter()
2211 .map(|authority| {
2212 authority
2213 .handle
2214 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
2215 })
2216 .collect::<Result<Vec<_>, InternalError>>()?;
2217 let initial_application = matches!(target.accepted_head(), ExpectedAcceptedHead::Empty);
2218 let mut candidates = match target.accepted_head() {
2219 ExpectedAcceptedHead::Empty => {
2220 let stores = authorities
2221 .iter()
2222 .map(|authority| ProposalStoreTarget {
2223 path: authority.path,
2224 identity: derive_store_identity(target.database_identity(), authority),
2225 })
2226 .collect::<Vec<_>>();
2227 let candidates = lower_initial_schema_proposal(proposal, stores.as_slice())?;
2228 #[cfg(feature = "migration")]
2229 {
2230 let planned = plan_new_entity_source_lineage(proposal, &[], &candidates)
2231 .map_err(schema_migration_planning_error)?;
2232 if planned.len()
2233 != proposal
2234 .fragments()
2235 .iter()
2236 .map(|fragment| fragment.entities().len())
2237 .sum::<usize>()
2238 {
2239 return Err(InternalError::store_invariant());
2240 }
2241 }
2242 candidates
2243 }
2244 ExpectedAcceptedHead::Exact { .. }
2245 if proposal.fragments().is_empty() && proposal.removals().is_empty() =>
2246 {
2247 Vec::new()
2248 }
2249 ExpectedAcceptedHead::Exact { .. } => {
2250 let stores = authorities
2251 .iter()
2252 .zip(¤t_bundles)
2253 .filter_map(|(authority, bundle)| {
2254 bundle.as_ref().map(|bundle| ExistingProposalStore {
2255 path: authority.path,
2256 identity: derive_store_identity(target.database_identity(), authority),
2257 bundle,
2258 })
2259 })
2260 .collect::<Vec<_>>();
2261 if ALLOW_REMOVALS {
2262 lower_existing_schema_proposal(proposal, stores.as_slice())?
2263 } else {
2264 lower_generated_existing_schema_proposal(proposal, stores.as_slice())?
2265 }
2266 }
2267 };
2268 let pending = if initial_application {
2269 preflight_initial_application(authorities, &candidates)?;
2270 None
2271 } else {
2272 preflight_existing_application(authorities, ¤t_bundles, &mut candidates)?
2273 };
2274 Ok(LoweredApplication {
2275 current_bundles,
2276 candidates,
2277 pending,
2278 })
2279}
2280
2281fn validate_database_identity_state_capacity(
2282 authorities: &[StoreApplicationAuthority],
2283 candidates: &[CandidateSchemaRevision],
2284 incarnation: crate::db::integrity::DatabaseIncarnationId,
2285) -> Result<(), InternalError> {
2286 let mut total = 0usize;
2287 for authority in authorities {
2288 let count = match candidates
2289 .iter()
2290 .find(|candidate| candidate.store_path() == authority.path)
2291 {
2292 Some(candidate) => authority.handle.with_schema(|store| {
2293 store.projected_identity_state_count(incarnation, candidate)
2294 })?,
2295 None => authority
2296 .handle
2297 .with_schema(|store| store.identity_state_inventory_for_integrity(incarnation))?
2298 .len(),
2299 };
2300 total = include_identity_state_count(total, count)?;
2301 }
2302 Ok(())
2303}
2304
2305fn include_identity_state_count(total: usize, count: usize) -> Result<usize, InternalError> {
2306 let total = total
2307 .checked_add(count)
2308 .ok_or_else(InternalError::identity_state_capacity_exhausted)?;
2309 if total > MAX_IDENTITY_STATE_RECORDS_PER_DATABASE {
2310 return Err(InternalError::identity_state_capacity_exhausted());
2311 }
2312 Ok(total)
2313}
2314
2315fn preflight_initial_application(
2316 authorities: &[StoreApplicationAuthority],
2317 candidates: &[crate::db::schema::CandidateSchemaRevision],
2318) -> Result<(), InternalError> {
2319 for candidate in candidates {
2320 let authority = authorities
2321 .iter()
2322 .find(|authority| authority.path == candidate.store_path())
2323 .ok_or_else(InternalError::store_invariant)?;
2324 if !authority.handle.with_data(DataStore::is_empty)
2325 || authority.handle.index_state() != IndexState::Ready
2326 || !authority.handle.with_index(IndexStore::is_empty)
2327 {
2328 return Err(InternalError::store_unsupported());
2329 }
2330 }
2331 Ok(())
2332}
2333
2334fn preflight_existing_application(
2341 authorities: &[StoreApplicationAuthority],
2342 current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
2343 candidates: &mut [CandidateSchemaRevision],
2344) -> Result<Option<PendingGeneratedRowLocalConstraint>, InternalError> {
2345 preflight_new_entity_domains(authorities, current_bundles, candidates)?;
2346 require_empty_physical_entity_removal(authorities, current_bundles, candidates)?;
2347 require_empty_physical_field_removals(authorities, current_bundles, candidates)?;
2348 require_empty_physical_index_removals(authorities, current_bundles, candidates)?;
2349 require_empty_physical_relation_removals(authorities, current_bundles, candidates)?;
2350 let proofs = generated_row_local_constraint_proofs(authorities, current_bundles, candidates)?;
2351 if proofs.iter().filter(|proof| proof.has_rows).count() > 1 {
2352 return Err(InternalError::store_unsupported());
2353 }
2354
2355 let mut pending = None;
2356 for candidate_index in 0..candidates.len() {
2357 let candidate = candidates
2358 .get(candidate_index)
2359 .cloned()
2360 .ok_or_else(InternalError::store_invariant)?;
2361 let candidate_proofs = proofs
2362 .iter()
2363 .filter(|proof| proof.candidate_index == candidate_index)
2364 .collect::<Vec<_>>();
2365 if candidate_proofs.is_empty() {
2366 continue;
2367 }
2368
2369 let mut snapshots = candidate.bundle().entity_snapshots().clone();
2370 for proof in candidate_proofs {
2371 let mut promote = true;
2372 if proof.has_rows {
2373 match validate_unpublished_row_local_candidate_bounded(
2374 proof.store,
2375 proof.store_path,
2376 proof.entity_tag,
2377 proof.entity_path.as_str(),
2378 &candidate,
2379 proof.constraint_id,
2380 )? {
2381 UnpublishedRowLocalValidation::Complete { .. } => {}
2382 UnpublishedRowLocalValidation::Incomplete(_) => {
2383 if proof.store.storage_capabilities().recovery()
2384 != StoreRecoveryCapability::StableBasePlusJournalReplay
2385 || pending.is_some()
2386 {
2387 return Err(InternalError::store_unsupported());
2388 }
2389 pending = Some(PendingGeneratedRowLocalConstraint {
2390 proof: (*proof).clone(),
2391 });
2392 promote = false;
2393 }
2394 }
2395 }
2396 if !promote {
2397 continue;
2398 }
2399 let snapshot = snapshots
2400 .get(&proof.entity_tag)
2401 .cloned()
2402 .ok_or_else(InternalError::store_invariant)?;
2403 let catalog = snapshot
2404 .constraint_catalog()
2405 .clone()
2406 .with_directly_validated_activation(proof.constraint_id)
2407 .map_err(|_| InternalError::store_invariant())?;
2408 snapshots.insert(proof.entity_tag, snapshot.with_constraint_catalog(catalog));
2409 }
2410 let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
2411 candidate.revision(),
2412 candidate.bundle().store_path(),
2413 candidate.bundle().enum_catalog().clone(),
2414 candidate.bundle().composite_catalog().clone(),
2415 candidate.bundle().source_bindings().clone(),
2416 snapshots,
2417 )?;
2418 candidates[candidate_index] = CandidateSchemaRevision::new(bundle)?;
2419 }
2420 Ok(pending)
2421}
2422
2423fn preflight_new_entity_domains(
2427 authorities: &[StoreApplicationAuthority],
2428 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2429 candidates: &[CandidateSchemaRevision],
2430) -> Result<(), InternalError> {
2431 let mut after = current_bundles.to_vec();
2432 for candidate in candidates {
2433 let position = authorities
2434 .iter()
2435 .position(|authority| authority.path == candidate.store_path())
2436 .ok_or_else(InternalError::store_invariant)?;
2437 let bundle = after
2438 .get_mut(position)
2439 .ok_or_else(InternalError::store_invariant)?;
2440 *bundle = Some(candidate.bundle().clone());
2441 }
2442 for candidate in candidates {
2443 let (position, authority) = authorities
2444 .iter()
2445 .enumerate()
2446 .find(|(_, authority)| authority.path == candidate.store_path())
2447 .ok_or_else(InternalError::store_invariant)?;
2448 let current = current_bundles
2449 .get(position)
2450 .and_then(Option::as_ref)
2451 .ok_or_else(InternalError::store_invariant)?;
2452 for (tag, snapshot) in candidate.bundle().entity_snapshots() {
2453 if current.entity_snapshots().contains_key(tag) {
2454 continue;
2455 }
2456 require_exact_empty_entity(authority.handle, *tag)?;
2457 authority
2458 .handle
2459 .with_index(|store| prove_empty_user_index_domain(store, *tag))
2460 .map_err(StagedUserIndexDomainError::into_internal_error)?;
2461 for relation in snapshot.relations() {
2462 let target =
2463 accepted_entity_store_for_path(authorities, &after, relation.target_path())?;
2464 target.with_index(|store| {
2465 prove_empty_reverse_relation_domain(store, *tag, snapshot, relation)
2466 })?;
2467 }
2468 }
2469 }
2470 Ok(())
2471}
2472
2473fn require_empty_physical_entity_removal(
2480 authorities: &[StoreApplicationAuthority],
2481 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2482 candidates: &[CandidateSchemaRevision],
2483) -> Result<(), InternalError> {
2484 let mut removed_entity = None;
2485 for candidate in candidates {
2486 let (position, source_authority) = authorities
2487 .iter()
2488 .enumerate()
2489 .find(|(_, authority)| authority.path == candidate.store_path())
2490 .ok_or_else(InternalError::store_invariant)?;
2491 let current = current_bundles
2492 .get(position)
2493 .and_then(Option::as_ref)
2494 .ok_or_else(InternalError::store_invariant)?;
2495 let removed = current
2496 .entity_snapshots()
2497 .iter()
2498 .filter(|(entity_tag, _)| {
2499 !candidate
2500 .bundle()
2501 .entity_snapshots()
2502 .contains_key(entity_tag)
2503 })
2504 .collect::<Vec<_>>();
2505 if removed.is_empty() {
2506 continue;
2507 }
2508 let [(entity_tag, snapshot)] = removed.as_slice() else {
2509 return Err(InternalError::store_unsupported());
2510 };
2511 let entity_tag = **entity_tag;
2512 let snapshot = *snapshot;
2513 if removed_entity.is_some()
2514 || current.entity_snapshots().len()
2515 != candidate
2516 .bundle()
2517 .entity_snapshots()
2518 .len()
2519 .saturating_add(1)
2520 {
2521 return Err(InternalError::store_unsupported());
2522 }
2523 require_exact_empty_entity(source_authority.handle, entity_tag)?;
2524 source_authority
2525 .handle
2526 .with_index(|store| prove_empty_user_index_domain(store, entity_tag))
2527 .map_err(StagedUserIndexDomainError::into_internal_error)?;
2528 for relation in snapshot.relations() {
2529 let target_store = accepted_entity_store_for_path(
2530 authorities,
2531 current_bundles,
2532 relation.target_path(),
2533 )?;
2534 target_store.with_index(|store| {
2535 prove_empty_reverse_relation_domain(store, entity_tag, snapshot, relation)
2536 })?;
2537 }
2538 removed_entity = Some(snapshot.entity_path());
2539 }
2540
2541 let Some(removed_path) = removed_entity else {
2542 return Ok(());
2543 };
2544 for (position, authority) in authorities.iter().enumerate() {
2545 let after = candidates
2546 .iter()
2547 .find(|candidate| candidate.store_path() == authority.path)
2548 .map(CandidateSchemaRevision::bundle)
2549 .or_else(|| current_bundles.get(position).and_then(Option::as_ref));
2550 let Some(after) = after else {
2551 continue;
2552 };
2553 if after
2554 .entity_snapshots()
2555 .values()
2556 .flat_map(crate::db::schema::PersistedSchemaSnapshot::relations)
2557 .any(|relation| relation.target_path() == removed_path)
2558 {
2559 return Err(InternalError::store_unsupported());
2560 }
2561 }
2562 Ok(())
2563}
2564
2565fn require_empty_physical_relation_removals(
2568 authorities: &[StoreApplicationAuthority],
2569 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2570 candidates: &[CandidateSchemaRevision],
2571) -> Result<(), InternalError> {
2572 for candidate in candidates {
2573 let (position, source_authority) = authorities
2574 .iter()
2575 .enumerate()
2576 .find(|(_, authority)| authority.path == candidate.store_path())
2577 .ok_or_else(InternalError::store_invariant)?;
2578 let current = current_bundles
2579 .get(position)
2580 .and_then(Option::as_ref)
2581 .ok_or_else(InternalError::store_invariant)?;
2582 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2583 let Some(before) = current.entity_snapshots().get(entity_tag) else {
2584 continue;
2585 };
2586 let removed = before
2587 .relations()
2588 .iter()
2589 .filter(|relation| {
2590 !after
2591 .relations()
2592 .iter()
2593 .any(|candidate| candidate.id() == relation.id())
2594 })
2595 .collect::<Vec<_>>();
2596 if removed.is_empty() {
2597 continue;
2598 }
2599 let added = after.relations().iter().any(|relation| {
2600 !before
2601 .relations()
2602 .iter()
2603 .any(|accepted| accepted.id() == relation.id())
2604 });
2605 let [removed] = removed.as_slice() else {
2606 return Err(InternalError::store_unsupported());
2607 };
2608 if added || before.relations().len() != after.relations().len().saturating_add(1) {
2609 return Err(InternalError::store_unsupported());
2610 }
2611 require_exact_empty_entity(source_authority.handle, *entity_tag)?;
2612 let target_store = accepted_entity_store_for_path(
2613 authorities,
2614 current_bundles,
2615 removed.target_path(),
2616 )?;
2617 target_store.with_index(|store| {
2618 prove_empty_reverse_relation_domain(store, *entity_tag, before, removed)
2619 })?;
2620 }
2621 }
2622 Ok(())
2623}
2624
2625fn accepted_entity_store_for_path(
2626 authorities: &[StoreApplicationAuthority],
2627 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2628 entity_path: &str,
2629) -> Result<StoreHandle, InternalError> {
2630 let mut resolved = None;
2631 for (position, bundle) in current_bundles.iter().enumerate() {
2632 let Some(bundle) = bundle else {
2633 continue;
2634 };
2635 if !bundle
2636 .entity_snapshots()
2637 .values()
2638 .any(|snapshot| snapshot.entity_path() == entity_path)
2639 {
2640 continue;
2641 }
2642 if resolved.is_some() {
2643 return Err(InternalError::store_invariant());
2644 }
2645 resolved = authorities.get(position).map(|authority| authority.handle);
2646 }
2647 resolved.ok_or_else(InternalError::store_unsupported)
2648}
2649
2650fn require_empty_physical_index_removals(
2654 authorities: &[StoreApplicationAuthority],
2655 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2656 candidates: &[CandidateSchemaRevision],
2657) -> Result<(), InternalError> {
2658 for candidate in candidates {
2659 let (position, authority) = authorities
2660 .iter()
2661 .enumerate()
2662 .find(|(_, authority)| authority.path == candidate.store_path())
2663 .ok_or_else(InternalError::store_invariant)?;
2664 let current = current_bundles
2665 .get(position)
2666 .and_then(Option::as_ref)
2667 .ok_or_else(InternalError::store_invariant)?;
2668 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2669 let Some(before) = current.entity_snapshots().get(entity_tag) else {
2670 continue;
2671 };
2672 if before.indexes().len() == after.indexes().len() {
2673 continue;
2674 }
2675 if before.indexes().len() != after.indexes().len().saturating_add(1) {
2676 return Err(InternalError::store_unsupported());
2677 }
2678 require_exact_empty_entity(authority.handle, *entity_tag)?;
2679 authority
2680 .handle
2681 .with_index(|store| prove_empty_user_index_domain(store, *entity_tag))
2682 .map_err(StagedUserIndexDomainError::into_internal_error)?;
2683 }
2684 }
2685 Ok(())
2686}
2687
2688fn require_empty_physical_field_removals(
2691 authorities: &[StoreApplicationAuthority],
2692 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2693 candidates: &[CandidateSchemaRevision],
2694) -> Result<(), InternalError> {
2695 for candidate in candidates {
2696 let (position, authority) = authorities
2697 .iter()
2698 .enumerate()
2699 .find(|(_, authority)| authority.path == candidate.store_path())
2700 .ok_or_else(InternalError::store_invariant)?;
2701 let current = current_bundles
2702 .get(position)
2703 .and_then(Option::as_ref)
2704 .ok_or_else(InternalError::store_invariant)?;
2705 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2706 let Some(before) = current.entity_snapshots().get(entity_tag) else {
2707 continue;
2708 };
2709 if before.row_layout() == after.row_layout() {
2710 continue;
2711 }
2712 if before.fields().len() != after.fields().len().saturating_add(1) {
2713 return Err(InternalError::store_unsupported());
2714 }
2715 require_exact_empty_entity(authority.handle, *entity_tag)?;
2716 }
2717 }
2718 Ok(())
2719}
2720
2721fn require_exact_empty_entity(
2723 store: StoreHandle,
2724 entity_tag: EntityTag,
2725) -> Result<(), InternalError> {
2726 if store.with_data(|data| entity_has_rows(data, entity_tag))? {
2727 return Err(InternalError::store_unsupported());
2728 }
2729
2730 Ok(())
2731}
2732
2733fn entity_has_rows(data: &DataStore, entity: EntityTag) -> Result<bool, InternalError> {
2736 let range = RawDataStoreKeyRange::entity_prefix(entity);
2737 let lower = Bound::Included(RawDataStoreKey::store_range_lower_key(&range));
2738 let upper = range
2739 .upper_exclusive()
2740 .map(RawDataStoreKey::from_store_range_bound)
2741 .map_or(Bound::Unbounded, Bound::Excluded);
2742 let mut has_rows = false;
2743 data.visit_key_range((lower, upper), |_| {
2744 has_rows = true;
2745 Ok::<_, InternalError>(StoreVisit::Stop)
2746 })?;
2747
2748 Ok(has_rows)
2749}
2750
2751fn generated_row_local_constraint_proofs(
2752 authorities: &[StoreApplicationAuthority],
2753 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2754 candidates: &[CandidateSchemaRevision],
2755) -> Result<Vec<DirectGeneratedRowLocalProof>, InternalError> {
2756 let mut proofs = Vec::new();
2757 for (candidate_index, candidate) in candidates.iter().enumerate() {
2758 let (position, authority) = authorities
2759 .iter()
2760 .enumerate()
2761 .find(|(_, authority)| authority.path == candidate.store_path())
2762 .ok_or_else(InternalError::store_invariant)?;
2763 let current = current_bundles
2764 .get(position)
2765 .and_then(Option::as_ref)
2766 .ok_or_else(InternalError::store_invariant)?;
2767 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2768 let Some(before) = current.entity_snapshots().get(entity_tag) else {
2769 continue;
2770 };
2771 let added = added_generated_row_local_activations(before, after);
2772 if added.is_empty() {
2773 continue;
2774 }
2775 let has_rows = authority
2776 .handle
2777 .with_data(|data| entity_has_rows(data, *entity_tag))?;
2778 for constraint_id in added {
2779 proofs.push(DirectGeneratedRowLocalProof {
2780 candidate_index,
2781 store: authority.handle,
2782 store_path: authority.path,
2783 entity_tag: *entity_tag,
2784 entity_path: after.entity_path().to_string(),
2785 constraint_id,
2786 has_rows,
2787 });
2788 }
2789 }
2790 }
2791 Ok(proofs)
2792}
2793
2794fn added_generated_row_local_activations(
2795 before: &crate::db::schema::PersistedSchemaSnapshot,
2796 after: &crate::db::schema::PersistedSchemaSnapshot,
2797) -> Vec<ConstraintId> {
2798 after
2799 .constraint_activations()
2800 .iter()
2801 .filter(|candidate| {
2802 candidate.origin() == ConstraintOrigin::Generated
2803 && matches!(
2804 candidate.kind(),
2805 ConstraintActivationKind::Check { .. }
2806 | ConstraintActivationKind::TargetedRule { .. }
2807 )
2808 && !before
2809 .constraint_activations()
2810 .iter()
2811 .any(|accepted| accepted.id() == candidate.id())
2812 })
2813 .map(crate::db::schema::ConstraintActivationSnapshot::id)
2814 .collect()
2815}
2816
2817fn final_candidates_for_pending_row_local_constraint(
2818 candidates: &[CandidateSchemaRevision],
2819 pending: &PendingGeneratedRowLocalConstraint,
2820) -> Result<Vec<CandidateSchemaRevision>, InternalError> {
2821 let mut final_candidates = candidates.to_vec();
2822 let candidate = final_candidates
2823 .get(pending.proof.candidate_index)
2824 .cloned()
2825 .ok_or_else(InternalError::store_invariant)?;
2826 if candidate.store_path() != pending.proof.store_path {
2827 return Err(InternalError::store_invariant());
2828 }
2829 let mut snapshots = candidate.bundle().entity_snapshots().clone();
2830 let snapshot = snapshots
2831 .get(&pending.proof.entity_tag)
2832 .cloned()
2833 .ok_or_else(InternalError::store_invariant)?;
2834 let catalog = snapshot
2835 .constraint_catalog()
2836 .clone()
2837 .with_directly_validated_activation(pending.proof.constraint_id)
2838 .map_err(|_| InternalError::store_invariant())?;
2839 snapshots.insert(
2840 pending.proof.entity_tag,
2841 snapshot.with_constraint_catalog(catalog),
2842 );
2843 let final_revision = candidate
2844 .revision()
2845 .checked_next()
2846 .and_then(AcceptedSchemaRevision::checked_next)
2847 .ok_or_else(InternalError::store_unsupported)?;
2848 let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
2849 final_revision,
2850 candidate.bundle().store_path(),
2851 candidate.bundle().enum_catalog().clone(),
2852 candidate.bundle().composite_catalog().clone(),
2853 candidate.bundle().source_bindings().clone(),
2854 snapshots,
2855 )?;
2856 final_candidates[pending.proof.candidate_index] = CandidateSchemaRevision::new(bundle)?;
2857 Ok(final_candidates)
2858}
2859
2860fn schema_change_progress_status(
2861 snapshot: &crate::db::schema::PersistedSchemaSnapshot,
2862 entity_tag: EntityTag,
2863 constraint_id: ConstraintId,
2864 progress: ConstraintValidationProgress,
2865) -> Result<SchemaChangeProgressStatus, InternalError> {
2866 match progress {
2867 ConstraintValidationProgress::Started => Ok(SchemaChangeProgressStatus::Started),
2868 ConstraintValidationProgress::Advanced {
2869 phase,
2870 rows_scanned,
2871 } => Ok(SchemaChangeProgressStatus::Advanced {
2872 phase: schema_change_validation_phase(phase),
2873 rows_scanned,
2874 }),
2875 ConstraintValidationProgress::Findings {
2876 receipt,
2877 phase,
2878 rows_scanned,
2879 } => {
2880 let activation = snapshot
2881 .constraint_catalog()
2882 .activation(constraint_id)
2883 .ok_or_else(InternalError::store_corruption)?;
2884 let fingerprint =
2885 crate::db::schema::accepted_schema_cache_fingerprint_for_persisted_snapshot(
2886 snapshot,
2887 )?;
2888 let findings = receipt
2889 .findings()
2890 .iter()
2891 .map(|finding| {
2892 constraint_validation_finding_output(
2893 fingerprint,
2894 entity_tag,
2895 activation,
2896 finding,
2897 )
2898 })
2899 .collect::<Result<Vec<_>, InternalError>>()?;
2900 Ok(SchemaChangeProgressStatus::Findings {
2901 phase: schema_change_validation_phase(phase),
2902 rows_scanned,
2903 page_sequence: receipt.page_sequence(),
2904 findings,
2905 })
2906 }
2907 ConstraintValidationProgress::Restarted { rows_scanned } => {
2908 Ok(SchemaChangeProgressStatus::Restarted { rows_scanned })
2909 }
2910 ConstraintValidationProgress::Promoted { .. } => Ok(SchemaChangeProgressStatus::Applied),
2911 }
2912}
2913
2914const fn schema_change_validation_phase(
2915 phase: ConstraintValidationPhase,
2916) -> SchemaChangeValidationPhase {
2917 match phase {
2918 ConstraintValidationPhase::Forward => SchemaChangeValidationPhase::Forward,
2919 ConstraintValidationPhase::Verify => SchemaChangeValidationPhase::Verify,
2920 }
2921}
2922
2923fn finalize_schema_application<C: CanisterKind>(
2924 db: &Db<C>,
2925 record: &SchemaApplicationRecord,
2926 candidate_head: &ExpectedAcceptedHead,
2927 status: SchemaChangeProgressStatus,
2928) -> Result<SchemaChangeProgress, InternalError> {
2929 if schema_application_target(db)?.accepted_head() != candidate_head {
2930 return Err(InternalError::schema_application_conflict());
2931 }
2932 let receipt = SchemaChangeReceipt::new(
2933 record.receipt().database_identity(),
2934 record.receipt().submission_key().clone(),
2935 record.receipt().proposal_digest(),
2936 record.receipt().prior_head().clone(),
2937 SchemaChangeOutcome::Applied {
2938 accepted_head: candidate_head.clone(),
2939 },
2940 )?;
2941 let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
2942 let operation = SchemaApplicationRecordOp::replace(record, &terminal)?;
2943 publish_accepted_schema_candidates_with_application_record(Vec::new(), operation)?;
2944 Ok(SchemaChangeProgress::new(receipt, status))
2945}
2946
2947fn application_publications<'a>(
2948 authorities: &[StoreApplicationAuthority],
2949 current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
2950 candidates: &'a [crate::db::schema::CandidateSchemaRevision],
2951) -> Result<Vec<AcceptedSchemaPublication<'a>>, InternalError> {
2952 candidates
2953 .iter()
2954 .map(|candidate| {
2955 let (position, authority) = authorities
2956 .iter()
2957 .enumerate()
2958 .find(|(_, authority)| authority.path == candidate.store_path())
2959 .ok_or_else(InternalError::store_invariant)?;
2960 let expected_revision = current_bundles[position].as_ref().map_or(
2961 AcceptedSchemaRevision::NONE,
2962 crate::db::schema::AcceptedSchemaRevisionBundle::revision,
2963 );
2964 Ok(AcceptedSchemaPublication::new(
2965 authority.path,
2966 authority.handle,
2967 expected_revision,
2968 candidate,
2969 ))
2970 })
2971 .collect()
2972}
2973
2974fn application_authorities<C: CanisterKind>(db: &Db<C>) -> Vec<StoreApplicationAuthority> {
2975 let mut authorities = db.with_store_registry(|registry| {
2976 registry
2977 .iter()
2978 .map(|(path, handle)| StoreApplicationAuthority { path, handle })
2979 .collect::<Vec<_>>()
2980 });
2981 icydb_schema::compact_sort_unstable_by(&mut authorities, |left, right| {
2982 left.path.cmp(right.path)
2983 });
2984 authorities
2985}
2986
2987fn accepted_head_after_candidates(
2988 authorities: &[StoreApplicationAuthority],
2989 candidates: &[crate::db::schema::CandidateSchemaRevision],
2990) -> Result<ExpectedAcceptedHead, InternalError> {
2991 let heads = authorities
2992 .iter()
2993 .map(|authority| {
2994 let candidate = candidates
2995 .iter()
2996 .find(|candidate| candidate.store_path() == authority.path);
2997 let head = match candidate {
2998 Some(candidate) => Some(AcceptedStoreHead {
2999 revision: candidate.revision().get(),
3000 fingerprint: candidate.root().fingerprint().as_bytes(),
3001 }),
3002 None => authority
3003 .handle
3004 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
3005 .map(|selection| AcceptedStoreHead {
3006 revision: selection.root().revision().get(),
3007 fingerprint: selection.root().fingerprint().as_bytes(),
3008 }),
3009 };
3010 Ok((authority.path, head))
3011 })
3012 .collect::<Result<Vec<_>, InternalError>>()?;
3013 Ok(derive_accepted_head(heads.as_slice()))
3014}
3015
3016fn derive_database_identity(
3017 incarnation: [u8; 16],
3018 stores: &[StoreApplicationAuthority],
3019) -> TargetDatabaseIdentity {
3020 let mut hasher = new_hash_sha256_prefixed(DATABASE_TARGET_FINGERPRINT_PROFILE);
3021 hasher.update(incarnation);
3022 write_hash_len_u32(&mut hasher, stores.len());
3023 for store in stores {
3024 write_store_authority(&mut hasher, store);
3025 }
3026 TargetDatabaseIdentity::from_bytes(finalize_hash_sha256(hasher))
3027}
3028
3029fn derive_store_identity(
3030 database_identity: TargetDatabaseIdentity,
3031 store: &StoreApplicationAuthority,
3032) -> TargetStoreIdentity {
3033 let mut hasher = new_hash_sha256_prefixed(STORE_TARGET_FINGERPRINT_PROFILE);
3034 hasher.update(database_identity.to_bytes());
3035 write_store_authority(&mut hasher, store);
3036 TargetStoreIdentity::from_bytes(finalize_hash_sha256(hasher))
3037}
3038
3039fn derive_accepted_head(stores: &[(&str, Option<AcceptedStoreHead>)]) -> ExpectedAcceptedHead {
3040 let Some(revision) = stores
3041 .iter()
3042 .filter_map(|(_, head)| head.map(|head| head.revision))
3043 .max()
3044 else {
3045 return ExpectedAcceptedHead::Empty;
3046 };
3047
3048 let mut hasher = new_hash_sha256_prefixed(ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE);
3049 write_hash_len_u32(&mut hasher, stores.len());
3050 for (path, head) in stores {
3051 write_hash_str_u32(&mut hasher, path);
3052 match head {
3053 None => write_hash_tag_u8(&mut hasher, 0),
3054 Some(head) => {
3055 write_hash_tag_u8(&mut hasher, 1);
3056 write_hash_u64(&mut hasher, head.revision);
3057 hasher.update(head.fingerprint);
3058 }
3059 }
3060 }
3061
3062 ExpectedAcceptedHead::Exact {
3063 revision,
3064 fingerprint: ExpectedSchemaFingerprint::from_bytes(finalize_hash_sha256(hasher)),
3065 }
3066}
3067
3068pub(in crate::db) fn generated_schema_reconciled(
3069 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
3070 incarnation: DatabaseIncarnationId,
3071 submission_key: &str,
3072) -> Result<(bool, ExpectedAcceptedHead), InternalError> {
3073 let submission_key = SchemaSubmissionKey::try_new(submission_key.to_string())
3074 .map_err(|_| InternalError::store_invariant())?;
3075 let (database_identity, accepted_head) = generated_schema_authority(registry, incarnation)?;
3076 let reconciled = generated_submission_is_reconciled(database_identity, &submission_key)?;
3077 Ok((reconciled, accepted_head))
3078}
3079
3080pub(in crate::db) fn generated_schema_is_reconciled(
3081 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
3082 incarnation: DatabaseIncarnationId,
3083 submission_key: &str,
3084) -> Result<bool, InternalError> {
3085 let submission_key = SchemaSubmissionKey::try_new(submission_key.to_string())
3086 .map_err(|_| InternalError::store_invariant())?;
3087 let database_identity = generated_database_identity(registry, incarnation);
3088 generated_submission_is_reconciled(database_identity, &submission_key)
3089}
3090
3091fn generated_submission_is_reconciled(
3092 database_identity: TargetDatabaseIdentity,
3093 submission_key: &SchemaSubmissionKey,
3094) -> Result<bool, InternalError> {
3095 Ok(
3096 load_schema_application_record_read_only(database_identity, submission_key)?.is_some_and(
3097 |record| match record.receipt().outcome() {
3098 SchemaChangeOutcome::NoOp { .. } | SchemaChangeOutcome::Applied { .. } => true,
3106 SchemaChangeOutcome::Pending { .. } | SchemaChangeOutcome::Aborted { .. } => false,
3107 },
3108 ),
3109 )
3110}
3111
3112pub(in crate::db) fn generated_schema_authority(
3113 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
3114 incarnation: DatabaseIncarnationId,
3115) -> Result<(TargetDatabaseIdentity, ExpectedAcceptedHead), InternalError> {
3116 let database_identity = generated_database_identity(registry, incarnation);
3117 let mut stores = store_application_authorities(registry);
3118 icydb_schema::compact_sort_unstable_by(&mut stores, |left, right| left.path.cmp(right.path));
3119 let heads = stores
3120 .iter()
3121 .map(|store| {
3122 let head = store
3123 .handle
3124 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
3125 .map(|selection| AcceptedStoreHead {
3126 revision: selection.root().revision().get(),
3127 fingerprint: selection.root().fingerprint().as_bytes(),
3128 });
3129 Ok((store.path, head))
3130 })
3131 .collect::<Result<Vec<_>, InternalError>>()?;
3132 let accepted_head = derive_accepted_head(heads.as_slice());
3133 Ok((database_identity, accepted_head))
3134}
3135
3136fn generated_database_identity(
3137 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
3138 incarnation: DatabaseIncarnationId,
3139) -> TargetDatabaseIdentity {
3140 let registry_key = std::ptr::from_ref(registry).cast::<()>() as usize;
3141 let cached = GENERATED_DATABASE_IDENTITY
3142 .with(Cell::get)
3143 .and_then(|entry| {
3144 (entry.registry == registry_key && entry.incarnation == incarnation)
3145 .then_some(entry.identity)
3146 });
3147 if let Some(identity) = cached {
3148 return identity;
3149 }
3150
3151 let mut stores = store_application_authorities(registry);
3152 icydb_schema::compact_sort_unstable_by(&mut stores, |left, right| left.path.cmp(right.path));
3153 let identity = derive_database_identity(incarnation.to_bytes(), stores.as_slice());
3154 GENERATED_DATABASE_IDENTITY.set(Some(GeneratedDatabaseIdentityCacheEntry {
3155 registry: registry_key,
3156 incarnation,
3157 identity,
3158 }));
3159 identity
3160}
3161
3162fn store_application_authorities(
3163 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
3164) -> Vec<StoreApplicationAuthority> {
3165 registry.with(|registry| {
3166 registry
3167 .iter()
3168 .map(|(path, handle)| StoreApplicationAuthority { path, handle })
3169 .collect()
3170 })
3171}
3172
3173fn write_store_authority(hasher: &mut sha2::Sha256, store: &StoreApplicationAuthority) {
3174 write_hash_str_u32(hasher, store.path);
3175 write_storage_capabilities(hasher, store.handle);
3176 for allocation in [
3177 store.handle.data_allocation(),
3178 store.handle.index_allocation(),
3179 store.handle.schema_allocation(),
3180 store.handle.journal_allocation(),
3181 ] {
3182 write_allocation_identity(hasher, allocation);
3183 }
3184}
3185
3186fn write_storage_capabilities(hasher: &mut sha2::Sha256, store: StoreHandle) {
3187 let capabilities = store.storage_capabilities();
3188 write_hash_tag_u8(
3189 hasher,
3190 match capabilities.storage_mode() {
3191 StoreRuntimeStorageMode::Heap => 0,
3192 StoreRuntimeStorageMode::Journaled => 1,
3193 },
3194 );
3195 write_hash_tag_u8(
3196 hasher,
3197 match capabilities.allocation_identity() {
3198 StoreAllocationIdentityCapability::Present => 0,
3199 StoreAllocationIdentityCapability::Absent => 1,
3200 },
3201 );
3202 write_hash_tag_u8(
3203 hasher,
3204 match capabilities.durability() {
3205 StoreDurability::Durable => 0,
3206 StoreDurability::Volatile => 1,
3207 },
3208 );
3209 write_hash_tag_u8(
3210 hasher,
3211 match capabilities.recovery() {
3212 StoreRecoveryCapability::StableBasePlusJournalReplay => 0,
3213 StoreRecoveryCapability::None => 1,
3214 },
3215 );
3216 write_hash_tag_u8(
3217 hasher,
3218 match capabilities.commit_participation() {
3219 StoreCommitParticipation::Durable => 0,
3220 StoreCommitParticipation::LiveOnly => 1,
3221 },
3222 );
3223 write_hash_tag_u8(
3224 hasher,
3225 match capabilities.schema_metadata() {
3226 StoreSchemaMetadataCapability::LiveRebuiltMetadata => 0,
3227 StoreSchemaMetadataCapability::CanonicalStableHistoryPlusJournalTail => 1,
3228 },
3229 );
3230 write_hash_tag_u8(
3231 hasher,
3232 match capabilities.relation_source() {
3233 StoreRelationSourceCapability::DurableSource => 0,
3234 StoreRelationSourceCapability::LiveSource => 1,
3235 },
3236 );
3237 write_hash_tag_u8(
3238 hasher,
3239 match capabilities.relation_target() {
3240 StoreRelationTargetCapability::DurableTarget => 0,
3241 StoreRelationTargetCapability::VolatileTarget => 1,
3242 },
3243 );
3244}
3245
3246fn write_allocation_identity(
3247 hasher: &mut sha2::Sha256,
3248 allocation: Option<StoreAllocationIdentity>,
3249) {
3250 match allocation {
3251 None => write_hash_tag_u8(hasher, 0),
3252 Some(allocation) => {
3253 write_hash_tag_u8(hasher, 1);
3254 write_hash_tag_u8(hasher, allocation.memory_id());
3255 write_hash_str_u32(hasher, allocation.stable_key());
3256 }
3257 }
3258}
3259
3260#[cfg(test)]
3261mod tests {
3262 #[cfg(feature = "sql")]
3263 mod sql_activations;
3264
3265 mod row_presence;
3266
3267 mod entity_creation;
3268
3269 mod collection_relations;
3270
3271 #[cfg(feature = "migration")]
3272 mod identity_field_removal;
3273
3274 #[cfg(feature = "migration")]
3275 mod populated_field_removal;
3276
3277 #[cfg(feature = "migration")]
3278 mod entity_rename;
3279
3280 #[cfg(all(feature = "migration", feature = "sql"))]
3281 mod migration_staging;
3282
3283 #[cfg(feature = "migration")]
3284 mod nested_migration;
3285
3286 #[cfg(feature = "migration")]
3287 mod migration_page_limits;
3288
3289 #[cfg(feature = "migration")]
3290 mod record_member_rename;
3291
3292 use super::{
3293 AcceptedSchemaPublication, AcceptedStoreHead, DirectGeneratedRowLocalProof,
3294 PendingGeneratedRowLocalConstraint, abort_schema_application,
3295 aborted_generated_row_local_candidate, accepted_head_after_candidates,
3296 application_authorities, apply_schema, continue_schema_application, derive_accepted_head,
3297 derive_schema_change_job_id, final_candidates_for_pending_row_local_constraint,
3298 generated_database_identity, include_identity_state_count, lower_existing_schema_proposal,
3299 lower_initial_schema_proposal, publish_accepted_schema_candidates_with_application_record,
3300 schema_application_target,
3301 };
3302 use crate::{
3303 db::{
3304 DatabaseStartupState, Db, GeneratedStartupDriverStep,
3305 commit::{
3306 RecoveryProgress, continue_recovery, database_incarnation_id,
3307 forget_recovered_domain_for_tests,
3308 },
3309 data::DataStore,
3310 drive_generated_startup_recovery_page,
3311 index::IndexStore,
3312 journal::JournalTailStore,
3313 observe_generated_startup_state,
3314 registry::{
3315 StoreAllocationIdentities, StoreAllocationIdentity, StoreHandle, StoreRegistry,
3316 StoreRuntimeStorageCapabilities,
3317 },
3318 schema::{
3319 AcceptedConstraintKind, AcceptedRuleOperation, AcceptedSchemaRevisionBundle,
3320 CandidateSchemaRevision, ConstraintOrigin, ConstraintValidationJob,
3321 ExistingProposalStore, ProposalStoreTarget, SchemaApplicationRecord,
3322 SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob,
3323 SchemaChangeOutcome, SchemaChangeProgressStatus, SchemaStore,
3324 cardinality_build::{
3325 CardinalityBuildAuthority, CardinalityGenerationPageOutcome,
3326 drive_cardinality_generation_page,
3327 },
3328 },
3329 },
3330 error::{ErrorClass, ErrorOrigin},
3331 testing::test_memory,
3332 traits::{CanisterKind, Path},
3333 };
3334 use icydb_schema::{
3335 ConstraintFragment, ConstraintSourceKey, DeclaredEntityVersion, EntityFragment,
3336 EntitySourceKey, EntityStoreAssignment, ExpectedAcceptedHead, ExpectedSchemaFingerprint,
3337 FieldFragment, FieldInsertPolicy, FieldSourceKey, FieldType, NamedTypeFragment,
3338 RuleSourceKey, ScalarLiteral, ScalarType, SchemaCapability, SchemaFragment, SchemaName,
3339 SchemaProposal, SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction,
3340 SourceRuleOperation, TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment,
3341 TypeSourceKey,
3342 };
3343 use std::cell::RefCell;
3344
3345 fn drive_startup_recovery_to_completion<C: CanisterKind>(db: &Db<C>) {
3346 for _ in 0..1_024 {
3347 match continue_recovery(db).expect("test startup recovery page should succeed") {
3348 RecoveryProgress::Complete => return,
3349 RecoveryProgress::Pending => {}
3350 }
3351 }
3352 panic!("test startup recovery should complete within 1,024 bounded pages");
3353 }
3354
3355 fn drive_cardinality_to_ready(store: StoreHandle) {
3356 let journal = store
3357 .journal_tail_store()
3358 .expect("cardinality fixture store should be journaled");
3359 for _ in 0..8 {
3360 let outcome = store
3361 .with_data(|data| {
3362 store.with_index(|index| {
3363 store.with_schema_mut(|schema| {
3364 drive_cardinality_generation_page(data, index, schema, |schema| {
3365 CardinalityBuildAuthority::derive(
3366 schema,
3367 database_incarnation_id()?,
3368 store.allocation_identities(),
3369 journal.with_borrow(JournalTailStore::fold_watermark)?,
3370 )
3371 })
3372 })
3373 })
3374 })
3375 .expect("bounded cardinality generation should advance");
3376 if outcome == CardinalityGenerationPageOutcome::Quiescent {
3377 return;
3378 }
3379 }
3380 panic!("cardinality generation should become Ready within eight bounded pages");
3381 }
3382
3383 #[cfg(feature = "migration")]
3384 use crate::db::schema::SchemaChangeReceipt;
3385 use crate::{
3386 db::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell},
3387 value::InputValue,
3388 };
3389 #[cfg(feature = "migration")]
3390 use icydb_schema::{
3391 EntityMigration, IndexFragment, IndexKeyFragment, RelationDeleteAction, RelationFragment,
3392 SchemaMigrationPlan, SchemaMigrationTransform, SchemaProposalDigest,
3393 };
3394
3395 fn version_one() -> DeclaredEntityVersion {
3396 DeclaredEntityVersion::try_new(1).expect("fixture version should admit")
3397 }
3398
3399 const ABORT_STORE_PATH: &str = "schema_application_tests::AbortStore";
3400 const EVOLUTION_STORE_PATH: &str = "schema_application_tests::EvolutionStore";
3401 #[cfg(feature = "migration")]
3402 const MIGRATION_STORE_PATH: &str = "schema_application_tests::MigrationStore";
3403 #[cfg(feature = "migration")]
3404 const MIGRATION_EXECUTION_STORE_PATH: &str =
3405 "schema_application_tests::MigrationExecutionStore";
3406 #[cfg(feature = "migration")]
3407 const MIGRATION_FINDING_STORE_PATH: &str = "schema_application_tests::MigrationFindingStore";
3408
3409 #[test]
3410 fn database_identity_state_capacity_combines_store_inventories_exactly() {
3411 let below = include_identity_state_count(0, 65_535)
3412 .expect("the first store inventory should remain below the database cap");
3413 let exact = include_identity_state_count(below, 1)
3414 .expect("the combined database boundary should admit");
3415 assert_eq!(exact, 65_536);
3416
3417 let error = include_identity_state_count(exact, 1)
3418 .expect_err("the next owner in another store must reject");
3419 assert_eq!(error.class(), ErrorClass::Unsupported);
3420 assert_eq!(error.origin(), ErrorOrigin::Identity);
3421 }
3422
3423 #[test]
3424 fn generated_database_identity_cache_is_bound_to_the_incarnation() {
3425 let first_incarnation = crate::db::DatabaseIncarnationId::for_tests(0x41);
3426 let second_incarnation = crate::db::DatabaseIncarnationId::for_tests(0x42);
3427 let first = generated_database_identity(&ABORT_REGISTRY, first_incarnation);
3428 assert_eq!(
3429 generated_database_identity(&ABORT_REGISTRY, first_incarnation),
3430 first,
3431 );
3432 let second = generated_database_identity(&ABORT_REGISTRY, second_incarnation);
3433 assert_ne!(second, first);
3434 assert_eq!(
3435 generated_database_identity(&ABORT_REGISTRY, first_incarnation),
3436 first,
3437 );
3438 }
3439
3440 thread_local! {
3441 static ABORT_DATA_MEMORY: crate::testing::TestMemoryOwner = crate::testing::test_memory_owner(180);
3442 static ABORT_INDEX_MEMORY: crate::testing::TestMemoryOwner = crate::testing::test_memory_owner(181);
3443 static ABORT_SCHEMA_MEMORY: crate::testing::TestMemoryOwner = crate::testing::test_memory_owner(182);
3444 static ABORT_JOURNAL_MEMORY: crate::testing::TestMemoryOwner = crate::testing::test_memory_owner(183);
3445 static ABORT_DATA: RefCell<DataStore> =
3446 ABORT_DATA_MEMORY.with(|memory| {
3447 RefCell::new(DataStore::init_journaled(memory.open()))
3448 });
3449 static ABORT_INDEX: RefCell<IndexStore> =
3450 ABORT_INDEX_MEMORY.with(|memory| {
3451 RefCell::new(IndexStore::init_journaled(memory.open()))
3452 });
3453 static ABORT_SCHEMA: RefCell<SchemaStore> =
3454 ABORT_SCHEMA_MEMORY.with(|memory| {
3455 RefCell::new(SchemaStore::init_journaled(memory.open()))
3456 });
3457 static ABORT_JOURNAL: RefCell<JournalTailStore> =
3458 ABORT_JOURNAL_MEMORY.with(|memory| {
3459 RefCell::new(JournalTailStore::init(memory.open()))
3460 });
3461 static ABORT_REGISTRY: StoreRegistry = {
3462 let mut registry = StoreRegistry::new();
3463 registry.register_journaled_store(
3464 ABORT_STORE_PATH,
3465 &ABORT_DATA,
3466 &ABORT_INDEX,
3467 &ABORT_SCHEMA,
3468 &ABORT_JOURNAL,
3469 StoreAllocationIdentities::new_journaled(
3470 StoreAllocationIdentity::new(180, "icydb.test.application_abort.data.v1"),
3471 StoreAllocationIdentity::new(181, "icydb.test.application_abort.index.v1"),
3472 StoreAllocationIdentity::new(182, "icydb.test.application_abort.schema.v1"),
3473 StoreAllocationIdentity::new(183, "icydb.test.application_abort.journal.v1"),
3474 ),
3475 StoreRuntimeStorageCapabilities::journaled(),
3476 ).expect("abort journaled store should register");
3477 registry
3478 };
3479 }
3480
3481 #[cfg(feature = "migration")]
3482 thread_local! {
3483 static MIGRATION_EXECUTION_DATA: RefCell<DataStore> =
3484 RefCell::new(DataStore::init_journaled(test_memory(210)));
3485 static MIGRATION_EXECUTION_INDEX: RefCell<IndexStore> =
3486 RefCell::new(IndexStore::init_journaled(test_memory(211)));
3487 static MIGRATION_EXECUTION_SCHEMA: RefCell<SchemaStore> =
3488 RefCell::new(SchemaStore::init_journaled(test_memory(212)));
3489 static MIGRATION_EXECUTION_JOURNAL: RefCell<JournalTailStore> =
3490 RefCell::new(JournalTailStore::init(test_memory(213)));
3491 static MIGRATION_EXECUTION_REGISTRY: StoreRegistry = {
3492 let mut registry = StoreRegistry::new();
3493 registry.register_journaled_store(
3494 MIGRATION_EXECUTION_STORE_PATH,
3495 &MIGRATION_EXECUTION_DATA,
3496 &MIGRATION_EXECUTION_INDEX,
3497 &MIGRATION_EXECUTION_SCHEMA,
3498 &MIGRATION_EXECUTION_JOURNAL,
3499 StoreAllocationIdentities::new_journaled(
3500 StoreAllocationIdentity::new(210, "icydb.test.migration_execution.data.v1"),
3501 StoreAllocationIdentity::new(211, "icydb.test.migration_execution.index.v1"),
3502 StoreAllocationIdentity::new(212, "icydb.test.migration_execution.schema.v1"),
3503 StoreAllocationIdentity::new(213, "icydb.test.migration_execution.journal.v1"),
3504 ),
3505 StoreRuntimeStorageCapabilities::journaled(),
3506 ).expect("migration execution store should register");
3507 registry
3508 };
3509 }
3510
3511 #[cfg(feature = "migration")]
3512 thread_local! {
3513 static MIGRATION_DATA: RefCell<DataStore> =
3514 RefCell::new(DataStore::init_journaled(test_memory(200)));
3515 static MIGRATION_INDEX: RefCell<IndexStore> =
3516 RefCell::new(IndexStore::init_journaled(test_memory(201)));
3517 static MIGRATION_SCHEMA: RefCell<SchemaStore> =
3518 RefCell::new(SchemaStore::init_journaled(test_memory(202)));
3519 static MIGRATION_JOURNAL: RefCell<JournalTailStore> =
3520 RefCell::new(JournalTailStore::init(test_memory(203)));
3521 static MIGRATION_REGISTRY: StoreRegistry = {
3522 let mut registry = StoreRegistry::new();
3523 registry.register_journaled_store(
3524 MIGRATION_STORE_PATH,
3525 &MIGRATION_DATA,
3526 &MIGRATION_INDEX,
3527 &MIGRATION_SCHEMA,
3528 &MIGRATION_JOURNAL,
3529 StoreAllocationIdentities::new_journaled(
3530 StoreAllocationIdentity::new(200, "icydb.test.migration_validation.data.v1"),
3531 StoreAllocationIdentity::new(201, "icydb.test.migration_validation.index.v1"),
3532 StoreAllocationIdentity::new(202, "icydb.test.migration_validation.schema.v1"),
3533 StoreAllocationIdentity::new(203, "icydb.test.migration_validation.journal.v1"),
3534 ),
3535 StoreRuntimeStorageCapabilities::journaled(),
3536 ).expect("migration validation store should register");
3537 registry
3538 };
3539 }
3540
3541 #[cfg(feature = "migration")]
3542 thread_local! {
3543 static MIGRATION_FINDING_DATA: RefCell<DataStore> =
3544 RefCell::new(DataStore::init_journaled(test_memory(206)));
3545 static MIGRATION_FINDING_INDEX: RefCell<IndexStore> =
3546 RefCell::new(IndexStore::init_journaled(test_memory(207)));
3547 static MIGRATION_FINDING_SCHEMA: RefCell<SchemaStore> =
3548 RefCell::new(SchemaStore::init_journaled(test_memory(208)));
3549 static MIGRATION_FINDING_JOURNAL: RefCell<JournalTailStore> =
3550 RefCell::new(JournalTailStore::init(test_memory(209)));
3551 static MIGRATION_FINDING_REGISTRY: StoreRegistry = {
3552 let mut registry = StoreRegistry::new();
3553 registry.register_journaled_store(
3554 MIGRATION_FINDING_STORE_PATH,
3555 &MIGRATION_FINDING_DATA,
3556 &MIGRATION_FINDING_INDEX,
3557 &MIGRATION_FINDING_SCHEMA,
3558 &MIGRATION_FINDING_JOURNAL,
3559 StoreAllocationIdentities::new_journaled(
3560 StoreAllocationIdentity::new(206, "icydb.test.migration_finding.data.v1"),
3561 StoreAllocationIdentity::new(207, "icydb.test.migration_finding.index.v1"),
3562 StoreAllocationIdentity::new(208, "icydb.test.migration_finding.schema.v1"),
3563 StoreAllocationIdentity::new(209, "icydb.test.migration_finding.journal.v1"),
3564 ),
3565 StoreRuntimeStorageCapabilities::journaled(),
3566 ).expect("migration finding store should register");
3567 registry
3568 };
3569 }
3570
3571 thread_local! {
3572 static EVOLUTION_DATA: RefCell<DataStore> =
3573 RefCell::new(DataStore::init_journaled(test_memory(192)));
3574 static EVOLUTION_INDEX: RefCell<IndexStore> =
3575 RefCell::new(IndexStore::init_journaled(test_memory(193)));
3576 static EVOLUTION_SCHEMA: RefCell<SchemaStore> =
3577 RefCell::new(SchemaStore::init_journaled(test_memory(194)));
3578 static EVOLUTION_JOURNAL: RefCell<JournalTailStore> =
3579 RefCell::new(JournalTailStore::init(test_memory(195)));
3580 static EVOLUTION_REGISTRY: StoreRegistry = {
3581 let mut registry = StoreRegistry::new();
3582 registry.register_journaled_store(
3583 EVOLUTION_STORE_PATH,
3584 &EVOLUTION_DATA,
3585 &EVOLUTION_INDEX,
3586 &EVOLUTION_SCHEMA,
3587 &EVOLUTION_JOURNAL,
3588 StoreAllocationIdentities::new_journaled(
3589 StoreAllocationIdentity::new(192, "icydb.test.rule_evolution.data.v1"),
3590 StoreAllocationIdentity::new(193, "icydb.test.rule_evolution.index.v1"),
3591 StoreAllocationIdentity::new(194, "icydb.test.rule_evolution.schema.v1"),
3592 StoreAllocationIdentity::new(195, "icydb.test.rule_evolution.journal.v1"),
3593 ),
3594 StoreRuntimeStorageCapabilities::journaled(),
3595 ).expect("rule-evolution journaled store should register");
3596 registry
3597 };
3598 }
3599
3600 struct AbortCanister;
3601
3602 impl Path for AbortCanister {
3603 const PATH: &'static str = "schema_application_tests::AbortCanister";
3604 }
3605
3606 impl CanisterKind for AbortCanister {
3607 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3608 Ok(184)
3609 }
3610 const COMMIT_STABLE_KEY: &'static str = "icydb.test.application_abort.commit.v1";
3611 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3612 Ok(186)
3613 }
3614 const STARTUP_STABLE_KEY: &'static str = "icydb.test.application_abort.startup.control.v1";
3615 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3616 Ok(185)
3617 }
3618 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3619 "icydb.test.application_abort.integrity.v1";
3620 }
3621
3622 struct EvolutionCanister;
3623
3624 impl Path for EvolutionCanister {
3625 const PATH: &'static str = "schema_application_tests::EvolutionCanister";
3626 }
3627
3628 impl CanisterKind for EvolutionCanister {
3629 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3630 Ok(196)
3631 }
3632 const COMMIT_STABLE_KEY: &'static str = "icydb.test.rule_evolution.commit.v1";
3633 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3634 Ok(198)
3635 }
3636 const STARTUP_STABLE_KEY: &'static str = "icydb.test.rule_evolution.startup.control.v1";
3637 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3638 Ok(197)
3639 }
3640 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3641 "icydb.test.rule_evolution.integrity.v1";
3642 }
3643
3644 #[cfg(feature = "migration")]
3645 struct MigrationCanister;
3646
3647 #[cfg(feature = "migration")]
3648 impl Path for MigrationCanister {
3649 const PATH: &'static str = "schema_application_tests::MigrationCanister";
3650 }
3651
3652 #[cfg(feature = "migration")]
3653 impl CanisterKind for MigrationCanister {
3654 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3655 Ok(204)
3656 }
3657 const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_validation.commit.v1";
3658 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3659 Ok(206)
3660 }
3661 const STARTUP_STABLE_KEY: &'static str =
3662 "icydb.test.migration_validation.startup.control.v1";
3663 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3664 Ok(205)
3665 }
3666 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3667 "icydb.test.migration_validation.integrity.v1";
3668 }
3669
3670 #[cfg(feature = "migration")]
3671 struct MigrationExecutionCanister;
3672
3673 #[cfg(feature = "migration")]
3674 impl Path for MigrationExecutionCanister {
3675 const PATH: &'static str = "schema_application_tests::MigrationExecutionCanister";
3676 }
3677
3678 #[cfg(feature = "migration")]
3679 impl CanisterKind for MigrationExecutionCanister {
3680 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3681 Ok(214)
3682 }
3683 const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_execution.commit.v1";
3684 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3685 Ok(216)
3686 }
3687 const STARTUP_STABLE_KEY: &'static str =
3688 "icydb.test.migration_execution.startup.control.v1";
3689 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3690 Ok(215)
3691 }
3692 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3693 "icydb.test.migration_execution.integrity.v1";
3694 }
3695
3696 #[cfg(feature = "migration")]
3697 struct MigrationFindingCanister;
3698
3699 #[cfg(feature = "migration")]
3700 impl Path for MigrationFindingCanister {
3701 const PATH: &'static str = "schema_application_tests::MigrationFindingCanister";
3702 }
3703
3704 #[cfg(feature = "migration")]
3705 impl CanisterKind for MigrationFindingCanister {
3706 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3707 Ok(210)
3708 }
3709 const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_finding.commit.v1";
3710 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3711 Ok(212)
3712 }
3713 const STARTUP_STABLE_KEY: &'static str = "icydb.test.migration_finding.startup.control.v1";
3714 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3715 Ok(211)
3716 }
3717 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3718 "icydb.test.migration_finding.integrity.v1";
3719 }
3720
3721 fn name(value: &str) -> SchemaName {
3722 SchemaName::try_new(value).expect("test schema name should admit")
3723 }
3724
3725 fn generated_check_proposal(
3726 expected_head: ExpectedAcceptedHead,
3727 submission_key: &str,
3728 include_check: bool,
3729 database: TargetDatabaseIdentity,
3730 store: TargetStoreIdentity,
3731 ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
3732 let entity_source = EntitySourceKey::try_new("Item").expect("entity source should admit");
3733 let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
3734 let score_source = FieldSourceKey::try_new("score").expect("score source should admit");
3735 let check_source =
3736 ConstraintSourceKey::try_new("score_non_negative").expect("check source should admit");
3737 let check = SourceCheckExpr::try_new(vec![
3738 SourceCheckInstruction::Field(score_source),
3739 SourceCheckInstruction::Literal(ScalarLiteral::Int(0)),
3740 SourceCheckInstruction::GreaterThanOrEqual,
3741 ])
3742 .expect("check expression should admit");
3743 let constraints = include_check
3744 .then(|| ConstraintFragment::check(name("score_non_negative"), check))
3745 .into_iter()
3746 .collect();
3747 let entity = EntityFragment::try_new(
3748 name("Item"),
3749 version_one(),
3750 vec![
3751 FieldFragment::new(
3752 name("id"),
3753 FieldType::Scalar(ScalarType::Nat64),
3754 false,
3755 FieldInsertPolicy::Required,
3756 None,
3757 ),
3758 FieldFragment::new(
3759 name("score"),
3760 FieldType::Scalar(ScalarType::Int64),
3761 false,
3762 FieldInsertPolicy::Required,
3763 None,
3764 ),
3765 ],
3766 vec![id_source],
3767 Vec::new(),
3768 Vec::new(),
3769 constraints,
3770 )
3771 .expect("entity should admit");
3772 let proposal = SchemaProposal::try_compose(
3773 vec![SchemaCapability::ACCEPTED_CHECKS],
3774 database,
3775 SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
3776 expected_head,
3777 vec![
3778 SchemaFragment::try_new(vec![entity], Vec::new())
3779 .expect("schema fragment should admit"),
3780 ],
3781 vec![EntityStoreAssignment::new(entity_source.clone(), store)],
3782 Vec::new(),
3783 None,
3784 )
3785 .expect("schema proposal should compose");
3786 (proposal, entity_source, check_source)
3787 }
3788
3789 #[cfg(feature = "migration")]
3790 #[derive(Clone, Copy, Eq, PartialEq)]
3791 enum ValidationMigrationShape {
3792 Clean,
3793 AllFindingFamilies,
3794 }
3795
3796 #[cfg(feature = "migration")]
3797 #[expect(
3798 clippy::too_many_lines,
3799 reason = "the fixture keeps both predecessor and candidate source contracts adjacent"
3800 )]
3801 fn validation_migration_proposal(
3802 shape: ValidationMigrationShape,
3803 current: bool,
3804 expected_head: ExpectedAcceptedHead,
3805 database: TargetDatabaseIdentity,
3806 store: TargetStoreIdentity,
3807 ) -> SchemaProposal {
3808 let entity_source = EntitySourceKey::try_new("MigratingItem")
3809 .expect("migration entity source should admit");
3810 let old_value =
3811 FieldSourceKey::try_new("old_value").expect("predecessor field source should admit");
3812 let current_value =
3813 FieldSourceKey::try_new("value").expect("candidate field source should admit");
3814 let target_entity = EntitySourceKey::try_new("MigrationTarget")
3815 .expect("migration target source should admit");
3816 let target_id = FieldSourceKey::try_new("id").expect("target id source should admit");
3817 let constraint = SourceCheckExpr::try_new(vec![
3818 SourceCheckInstruction::Field(current_value.clone()),
3819 SourceCheckInstruction::Literal(ScalarLiteral::Nat(8)),
3820 SourceCheckInstruction::LessThanOrEqual,
3821 ])
3822 .expect("candidate check should admit");
3823 let findings = shape == ValidationMigrationShape::AllFindingFamilies;
3824 let entity = EntityFragment::try_new(
3825 name("MigratingItem"),
3826 DeclaredEntityVersion::try_new(if current { 2 } else { 1 })
3827 .expect("migration version should admit"),
3828 vec![
3829 FieldFragment::new(
3830 name("id"),
3831 FieldType::Scalar(ScalarType::Nat64),
3832 false,
3833 FieldInsertPolicy::Required,
3834 None,
3835 ),
3836 FieldFragment::new(
3837 name(if current { "value" } else { "old_value" }),
3838 FieldType::Scalar(if current {
3839 ScalarType::Nat8
3840 } else {
3841 ScalarType::Int64
3842 }),
3843 false,
3844 FieldInsertPolicy::Required,
3845 None,
3846 ),
3847 ],
3848 vec![FieldSourceKey::try_new("id").expect("id source should admit")],
3849 current
3850 .then(|| {
3851 IndexFragment::try_new(
3852 name("value_unique"),
3853 vec![IndexKeyFragment::Field(current_value.clone())],
3854 true,
3855 None,
3856 )
3857 .expect("candidate index should admit")
3858 })
3859 .into_iter()
3860 .collect(),
3861 (current && findings)
3862 .then(|| {
3863 RelationFragment::try_new(
3864 name("value_target"),
3865 icydb_schema::RelationSourceFragment::direct(vec![current_value.clone()]),
3866 target_entity.clone(),
3867 vec![target_id.clone()],
3868 RelationDeleteAction::Restrict,
3869 )
3870 .expect("candidate relation should admit")
3871 })
3872 .into_iter()
3873 .collect(),
3874 (current && findings)
3875 .then(|| ConstraintFragment::check(name("value_at_most_eight"), constraint))
3876 .into_iter()
3877 .collect(),
3878 )
3879 .expect("migration entity should admit");
3880 let target = EntityFragment::try_new(
3881 name("MigrationTarget"),
3882 version_one(),
3883 vec![FieldFragment::new(
3884 name("id"),
3885 FieldType::Scalar(ScalarType::Nat8),
3886 false,
3887 FieldInsertPolicy::Required,
3888 None,
3889 )],
3890 vec![target_id],
3891 Vec::new(),
3892 Vec::new(),
3893 Vec::new(),
3894 )
3895 .expect("migration relation target should admit");
3896 let migration = current.then(|| {
3897 SchemaMigrationPlan::try_new(vec![
3898 EntityMigration::try_new(
3899 entity_source.clone(),
3900 DeclaredEntityVersion::try_new(1).expect("predecessor should admit"),
3901 None,
3902 Vec::new(),
3903 vec![SchemaMigrationTransform::CheckedCast {
3904 from: old_value.clone(),
3905 to: current_value,
3906 target: ScalarType::Nat8,
3907 }],
3908 )
3909 .expect("migration transition should admit"),
3910 ])
3911 .expect("migration plan should admit")
3912 });
3913 let mut capabilities = Vec::new();
3914 if current && findings {
3915 capabilities.extend([
3916 SchemaCapability::ACCEPTED_CHECKS,
3917 SchemaCapability::SECONDARY_INDEXES,
3918 SchemaCapability::RESTRICTIVE_RELATIONS,
3919 ]);
3920 }
3921 if migration.is_some() {
3922 capabilities.push(SchemaCapability::VERSIONED_MIGRATIONS);
3923 }
3924 let mut entities = vec![entity];
3925 let mut assignments = vec![EntityStoreAssignment::new(entity_source.clone(), store)];
3926 if findings {
3927 entities.push(target);
3928 assignments.push(EntityStoreAssignment::new(target_entity, store));
3929 }
3930 SchemaProposal::try_compose(
3931 capabilities,
3932 database,
3933 SchemaSubmissionKey::try_new(if current {
3934 "migration-validation-v2"
3935 } else {
3936 "migration-validation-v1"
3937 })
3938 .expect("submission should admit"),
3939 expected_head,
3940 vec![
3941 SchemaFragment::try_new(entities, Vec::new())
3942 .expect("migration fragment should admit"),
3943 ],
3944 assignments,
3945 current
3946 .then_some(icydb_schema::SchemaRemoval::Field {
3947 entity: entity_source,
3948 field: old_value,
3949 })
3950 .into_iter()
3951 .collect(),
3952 migration,
3953 )
3954 .expect("migration proposal should compose")
3955 }
3956
3957 fn targeted_rule_proposal(
3958 expected_head: ExpectedAcceptedHead,
3959 submission_key: &str,
3960 operation: SourceRuleOperation,
3961 database: TargetDatabaseIdentity,
3962 store: TargetStoreIdentity,
3963 ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
3964 let entity_source =
3965 EntitySourceKey::try_new("Measured").expect("entity source should admit");
3966 let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
3967 let value_source = FieldSourceKey::try_new("value").expect("value source should admit");
3968 let value_type = TypeSourceKey::try_new("Measure").expect("type source should admit");
3969 let rule_source = RuleSourceKey::try_new("limit").expect("rule source should admit");
3970 let constraint_source =
3971 ConstraintSourceKey::for_targeted_field_rule(&value_source, &value_type, &rule_source);
3972 let entity = EntityFragment::try_new(
3973 name("Measured"),
3974 version_one(),
3975 vec![
3976 FieldFragment::new(
3977 name("id"),
3978 FieldType::Scalar(ScalarType::Nat64),
3979 false,
3980 FieldInsertPolicy::Required,
3981 None,
3982 ),
3983 FieldFragment::new(
3984 name("value"),
3985 FieldType::Named(value_type.clone()),
3986 false,
3987 FieldInsertPolicy::Required,
3988 None,
3989 ),
3990 ],
3991 vec![id_source],
3992 Vec::new(),
3993 Vec::new(),
3994 vec![ConstraintFragment::targeted_rule(
3995 TargetedRuleFragment::new(value_source, value_type, name("limit"), operation),
3996 )],
3997 )
3998 .expect("targeted entity should admit");
3999 let proposal = SchemaProposal::try_compose(
4000 vec![SchemaCapability::ACCEPTED_CHECKS],
4001 database,
4002 SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
4003 expected_head,
4004 vec![
4005 SchemaFragment::try_new(
4006 vec![entity],
4007 vec![NamedTypeFragment::newtype(
4008 name("Measure"),
4009 FieldType::Scalar(ScalarType::Nat8),
4010 )],
4011 )
4012 .expect("schema fragment should admit"),
4013 ],
4014 vec![EntityStoreAssignment::new(entity_source.clone(), store)],
4015 Vec::new(),
4016 None,
4017 )
4018 .expect("schema proposal should compose");
4019 (proposal, entity_source, constraint_source)
4020 }
4021
4022 #[test]
4023 fn database_head_is_empty_only_when_every_store_root_is_absent() {
4024 assert_eq!(
4025 derive_accepted_head(&[("test::A", None), ("test::B", None)]),
4026 ExpectedAcceptedHead::Empty,
4027 );
4028 }
4029
4030 #[test]
4031 fn database_head_covers_store_path_revision_fingerprint_and_absence() {
4032 let first = derive_accepted_head(&[
4033 (
4034 "test::A",
4035 Some(AcceptedStoreHead {
4036 revision: 3,
4037 fingerprint: [0x11; 32],
4038 }),
4039 ),
4040 ("test::B", None),
4041 ]);
4042 let changed_fingerprint = derive_accepted_head(&[
4043 (
4044 "test::A",
4045 Some(AcceptedStoreHead {
4046 revision: 3,
4047 fingerprint: [0x12; 32],
4048 }),
4049 ),
4050 ("test::B", None),
4051 ]);
4052 let changed_absence = derive_accepted_head(&[
4053 (
4054 "test::A",
4055 Some(AcceptedStoreHead {
4056 revision: 3,
4057 fingerprint: [0x11; 32],
4058 }),
4059 ),
4060 (
4061 "test::B",
4062 Some(AcceptedStoreHead {
4063 revision: 1,
4064 fingerprint: [0x22; 32],
4065 }),
4066 ),
4067 ]);
4068
4069 assert_ne!(first, changed_fingerprint);
4070 assert_ne!(first, changed_absence);
4071 assert!(matches!(
4072 first,
4073 ExpectedAcceptedHead::Exact { revision: 3, .. }
4074 ));
4075 }
4076
4077 #[test]
4078 #[expect(
4079 clippy::too_many_lines,
4080 reason = "the end-to-end catalog assertion is clearer as one lifecycle test"
4081 )]
4082 fn generated_check_abort_retires_source_identity_and_allows_fresh_reproposal() {
4083 let database = TargetDatabaseIdentity::from_bytes([0x71; 32]);
4084 let store = TargetStoreIdentity::from_bytes([0x72; 32]);
4085 let (initial, entity_source, _) = generated_check_proposal(
4086 ExpectedAcceptedHead::Empty,
4087 "abort-initial",
4088 false,
4089 database,
4090 store,
4091 );
4092 let initial_candidate = lower_initial_schema_proposal(
4093 &initial,
4094 &[ProposalStoreTarget {
4095 path: "abort::Store",
4096 identity: store,
4097 }],
4098 )
4099 .expect("initial proposal should lower")
4100 .pop()
4101 .expect("initial proposal should produce one candidate");
4102 let (with_check, _, check_source) = generated_check_proposal(
4103 ExpectedAcceptedHead::Exact {
4104 revision: 1,
4105 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x73; 32]),
4106 },
4107 "abort-add-check",
4108 true,
4109 database,
4110 store,
4111 );
4112 let pending_candidate = lower_existing_schema_proposal(
4113 &with_check,
4114 &[ExistingProposalStore {
4115 path: "abort::Store",
4116 identity: store,
4117 bundle: initial_candidate.bundle(),
4118 }],
4119 )
4120 .expect("generated check should lower")
4121 .pop()
4122 .expect("generated check should produce one candidate");
4123 let entity_tag = pending_candidate
4124 .bundle()
4125 .source_bindings_for_tests()
4126 .entity(&entity_source)
4127 .expect("entity source should remain bound");
4128 let constraint_id = pending_candidate
4129 .bundle()
4130 .source_bindings_for_tests()
4131 .constraint(entity_tag, &check_source)
4132 .expect("generated check source should bind");
4133 let pending_snapshot = pending_candidate
4134 .bundle()
4135 .entity_snapshots()
4136 .get(&entity_tag)
4137 .expect("pending entity should exist");
4138 let activation = pending_snapshot
4139 .constraint_catalog()
4140 .activation(constraint_id)
4141 .expect("generated check should remain an activation");
4142 assert_eq!(activation.origin(), ConstraintOrigin::Generated);
4143
4144 let aborted = aborted_generated_row_local_candidate(
4145 pending_candidate.bundle(),
4146 entity_tag,
4147 constraint_id,
4148 )
4149 .expect("generated check abort should build one catalog-native candidate");
4150 let aborted_snapshot = aborted
4151 .bundle()
4152 .entity_snapshots()
4153 .get(&entity_tag)
4154 .expect("aborted entity should remain");
4155 assert!(
4156 aborted_snapshot
4157 .constraint_catalog()
4158 .activation(constraint_id)
4159 .is_none(),
4160 );
4161 assert_eq!(aborted_snapshot.row_layout(), pending_snapshot.row_layout());
4162 assert!(
4163 aborted
4164 .bundle()
4165 .source_bindings_for_tests()
4166 .constraint(entity_tag, &check_source)
4167 .is_none(),
4168 );
4169
4170 let reproposed = lower_existing_schema_proposal(
4171 &with_check,
4172 &[ExistingProposalStore {
4173 path: "abort::Store",
4174 identity: store,
4175 bundle: aborted.bundle(),
4176 }],
4177 )
4178 .expect("aborted generated check should be independently reproposable")
4179 .pop()
4180 .expect("reproposal should produce one candidate");
4181 let replacement_id = reproposed
4182 .bundle()
4183 .source_bindings_for_tests()
4184 .constraint(entity_tag, &check_source)
4185 .expect("reproposal should bind a fresh constraint identity");
4186 assert!(
4187 replacement_id > constraint_id,
4188 "aborted accepted IDs must remain retired",
4189 );
4190 }
4191
4192 #[test]
4193 fn targeted_rule_edit_abort_keeps_prior_accepted_semantics_and_source_identity() {
4194 let database = TargetDatabaseIdentity::from_bytes([0x81; 32]);
4195 let store = TargetStoreIdentity::from_bytes([0x82; 32]);
4196 let (initial, entity_source, constraint_source) = targeted_rule_proposal(
4197 ExpectedAcceptedHead::Empty,
4198 "targeted-abort-initial",
4199 SourceRuleOperation::NumericRangeInclusive {
4200 min: ScalarLiteral::Nat(0),
4201 max: ScalarLiteral::Nat(10),
4202 },
4203 database,
4204 store,
4205 );
4206 let initial_candidate = lower_initial_schema_proposal(
4207 &initial,
4208 &[ProposalStoreTarget {
4209 path: "abort::TargetedStore",
4210 identity: store,
4211 }],
4212 )
4213 .expect("initial targeted proposal should lower")
4214 .pop()
4215 .expect("initial targeted proposal should produce one candidate");
4216 let initial_bundle = initial_candidate.bundle();
4217 let entity_tag = initial_bundle
4218 .source_bindings_for_tests()
4219 .entity(&entity_source)
4220 .expect("entity source should bind");
4221 let constraint_id = initial_bundle
4222 .source_bindings_for_tests()
4223 .constraint(entity_tag, &constraint_source)
4224 .expect("targeted source should bind");
4225 let high_water = initial_bundle.entity_snapshots()[&entity_tag]
4226 .constraint_id_allocator()
4227 .high_water();
4228 let (edited, _, _) = targeted_rule_proposal(
4229 ExpectedAcceptedHead::Exact {
4230 revision: initial_bundle.revision().get(),
4231 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x83; 32]),
4232 },
4233 "targeted-abort-edit",
4234 SourceRuleOperation::NumericMaximumInclusive {
4235 value: ScalarLiteral::Nat(8),
4236 },
4237 database,
4238 store,
4239 );
4240 let staged = lower_existing_schema_proposal(
4241 &edited,
4242 &[ExistingProposalStore {
4243 path: "abort::TargetedStore",
4244 identity: store,
4245 bundle: initial_bundle,
4246 }],
4247 )
4248 .expect("targeted semantic edit should stage")
4249 .pop()
4250 .expect("targeted semantic edit should produce one candidate");
4251 let aborted =
4252 aborted_generated_row_local_candidate(staged.bundle(), entity_tag, constraint_id)
4253 .expect("targeted semantic edit should abort through catalog authority");
4254 let snapshot = &aborted.bundle().entity_snapshots()[&entity_tag];
4255
4256 assert!(
4257 snapshot
4258 .constraint_catalog()
4259 .activation(constraint_id)
4260 .is_none()
4261 );
4262 assert_eq!(snapshot.constraint_id_allocator().high_water(), high_water);
4263 assert_eq!(
4264 aborted
4265 .bundle()
4266 .source_bindings_for_tests()
4267 .constraint(entity_tag, &constraint_source),
4268 Some(constraint_id),
4269 );
4270 assert!(snapshot.constraints().iter().any(|constraint| {
4271 constraint.id() == constraint_id
4272 && matches!(
4273 constraint.kind(),
4274 AcceptedConstraintKind::TargetedRule { operation, .. }
4275 if matches!(
4276 operation.as_ref(),
4277 AcceptedRuleOperation::NumericRangeInclusive { .. }
4278 )
4279 )
4280 }));
4281 }
4282
4283 #[test]
4284 #[expect(
4285 clippy::too_many_lines,
4286 reason = "the staged publication, recovery, and promotion assertions form one lifecycle"
4287 )]
4288 fn generated_startup_driver_resumes_pending_activation_and_promotes_without_source_model() {
4289 let db = Db::<EvolutionCanister>::new(
4290 &EVOLUTION_REGISTRY,
4291 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4292 );
4293 drive_startup_recovery_to_completion(&db);
4294 let empty_target =
4295 schema_application_target(&db).expect("empty evolution target should issue");
4296 let store_identity = empty_target
4297 .stores()
4298 .first()
4299 .expect("evolution store should register")
4300 .identity();
4301 let (initial, entity_source, constraint_source) = targeted_rule_proposal(
4302 empty_target.accepted_head().clone(),
4303 "targeted-recovery-initial",
4304 SourceRuleOperation::NumericRangeInclusive {
4305 min: ScalarLiteral::Nat(0),
4306 max: ScalarLiteral::Nat(10),
4307 },
4308 empty_target.database_identity(),
4309 store_identity,
4310 );
4311 assert!(matches!(
4312 apply_schema(&db, &initial)
4313 .expect("initial targeted proposal should publish")
4314 .outcome(),
4315 SchemaChangeOutcome::Applied { .. },
4316 ));
4317 drive_startup_recovery_to_completion(&db);
4318 drive_cardinality_to_ready(
4319 db.store_handle(EVOLUTION_STORE_PATH)
4320 .expect("evolution store should resolve"),
4321 );
4322
4323 let direct_target =
4324 schema_application_target(&db).expect("direct evolution target should issue");
4325 let (direct_edit, _, _) = targeted_rule_proposal(
4326 direct_target.accepted_head().clone(),
4327 "targeted-direct-edit",
4328 SourceRuleOperation::NumericMaximumInclusive {
4329 value: ScalarLiteral::Nat(8),
4330 },
4331 direct_target.database_identity(),
4332 store_identity,
4333 );
4334 assert!(matches!(
4335 apply_schema(&db, &direct_edit)
4336 .expect("empty-domain semantic edit should publish directly")
4337 .outcome(),
4338 SchemaChangeOutcome::Applied { .. },
4339 ));
4340 let store = db
4341 .store_handle(EVOLUTION_STORE_PATH)
4342 .expect("evolution store should resolve");
4343 let direct = store
4344 .with_schema(SchemaStore::current_accepted_schema_bundle)
4345 .expect("directly edited bundle should remain readable")
4346 .expect("directly edited bundle should exist");
4347 let entity_tag = direct
4348 .source_bindings_for_tests()
4349 .entity(&entity_source)
4350 .expect("entity source should remain bound");
4351 let constraint_id = direct
4352 .source_bindings_for_tests()
4353 .constraint(entity_tag, &constraint_source)
4354 .expect("direct edit should preserve constraint identity");
4355 assert!(
4356 direct.entity_snapshots()[&entity_tag]
4357 .constraint_catalog()
4358 .activation(constraint_id)
4359 .is_none()
4360 );
4361 assert!(
4362 direct.entity_snapshots()[&entity_tag]
4363 .constraints()
4364 .iter()
4365 .any(|constraint| {
4366 constraint.id() == constraint_id
4367 && matches!(
4368 constraint.kind(),
4369 AcceptedConstraintKind::TargetedRule { operation, .. }
4370 if matches!(
4371 operation.as_ref(),
4372 AcceptedRuleOperation::NumericMaximumInclusive { .. }
4373 )
4374 )
4375 })
4376 );
4377
4378 let target = schema_application_target(&db).expect("staged evolution target should issue");
4379 let (edited, _, _) = targeted_rule_proposal(
4380 target.accepted_head().clone(),
4381 "targeted-recovery-edit",
4382 SourceRuleOperation::MultipleOf {
4383 divisor: ScalarLiteral::Nat(2),
4384 },
4385 target.database_identity(),
4386 store_identity,
4387 );
4388 let current = store
4389 .with_schema(SchemaStore::current_accepted_schema_bundle)
4390 .expect("accepted evolution bundle should remain readable")
4391 .expect("directly edited evolution bundle should exist");
4392 let staged = lower_existing_schema_proposal(
4393 &edited,
4394 &[ExistingProposalStore {
4395 path: EVOLUTION_STORE_PATH,
4396 identity: store_identity,
4397 bundle: ¤t,
4398 }],
4399 )
4400 .expect("targeted edit should stage")
4401 .pop()
4402 .expect("targeted edit should produce one candidate");
4403 assert_eq!(
4404 staged
4405 .bundle()
4406 .source_bindings_for_tests()
4407 .constraint(entity_tag, &constraint_source),
4408 Some(constraint_id),
4409 );
4410 let proof = DirectGeneratedRowLocalProof {
4411 candidate_index: 0,
4412 store,
4413 store_path: EVOLUTION_STORE_PATH,
4414 entity_tag,
4415 entity_path: staged.bundle().entity_snapshots()[&entity_tag]
4416 .entity_path()
4417 .to_string(),
4418 constraint_id,
4419 has_rows: false,
4420 };
4421 let final_candidates = final_candidates_for_pending_row_local_constraint(
4422 std::slice::from_ref(&staged),
4423 &PendingGeneratedRowLocalConstraint { proof },
4424 )
4425 .expect("final semantic replacement should derive without source input");
4426 let authorities = application_authorities(&db);
4427 let candidate_head =
4428 accepted_head_after_candidates(authorities.as_slice(), &final_candidates)
4429 .expect("final candidate head should derive");
4430 let digest = edited.digest().expect("proposal digest should derive");
4431 let job_id = derive_schema_change_job_id(
4432 target.database_identity(),
4433 edited.submission_key(),
4434 digest,
4435 target.accepted_head(),
4436 )
4437 .expect("job identity should derive");
4438 let receipt = crate::db::schema::SchemaChangeReceipt::new(
4439 target.database_identity(),
4440 edited.submission_key().clone(),
4441 digest,
4442 target.accepted_head().clone(),
4443 SchemaChangeOutcome::Pending {
4444 job: SchemaChangeJob::new(job_id),
4445 candidate_head,
4446 },
4447 )
4448 .expect("pending replacement receipt should admit");
4449 let record = SchemaApplicationRecord::new(
4450 receipt,
4451 vec![
4452 SchemaChangeActivation::new(
4453 store_identity,
4454 entity_tag.value(),
4455 constraint_id.get(),
4456 )
4457 .expect("replacement activation should admit"),
4458 ],
4459 )
4460 .expect("pending replacement record should admit");
4461 let operation =
4462 SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4463 publish_accepted_schema_candidates_with_application_record(
4464 vec![AcceptedSchemaPublication::new(
4465 EVOLUTION_STORE_PATH,
4466 store,
4467 current.revision(),
4468 &staged,
4469 )],
4470 operation,
4471 )
4472 .expect("staged replacement and record should publish atomically");
4473
4474 forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4475 drive_startup_recovery_to_completion(&db);
4476
4477 let recovered = store
4478 .with_schema(SchemaStore::current_accepted_schema_bundle)
4479 .expect("recovered staged bundle should decode")
4480 .expect("recovered staged bundle should exist");
4481 let recovered_snapshot = recovered.entity_snapshots()[&entity_tag].clone();
4482 let validating_catalog = recovered_snapshot
4483 .constraint_catalog()
4484 .clone()
4485 .with_validation_started(constraint_id)
4486 .expect("recovered replacement should enter validation");
4487 let mut validating_snapshots = recovered.entity_snapshots().clone();
4488 validating_snapshots.insert(
4489 entity_tag,
4490 recovered_snapshot.with_constraint_catalog(validating_catalog),
4491 );
4492 let validating_bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
4493 recovered
4494 .revision()
4495 .checked_next()
4496 .expect("validation revision should remain available"),
4497 recovered.store_path(),
4498 recovered.enum_catalog().clone(),
4499 recovered.composite_catalog().clone(),
4500 recovered.source_bindings_for_tests().clone(),
4501 validating_snapshots,
4502 )
4503 .expect("validating replacement bundle should close");
4504 let validating_candidate = CandidateSchemaRevision::new(validating_bundle)
4505 .expect("validating replacement candidate should encode");
4506 let validating_activation = validating_candidate.bundle().entity_snapshots()[&entity_tag]
4507 .constraint_catalog()
4508 .activation(constraint_id)
4509 .expect("validating replacement activation should remain present");
4510 let validation_job = ConstraintValidationJob::start(
4511 entity_tag,
4512 validating_candidate.bundle().entity_snapshots()[&entity_tag]
4513 .entity_path()
4514 .to_string(),
4515 validating_activation,
4516 None,
4517 )
4518 .expect("validating replacement job should derive from accepted state");
4519 store
4520 .with_schema(|schema| {
4521 schema.validate_live_activation_transition(validating_candidate.bundle())?;
4522 schema.validate_constraint_validation_job_closure_with_change(
4523 validating_candidate.bundle(),
4524 Some(&validation_job),
4525 None,
4526 )
4527 })
4528 .expect("validating replacement transition and job should close");
4529 let startup_root = crate::db::RequestExecutionRoot::__new_runtime_root();
4530 let startup_session =
4531 crate::db::DbSession::<EvolutionCanister>::new(&EVOLUTION_REGISTRY, &startup_root);
4532
4533 assert_eq!(
4534 drive_generated_startup_recovery_page(
4535 &startup_session,
4536 &EVOLUTION_REGISTRY,
4537 edited.submission_key().as_str(),
4538 )
4539 .expect("generated startup should begin pending validation"),
4540 GeneratedStartupDriverStep::Recovering,
4541 );
4542 let mut terminal = false;
4543 for _ in 0..8 {
4544 match drive_generated_startup_recovery_page(
4545 &startup_session,
4546 &EVOLUTION_REGISTRY,
4547 edited.submission_key().as_str(),
4548 )
4549 .expect("generated startup should advance pending validation")
4550 {
4551 GeneratedStartupDriverStep::Recovering => {}
4552 GeneratedStartupDriverStep::Terminal => {
4553 terminal = true;
4554 break;
4555 }
4556 GeneratedStartupDriverStep::ApplyGeneratedSchema => {
4557 panic!("an exact pending receipt must resume instead of being resubmitted")
4558 }
4559 }
4560 }
4561 assert!(
4562 terminal,
4563 "empty historical domain should promote within bounded startup steps"
4564 );
4565 assert_eq!(
4566 observe_generated_startup_state::<EvolutionCanister>(
4567 &EVOLUTION_REGISTRY,
4568 edited.submission_key().as_str(),
4569 ),
4570 Ok(DatabaseStartupState::Ready),
4571 );
4572 let applied = super::exact_schema_application_receipt(
4573 &edited,
4574 edited
4575 .digest()
4576 .expect("proposal digest should remain stable"),
4577 )
4578 .expect("terminal generated receipt should remain readable")
4579 .expect("terminal generated receipt should remain present");
4580 assert!(matches!(
4581 applied.outcome(),
4582 SchemaChangeOutcome::Applied { .. }
4583 ));
4584 let promoted = store
4585 .with_schema(SchemaStore::current_accepted_schema_bundle)
4586 .expect("promoted bundle should remain readable")
4587 .expect("promoted bundle should exist");
4588 let snapshot = &promoted.entity_snapshots()[&entity_tag];
4589 assert!(
4590 snapshot
4591 .constraint_catalog()
4592 .activation(constraint_id)
4593 .is_none()
4594 );
4595 assert_eq!(
4596 promoted
4597 .source_bindings_for_tests()
4598 .constraint(entity_tag, &constraint_source),
4599 Some(constraint_id),
4600 );
4601 assert!(snapshot.constraints().iter().any(|constraint| {
4602 constraint.id() == constraint_id
4603 && matches!(
4604 constraint.kind(),
4605 AcceptedConstraintKind::TargetedRule { operation, .. }
4606 if matches!(
4607 operation.as_ref(),
4608 AcceptedRuleOperation::MultipleOf { .. }
4609 )
4610 )
4611 }));
4612 }
4613
4614 #[test]
4615 #[expect(
4616 clippy::too_many_lines,
4617 reason = "the durable pending job, startup failure, and retained finding assertions form one scenario"
4618 )]
4619 fn generated_startup_driver_persists_e223_for_a_retained_historical_finding() {
4620 let db = Db::<AbortCanister>::new(
4621 &ABORT_REGISTRY,
4622 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4623 );
4624 drive_startup_recovery_to_completion(&db);
4625 let empty_target =
4626 schema_application_target(&db).expect("empty application target should issue");
4627 let store_identity = empty_target
4628 .stores()
4629 .first()
4630 .expect("abort store should be registered")
4631 .identity();
4632 let (initial, _, _) = generated_check_proposal(
4633 empty_target.accepted_head().clone(),
4634 "startup-finding-initial",
4635 false,
4636 empty_target.database_identity(),
4637 store_identity,
4638 );
4639 apply_schema(&db, &initial).expect("initial generated schema should publish");
4640
4641 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
4642 let session = DbSession::<AbortCanister>::new(&ABORT_REGISTRY, &root);
4643 let rows = (1..=257)
4644 .map(|id| DynamicMutation::Insert {
4645 entity: "Item".to_string(),
4646 patch: DynamicStructuralPatch::new(vec![
4647 (
4648 "id".to_string(),
4649 DynamicWriteCell::Value(InputValue::nat64(id)),
4650 ),
4651 (
4652 "score".to_string(),
4653 DynamicWriteCell::Value(InputValue::int64(if id == 257 { -1 } else { 1 })),
4654 ),
4655 ]),
4656 })
4657 .collect();
4658 session
4659 .execute_trusted_dynamic_mutation_batch(rows)
4660 .expect("historical finding fixture rows should commit as one legal batch");
4661 drive_startup_recovery_to_completion(&db);
4662 drive_cardinality_to_ready(
4663 db.store_handle(ABORT_STORE_PATH)
4664 .expect("abort store should resolve"),
4665 );
4666
4667 let target =
4668 schema_application_target(&db).expect("existing application target should issue");
4669 let (with_check, _, _) = generated_check_proposal(
4670 target.accepted_head().clone(),
4671 "startup-finding-pending",
4672 true,
4673 target.database_identity(),
4674 store_identity,
4675 );
4676 let pending = apply_schema(&db, &with_check)
4677 .expect("the first clean page should admit durable continuation");
4678 let SchemaChangeOutcome::Pending { job, .. } = pending.outcome() else {
4679 panic!("a 257-row domain must exceed the 256-row direct proof page")
4680 };
4681
4682 let mut terminal = false;
4683 for _ in 0..8 {
4684 match drive_generated_startup_recovery_page(
4685 &session,
4686 &ABORT_REGISTRY,
4687 with_check.submission_key().as_str(),
4688 )
4689 .expect("generated startup should retain a typed finding failure")
4690 {
4691 GeneratedStartupDriverStep::Recovering => {}
4692 GeneratedStartupDriverStep::Terminal => {
4693 terminal = true;
4694 break;
4695 }
4696 GeneratedStartupDriverStep::ApplyGeneratedSchema => {
4697 panic!("an exact pending receipt must not be resubmitted")
4698 }
4699 }
4700 }
4701 assert!(terminal, "the retained finding should become terminal");
4702 let failure = observe_generated_startup_state::<AbortCanister>(
4703 &ABORT_REGISTRY,
4704 with_check.submission_key().as_str(),
4705 )
4706 .expect_err("the retained finding must remain durably observable");
4707 assert_eq!(
4708 failure.kind(),
4709 crate::db::StartupFailureKind::SchemaReconciliation,
4710 );
4711 assert_eq!(
4712 failure.diagnostic().error_code(),
4713 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_CONSTRAINT_VIOLATION,
4714 );
4715 assert_eq!(
4716 ABORT_DATA.with(|store| store.borrow().len()),
4717 257,
4718 "terminal startup publication must not change historical rows",
4719 );
4720 assert!(matches!(
4721 continue_schema_application(&db, job.id(), None)
4722 .expect("the retained finding page should replay exactly")
4723 .status(),
4724 SchemaChangeProgressStatus::Findings { findings, .. } if !findings.is_empty(),
4725 ));
4726 }
4727
4728 #[test]
4729 #[expect(
4730 clippy::too_many_lines,
4731 reason = "the journaled abort, replay, and recovery assertions form one scenario"
4732 )]
4733 fn pending_generated_check_abort_is_atomic_terminal_and_replayable() {
4734 let db = Db::<AbortCanister>::new(
4735 &ABORT_REGISTRY,
4736 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4737 );
4738 drive_startup_recovery_to_completion(&db);
4739 let empty_target =
4740 schema_application_target(&db).expect("empty application target should issue");
4741 let store_identity = empty_target
4742 .stores()
4743 .first()
4744 .expect("abort store should be registered")
4745 .identity();
4746 let (initial, entity_source, _) = generated_check_proposal(
4747 empty_target.accepted_head().clone(),
4748 "abort-runtime-initial",
4749 false,
4750 empty_target.database_identity(),
4751 store_identity,
4752 );
4753 assert!(matches!(
4754 apply_schema(&db, &initial)
4755 .expect("initial application should publish")
4756 .outcome(),
4757 SchemaChangeOutcome::Applied { .. },
4758 ));
4759
4760 let target =
4761 schema_application_target(&db).expect("existing application target should issue");
4762 let (with_check, _, check_source) = generated_check_proposal(
4763 target.accepted_head().clone(),
4764 "abort-runtime-pending",
4765 true,
4766 target.database_identity(),
4767 store_identity,
4768 );
4769 let store = db
4770 .store_handle(ABORT_STORE_PATH)
4771 .expect("abort store should resolve");
4772 let current = store
4773 .with_schema(SchemaStore::current_accepted_schema_bundle)
4774 .expect("accepted bundle should remain readable")
4775 .expect("initial accepted bundle should exist");
4776 let pending_candidate = lower_existing_schema_proposal(
4777 &with_check,
4778 &[ExistingProposalStore {
4779 path: ABORT_STORE_PATH,
4780 identity: store_identity,
4781 bundle: ¤t,
4782 }],
4783 )
4784 .expect("pending generated check should lower")
4785 .pop()
4786 .expect("pending generated check should produce one candidate");
4787 let entity_tag = pending_candidate
4788 .bundle()
4789 .source_bindings_for_tests()
4790 .entity(&entity_source)
4791 .expect("entity source should bind");
4792 let constraint_id = pending_candidate
4793 .bundle()
4794 .source_bindings_for_tests()
4795 .constraint(entity_tag, &check_source)
4796 .expect("generated check source should bind");
4797 let digest = with_check.digest().expect("proposal digest should derive");
4798 let job_id = derive_schema_change_job_id(
4799 target.database_identity(),
4800 with_check.submission_key(),
4801 digest,
4802 target.accepted_head(),
4803 )
4804 .expect("job identity should derive");
4805 let receipt = crate::db::schema::SchemaChangeReceipt::new(
4806 target.database_identity(),
4807 with_check.submission_key().clone(),
4808 digest,
4809 target.accepted_head().clone(),
4810 SchemaChangeOutcome::Pending {
4811 job: SchemaChangeJob::new(job_id),
4812 candidate_head: ExpectedAcceptedHead::Exact {
4813 revision: pending_candidate.revision().get().saturating_add(2),
4814 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x76; 32]),
4815 },
4816 },
4817 )
4818 .expect("pending receipt should admit");
4819 let record = SchemaApplicationRecord::new(
4820 receipt,
4821 vec![
4822 SchemaChangeActivation::new(
4823 store_identity,
4824 entity_tag.value(),
4825 constraint_id.get(),
4826 )
4827 .expect("application activation should admit"),
4828 ],
4829 )
4830 .expect("pending application record should admit");
4831 let operation =
4832 SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4833 publish_accepted_schema_candidates_with_application_record(
4834 vec![AcceptedSchemaPublication::new(
4835 ABORT_STORE_PATH,
4836 store,
4837 current.revision(),
4838 &pending_candidate,
4839 )],
4840 operation,
4841 )
4842 .expect("pending candidate and record should publish atomically");
4843
4844 let started = continue_schema_application(&db, job_id, None)
4845 .expect("first continuation should durably start validation");
4846 assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
4847 let progress =
4848 abort_schema_application(&db, job_id, None).expect("pending application should abort");
4849 assert_eq!(progress.status(), &SchemaChangeProgressStatus::Aborted);
4850 assert!(matches!(
4851 progress.receipt().outcome(),
4852 SchemaChangeOutcome::Aborted { .. },
4853 ));
4854 let replay =
4855 abort_schema_application(&db, job_id, None).expect("terminal abort should replay");
4856 assert_eq!(replay, progress);
4857 assert_eq!(
4858 continue_schema_application(&db, job_id, None)
4859 .expect("continuation after abort should replay terminal state"),
4860 progress,
4861 );
4862
4863 let aborted = store
4864 .with_schema(SchemaStore::current_accepted_schema_bundle)
4865 .expect("accepted bundle should remain readable")
4866 .expect("aborted accepted bundle should exist");
4867 assert!(
4868 aborted
4869 .entity_snapshots()
4870 .get(&entity_tag)
4871 .expect("entity should remain after abort")
4872 .constraint_catalog()
4873 .activation(constraint_id)
4874 .is_none(),
4875 );
4876 assert!(
4877 aborted
4878 .source_bindings_for_tests()
4879 .constraint(entity_tag, &check_source)
4880 .is_none(),
4881 );
4882 assert!(
4883 store
4884 .with_schema(|schema| {
4885 schema.constraint_validation_job(entity_tag, constraint_id)
4886 })
4887 .expect("validation-job storage should remain readable")
4888 .is_none(),
4889 );
4890
4891 ABORT_DATA.with(|store| {
4892 drop(store.replace(DataStore::init_heap()));
4894 ABORT_DATA_MEMORY.with(|memory| {
4895 drop(store.replace(DataStore::init_journaled(memory.open())));
4896 });
4897 });
4898 ABORT_INDEX.with(|store| {
4899 drop(store.replace(IndexStore::init_heap()));
4901 ABORT_INDEX_MEMORY.with(|memory| {
4902 drop(store.replace(IndexStore::init_journaled(memory.open())));
4903 });
4904 });
4905 ABORT_SCHEMA.with(|store| {
4906 drop(store.replace(SchemaStore::init_heap()));
4908 ABORT_SCHEMA_MEMORY.with(|memory| {
4909 drop(store.replace(SchemaStore::init_journaled(memory.open())));
4910 });
4911 });
4912 ABORT_JOURNAL.with(|store| {
4913 drop(store.replace(JournalTailStore::init(test_memory(254))));
4915 ABORT_JOURNAL_MEMORY.with(|memory| {
4916 drop(store.replace(JournalTailStore::init(memory.open())));
4917 });
4918 });
4919 forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4920 drive_startup_recovery_to_completion(&db);
4921 assert_eq!(
4922 abort_schema_application(&db, job_id, None)
4923 .expect("recovered terminal abort should replay"),
4924 progress,
4925 );
4926 assert!(
4927 store
4928 .with_schema(|schema| {
4929 schema.constraint_validation_job(entity_tag, constraint_id)
4930 })
4931 .expect("recovered validation-job storage should remain readable")
4932 .is_none(),
4933 );
4934 let startup_root = crate::db::RequestExecutionRoot::__new_runtime_root();
4935 let startup_session =
4936 crate::db::DbSession::<AbortCanister>::new(&ABORT_REGISTRY, &startup_root);
4937 ABORT_JOURNAL.with(|journal| {
4938 let journal = journal.borrow();
4939 assert!(
4940 journal
4941 .validate_current_tail_authority()
4942 .expect("recovered abort tail control should remain readable")
4943 .is_empty(),
4944 "recovered abort tail control should close exactly",
4945 );
4946 });
4947 assert_eq!(
4948 drive_generated_startup_recovery_page(
4949 &startup_session,
4950 &ABORT_REGISTRY,
4951 with_check.submission_key().as_str(),
4952 )
4953 .expect("an exact aborted generated submission should publish terminal startup state"),
4954 GeneratedStartupDriverStep::Terminal,
4955 );
4956 let failure = observe_generated_startup_state::<AbortCanister>(
4957 &ABORT_REGISTRY,
4958 with_check.submission_key().as_str(),
4959 )
4960 .expect_err("an aborted generated submission must not remain retryable forever");
4961 assert_eq!(
4962 failure.kind(),
4963 crate::db::StartupFailureKind::SchemaReconciliation,
4964 );
4965 assert_eq!(
4966 failure.diagnostic().error_code(),
4967 icydb_diagnostic_code::ErrorCode::RUNTIME_CONFLICT,
4968 );
4969 }
4970
4971 #[cfg(feature = "migration")]
4972 #[test]
4973 fn migration_planning_failures_retain_typed_public_classification() {
4974 use super::schema_migration_planning_error;
4975 use crate::db::schema::migration_planner::SchemaMigrationPlanningError;
4976 use icydb_diagnostic_code::{DiagnosticDetail, SchemaMigrationCode};
4977
4978 for (error, reason) in [
4979 (
4980 SchemaMigrationPlanningError::Unadopted,
4981 SchemaMigrationCode::Unadopted,
4982 ),
4983 (
4984 SchemaMigrationPlanningError::MissingMigration,
4985 SchemaMigrationCode::MissingMigration,
4986 ),
4987 (
4988 SchemaMigrationPlanningError::VersionGap,
4989 SchemaMigrationCode::VersionGap,
4990 ),
4991 (
4992 SchemaMigrationPlanningError::Downgrade,
4993 SchemaMigrationCode::Downgrade,
4994 ),
4995 (
4996 SchemaMigrationPlanningError::EmptyEntityVersionBump,
4997 SchemaMigrationCode::EmptyEntityVersionBump,
4998 ),
4999 (
5000 SchemaMigrationPlanningError::UnknownFromObject,
5001 SchemaMigrationCode::UnknownFromObject,
5002 ),
5003 (
5004 SchemaMigrationPlanningError::UnknownToObject,
5005 SchemaMigrationCode::UnknownToObject,
5006 ),
5007 (
5008 SchemaMigrationPlanningError::KindMismatch,
5009 SchemaMigrationCode::KindMismatch,
5010 ),
5011 (
5012 SchemaMigrationPlanningError::IdentityConflict,
5013 SchemaMigrationCode::IdentityConflict,
5014 ),
5015 (
5016 SchemaMigrationPlanningError::UnexplainedSchemaDifference,
5017 SchemaMigrationCode::UnexplainedSchemaDifference,
5018 ),
5019 (
5020 SchemaMigrationPlanningError::UnsupportedTransform,
5021 SchemaMigrationCode::UnsupportedTransform,
5022 ),
5023 (
5024 SchemaMigrationPlanningError::RekeyedCatalogInvalid,
5025 SchemaMigrationCode::CandidateMismatch,
5026 ),
5027 (
5028 SchemaMigrationPlanningError::CandidateMismatch,
5029 SchemaMigrationCode::CandidateMismatch,
5030 ),
5031 (
5032 SchemaMigrationPlanningError::CorruptLineage,
5033 SchemaMigrationCode::ProgressCorrupt,
5034 ),
5035 ] {
5036 let error = schema_migration_planning_error(error);
5037 let diagnostic = error.diagnostic();
5038 assert_eq!(
5039 diagnostic.detail(),
5040 Some(&DiagnosticDetail::SchemaMigration { reason }),
5041 );
5042 assert_eq!(diagnostic.code(), reason.diagnostic_code());
5043 let failure = crate::db::startup::classify_terminal_failure(
5044 crate::db::StartupFailureKind::SchemaReconciliation,
5045 &error,
5046 )
5047 .expect("every deterministic planner rejection must stop generated startup");
5048 assert_eq!(*failure.diagnostic(), diagnostic);
5049 }
5050 }
5051
5052 #[cfg(feature = "migration")]
5053 #[test]
5054 fn migration_preparation_failure_retains_original_budget_diagnostic() {
5055 use crate::db::{
5056 resource::budget::MaintenanceConstructionBudget,
5057 resource::construction::ConstructionBudget,
5058 schema::migration_planner::SchemaMigrationPlanningError,
5059 };
5060 use icydb_diagnostic_code::DiagnosticExecutionBudgetResource as Resource;
5061
5062 let budget =
5063 MaintenanceConstructionBudget::with_limit_for_tests(Resource::TemporaryBytes, 0);
5064 let error = budget.charge(Resource::TemporaryBytes, 1).unwrap_err();
5065 let expected = error.diagnostic();
5066 let result = super::schema_migration_planning_error(
5067 SchemaMigrationPlanningError::Preparation(error),
5068 );
5069 assert_eq!(result.diagnostic(), expected);
5070 assert!(
5071 crate::db::startup::classify_terminal_failure(
5072 crate::db::StartupFailureKind::SchemaReconciliation,
5073 &result,
5074 )
5075 .is_none(),
5076 "transient preparation budgets must not become terminal migration failures",
5077 );
5078 }
5079
5080 #[cfg(feature = "migration")]
5081 #[test]
5082 fn physical_migration_validation_is_bounded_staged_and_does_not_rewrite_rows() {
5083 qualify_migration_abort_domains(0);
5084 }
5085
5086 #[cfg(feature = "migration")]
5087 #[test]
5088 fn physical_migration_abort_resumes_across_candidate_namespaces() {
5089 qualify_migration_abort_domains(513);
5090 }
5091
5092 #[cfg(feature = "migration")]
5093 #[expect(
5094 clippy::too_many_lines,
5095 reason = "the validation replay, staging, and unchanged-row assertions form one scenario"
5096 )]
5097 fn qualify_migration_abort_domains(extra_entries: u64) {
5098 use std::convert::Infallible;
5099
5100 use super::{ensure_generated_schema_application_admitted, migrate_schema};
5101 use crate::db::{
5102 data::StoreVisit,
5103 index::{IndexEntryValue, IndexId, IndexKey, IndexKeyKind},
5104 key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
5105 schema::{SchemaMigrationCommand, SchemaMigrationPhase},
5106 };
5107 use crate::types::EntityTag;
5108
5109 let db = Db::<MigrationCanister>::new(
5110 &MIGRATION_REGISTRY,
5111 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5112 );
5113 drive_startup_recovery_to_completion(&db);
5114 let initial_target = schema_application_target(&db).expect("initial target should issue");
5115 let store_identity = initial_target
5116 .stores()
5117 .first()
5118 .expect("migration store should exist")
5119 .identity();
5120 let initial = validation_migration_proposal(
5121 ValidationMigrationShape::Clean,
5122 false,
5123 initial_target.accepted_head().clone(),
5124 initial_target.database_identity(),
5125 store_identity,
5126 );
5127 apply_schema(&db, &initial).expect("initial schema should publish");
5128
5129 let session = DbSession::<MigrationCanister>::new(
5130 &MIGRATION_REGISTRY,
5131 &crate::db::RequestExecutionRoot::__new_runtime_root(),
5132 );
5133 for (id, value) in [(1, 7), (2, 8)] {
5134 session
5135 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5136 entity: "MigratingItem".to_string(),
5137 patch: DynamicStructuralPatch::new(vec![
5138 (
5139 "id".to_string(),
5140 DynamicWriteCell::Value(InputValue::nat64(id)),
5141 ),
5142 (
5143 "old_value".to_string(),
5144 DynamicWriteCell::Value(InputValue::int64(value)),
5145 ),
5146 ]),
5147 })
5148 .expect("predecessor row should insert");
5149 }
5150 let store = db
5151 .store_handle(MIGRATION_STORE_PATH)
5152 .expect("migration store should resolve");
5153 let row_bytes = || {
5154 store.with_data(|data| {
5155 let mut rows = Vec::new();
5156 let result: Result<(), Infallible> = data.visit_entries(|key, row| {
5157 rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
5158 Ok(StoreVisit::Continue)
5159 });
5160 result.expect("infallible row visit should complete");
5161 rows
5162 })
5163 };
5164 let before_rows = row_bytes();
5165
5166 let target = schema_application_target(&db).expect("migration target should issue");
5167 let proposal = validation_migration_proposal(
5168 ValidationMigrationShape::Clean,
5169 true,
5170 target.accepted_head().clone(),
5171 target.database_identity(),
5172 store_identity,
5173 );
5174 let plan = proposal
5175 .migration()
5176 .expect("migration plan should exist")
5177 .digest();
5178 let pending = apply_schema(&db, &proposal)
5179 .expect_err("valid adjacent migration requires explicit advancement");
5180 assert_eq!(
5181 pending.diagnostic().error_code(),
5182 icydb_diagnostic_code::ErrorCode::SCHEMA_MIGRATION_IN_PROGRESS,
5183 );
5184 assert_eq!(
5185 schema_application_target(&db).unwrap().accepted_head(),
5186 target.accepted_head(),
5187 "ordinary startup must not publish the pending successor",
5188 );
5189 let command = || SchemaMigrationCommand::Advance {
5190 expected_database: target.database_identity(),
5191 expected_head: target.accepted_head().clone(),
5192 expected_plan: plan,
5193 acknowledged_finding_page: None,
5194 };
5195 assert_eq!(
5196 migrate_schema(&db, &proposal, command())
5197 .unwrap_or_else(|error| {
5198 panic!(
5199 "physical migration should prepare: {:?}",
5200 error.diagnostic()
5201 )
5202 })
5203 .phase(),
5204 SchemaMigrationPhase::Prepared,
5205 );
5206 let pending = ensure_generated_schema_application_admitted(&db, &proposal)
5207 .expect_err("prepared migration still owns generated schema publication");
5208 assert_eq!(
5209 pending.diagnostic().error_code(),
5210 icydb_diagnostic_code::ErrorCode::SCHEMA_MIGRATION_IN_PROGRESS,
5211 );
5212 let mismatched = ensure_generated_schema_application_admitted(&db, &initial)
5213 .expect_err("pending must not hide a mismatched deployed proposal");
5214 assert_eq!(
5215 mismatched.diagnostic().error_code(),
5216 icydb_diagnostic_code::ErrorCode::SCHEMA_MIGRATION_PLAN_CHANGED,
5217 );
5218 assert_eq!(
5219 migrate_schema(&db, &proposal, command())
5220 .expect("physical migration should enter validation")
5221 .phase(),
5222 SchemaMigrationPhase::Validating,
5223 );
5224 let record = super::load_schema_migration_record()
5225 .expect("migration record should remain readable")
5226 .expect("validating migration record should exist");
5227 let planned = super::recompile_active_physical_migration(&db, &proposal, &record)
5228 .expect("the exact active plan should recompile");
5229 for _ in 0..2 {
5230 let page = super::validate_migration_page(
5231 &db,
5232 &planned,
5233 record.progress(),
5234 record.plan_digest(),
5235 )
5236 .expect("the same validation page should remain replayable");
5237 let (progress, staged, exhausted) = page.into_parts();
5238 assert!(progress.findings().is_empty());
5239 assert!(exhausted);
5240 super::stage_migration_index_entries(staged)
5241 .expect("staging before a cursor marker should be idempotent");
5242 }
5243 assert_eq!(
5244 store.with_index(IndexStore::len),
5245 2,
5246 "replaying an uncheckpointed page must retain one exact staged key per row",
5247 );
5248 let ready =
5249 migrate_schema(&db, &proposal, command()).expect("bounded validation should complete");
5250 assert_eq!(ready.phase(), SchemaMigrationPhase::ReadyToRewrite);
5251 assert_eq!(ready.rows_validated(), 2);
5252 assert!(ready.findings().is_empty());
5253 assert_eq!(row_bytes(), before_rows, "validation must not rewrite rows");
5254 assert_eq!(
5255 store.with_index(IndexStore::len),
5256 2,
5257 "the isolated candidate unique generation should be durably staged",
5258 );
5259 let candidate_id = store.with_index(|index| {
5260 let mut candidate = None;
5261 index
5262 .visit_raw_entries_in_range(
5263 (std::ops::Bound::Unbounded, std::ops::Bound::Unbounded),
5264 crate::db::direction::Direction::Asc,
5265 |raw, _| {
5266 candidate = Some(*IndexKey::try_from_raw(raw).unwrap().index_id());
5267 Ok::<_, Infallible>(true)
5268 },
5269 )
5270 .unwrap();
5271 candidate.unwrap()
5272 });
5273 store.with_index_mut(|index| {
5274 for ordinal in 0..extra_entries {
5277 let key = IndexKey::new_from_components_with_primary_key_value(
5278 &candidate_id,
5279 IndexKeyKind::System,
5280 &[ordinal.to_be_bytes()],
5281 &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(ordinal)),
5282 )
5283 .unwrap()
5284 .to_raw()
5285 .unwrap();
5286 index
5287 .apply_canonical_entry(key, Some(IndexEntryValue::presence()))
5288 .unwrap();
5289 }
5290 for ordinal in 0..513_u64 {
5291 let component = ordinal.to_be_bytes();
5292 let key = IndexKey::new_from_components_with_primary_key_value(
5293 &IndexId::new(EntityTag::new(2), 0),
5294 IndexKeyKind::User,
5295 &[component],
5296 &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(ordinal)),
5297 )
5298 .expect("unrelated abort-scan key should build")
5299 .to_raw()
5300 .expect("unrelated abort-scan key should encode");
5301 index.insert(key, IndexEntryValue::presence());
5302 }
5303 });
5304 let abort = || SchemaMigrationCommand::Abort {
5305 expected_database: target.database_identity(),
5306 expected_head: target.accepted_head().clone(),
5307 expected_plan: plan,
5308 };
5309 if extra_entries != 0 {
5310 let cleaning = migrate_schema(&db, &proposal, abort()).unwrap();
5311 assert_eq!(cleaning.phase(), SchemaMigrationPhase::ReadyToRewrite);
5312 assert_eq!(store.with_index(IndexStore::len), 516);
5313 }
5314 let aborted =
5315 migrate_schema(&db, &proposal, abort()).expect("pre-rewrite migration should abort");
5316 assert_eq!(aborted.phase(), SchemaMigrationPhase::Aborted);
5317 assert_eq!(
5318 store.with_index(IndexStore::len),
5319 513,
5320 "abort must remove only planner-invisible candidate generations",
5321 );
5322 assert_eq!(
5323 row_bytes(),
5324 before_rows,
5325 "abort must retain predecessor rows"
5326 );
5327 ensure_generated_schema_application_admitted(&db, &proposal)
5328 .expect("terminal aborted record must not block generated startup");
5329 }
5330
5331 #[cfg(feature = "migration")]
5332 #[test]
5333 #[expect(
5334 clippy::too_many_lines,
5335 reason = "the interrupted rewrite, recovery, final proof, and publication form one scenario"
5336 )]
5337 fn physical_migration_rewrite_recovers_and_publishes_one_complete_candidate() {
5338 use super::{
5339 ensure_generated_schema_application_admitted, migrate_schema,
5340 schema_migration_status_for_target,
5341 };
5342 use crate::db::{
5343 data::{CanonicalSlotReader, DecodedDataStoreKey, StoreVisit, StructuralSlotReader},
5344 schema::{
5345 MigrationRewriteInterruption, SchemaMigrationCommand, SchemaMigrationPhase,
5346 ensure_schema_migration_ready_for_ordinary_operations,
5347 interrupt_next_migration_rewrite_at,
5348 },
5349 };
5350 use crate::error::InternalError;
5351
5352 let db = Db::<MigrationExecutionCanister>::new(
5353 &MIGRATION_EXECUTION_REGISTRY,
5354 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5355 );
5356 drive_startup_recovery_to_completion(&db);
5357 let initial_target = schema_application_target(&db).expect("initial target should issue");
5358 let store_identity = initial_target
5359 .stores()
5360 .first()
5361 .expect("migration execution store should exist")
5362 .identity();
5363 let initial = validation_migration_proposal(
5364 ValidationMigrationShape::Clean,
5365 false,
5366 initial_target.accepted_head().clone(),
5367 initial_target.database_identity(),
5368 store_identity,
5369 );
5370 apply_schema(&db, &initial).expect("initial schema should publish");
5371 let session = DbSession::<MigrationExecutionCanister>::new(
5372 &MIGRATION_EXECUTION_REGISTRY,
5373 &crate::db::RequestExecutionRoot::__new_runtime_root(),
5374 );
5375 for (id, value) in [(1, 7), (2, 8), (3, 9)] {
5376 session
5377 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5378 entity: "MigratingItem".to_string(),
5379 patch: DynamicStructuralPatch::new(vec![
5380 (
5381 "id".to_string(),
5382 DynamicWriteCell::Value(InputValue::nat64(id)),
5383 ),
5384 (
5385 "old_value".to_string(),
5386 DynamicWriteCell::Value(InputValue::int64(value)),
5387 ),
5388 ]),
5389 })
5390 .expect("predecessor row should insert");
5391 }
5392 let target = schema_application_target(&db).expect("migration target should issue");
5393 let proposal = validation_migration_proposal(
5394 ValidationMigrationShape::Clean,
5395 true,
5396 target.accepted_head().clone(),
5397 target.database_identity(),
5398 store_identity,
5399 );
5400 let plan = proposal
5401 .migration()
5402 .expect("migration plan should exist")
5403 .digest();
5404 let command = || SchemaMigrationCommand::Advance {
5405 expected_database: target.database_identity(),
5406 expected_head: target.accepted_head().clone(),
5407 expected_plan: plan,
5408 acknowledged_finding_page: None,
5409 };
5410 for expected in [
5411 SchemaMigrationPhase::Prepared,
5412 SchemaMigrationPhase::Validating,
5413 SchemaMigrationPhase::ReadyToRewrite,
5414 SchemaMigrationPhase::RewritingRows,
5415 ] {
5416 assert_eq!(
5417 migrate_schema(&db, &proposal, command())
5418 .expect("migration phase should advance")
5419 .phase(),
5420 expected,
5421 );
5422 }
5423
5424 for interruption in [
5425 MigrationRewriteInterruption::MarkerPersisted,
5426 MigrationRewriteInterruption::JournalPublished,
5427 MigrationRewriteInterruption::PhysicalApplied,
5428 ] {
5429 interrupt_next_migration_rewrite_at(interruption);
5430 migrate_schema(&db, &proposal, command())
5431 .expect_err("injected interruption should retain the rewrite marker");
5432
5433 forget_recovered_domain_for_tests(&db)
5434 .expect("upgrade should reset recovery ownership");
5435 drive_startup_recovery_to_completion(&db);
5436 }
5437
5438 let rebuilding = schema_migration_status_for_target(
5439 &db,
5440 &proposal,
5441 &schema_application_target(&db).expect("recovered target should issue"),
5442 )
5443 .expect("recovered status should remain readable");
5444 assert_eq!(rebuilding.phase(), SchemaMigrationPhase::RebuildingIndexes);
5445 assert_eq!(rebuilding.rows_rewritten(), 3);
5446 assert_eq!(
5447 migrate_schema(&db, &proposal, command())
5448 .expect("derived generations should complete")
5449 .phase(),
5450 SchemaMigrationPhase::FinalValidation,
5451 );
5452 assert_eq!(
5453 migrate_schema(&db, &proposal, command())
5454 .expect("final validation should complete")
5455 .phase(),
5456 SchemaMigrationPhase::Publishing,
5457 );
5458 let applied = migrate_schema(&db, &proposal, command())
5459 .expect("candidate publication should complete atomically");
5460 assert_eq!(applied.phase(), SchemaMigrationPhase::Applied);
5461 assert_eq!(applied.rows_rewritten(), 3);
5462 assert_eq!(applied.indexes_rebuilt(), 1);
5463 assert_ne!(applied.accepted_head(), target.accepted_head());
5464 let terminal_target = schema_application_target(&db).expect("terminal target should issue");
5465 let terminal_proposal = validation_migration_proposal(
5466 ValidationMigrationShape::Clean,
5467 true,
5468 terminal_target.accepted_head().clone(),
5469 terminal_target.database_identity(),
5470 store_identity,
5471 );
5472 ensure_generated_schema_application_admitted(&db, &terminal_proposal)
5473 .expect("terminal record must not block generated startup");
5474
5475 let store = db
5476 .store_handle(MIGRATION_EXECUTION_STORE_PATH)
5477 .expect("migration execution store should resolve");
5478 let runtime = db
5479 .accepted_runtime_entity_for_path("MigratingItem")
5480 .expect("published candidate entity should resolve");
5481 let selection = store
5482 .with_schema(|schema| {
5483 schema.current_accepted_catalog_selection(
5484 runtime.entity_tag(),
5485 runtime.entity_path(),
5486 runtime.store_path(),
5487 )
5488 })
5489 .expect("candidate selection should remain readable")
5490 .expect("candidate selection should exist");
5491 let contract = crate::db::data::AcceptedStructuralRowAuthority::from_catalog_selection(
5492 runtime.entity_path(),
5493 &selection,
5494 )
5495 .expect("candidate row authority should compile")
5496 .into_row_contract();
5497 let mut values = Vec::new();
5498 store
5499 .with_data(|data| {
5500 data.visit_entries(|key, row| {
5501 let decoded = DecodedDataStoreKey::try_from_raw(key)
5502 .expect("rewritten key should decode");
5503 let reader =
5504 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
5505 row, &contract,
5506 )
5507 .expect("rewritten row should use the candidate layout");
5508 reader
5509 .validate_primary_key(&decoded)
5510 .expect("rewritten row and key should remain bound");
5511 values.push(
5512 reader
5513 .required_value_by_contract(1)
5514 .expect("candidate value slot should decode"),
5515 );
5516 Ok::<StoreVisit, InternalError>(StoreVisit::Continue)
5517 })
5518 })
5519 .expect("rewritten row scan should complete");
5520 assert_eq!(
5521 values,
5522 vec![
5523 crate::value::Value::Nat64(7),
5524 crate::value::Value::Nat64(8),
5525 crate::value::Value::Nat64(9),
5526 ],
5527 );
5528 assert_eq!(store.with_index(IndexStore::len), 3);
5529 let accepted = store
5530 .with_schema(SchemaStore::current_accepted_schema_bundle)
5531 .expect("published candidate bundle should remain readable")
5532 .expect("published candidate bundle should exist");
5533 let entity_source = EntitySourceKey::try_new("MigratingItem")
5534 .expect("migration entity source should admit");
5535 let entity_tag = accepted
5536 .source_bindings_for_tests()
5537 .entity(&entity_source)
5538 .expect("candidate entity source should remain bound");
5539 let old_value =
5540 FieldSourceKey::try_new("old_value").expect("predecessor source should admit");
5541 let current_value =
5542 FieldSourceKey::try_new("value").expect("candidate source should admit");
5543 assert_eq!(
5544 accepted
5545 .source_bindings_for_tests()
5546 .field(entity_tag, &old_value),
5547 None,
5548 );
5549 assert!(
5550 accepted
5551 .source_bindings_for_tests()
5552 .field(entity_tag, ¤t_value)
5553 .is_some(),
5554 );
5555 ensure_schema_migration_ready_for_ordinary_operations()
5556 .expect("terminal publication must clear the database-wide gate");
5557 }
5558
5559 #[cfg(feature = "migration")]
5560 #[test]
5561 #[expect(
5562 clippy::too_many_lines,
5563 reason = "all four finding families share one ordered historical scan fixture"
5564 )]
5565 fn physical_migration_validation_reports_every_typed_finding_family_without_writes() {
5566 use std::convert::Infallible;
5567
5568 use super::migrate_schema;
5569 use crate::db::{
5570 data::StoreVisit,
5571 schema::{SchemaMigrationCommand, SchemaMigrationFindingKind, SchemaMigrationPhase},
5572 };
5573
5574 let db = Db::<MigrationFindingCanister>::new(
5575 &MIGRATION_FINDING_REGISTRY,
5576 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5577 );
5578 drive_startup_recovery_to_completion(&db);
5579 let initial_target = schema_application_target(&db).expect("initial target should issue");
5580 let store_identity = initial_target
5581 .stores()
5582 .first()
5583 .expect("migration finding store should exist")
5584 .identity();
5585 let initial = validation_migration_proposal(
5586 ValidationMigrationShape::AllFindingFamilies,
5587 false,
5588 initial_target.accepted_head().clone(),
5589 initial_target.database_identity(),
5590 store_identity,
5591 );
5592 apply_schema(&db, &initial).expect("initial finding schema should publish");
5593
5594 let session = DbSession::<MigrationFindingCanister>::new(
5595 &MIGRATION_FINDING_REGISTRY,
5596 &crate::db::RequestExecutionRoot::__new_runtime_root(),
5597 );
5598 session
5599 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5600 entity: "MigrationTarget".to_string(),
5601 patch: DynamicStructuralPatch::new(vec![(
5602 "id".to_string(),
5603 DynamicWriteCell::Value(InputValue::nat64(7)),
5604 )]),
5605 })
5606 .expect("relation target should insert");
5607 for (id, value) in [(1, 9), (2, 8), (3, 7), (4, 7), (5, 300)] {
5608 session
5609 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5610 entity: "MigratingItem".to_string(),
5611 patch: DynamicStructuralPatch::new(vec![
5612 (
5613 "id".to_string(),
5614 DynamicWriteCell::Value(InputValue::nat64(id)),
5615 ),
5616 (
5617 "old_value".to_string(),
5618 DynamicWriteCell::Value(InputValue::int64(value)),
5619 ),
5620 ]),
5621 })
5622 .expect("predecessor finding row should insert");
5623 }
5624 let store = db
5625 .store_handle(MIGRATION_FINDING_STORE_PATH)
5626 .expect("migration finding store should resolve");
5627 let row_bytes = || {
5628 store.with_data(|data| {
5629 let mut rows = Vec::new();
5630 let result: Result<(), Infallible> = data.visit_entries(|key, row| {
5631 rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
5632 Ok(StoreVisit::Continue)
5633 });
5634 result.expect("infallible row visit should complete");
5635 rows
5636 })
5637 };
5638 let before_rows = row_bytes();
5639
5640 let target = schema_application_target(&db).expect("migration target should issue");
5641 let proposal = validation_migration_proposal(
5642 ValidationMigrationShape::AllFindingFamilies,
5643 true,
5644 target.accepted_head().clone(),
5645 target.database_identity(),
5646 store_identity,
5647 );
5648 let plan = proposal
5649 .migration()
5650 .expect("migration plan should exist")
5651 .digest();
5652 let command = || SchemaMigrationCommand::Advance {
5653 expected_database: target.database_identity(),
5654 expected_head: target.accepted_head().clone(),
5655 expected_plan: plan,
5656 acknowledged_finding_page: None,
5657 };
5658 assert_eq!(
5659 migrate_schema(&db, &proposal, command())
5660 .expect("finding migration should prepare")
5661 .phase(),
5662 SchemaMigrationPhase::Prepared,
5663 );
5664 assert_eq!(
5665 migrate_schema(&db, &proposal, command())
5666 .expect("finding migration should enter validation")
5667 .phase(),
5668 SchemaMigrationPhase::Validating,
5669 );
5670 let rejected =
5671 migrate_schema(&db, &proposal, command()).expect("validation should report findings");
5672 assert_eq!(rejected.phase(), SchemaMigrationPhase::Rejected);
5673 assert_eq!(rejected.rows_validated(), 5);
5674 assert_eq!(
5675 rejected
5676 .findings()
5677 .iter()
5678 .map(crate::db::schema::SchemaMigrationFinding::kind)
5679 .collect::<Vec<_>>(),
5680 vec![
5681 SchemaMigrationFindingKind::Constraint,
5682 SchemaMigrationFindingKind::Relation,
5683 SchemaMigrationFindingKind::UniqueIndex,
5684 SchemaMigrationFindingKind::Transform,
5685 ],
5686 );
5687 assert_eq!(
5688 row_bytes(),
5689 before_rows,
5690 "rejected validation must not rewrite accepted rows"
5691 );
5692 assert_eq!(
5693 store.with_index(IndexStore::len),
5694 0,
5695 "a rejected page must not publish any staged generation"
5696 );
5697 }
5698
5699 #[cfg(feature = "migration")]
5700 #[test]
5701 fn exact_migration_retry_binds_the_terminal_head_not_the_predecessor_head() {
5702 use super::exact_migration_replay_target;
5703
5704 let db = Db::<EvolutionCanister>::new(
5705 &EVOLUTION_REGISTRY,
5706 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5707 );
5708 drive_startup_recovery_to_completion(&db);
5709 let initial_target = schema_application_target(&db).expect("initial target should issue");
5710 let (proposal, _, _) = generated_check_proposal(
5711 initial_target.accepted_head().clone(),
5712 "migration-retry-initial",
5713 false,
5714 initial_target.database_identity(),
5715 initial_target
5716 .stores()
5717 .first()
5718 .expect("test store should exist")
5719 .identity(),
5720 );
5721 apply_schema(&db, &proposal).expect("initial schema should publish");
5722 let current_target = schema_application_target(&db).expect("current target should issue");
5723 assert_ne!(
5724 current_target.accepted_head(),
5725 initial_target.accepted_head(),
5726 );
5727
5728 let receipt = SchemaChangeReceipt::new(
5729 current_target.database_identity(),
5730 SchemaSubmissionKey::try_new("migration/retry")
5731 .expect("migration submission should admit"),
5732 SchemaProposalDigest::from_bytes([0x77; 32]),
5733 initial_target.accepted_head().clone(),
5734 SchemaChangeOutcome::Applied {
5735 accepted_head: current_target.accepted_head().clone(),
5736 },
5737 )
5738 .expect("terminal migration receipt should admit");
5739 let record = SchemaApplicationRecord::new(receipt, Vec::new())
5740 .expect("terminal migration record should admit");
5741
5742 assert_eq!(
5743 exact_migration_replay_target(&db, current_target.database_identity(), &record,)
5744 .expect("exact retry should resolve the terminal target"),
5745 current_target,
5746 );
5747 }
5748}