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};
51use candid::CandidType;
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(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
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(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
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
2100 .fragments()
2101 .iter()
2102 .flat_map(icydb_schema::SchemaFragment::entities)
2103 .collect::<Vec<_>>();
2104 if entities.len() != lineage.entries().len() {
2105 return Ok(false);
2106 }
2107 let target = proposal.target_database();
2108 let authorities = application_authorities(db);
2109 for entity in entities {
2110 let source = entity.source_key();
2111 let digest = proposal
2112 .entity_source_digest(source)
2113 .map_err(|_| InternalError::store_invariant())?;
2114 let mut matched = false;
2115 for authority in &authorities {
2116 let store_identity = derive_store_identity(target, authority);
2117 let entity_tag = authority
2118 .handle
2119 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
2120 .and_then(|bundle| bundle.source_bindings().entity(source));
2121 let Some(entity_tag) = entity_tag else {
2122 continue;
2123 };
2124 let Some(entry) = lineage.get(store_identity, entity_tag) else {
2125 return Ok(false);
2126 };
2127 matched = matches!(
2128 entry.state(),
2129 AcceptedEntitySourceLineageState::Adopted { version, source_digest }
2130 if version.get() == entity.version().get() && *source_digest == digest
2131 );
2132 break;
2133 }
2134 if !matched {
2135 return Ok(false);
2136 }
2137 }
2138 Ok(true)
2139}
2140
2141#[cfg(feature = "migration")]
2142fn preflight_unpublished_schema_migration<C: CanisterKind>(
2143 target: &SchemaApplicationTarget,
2144 proposal: &SchemaProposal,
2145 db: &Db<C>,
2146) -> Result<(), InternalError> {
2147 let authorities = application_authorities(db);
2148 let current_bundles = authorities
2149 .iter()
2150 .map(|authority| {
2151 authority
2152 .handle
2153 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
2154 })
2155 .collect::<Result<Vec<_>, InternalError>>()?;
2156 let stores = authorities
2157 .iter()
2158 .zip(¤t_bundles)
2159 .filter_map(|(authority, bundle)| {
2160 bundle.as_ref().map(|bundle| ExistingProposalStore {
2161 path: authority.path,
2162 identity: derive_store_identity(target.database_identity(), authority),
2163 bundle,
2164 })
2165 })
2166 .collect::<Vec<_>>();
2167 let lineage = load_entity_source_lineage_catalog()?.unwrap_or_default();
2168 let planned = plan_schema_migration(proposal, stores.as_slice(), &lineage)
2169 .map_err(schema_migration_planning_error)?;
2170 if planned.candidates().is_empty() || planned.lineage().is_empty() {
2171 return Err(InternalError::store_invariant());
2172 }
2173 for next in planned.lineage() {
2174 let Some(current) = lineage.get(next.store(), next.entity()) else {
2175 if next.version().get() != 1
2178 || stores.iter().any(|store| {
2179 store.identity == next.store()
2180 && store.bundle.entity_snapshots().contains_key(&next.entity())
2181 })
2182 {
2183 return Err(InternalError::store_invariant());
2184 }
2185 continue;
2186 };
2187 let AcceptedEntitySourceLineageState::Adopted {
2188 version,
2189 source_digest,
2190 } = current.state()
2191 else {
2192 return Err(InternalError::store_invariant());
2193 };
2194 let expected_version = version
2195 .get()
2196 .checked_add(1)
2197 .ok_or_else(InternalError::store_invariant)?;
2198 if next.version().get() != expected_version || next.digest() == *source_digest {
2199 return Err(InternalError::store_invariant());
2200 }
2201 }
2202 Ok(())
2203}
2204
2205fn lower_application_candidates<const ALLOW_REMOVALS: bool>(
2206 target: &SchemaApplicationTarget,
2207 proposal: &SchemaProposal,
2208 authorities: &[StoreApplicationAuthority],
2209) -> Result<LoweredApplication, InternalError> {
2210 let _span = ExecutionMetricsSpan::new(ExecutionMetricsPhase::Lowering);
2211 let current_bundles = authorities
2212 .iter()
2213 .map(|authority| {
2214 authority
2215 .handle
2216 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
2217 })
2218 .collect::<Result<Vec<_>, InternalError>>()?;
2219 let initial_application = matches!(target.accepted_head(), ExpectedAcceptedHead::Empty);
2220 let mut candidates = match target.accepted_head() {
2221 ExpectedAcceptedHead::Empty => {
2222 let stores = authorities
2223 .iter()
2224 .map(|authority| ProposalStoreTarget {
2225 path: authority.path,
2226 identity: derive_store_identity(target.database_identity(), authority),
2227 })
2228 .collect::<Vec<_>>();
2229 let candidates = lower_initial_schema_proposal(proposal, stores.as_slice())?;
2230 #[cfg(feature = "migration")]
2231 {
2232 let planned = plan_new_entity_source_lineage(proposal, &[], &candidates)
2233 .map_err(schema_migration_planning_error)?;
2234 if planned.len()
2235 != proposal
2236 .fragments()
2237 .iter()
2238 .map(|fragment| fragment.entities().len())
2239 .sum::<usize>()
2240 {
2241 return Err(InternalError::store_invariant());
2242 }
2243 }
2244 candidates
2245 }
2246 ExpectedAcceptedHead::Exact { .. }
2247 if proposal.fragments().is_empty() && proposal.removals().is_empty() =>
2248 {
2249 Vec::new()
2250 }
2251 ExpectedAcceptedHead::Exact { .. } => {
2252 let stores = authorities
2253 .iter()
2254 .zip(¤t_bundles)
2255 .filter_map(|(authority, bundle)| {
2256 bundle.as_ref().map(|bundle| ExistingProposalStore {
2257 path: authority.path,
2258 identity: derive_store_identity(target.database_identity(), authority),
2259 bundle,
2260 })
2261 })
2262 .collect::<Vec<_>>();
2263 if ALLOW_REMOVALS {
2264 lower_existing_schema_proposal(proposal, stores.as_slice())?
2265 } else {
2266 lower_generated_existing_schema_proposal(proposal, stores.as_slice())?
2267 }
2268 }
2269 };
2270 let pending = if initial_application {
2271 preflight_initial_application(authorities, &candidates)?;
2272 None
2273 } else {
2274 preflight_existing_application(authorities, ¤t_bundles, &mut candidates)?
2275 };
2276 Ok(LoweredApplication {
2277 current_bundles,
2278 candidates,
2279 pending,
2280 })
2281}
2282
2283fn validate_database_identity_state_capacity(
2284 authorities: &[StoreApplicationAuthority],
2285 candidates: &[CandidateSchemaRevision],
2286 incarnation: crate::db::integrity::DatabaseIncarnationId,
2287) -> Result<(), InternalError> {
2288 let mut total = 0usize;
2289 for authority in authorities {
2290 let count = match candidates
2291 .iter()
2292 .find(|candidate| candidate.store_path() == authority.path)
2293 {
2294 Some(candidate) => authority.handle.with_schema(|store| {
2295 store.projected_identity_state_count(incarnation, candidate)
2296 })?,
2297 None => authority
2298 .handle
2299 .with_schema(|store| store.identity_state_inventory_for_integrity(incarnation))?
2300 .len(),
2301 };
2302 total = include_identity_state_count(total, count)?;
2303 }
2304 Ok(())
2305}
2306
2307fn include_identity_state_count(total: usize, count: usize) -> Result<usize, InternalError> {
2308 let total = total
2309 .checked_add(count)
2310 .ok_or_else(InternalError::identity_state_capacity_exhausted)?;
2311 if total > MAX_IDENTITY_STATE_RECORDS_PER_DATABASE {
2312 return Err(InternalError::identity_state_capacity_exhausted());
2313 }
2314 Ok(total)
2315}
2316
2317fn preflight_initial_application(
2318 authorities: &[StoreApplicationAuthority],
2319 candidates: &[crate::db::schema::CandidateSchemaRevision],
2320) -> Result<(), InternalError> {
2321 for candidate in candidates {
2322 let authority = authorities
2323 .iter()
2324 .find(|authority| authority.path == candidate.store_path())
2325 .ok_or_else(InternalError::store_invariant)?;
2326 if !authority.handle.with_data(DataStore::is_empty)
2327 || authority.handle.index_state() != IndexState::Ready
2328 || !authority.handle.with_index(IndexStore::is_empty)
2329 {
2330 return Err(InternalError::store_unsupported());
2331 }
2332 }
2333 Ok(())
2334}
2335
2336fn preflight_existing_application(
2343 authorities: &[StoreApplicationAuthority],
2344 current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
2345 candidates: &mut [CandidateSchemaRevision],
2346) -> Result<Option<PendingGeneratedRowLocalConstraint>, InternalError> {
2347 preflight_new_entity_domains(authorities, current_bundles, candidates)?;
2348 require_empty_physical_entity_removal(authorities, current_bundles, candidates)?;
2349 require_empty_physical_field_removals(authorities, current_bundles, candidates)?;
2350 require_empty_physical_index_removals(authorities, current_bundles, candidates)?;
2351 require_empty_physical_relation_removals(authorities, current_bundles, candidates)?;
2352 let proofs = generated_row_local_constraint_proofs(authorities, current_bundles, candidates)?;
2353 if proofs.iter().filter(|proof| proof.has_rows).count() > 1 {
2354 return Err(InternalError::store_unsupported());
2355 }
2356
2357 let mut pending = None;
2358 for candidate_index in 0..candidates.len() {
2359 let candidate = candidates
2360 .get(candidate_index)
2361 .cloned()
2362 .ok_or_else(InternalError::store_invariant)?;
2363 let candidate_proofs = proofs
2364 .iter()
2365 .filter(|proof| proof.candidate_index == candidate_index)
2366 .collect::<Vec<_>>();
2367 if candidate_proofs.is_empty() {
2368 continue;
2369 }
2370
2371 let mut snapshots = candidate.bundle().entity_snapshots().clone();
2372 for proof in candidate_proofs {
2373 let mut promote = true;
2374 if proof.has_rows {
2375 match validate_unpublished_row_local_candidate_bounded(
2376 proof.store,
2377 proof.store_path,
2378 proof.entity_tag,
2379 proof.entity_path.as_str(),
2380 &candidate,
2381 proof.constraint_id,
2382 )? {
2383 UnpublishedRowLocalValidation::Complete { .. } => {}
2384 UnpublishedRowLocalValidation::Incomplete => {
2385 if proof.store.storage_capabilities().recovery()
2386 != StoreRecoveryCapability::StableBasePlusJournalReplay
2387 || pending.is_some()
2388 {
2389 return Err(InternalError::store_unsupported());
2390 }
2391 pending = Some(PendingGeneratedRowLocalConstraint {
2392 proof: (*proof).clone(),
2393 });
2394 promote = false;
2395 }
2396 }
2397 }
2398 if !promote {
2399 continue;
2400 }
2401 let snapshot = snapshots
2402 .get(&proof.entity_tag)
2403 .cloned()
2404 .ok_or_else(InternalError::store_invariant)?;
2405 let catalog = snapshot
2406 .constraint_catalog()
2407 .clone()
2408 .with_directly_validated_activation(proof.constraint_id)
2409 .map_err(|_| InternalError::store_invariant())?;
2410 snapshots.insert(proof.entity_tag, snapshot.with_constraint_catalog(catalog));
2411 }
2412 let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
2413 candidate.revision(),
2414 candidate.bundle().store_path(),
2415 candidate.bundle().enum_catalog().clone(),
2416 candidate.bundle().composite_catalog().clone(),
2417 candidate.bundle().source_bindings().clone(),
2418 snapshots,
2419 )?;
2420 candidates[candidate_index] = CandidateSchemaRevision::new(bundle)?;
2421 }
2422 Ok(pending)
2423}
2424
2425fn preflight_new_entity_domains(
2429 authorities: &[StoreApplicationAuthority],
2430 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2431 candidates: &[CandidateSchemaRevision],
2432) -> Result<(), InternalError> {
2433 let mut after = current_bundles.to_vec();
2434 for candidate in candidates {
2435 let position = authorities
2436 .iter()
2437 .position(|authority| authority.path == candidate.store_path())
2438 .ok_or_else(InternalError::store_invariant)?;
2439 let bundle = after
2440 .get_mut(position)
2441 .ok_or_else(InternalError::store_invariant)?;
2442 *bundle = Some(candidate.bundle().clone());
2443 }
2444 for candidate in candidates {
2445 let (position, authority) = authorities
2446 .iter()
2447 .enumerate()
2448 .find(|(_, authority)| authority.path == candidate.store_path())
2449 .ok_or_else(InternalError::store_invariant)?;
2450 let current = current_bundles
2451 .get(position)
2452 .and_then(Option::as_ref)
2453 .ok_or_else(InternalError::store_invariant)?;
2454 for (tag, snapshot) in candidate.bundle().entity_snapshots() {
2455 if current.entity_snapshots().contains_key(tag) {
2456 continue;
2457 }
2458 require_exact_empty_entity(authority.handle, *tag)?;
2459 authority
2460 .handle
2461 .with_index(|store| prove_empty_user_index_domain(store, *tag))
2462 .map_err(StagedUserIndexDomainError::into_internal_error)?;
2463 for relation in snapshot.relations() {
2464 let target =
2465 accepted_entity_store_for_path(authorities, &after, relation.target_path())?;
2466 target.with_index(|store| {
2467 prove_empty_reverse_relation_domain(store, *tag, snapshot, relation)
2468 })?;
2469 }
2470 }
2471 }
2472 Ok(())
2473}
2474
2475fn require_empty_physical_entity_removal(
2482 authorities: &[StoreApplicationAuthority],
2483 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2484 candidates: &[CandidateSchemaRevision],
2485) -> Result<(), InternalError> {
2486 let mut removed_entity = None;
2487 for candidate in candidates {
2488 let (position, source_authority) = authorities
2489 .iter()
2490 .enumerate()
2491 .find(|(_, authority)| authority.path == candidate.store_path())
2492 .ok_or_else(InternalError::store_invariant)?;
2493 let current = current_bundles
2494 .get(position)
2495 .and_then(Option::as_ref)
2496 .ok_or_else(InternalError::store_invariant)?;
2497 let removed = current
2498 .entity_snapshots()
2499 .iter()
2500 .filter(|(entity_tag, _)| {
2501 !candidate
2502 .bundle()
2503 .entity_snapshots()
2504 .contains_key(entity_tag)
2505 })
2506 .collect::<Vec<_>>();
2507 if removed.is_empty() {
2508 continue;
2509 }
2510 let [(entity_tag, snapshot)] = removed.as_slice() else {
2511 return Err(InternalError::store_unsupported());
2512 };
2513 let entity_tag = **entity_tag;
2514 let snapshot = *snapshot;
2515 if removed_entity.is_some()
2516 || current.entity_snapshots().len()
2517 != candidate
2518 .bundle()
2519 .entity_snapshots()
2520 .len()
2521 .saturating_add(1)
2522 {
2523 return Err(InternalError::store_unsupported());
2524 }
2525 require_exact_empty_entity(source_authority.handle, entity_tag)?;
2526 source_authority
2527 .handle
2528 .with_index(|store| prove_empty_user_index_domain(store, entity_tag))
2529 .map_err(StagedUserIndexDomainError::into_internal_error)?;
2530 for relation in snapshot.relations() {
2531 let target_store = accepted_entity_store_for_path(
2532 authorities,
2533 current_bundles,
2534 relation.target_path(),
2535 )?;
2536 target_store.with_index(|store| {
2537 prove_empty_reverse_relation_domain(store, entity_tag, snapshot, relation)
2538 })?;
2539 }
2540 removed_entity = Some(snapshot.entity_path());
2541 }
2542
2543 let Some(removed_path) = removed_entity else {
2544 return Ok(());
2545 };
2546 for (position, authority) in authorities.iter().enumerate() {
2547 let after = candidates
2548 .iter()
2549 .find(|candidate| candidate.store_path() == authority.path)
2550 .map(CandidateSchemaRevision::bundle)
2551 .or_else(|| current_bundles.get(position).and_then(Option::as_ref));
2552 let Some(after) = after else {
2553 continue;
2554 };
2555 if after
2556 .entity_snapshots()
2557 .values()
2558 .flat_map(crate::db::schema::PersistedSchemaSnapshot::relations)
2559 .any(|relation| relation.target_path() == removed_path)
2560 {
2561 return Err(InternalError::store_unsupported());
2562 }
2563 }
2564 Ok(())
2565}
2566
2567fn require_empty_physical_relation_removals(
2570 authorities: &[StoreApplicationAuthority],
2571 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2572 candidates: &[CandidateSchemaRevision],
2573) -> Result<(), InternalError> {
2574 for candidate in candidates {
2575 let (position, source_authority) = authorities
2576 .iter()
2577 .enumerate()
2578 .find(|(_, authority)| authority.path == candidate.store_path())
2579 .ok_or_else(InternalError::store_invariant)?;
2580 let current = current_bundles
2581 .get(position)
2582 .and_then(Option::as_ref)
2583 .ok_or_else(InternalError::store_invariant)?;
2584 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2585 let Some(before) = current.entity_snapshots().get(entity_tag) else {
2586 continue;
2587 };
2588 let removed = before
2589 .relations()
2590 .iter()
2591 .filter(|relation| {
2592 !after
2593 .relations()
2594 .iter()
2595 .any(|candidate| candidate.id() == relation.id())
2596 })
2597 .collect::<Vec<_>>();
2598 if removed.is_empty() {
2599 continue;
2600 }
2601 let added = after.relations().iter().any(|relation| {
2602 !before
2603 .relations()
2604 .iter()
2605 .any(|accepted| accepted.id() == relation.id())
2606 });
2607 let [removed] = removed.as_slice() else {
2608 return Err(InternalError::store_unsupported());
2609 };
2610 if added || before.relations().len() != after.relations().len().saturating_add(1) {
2611 return Err(InternalError::store_unsupported());
2612 }
2613 require_exact_empty_entity(source_authority.handle, *entity_tag)?;
2614 let target_store = accepted_entity_store_for_path(
2615 authorities,
2616 current_bundles,
2617 removed.target_path(),
2618 )?;
2619 target_store.with_index(|store| {
2620 prove_empty_reverse_relation_domain(store, *entity_tag, before, removed)
2621 })?;
2622 }
2623 }
2624 Ok(())
2625}
2626
2627fn accepted_entity_store_for_path(
2628 authorities: &[StoreApplicationAuthority],
2629 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2630 entity_path: &str,
2631) -> Result<StoreHandle, InternalError> {
2632 let mut resolved = None;
2633 for (position, bundle) in current_bundles.iter().enumerate() {
2634 let Some(bundle) = bundle else {
2635 continue;
2636 };
2637 if !bundle
2638 .entity_snapshots()
2639 .values()
2640 .any(|snapshot| snapshot.entity_path() == entity_path)
2641 {
2642 continue;
2643 }
2644 if resolved.is_some() {
2645 return Err(InternalError::store_invariant());
2646 }
2647 resolved = authorities.get(position).map(|authority| authority.handle);
2648 }
2649 resolved.ok_or_else(InternalError::store_unsupported)
2650}
2651
2652fn require_empty_physical_index_removals(
2656 authorities: &[StoreApplicationAuthority],
2657 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2658 candidates: &[CandidateSchemaRevision],
2659) -> Result<(), InternalError> {
2660 for candidate in candidates {
2661 let (position, authority) = authorities
2662 .iter()
2663 .enumerate()
2664 .find(|(_, authority)| authority.path == candidate.store_path())
2665 .ok_or_else(InternalError::store_invariant)?;
2666 let current = current_bundles
2667 .get(position)
2668 .and_then(Option::as_ref)
2669 .ok_or_else(InternalError::store_invariant)?;
2670 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2671 let Some(before) = current.entity_snapshots().get(entity_tag) else {
2672 continue;
2673 };
2674 if before.indexes().len() == after.indexes().len() {
2675 continue;
2676 }
2677 if before.indexes().len() != after.indexes().len().saturating_add(1) {
2678 return Err(InternalError::store_unsupported());
2679 }
2680 require_exact_empty_entity(authority.handle, *entity_tag)?;
2681 authority
2682 .handle
2683 .with_index(|store| prove_empty_user_index_domain(store, *entity_tag))
2684 .map_err(StagedUserIndexDomainError::into_internal_error)?;
2685 }
2686 }
2687 Ok(())
2688}
2689
2690fn require_empty_physical_field_removals(
2693 authorities: &[StoreApplicationAuthority],
2694 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2695 candidates: &[CandidateSchemaRevision],
2696) -> Result<(), InternalError> {
2697 for candidate in candidates {
2698 let (position, authority) = authorities
2699 .iter()
2700 .enumerate()
2701 .find(|(_, authority)| authority.path == candidate.store_path())
2702 .ok_or_else(InternalError::store_invariant)?;
2703 let current = current_bundles
2704 .get(position)
2705 .and_then(Option::as_ref)
2706 .ok_or_else(InternalError::store_invariant)?;
2707 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2708 let Some(before) = current.entity_snapshots().get(entity_tag) else {
2709 continue;
2710 };
2711 if before.row_layout() == after.row_layout() {
2712 continue;
2713 }
2714 if before.fields().len() != after.fields().len().saturating_add(1) {
2715 return Err(InternalError::store_unsupported());
2716 }
2717 require_exact_empty_entity(authority.handle, *entity_tag)?;
2718 }
2719 }
2720 Ok(())
2721}
2722
2723fn require_exact_empty_entity(
2725 store: StoreHandle,
2726 entity_tag: EntityTag,
2727) -> Result<(), InternalError> {
2728 if store.with_data(|data| entity_has_rows(data, entity_tag))? {
2729 return Err(InternalError::store_unsupported());
2730 }
2731
2732 Ok(())
2733}
2734
2735fn entity_has_rows(data: &DataStore, entity: EntityTag) -> Result<bool, InternalError> {
2738 let range = RawDataStoreKeyRange::entity_prefix(entity);
2739 let lower = Bound::Included(RawDataStoreKey::store_range_lower_key(&range));
2740 let upper = range
2741 .upper_exclusive()
2742 .map(RawDataStoreKey::from_store_range_bound)
2743 .map_or(Bound::Unbounded, Bound::Excluded);
2744 let mut has_rows = false;
2745 data.visit_key_range((lower, upper), |_| {
2746 has_rows = true;
2747 Ok::<_, InternalError>(StoreVisit::Stop)
2748 })?;
2749
2750 Ok(has_rows)
2751}
2752
2753fn generated_row_local_constraint_proofs(
2754 authorities: &[StoreApplicationAuthority],
2755 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2756 candidates: &[CandidateSchemaRevision],
2757) -> Result<Vec<DirectGeneratedRowLocalProof>, InternalError> {
2758 let mut proofs = Vec::new();
2759 for (candidate_index, candidate) in candidates.iter().enumerate() {
2760 let (position, authority) = authorities
2761 .iter()
2762 .enumerate()
2763 .find(|(_, authority)| authority.path == candidate.store_path())
2764 .ok_or_else(InternalError::store_invariant)?;
2765 let current = current_bundles
2766 .get(position)
2767 .and_then(Option::as_ref)
2768 .ok_or_else(InternalError::store_invariant)?;
2769 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2770 let Some(before) = current.entity_snapshots().get(entity_tag) else {
2771 continue;
2772 };
2773 let added = added_generated_row_local_activations(before, after);
2774 if added.is_empty() {
2775 continue;
2776 }
2777 let has_rows = authority
2778 .handle
2779 .with_data(|data| entity_has_rows(data, *entity_tag))?;
2780 for constraint_id in added {
2781 proofs.push(DirectGeneratedRowLocalProof {
2782 candidate_index,
2783 store: authority.handle,
2784 store_path: authority.path,
2785 entity_tag: *entity_tag,
2786 entity_path: after.entity_path().to_string(),
2787 constraint_id,
2788 has_rows,
2789 });
2790 }
2791 }
2792 }
2793 Ok(proofs)
2794}
2795
2796fn added_generated_row_local_activations(
2797 before: &crate::db::schema::PersistedSchemaSnapshot,
2798 after: &crate::db::schema::PersistedSchemaSnapshot,
2799) -> Vec<ConstraintId> {
2800 after
2801 .constraint_activations()
2802 .iter()
2803 .filter(|candidate| {
2804 candidate.origin() == ConstraintOrigin::Generated
2805 && matches!(
2806 candidate.kind(),
2807 ConstraintActivationKind::Check { .. }
2808 | ConstraintActivationKind::TargetedRule { .. }
2809 )
2810 && !before
2811 .constraint_activations()
2812 .iter()
2813 .any(|accepted| accepted.id() == candidate.id())
2814 })
2815 .map(crate::db::schema::ConstraintActivationSnapshot::id)
2816 .collect()
2817}
2818
2819fn final_candidates_for_pending_row_local_constraint(
2820 candidates: &[CandidateSchemaRevision],
2821 pending: &PendingGeneratedRowLocalConstraint,
2822) -> Result<Vec<CandidateSchemaRevision>, InternalError> {
2823 let mut final_candidates = candidates.to_vec();
2824 let candidate = final_candidates
2825 .get(pending.proof.candidate_index)
2826 .cloned()
2827 .ok_or_else(InternalError::store_invariant)?;
2828 if candidate.store_path() != pending.proof.store_path {
2829 return Err(InternalError::store_invariant());
2830 }
2831 let mut snapshots = candidate.bundle().entity_snapshots().clone();
2832 let snapshot = snapshots
2833 .get(&pending.proof.entity_tag)
2834 .cloned()
2835 .ok_or_else(InternalError::store_invariant)?;
2836 let catalog = snapshot
2837 .constraint_catalog()
2838 .clone()
2839 .with_directly_validated_activation(pending.proof.constraint_id)
2840 .map_err(|_| InternalError::store_invariant())?;
2841 snapshots.insert(
2842 pending.proof.entity_tag,
2843 snapshot.with_constraint_catalog(catalog),
2844 );
2845 let final_revision = candidate
2846 .revision()
2847 .checked_next()
2848 .and_then(AcceptedSchemaRevision::checked_next)
2849 .ok_or_else(InternalError::store_unsupported)?;
2850 let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
2851 final_revision,
2852 candidate.bundle().store_path(),
2853 candidate.bundle().enum_catalog().clone(),
2854 candidate.bundle().composite_catalog().clone(),
2855 candidate.bundle().source_bindings().clone(),
2856 snapshots,
2857 )?;
2858 final_candidates[pending.proof.candidate_index] = CandidateSchemaRevision::new(bundle)?;
2859 Ok(final_candidates)
2860}
2861
2862fn schema_change_progress_status(
2863 snapshot: &crate::db::schema::PersistedSchemaSnapshot,
2864 entity_tag: EntityTag,
2865 constraint_id: ConstraintId,
2866 progress: ConstraintValidationProgress,
2867) -> Result<SchemaChangeProgressStatus, InternalError> {
2868 match progress {
2869 ConstraintValidationProgress::Started => Ok(SchemaChangeProgressStatus::Started),
2870 ConstraintValidationProgress::Advanced {
2871 phase,
2872 rows_scanned,
2873 } => Ok(SchemaChangeProgressStatus::Advanced {
2874 phase: schema_change_validation_phase(phase),
2875 rows_scanned,
2876 }),
2877 ConstraintValidationProgress::Findings {
2878 receipt,
2879 phase,
2880 rows_scanned,
2881 } => {
2882 let activation = snapshot
2883 .constraint_catalog()
2884 .activation(constraint_id)
2885 .ok_or_else(InternalError::store_corruption)?;
2886 let fingerprint =
2887 crate::db::schema::accepted_schema_cache_fingerprint_for_persisted_snapshot(
2888 snapshot,
2889 )?;
2890 let findings = receipt
2891 .findings()
2892 .iter()
2893 .map(|finding| {
2894 constraint_validation_finding_output(
2895 fingerprint,
2896 entity_tag,
2897 activation,
2898 finding,
2899 )
2900 })
2901 .collect::<Result<Vec<_>, InternalError>>()?;
2902 Ok(SchemaChangeProgressStatus::Findings {
2903 phase: schema_change_validation_phase(phase),
2904 rows_scanned,
2905 page_sequence: receipt.page_sequence(),
2906 findings,
2907 })
2908 }
2909 ConstraintValidationProgress::Restarted { rows_scanned } => {
2910 Ok(SchemaChangeProgressStatus::Restarted { rows_scanned })
2911 }
2912 ConstraintValidationProgress::Promoted { .. } => Ok(SchemaChangeProgressStatus::Applied),
2913 }
2914}
2915
2916const fn schema_change_validation_phase(
2917 phase: ConstraintValidationPhase,
2918) -> SchemaChangeValidationPhase {
2919 match phase {
2920 ConstraintValidationPhase::Forward => SchemaChangeValidationPhase::Forward,
2921 ConstraintValidationPhase::Verify => SchemaChangeValidationPhase::Verify,
2922 }
2923}
2924
2925fn finalize_schema_application<C: CanisterKind>(
2926 db: &Db<C>,
2927 record: &SchemaApplicationRecord,
2928 candidate_head: &ExpectedAcceptedHead,
2929 status: SchemaChangeProgressStatus,
2930) -> Result<SchemaChangeProgress, InternalError> {
2931 if schema_application_target(db)?.accepted_head() != candidate_head {
2932 return Err(InternalError::schema_application_conflict());
2933 }
2934 let receipt = SchemaChangeReceipt::new(
2935 record.receipt().database_identity(),
2936 record.receipt().submission_key().clone(),
2937 record.receipt().proposal_digest(),
2938 record.receipt().prior_head().clone(),
2939 SchemaChangeOutcome::Applied {
2940 accepted_head: candidate_head.clone(),
2941 },
2942 )?;
2943 let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
2944 let operation = SchemaApplicationRecordOp::replace(record, &terminal)?;
2945 publish_accepted_schema_candidates_with_application_record(Vec::new(), operation)?;
2946 Ok(SchemaChangeProgress::new(receipt, status))
2947}
2948
2949fn application_publications<'a>(
2950 authorities: &[StoreApplicationAuthority],
2951 current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
2952 candidates: &'a [crate::db::schema::CandidateSchemaRevision],
2953) -> Result<Vec<AcceptedSchemaPublication<'a>>, InternalError> {
2954 candidates
2955 .iter()
2956 .map(|candidate| {
2957 let (position, authority) = authorities
2958 .iter()
2959 .enumerate()
2960 .find(|(_, authority)| authority.path == candidate.store_path())
2961 .ok_or_else(InternalError::store_invariant)?;
2962 let expected_revision = current_bundles[position].as_ref().map_or(
2963 AcceptedSchemaRevision::NONE,
2964 crate::db::schema::AcceptedSchemaRevisionBundle::revision,
2965 );
2966 Ok(AcceptedSchemaPublication::new(
2967 authority.path,
2968 authority.handle,
2969 expected_revision,
2970 candidate,
2971 ))
2972 })
2973 .collect()
2974}
2975
2976fn application_authorities<C: CanisterKind>(db: &Db<C>) -> Vec<StoreApplicationAuthority> {
2977 let mut authorities = db.with_store_registry(|registry| {
2978 registry
2979 .iter()
2980 .map(|(path, handle)| StoreApplicationAuthority { path, handle })
2981 .collect::<Vec<_>>()
2982 });
2983 icydb_schema::compact_sort_unstable_by(&mut authorities, |left, right| {
2984 left.path.cmp(right.path)
2985 });
2986 authorities
2987}
2988
2989fn accepted_head_after_candidates(
2990 authorities: &[StoreApplicationAuthority],
2991 candidates: &[crate::db::schema::CandidateSchemaRevision],
2992) -> Result<ExpectedAcceptedHead, InternalError> {
2993 let heads = authorities
2994 .iter()
2995 .map(|authority| {
2996 let candidate = candidates
2997 .iter()
2998 .find(|candidate| candidate.store_path() == authority.path);
2999 let head = match candidate {
3000 Some(candidate) => Some(AcceptedStoreHead {
3001 revision: candidate.revision().get(),
3002 fingerprint: candidate.root().fingerprint().as_bytes(),
3003 }),
3004 None => authority
3005 .handle
3006 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
3007 .map(|selection| AcceptedStoreHead {
3008 revision: selection.root().revision().get(),
3009 fingerprint: selection.root().fingerprint().as_bytes(),
3010 }),
3011 };
3012 Ok((authority.path, head))
3013 })
3014 .collect::<Result<Vec<_>, InternalError>>()?;
3015 Ok(derive_accepted_head(heads.as_slice()))
3016}
3017
3018fn derive_database_identity(
3019 incarnation: [u8; 16],
3020 stores: &[StoreApplicationAuthority],
3021) -> TargetDatabaseIdentity {
3022 let mut hasher = new_hash_sha256_prefixed(DATABASE_TARGET_FINGERPRINT_PROFILE);
3023 hasher.update(incarnation);
3024 write_hash_len_u32(&mut hasher, stores.len());
3025 for store in stores {
3026 write_store_authority(&mut hasher, store);
3027 }
3028 TargetDatabaseIdentity::from_bytes(finalize_hash_sha256(hasher))
3029}
3030
3031fn derive_store_identity(
3032 database_identity: TargetDatabaseIdentity,
3033 store: &StoreApplicationAuthority,
3034) -> TargetStoreIdentity {
3035 let mut hasher = new_hash_sha256_prefixed(STORE_TARGET_FINGERPRINT_PROFILE);
3036 hasher.update(database_identity.to_bytes());
3037 write_store_authority(&mut hasher, store);
3038 TargetStoreIdentity::from_bytes(finalize_hash_sha256(hasher))
3039}
3040
3041fn derive_accepted_head(stores: &[(&str, Option<AcceptedStoreHead>)]) -> ExpectedAcceptedHead {
3042 let Some(revision) = stores
3043 .iter()
3044 .filter_map(|(_, head)| head.map(|head| head.revision))
3045 .max()
3046 else {
3047 return ExpectedAcceptedHead::Empty;
3048 };
3049
3050 let mut hasher = new_hash_sha256_prefixed(ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE);
3051 write_hash_len_u32(&mut hasher, stores.len());
3052 for (path, head) in stores {
3053 write_hash_str_u32(&mut hasher, path);
3054 match head {
3055 None => write_hash_tag_u8(&mut hasher, 0),
3056 Some(head) => {
3057 write_hash_tag_u8(&mut hasher, 1);
3058 write_hash_u64(&mut hasher, head.revision);
3059 hasher.update(head.fingerprint);
3060 }
3061 }
3062 }
3063
3064 ExpectedAcceptedHead::Exact {
3065 revision,
3066 fingerprint: ExpectedSchemaFingerprint::from_bytes(finalize_hash_sha256(hasher)),
3067 }
3068}
3069
3070pub(in crate::db) fn generated_schema_reconciled(
3071 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
3072 incarnation: DatabaseIncarnationId,
3073 submission_key: &str,
3074) -> Result<(bool, ExpectedAcceptedHead), InternalError> {
3075 let submission_key = SchemaSubmissionKey::try_new(submission_key.to_string())
3076 .map_err(|_| InternalError::store_invariant())?;
3077 let (database_identity, accepted_head) = generated_schema_authority(registry, incarnation)?;
3078 let reconciled = generated_submission_is_reconciled(database_identity, &submission_key)?;
3079 Ok((reconciled, accepted_head))
3080}
3081
3082pub(in crate::db) fn generated_schema_is_reconciled(
3083 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
3084 incarnation: DatabaseIncarnationId,
3085 submission_key: &str,
3086) -> Result<bool, InternalError> {
3087 let submission_key = SchemaSubmissionKey::try_new(submission_key.to_string())
3088 .map_err(|_| InternalError::store_invariant())?;
3089 let database_identity = generated_database_identity(registry, incarnation);
3090 generated_submission_is_reconciled(database_identity, &submission_key)
3091}
3092
3093fn generated_submission_is_reconciled(
3094 database_identity: TargetDatabaseIdentity,
3095 submission_key: &SchemaSubmissionKey,
3096) -> Result<bool, InternalError> {
3097 Ok(
3098 load_schema_application_record_read_only(database_identity, submission_key)?.is_some_and(
3099 |record| match record.receipt().outcome() {
3100 SchemaChangeOutcome::NoOp { .. } | SchemaChangeOutcome::Applied { .. } => true,
3108 SchemaChangeOutcome::Pending { .. } | SchemaChangeOutcome::Aborted { .. } => false,
3109 },
3110 ),
3111 )
3112}
3113
3114pub(in crate::db) fn generated_schema_authority(
3115 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
3116 incarnation: DatabaseIncarnationId,
3117) -> Result<(TargetDatabaseIdentity, ExpectedAcceptedHead), InternalError> {
3118 let database_identity = generated_database_identity(registry, incarnation);
3119 let mut stores = store_application_authorities(registry);
3120 icydb_schema::compact_sort_unstable_by(&mut stores, |left, right| left.path.cmp(right.path));
3121 let heads = stores
3122 .iter()
3123 .map(|store| {
3124 let head = store
3125 .handle
3126 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
3127 .map(|selection| AcceptedStoreHead {
3128 revision: selection.root().revision().get(),
3129 fingerprint: selection.root().fingerprint().as_bytes(),
3130 });
3131 Ok((store.path, head))
3132 })
3133 .collect::<Result<Vec<_>, InternalError>>()?;
3134 let accepted_head = derive_accepted_head(heads.as_slice());
3135 Ok((database_identity, accepted_head))
3136}
3137
3138fn generated_database_identity(
3139 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
3140 incarnation: DatabaseIncarnationId,
3141) -> TargetDatabaseIdentity {
3142 let registry_key = std::ptr::from_ref(registry).cast::<()>() as usize;
3143 let cached = GENERATED_DATABASE_IDENTITY
3144 .with(Cell::get)
3145 .and_then(|entry| {
3146 (entry.registry == registry_key && entry.incarnation == incarnation)
3147 .then_some(entry.identity)
3148 });
3149 if let Some(identity) = cached {
3150 return identity;
3151 }
3152
3153 let mut stores = store_application_authorities(registry);
3154 icydb_schema::compact_sort_unstable_by(&mut stores, |left, right| left.path.cmp(right.path));
3155 let identity = derive_database_identity(incarnation.to_bytes(), stores.as_slice());
3156 GENERATED_DATABASE_IDENTITY.set(Some(GeneratedDatabaseIdentityCacheEntry {
3157 registry: registry_key,
3158 incarnation,
3159 identity,
3160 }));
3161 identity
3162}
3163
3164fn store_application_authorities(
3165 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
3166) -> Vec<StoreApplicationAuthority> {
3167 registry.with(|registry| {
3168 registry
3169 .iter()
3170 .map(|(path, handle)| StoreApplicationAuthority { path, handle })
3171 .collect()
3172 })
3173}
3174
3175fn write_store_authority(hasher: &mut sha2::Sha256, store: &StoreApplicationAuthority) {
3176 write_hash_str_u32(hasher, store.path);
3177 write_storage_capabilities(hasher, store.handle);
3178 for allocation in [
3179 store.handle.data_allocation(),
3180 store.handle.index_allocation(),
3181 store.handle.schema_allocation(),
3182 store.handle.journal_allocation(),
3183 ] {
3184 write_allocation_identity(hasher, allocation);
3185 }
3186}
3187
3188fn write_storage_capabilities(hasher: &mut sha2::Sha256, store: StoreHandle) {
3189 let capabilities = store.storage_capabilities();
3190 write_hash_tag_u8(
3191 hasher,
3192 match capabilities.storage_mode() {
3193 StoreRuntimeStorageMode::Heap => 0,
3194 StoreRuntimeStorageMode::Journaled => 1,
3195 },
3196 );
3197 write_hash_tag_u8(
3198 hasher,
3199 match capabilities.allocation_identity() {
3200 StoreAllocationIdentityCapability::Present => 0,
3201 StoreAllocationIdentityCapability::Absent => 1,
3202 },
3203 );
3204 write_hash_tag_u8(
3205 hasher,
3206 match capabilities.durability() {
3207 StoreDurability::Durable => 0,
3208 StoreDurability::Volatile => 1,
3209 },
3210 );
3211 write_hash_tag_u8(
3212 hasher,
3213 match capabilities.recovery() {
3214 StoreRecoveryCapability::StableBasePlusJournalReplay => 0,
3215 StoreRecoveryCapability::None => 1,
3216 },
3217 );
3218 write_hash_tag_u8(
3219 hasher,
3220 match capabilities.commit_participation() {
3221 StoreCommitParticipation::Durable => 0,
3222 StoreCommitParticipation::LiveOnly => 1,
3223 },
3224 );
3225 write_hash_tag_u8(
3226 hasher,
3227 match capabilities.schema_metadata() {
3228 StoreSchemaMetadataCapability::LiveRebuiltMetadata => 0,
3229 StoreSchemaMetadataCapability::CanonicalStableHistoryPlusJournalTail => 1,
3230 },
3231 );
3232 write_hash_tag_u8(
3233 hasher,
3234 match capabilities.relation_source() {
3235 StoreRelationSourceCapability::DurableSource => 0,
3236 StoreRelationSourceCapability::LiveSource => 1,
3237 },
3238 );
3239 write_hash_tag_u8(
3240 hasher,
3241 match capabilities.relation_target() {
3242 StoreRelationTargetCapability::DurableTarget => 0,
3243 StoreRelationTargetCapability::VolatileTarget => 1,
3244 },
3245 );
3246}
3247
3248fn write_allocation_identity(
3249 hasher: &mut sha2::Sha256,
3250 allocation: Option<StoreAllocationIdentity>,
3251) {
3252 match allocation {
3253 None => write_hash_tag_u8(hasher, 0),
3254 Some(allocation) => {
3255 write_hash_tag_u8(hasher, 1);
3256 write_hash_tag_u8(hasher, allocation.memory_id());
3257 write_hash_str_u32(hasher, allocation.stable_key());
3258 }
3259 }
3260}
3261
3262#[cfg(test)]
3263mod tests {
3264 #[cfg(feature = "sql")]
3265 mod sql_activations;
3266
3267 mod row_presence;
3268
3269 mod entity_creation;
3270
3271 mod collection_relations;
3272
3273 #[cfg(feature = "migration")]
3274 mod identity_field_removal;
3275
3276 #[cfg(feature = "migration")]
3277 mod populated_field_removal;
3278
3279 #[cfg(feature = "migration")]
3280 mod entity_rename;
3281
3282 #[cfg(all(feature = "migration", feature = "sql"))]
3283 mod migration_staging;
3284
3285 #[cfg(feature = "migration")]
3286 mod nested_migration;
3287
3288 #[cfg(feature = "migration")]
3289 mod migration_page_limits;
3290
3291 #[cfg(feature = "migration")]
3292 mod record_member_rename;
3293
3294 use super::{
3295 AcceptedSchemaPublication, AcceptedStoreHead, DirectGeneratedRowLocalProof,
3296 PendingGeneratedRowLocalConstraint, abort_schema_application,
3297 aborted_generated_row_local_candidate, accepted_head_after_candidates,
3298 application_authorities, apply_schema, continue_schema_application, derive_accepted_head,
3299 derive_schema_change_job_id, final_candidates_for_pending_row_local_constraint,
3300 generated_database_identity, include_identity_state_count, lower_existing_schema_proposal,
3301 lower_initial_schema_proposal, publish_accepted_schema_candidates_with_application_record,
3302 schema_application_target,
3303 };
3304 use crate::{
3305 db::{
3306 DatabaseStartupState, Db, GeneratedStartupDriverStep,
3307 commit::{
3308 RecoveryProgress, continue_recovery, database_incarnation_id,
3309 forget_recovered_domain_for_tests,
3310 },
3311 data::DataStore,
3312 drive_generated_startup_recovery_page,
3313 index::IndexStore,
3314 journal::JournalTailStore,
3315 observe_generated_startup_state,
3316 registry::{
3317 StoreAllocationIdentities, StoreAllocationIdentity, StoreHandle, StoreRegistry,
3318 StoreRuntimeStorageCapabilities,
3319 },
3320 schema::{
3321 AcceptedConstraintKind, AcceptedRuleOperation, AcceptedSchemaRevisionBundle,
3322 CandidateSchemaRevision, ConstraintOrigin, ConstraintValidationJob,
3323 ExistingProposalStore, ProposalStoreTarget, SchemaApplicationRecord,
3324 SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob,
3325 SchemaChangeOutcome, SchemaChangeProgressStatus, SchemaStore,
3326 cardinality_build::{
3327 CardinalityBuildAuthority, CardinalityGenerationPageOutcome,
3328 drive_cardinality_generation_page,
3329 },
3330 },
3331 },
3332 error::{ErrorClass, ErrorOrigin},
3333 testing::test_memory,
3334 traits::{CanisterKind, Path},
3335 };
3336 use ic_memory::RuntimeMemory;
3337 use ic_memory::ic_stable_structures::DefaultMemoryImpl;
3338 use icydb_schema::{
3339 ConstraintFragment, ConstraintSourceKey, DeclaredEntityVersion, EntityFragment,
3340 EntitySourceKey, EntityStoreAssignment, ExpectedAcceptedHead, ExpectedSchemaFingerprint,
3341 FieldFragment, FieldInsertPolicy, FieldSourceKey, FieldType, NamedTypeFragment,
3342 RuleSourceKey, ScalarLiteral, ScalarType, SchemaCapability, SchemaFragment, SchemaName,
3343 SchemaProposal, SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction,
3344 SourceRuleOperation, TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment,
3345 TypeSourceKey,
3346 };
3347 use std::cell::RefCell;
3348
3349 fn drive_startup_recovery_to_completion<C: CanisterKind>(db: &Db<C>) {
3350 for _ in 0..1_024 {
3351 match continue_recovery(db).expect("test startup recovery page should succeed") {
3352 RecoveryProgress::Complete => return,
3353 RecoveryProgress::Pending => {}
3354 }
3355 }
3356 panic!("test startup recovery should complete within 1,024 bounded pages");
3357 }
3358
3359 fn drive_cardinality_to_ready(store: StoreHandle) {
3360 let journal = store
3361 .journal_tail_store()
3362 .expect("cardinality fixture store should be journaled");
3363 for _ in 0..8 {
3364 let outcome = store
3365 .with_data(|data| {
3366 store.with_index(|index| {
3367 store.with_schema_mut(|schema| {
3368 drive_cardinality_generation_page(data, index, schema, |schema| {
3369 CardinalityBuildAuthority::derive(
3370 schema,
3371 database_incarnation_id()?,
3372 store.allocation_identities(),
3373 journal.with_borrow(JournalTailStore::fold_watermark)?,
3374 )
3375 })
3376 })
3377 })
3378 })
3379 .expect("bounded cardinality generation should advance");
3380 if outcome == CardinalityGenerationPageOutcome::Quiescent {
3381 return;
3382 }
3383 }
3384 panic!("cardinality generation should become Ready within eight bounded pages");
3385 }
3386
3387 #[cfg(feature = "migration")]
3388 use crate::db::schema::SchemaChangeReceipt;
3389 use crate::{
3390 db::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell},
3391 value::InputValue,
3392 };
3393 #[cfg(feature = "migration")]
3394 use icydb_schema::{
3395 EntityMigration, IndexFragment, IndexKeyFragment, RelationDeleteAction, RelationFragment,
3396 SchemaMigrationPlan, SchemaMigrationTransform, SchemaProposalDigest,
3397 };
3398
3399 fn version_one() -> DeclaredEntityVersion {
3400 DeclaredEntityVersion::try_new(1).expect("fixture version should admit")
3401 }
3402
3403 const ABORT_STORE_PATH: &str = "schema_application_tests::AbortStore";
3404 const EVOLUTION_STORE_PATH: &str = "schema_application_tests::EvolutionStore";
3405 #[cfg(feature = "migration")]
3406 const MIGRATION_STORE_PATH: &str = "schema_application_tests::MigrationStore";
3407 #[cfg(feature = "migration")]
3408 const MIGRATION_EXECUTION_STORE_PATH: &str =
3409 "schema_application_tests::MigrationExecutionStore";
3410 #[cfg(feature = "migration")]
3411 const MIGRATION_FINDING_STORE_PATH: &str = "schema_application_tests::MigrationFindingStore";
3412
3413 #[test]
3414 fn database_identity_state_capacity_combines_store_inventories_exactly() {
3415 let below = include_identity_state_count(0, 65_535)
3416 .expect("the first store inventory should remain below the database cap");
3417 let exact = include_identity_state_count(below, 1)
3418 .expect("the combined database boundary should admit");
3419 assert_eq!(exact, 65_536);
3420
3421 let error = include_identity_state_count(exact, 1)
3422 .expect_err("the next owner in another store must reject");
3423 assert_eq!(error.class(), ErrorClass::Unsupported);
3424 assert_eq!(error.origin(), ErrorOrigin::Identity);
3425 }
3426
3427 #[test]
3428 fn generated_database_identity_cache_is_bound_to_the_incarnation() {
3429 let first_incarnation = crate::db::DatabaseIncarnationId::for_tests(0x41);
3430 let second_incarnation = crate::db::DatabaseIncarnationId::for_tests(0x42);
3431 let first = generated_database_identity(&ABORT_REGISTRY, first_incarnation);
3432 assert_eq!(
3433 generated_database_identity(&ABORT_REGISTRY, first_incarnation),
3434 first,
3435 );
3436 let second = generated_database_identity(&ABORT_REGISTRY, second_incarnation);
3437 assert_ne!(second, first);
3438 assert_eq!(
3439 generated_database_identity(&ABORT_REGISTRY, first_incarnation),
3440 first,
3441 );
3442 }
3443
3444 thread_local! {
3445 static ABORT_DATA_MEMORY: RuntimeMemory<DefaultMemoryImpl> = test_memory(180);
3446 static ABORT_INDEX_MEMORY: RuntimeMemory<DefaultMemoryImpl> = test_memory(181);
3447 static ABORT_SCHEMA_MEMORY: RuntimeMemory<DefaultMemoryImpl> = test_memory(182);
3448 static ABORT_JOURNAL_MEMORY: RuntimeMemory<DefaultMemoryImpl> = test_memory(183);
3449 static ABORT_DATA: RefCell<DataStore> =
3450 ABORT_DATA_MEMORY.with(|memory| {
3451 RefCell::new(DataStore::init_journaled(memory.clone()))
3452 });
3453 static ABORT_INDEX: RefCell<IndexStore> =
3454 ABORT_INDEX_MEMORY.with(|memory| {
3455 RefCell::new(IndexStore::init_journaled(memory.clone()))
3456 });
3457 static ABORT_SCHEMA: RefCell<SchemaStore> =
3458 ABORT_SCHEMA_MEMORY.with(|memory| {
3459 RefCell::new(SchemaStore::init_journaled(memory.clone()))
3460 });
3461 static ABORT_JOURNAL: RefCell<JournalTailStore> =
3462 ABORT_JOURNAL_MEMORY.with(|memory| {
3463 RefCell::new(JournalTailStore::init(memory.clone()))
3464 });
3465 static ABORT_REGISTRY: StoreRegistry = {
3466 let mut registry = StoreRegistry::new();
3467 registry.register_journaled_store(
3468 ABORT_STORE_PATH,
3469 &ABORT_DATA,
3470 &ABORT_INDEX,
3471 &ABORT_SCHEMA,
3472 &ABORT_JOURNAL,
3473 StoreAllocationIdentities::new_journaled(
3474 StoreAllocationIdentity::new(180, "icydb.test.application_abort.data.v1"),
3475 StoreAllocationIdentity::new(181, "icydb.test.application_abort.index.v1"),
3476 StoreAllocationIdentity::new(182, "icydb.test.application_abort.schema.v1"),
3477 StoreAllocationIdentity::new(183, "icydb.test.application_abort.journal.v1"),
3478 ),
3479 StoreRuntimeStorageCapabilities::journaled(),
3480 ).expect("abort journaled store should register");
3481 registry
3482 };
3483 }
3484
3485 #[cfg(feature = "migration")]
3486 thread_local! {
3487 static MIGRATION_EXECUTION_DATA: RefCell<DataStore> =
3488 RefCell::new(DataStore::init_journaled(test_memory(210)));
3489 static MIGRATION_EXECUTION_INDEX: RefCell<IndexStore> =
3490 RefCell::new(IndexStore::init_journaled(test_memory(211)));
3491 static MIGRATION_EXECUTION_SCHEMA: RefCell<SchemaStore> =
3492 RefCell::new(SchemaStore::init_journaled(test_memory(212)));
3493 static MIGRATION_EXECUTION_JOURNAL: RefCell<JournalTailStore> =
3494 RefCell::new(JournalTailStore::init(test_memory(213)));
3495 static MIGRATION_EXECUTION_REGISTRY: StoreRegistry = {
3496 let mut registry = StoreRegistry::new();
3497 registry.register_journaled_store(
3498 MIGRATION_EXECUTION_STORE_PATH,
3499 &MIGRATION_EXECUTION_DATA,
3500 &MIGRATION_EXECUTION_INDEX,
3501 &MIGRATION_EXECUTION_SCHEMA,
3502 &MIGRATION_EXECUTION_JOURNAL,
3503 StoreAllocationIdentities::new_journaled(
3504 StoreAllocationIdentity::new(210, "icydb.test.migration_execution.data.v1"),
3505 StoreAllocationIdentity::new(211, "icydb.test.migration_execution.index.v1"),
3506 StoreAllocationIdentity::new(212, "icydb.test.migration_execution.schema.v1"),
3507 StoreAllocationIdentity::new(213, "icydb.test.migration_execution.journal.v1"),
3508 ),
3509 StoreRuntimeStorageCapabilities::journaled(),
3510 ).expect("migration execution store should register");
3511 registry
3512 };
3513 }
3514
3515 #[cfg(feature = "migration")]
3516 thread_local! {
3517 static MIGRATION_DATA: RefCell<DataStore> =
3518 RefCell::new(DataStore::init_journaled(test_memory(200)));
3519 static MIGRATION_INDEX: RefCell<IndexStore> =
3520 RefCell::new(IndexStore::init_journaled(test_memory(201)));
3521 static MIGRATION_SCHEMA: RefCell<SchemaStore> =
3522 RefCell::new(SchemaStore::init_journaled(test_memory(202)));
3523 static MIGRATION_JOURNAL: RefCell<JournalTailStore> =
3524 RefCell::new(JournalTailStore::init(test_memory(203)));
3525 static MIGRATION_REGISTRY: StoreRegistry = {
3526 let mut registry = StoreRegistry::new();
3527 registry.register_journaled_store(
3528 MIGRATION_STORE_PATH,
3529 &MIGRATION_DATA,
3530 &MIGRATION_INDEX,
3531 &MIGRATION_SCHEMA,
3532 &MIGRATION_JOURNAL,
3533 StoreAllocationIdentities::new_journaled(
3534 StoreAllocationIdentity::new(200, "icydb.test.migration_validation.data.v1"),
3535 StoreAllocationIdentity::new(201, "icydb.test.migration_validation.index.v1"),
3536 StoreAllocationIdentity::new(202, "icydb.test.migration_validation.schema.v1"),
3537 StoreAllocationIdentity::new(203, "icydb.test.migration_validation.journal.v1"),
3538 ),
3539 StoreRuntimeStorageCapabilities::journaled(),
3540 ).expect("migration validation store should register");
3541 registry
3542 };
3543 }
3544
3545 #[cfg(feature = "migration")]
3546 thread_local! {
3547 static MIGRATION_FINDING_DATA: RefCell<DataStore> =
3548 RefCell::new(DataStore::init_journaled(test_memory(206)));
3549 static MIGRATION_FINDING_INDEX: RefCell<IndexStore> =
3550 RefCell::new(IndexStore::init_journaled(test_memory(207)));
3551 static MIGRATION_FINDING_SCHEMA: RefCell<SchemaStore> =
3552 RefCell::new(SchemaStore::init_journaled(test_memory(208)));
3553 static MIGRATION_FINDING_JOURNAL: RefCell<JournalTailStore> =
3554 RefCell::new(JournalTailStore::init(test_memory(209)));
3555 static MIGRATION_FINDING_REGISTRY: StoreRegistry = {
3556 let mut registry = StoreRegistry::new();
3557 registry.register_journaled_store(
3558 MIGRATION_FINDING_STORE_PATH,
3559 &MIGRATION_FINDING_DATA,
3560 &MIGRATION_FINDING_INDEX,
3561 &MIGRATION_FINDING_SCHEMA,
3562 &MIGRATION_FINDING_JOURNAL,
3563 StoreAllocationIdentities::new_journaled(
3564 StoreAllocationIdentity::new(206, "icydb.test.migration_finding.data.v1"),
3565 StoreAllocationIdentity::new(207, "icydb.test.migration_finding.index.v1"),
3566 StoreAllocationIdentity::new(208, "icydb.test.migration_finding.schema.v1"),
3567 StoreAllocationIdentity::new(209, "icydb.test.migration_finding.journal.v1"),
3568 ),
3569 StoreRuntimeStorageCapabilities::journaled(),
3570 ).expect("migration finding store should register");
3571 registry
3572 };
3573 }
3574
3575 thread_local! {
3576 static EVOLUTION_DATA: RefCell<DataStore> =
3577 RefCell::new(DataStore::init_journaled(test_memory(192)));
3578 static EVOLUTION_INDEX: RefCell<IndexStore> =
3579 RefCell::new(IndexStore::init_journaled(test_memory(193)));
3580 static EVOLUTION_SCHEMA: RefCell<SchemaStore> =
3581 RefCell::new(SchemaStore::init_journaled(test_memory(194)));
3582 static EVOLUTION_JOURNAL: RefCell<JournalTailStore> =
3583 RefCell::new(JournalTailStore::init(test_memory(195)));
3584 static EVOLUTION_REGISTRY: StoreRegistry = {
3585 let mut registry = StoreRegistry::new();
3586 registry.register_journaled_store(
3587 EVOLUTION_STORE_PATH,
3588 &EVOLUTION_DATA,
3589 &EVOLUTION_INDEX,
3590 &EVOLUTION_SCHEMA,
3591 &EVOLUTION_JOURNAL,
3592 StoreAllocationIdentities::new_journaled(
3593 StoreAllocationIdentity::new(192, "icydb.test.rule_evolution.data.v1"),
3594 StoreAllocationIdentity::new(193, "icydb.test.rule_evolution.index.v1"),
3595 StoreAllocationIdentity::new(194, "icydb.test.rule_evolution.schema.v1"),
3596 StoreAllocationIdentity::new(195, "icydb.test.rule_evolution.journal.v1"),
3597 ),
3598 StoreRuntimeStorageCapabilities::journaled(),
3599 ).expect("rule-evolution journaled store should register");
3600 registry
3601 };
3602 }
3603
3604 struct AbortCanister;
3605
3606 impl Path for AbortCanister {
3607 const PATH: &'static str = "schema_application_tests::AbortCanister";
3608 }
3609
3610 impl CanisterKind for AbortCanister {
3611 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3612 Ok(184)
3613 }
3614 const COMMIT_STABLE_KEY: &'static str = "icydb.test.application_abort.commit.v1";
3615 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3616 Ok(186)
3617 }
3618 const STARTUP_STABLE_KEY: &'static str = "icydb.test.application_abort.startup.control.v1";
3619 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3620 Ok(185)
3621 }
3622 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3623 "icydb.test.application_abort.integrity.v1";
3624 }
3625
3626 struct EvolutionCanister;
3627
3628 impl Path for EvolutionCanister {
3629 const PATH: &'static str = "schema_application_tests::EvolutionCanister";
3630 }
3631
3632 impl CanisterKind for EvolutionCanister {
3633 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3634 Ok(196)
3635 }
3636 const COMMIT_STABLE_KEY: &'static str = "icydb.test.rule_evolution.commit.v1";
3637 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3638 Ok(198)
3639 }
3640 const STARTUP_STABLE_KEY: &'static str = "icydb.test.rule_evolution.startup.control.v1";
3641 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3642 Ok(197)
3643 }
3644 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3645 "icydb.test.rule_evolution.integrity.v1";
3646 }
3647
3648 #[cfg(feature = "migration")]
3649 struct MigrationCanister;
3650
3651 #[cfg(feature = "migration")]
3652 impl Path for MigrationCanister {
3653 const PATH: &'static str = "schema_application_tests::MigrationCanister";
3654 }
3655
3656 #[cfg(feature = "migration")]
3657 impl CanisterKind for MigrationCanister {
3658 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3659 Ok(204)
3660 }
3661 const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_validation.commit.v1";
3662 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3663 Ok(206)
3664 }
3665 const STARTUP_STABLE_KEY: &'static str =
3666 "icydb.test.migration_validation.startup.control.v1";
3667 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3668 Ok(205)
3669 }
3670 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3671 "icydb.test.migration_validation.integrity.v1";
3672 }
3673
3674 #[cfg(feature = "migration")]
3675 struct MigrationExecutionCanister;
3676
3677 #[cfg(feature = "migration")]
3678 impl Path for MigrationExecutionCanister {
3679 const PATH: &'static str = "schema_application_tests::MigrationExecutionCanister";
3680 }
3681
3682 #[cfg(feature = "migration")]
3683 impl CanisterKind for MigrationExecutionCanister {
3684 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3685 Ok(214)
3686 }
3687 const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_execution.commit.v1";
3688 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3689 Ok(216)
3690 }
3691 const STARTUP_STABLE_KEY: &'static str =
3692 "icydb.test.migration_execution.startup.control.v1";
3693 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3694 Ok(215)
3695 }
3696 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3697 "icydb.test.migration_execution.integrity.v1";
3698 }
3699
3700 #[cfg(feature = "migration")]
3701 struct MigrationFindingCanister;
3702
3703 #[cfg(feature = "migration")]
3704 impl Path for MigrationFindingCanister {
3705 const PATH: &'static str = "schema_application_tests::MigrationFindingCanister";
3706 }
3707
3708 #[cfg(feature = "migration")]
3709 impl CanisterKind for MigrationFindingCanister {
3710 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3711 Ok(210)
3712 }
3713 const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_finding.commit.v1";
3714 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3715 Ok(212)
3716 }
3717 const STARTUP_STABLE_KEY: &'static str = "icydb.test.migration_finding.startup.control.v1";
3718 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3719 Ok(211)
3720 }
3721 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3722 "icydb.test.migration_finding.integrity.v1";
3723 }
3724
3725 fn name(value: &str) -> SchemaName {
3726 SchemaName::try_new(value).expect("test schema name should admit")
3727 }
3728
3729 fn generated_check_proposal(
3730 expected_head: ExpectedAcceptedHead,
3731 submission_key: &str,
3732 include_check: bool,
3733 database: TargetDatabaseIdentity,
3734 store: TargetStoreIdentity,
3735 ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
3736 let entity_source = EntitySourceKey::try_new("Item").expect("entity source should admit");
3737 let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
3738 let score_source = FieldSourceKey::try_new("score").expect("score source should admit");
3739 let check_source =
3740 ConstraintSourceKey::try_new("score_non_negative").expect("check source should admit");
3741 let check = SourceCheckExpr::try_new(vec![
3742 SourceCheckInstruction::Field(score_source),
3743 SourceCheckInstruction::Literal(ScalarLiteral::Int(0)),
3744 SourceCheckInstruction::GreaterThanOrEqual,
3745 ])
3746 .expect("check expression should admit");
3747 let constraints = include_check
3748 .then(|| ConstraintFragment::check(name("score_non_negative"), check))
3749 .into_iter()
3750 .collect();
3751 let entity = EntityFragment::try_new(
3752 name("Item"),
3753 version_one(),
3754 vec![
3755 FieldFragment::new(
3756 name("id"),
3757 FieldType::Scalar(ScalarType::Nat64),
3758 false,
3759 FieldInsertPolicy::Required,
3760 None,
3761 ),
3762 FieldFragment::new(
3763 name("score"),
3764 FieldType::Scalar(ScalarType::Int64),
3765 false,
3766 FieldInsertPolicy::Required,
3767 None,
3768 ),
3769 ],
3770 vec![id_source],
3771 Vec::new(),
3772 Vec::new(),
3773 constraints,
3774 )
3775 .expect("entity should admit");
3776 let proposal = SchemaProposal::try_compose(
3777 vec![SchemaCapability::ACCEPTED_CHECKS],
3778 database,
3779 SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
3780 expected_head,
3781 vec![
3782 SchemaFragment::try_new(vec![entity], Vec::new())
3783 .expect("schema fragment should admit"),
3784 ],
3785 vec![EntityStoreAssignment::new(entity_source.clone(), store)],
3786 Vec::new(),
3787 None,
3788 )
3789 .expect("schema proposal should compose");
3790 (proposal, entity_source, check_source)
3791 }
3792
3793 #[cfg(feature = "migration")]
3794 #[derive(Clone, Copy, Eq, PartialEq)]
3795 enum ValidationMigrationShape {
3796 Clean,
3797 AllFindingFamilies,
3798 }
3799
3800 #[cfg(feature = "migration")]
3801 #[expect(
3802 clippy::too_many_lines,
3803 reason = "the fixture keeps both predecessor and candidate source contracts adjacent"
3804 )]
3805 fn validation_migration_proposal(
3806 shape: ValidationMigrationShape,
3807 current: bool,
3808 expected_head: ExpectedAcceptedHead,
3809 database: TargetDatabaseIdentity,
3810 store: TargetStoreIdentity,
3811 ) -> SchemaProposal {
3812 let entity_source = EntitySourceKey::try_new("MigratingItem")
3813 .expect("migration entity source should admit");
3814 let old_value =
3815 FieldSourceKey::try_new("old_value").expect("predecessor field source should admit");
3816 let current_value =
3817 FieldSourceKey::try_new("value").expect("candidate field source should admit");
3818 let target_entity = EntitySourceKey::try_new("MigrationTarget")
3819 .expect("migration target source should admit");
3820 let target_id = FieldSourceKey::try_new("id").expect("target id source should admit");
3821 let constraint = SourceCheckExpr::try_new(vec![
3822 SourceCheckInstruction::Field(current_value.clone()),
3823 SourceCheckInstruction::Literal(ScalarLiteral::Nat(8)),
3824 SourceCheckInstruction::LessThanOrEqual,
3825 ])
3826 .expect("candidate check should admit");
3827 let findings = shape == ValidationMigrationShape::AllFindingFamilies;
3828 let entity = EntityFragment::try_new(
3829 name("MigratingItem"),
3830 DeclaredEntityVersion::try_new(if current { 2 } else { 1 })
3831 .expect("migration version should admit"),
3832 vec![
3833 FieldFragment::new(
3834 name("id"),
3835 FieldType::Scalar(ScalarType::Nat64),
3836 false,
3837 FieldInsertPolicy::Required,
3838 None,
3839 ),
3840 FieldFragment::new(
3841 name(if current { "value" } else { "old_value" }),
3842 FieldType::Scalar(if current {
3843 ScalarType::Nat8
3844 } else {
3845 ScalarType::Int64
3846 }),
3847 false,
3848 FieldInsertPolicy::Required,
3849 None,
3850 ),
3851 ],
3852 vec![FieldSourceKey::try_new("id").expect("id source should admit")],
3853 current
3854 .then(|| {
3855 IndexFragment::try_new(
3856 name("value_unique"),
3857 vec![IndexKeyFragment::Field(current_value.clone())],
3858 true,
3859 None,
3860 )
3861 .expect("candidate index should admit")
3862 })
3863 .into_iter()
3864 .collect(),
3865 (current && findings)
3866 .then(|| {
3867 RelationFragment::try_new(
3868 name("value_target"),
3869 icydb_schema::RelationSourceFragment::direct(vec![current_value.clone()]),
3870 target_entity.clone(),
3871 vec![target_id.clone()],
3872 RelationDeleteAction::Restrict,
3873 )
3874 .expect("candidate relation should admit")
3875 })
3876 .into_iter()
3877 .collect(),
3878 (current && findings)
3879 .then(|| ConstraintFragment::check(name("value_at_most_eight"), constraint))
3880 .into_iter()
3881 .collect(),
3882 )
3883 .expect("migration entity should admit");
3884 let target = EntityFragment::try_new(
3885 name("MigrationTarget"),
3886 version_one(),
3887 vec![FieldFragment::new(
3888 name("id"),
3889 FieldType::Scalar(ScalarType::Nat8),
3890 false,
3891 FieldInsertPolicy::Required,
3892 None,
3893 )],
3894 vec![target_id],
3895 Vec::new(),
3896 Vec::new(),
3897 Vec::new(),
3898 )
3899 .expect("migration relation target should admit");
3900 let migration = current.then(|| {
3901 SchemaMigrationPlan::try_new(vec![
3902 EntityMigration::try_new(
3903 entity_source.clone(),
3904 DeclaredEntityVersion::try_new(1).expect("predecessor should admit"),
3905 None,
3906 Vec::new(),
3907 vec![SchemaMigrationTransform::CheckedCast {
3908 from: old_value.clone(),
3909 to: current_value,
3910 target: ScalarType::Nat8,
3911 }],
3912 )
3913 .expect("migration transition should admit"),
3914 ])
3915 .expect("migration plan should admit")
3916 });
3917 let mut capabilities = Vec::new();
3918 if current && findings {
3919 capabilities.extend([
3920 SchemaCapability::ACCEPTED_CHECKS,
3921 SchemaCapability::SECONDARY_INDEXES,
3922 SchemaCapability::RESTRICTIVE_RELATIONS,
3923 ]);
3924 }
3925 if migration.is_some() {
3926 capabilities.push(SchemaCapability::VERSIONED_MIGRATIONS);
3927 }
3928 let mut entities = vec![entity];
3929 let mut assignments = vec![EntityStoreAssignment::new(entity_source.clone(), store)];
3930 if findings {
3931 entities.push(target);
3932 assignments.push(EntityStoreAssignment::new(target_entity, store));
3933 }
3934 SchemaProposal::try_compose(
3935 capabilities,
3936 database,
3937 SchemaSubmissionKey::try_new(if current {
3938 "migration-validation-v2"
3939 } else {
3940 "migration-validation-v1"
3941 })
3942 .expect("submission should admit"),
3943 expected_head,
3944 vec![
3945 SchemaFragment::try_new(entities, Vec::new())
3946 .expect("migration fragment should admit"),
3947 ],
3948 assignments,
3949 current
3950 .then_some(icydb_schema::SchemaRemoval::Field {
3951 entity: entity_source,
3952 field: old_value,
3953 })
3954 .into_iter()
3955 .collect(),
3956 migration,
3957 )
3958 .expect("migration proposal should compose")
3959 }
3960
3961 fn targeted_rule_proposal(
3962 expected_head: ExpectedAcceptedHead,
3963 submission_key: &str,
3964 operation: SourceRuleOperation,
3965 database: TargetDatabaseIdentity,
3966 store: TargetStoreIdentity,
3967 ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
3968 let entity_source =
3969 EntitySourceKey::try_new("Measured").expect("entity source should admit");
3970 let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
3971 let value_source = FieldSourceKey::try_new("value").expect("value source should admit");
3972 let value_type = TypeSourceKey::try_new("Measure").expect("type source should admit");
3973 let rule_source = RuleSourceKey::try_new("limit").expect("rule source should admit");
3974 let constraint_source =
3975 ConstraintSourceKey::for_targeted_field_rule(&value_source, &value_type, &rule_source);
3976 let entity = EntityFragment::try_new(
3977 name("Measured"),
3978 version_one(),
3979 vec![
3980 FieldFragment::new(
3981 name("id"),
3982 FieldType::Scalar(ScalarType::Nat64),
3983 false,
3984 FieldInsertPolicy::Required,
3985 None,
3986 ),
3987 FieldFragment::new(
3988 name("value"),
3989 FieldType::Named(value_type.clone()),
3990 false,
3991 FieldInsertPolicy::Required,
3992 None,
3993 ),
3994 ],
3995 vec![id_source],
3996 Vec::new(),
3997 Vec::new(),
3998 vec![ConstraintFragment::targeted_rule(
3999 TargetedRuleFragment::new(value_source, value_type, name("limit"), operation),
4000 )],
4001 )
4002 .expect("targeted entity should admit");
4003 let proposal = SchemaProposal::try_compose(
4004 vec![SchemaCapability::ACCEPTED_CHECKS],
4005 database,
4006 SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
4007 expected_head,
4008 vec![
4009 SchemaFragment::try_new(
4010 vec![entity],
4011 vec![NamedTypeFragment::newtype(
4012 name("Measure"),
4013 FieldType::Scalar(ScalarType::Nat8),
4014 )],
4015 )
4016 .expect("schema fragment should admit"),
4017 ],
4018 vec![EntityStoreAssignment::new(entity_source.clone(), store)],
4019 Vec::new(),
4020 None,
4021 )
4022 .expect("schema proposal should compose");
4023 (proposal, entity_source, constraint_source)
4024 }
4025
4026 #[test]
4027 fn database_head_is_empty_only_when_every_store_root_is_absent() {
4028 assert_eq!(
4029 derive_accepted_head(&[("test::A", None), ("test::B", None)]),
4030 ExpectedAcceptedHead::Empty,
4031 );
4032 }
4033
4034 #[test]
4035 fn database_head_covers_store_path_revision_fingerprint_and_absence() {
4036 let first = derive_accepted_head(&[
4037 (
4038 "test::A",
4039 Some(AcceptedStoreHead {
4040 revision: 3,
4041 fingerprint: [0x11; 32],
4042 }),
4043 ),
4044 ("test::B", None),
4045 ]);
4046 let changed_fingerprint = derive_accepted_head(&[
4047 (
4048 "test::A",
4049 Some(AcceptedStoreHead {
4050 revision: 3,
4051 fingerprint: [0x12; 32],
4052 }),
4053 ),
4054 ("test::B", None),
4055 ]);
4056 let changed_absence = derive_accepted_head(&[
4057 (
4058 "test::A",
4059 Some(AcceptedStoreHead {
4060 revision: 3,
4061 fingerprint: [0x11; 32],
4062 }),
4063 ),
4064 (
4065 "test::B",
4066 Some(AcceptedStoreHead {
4067 revision: 1,
4068 fingerprint: [0x22; 32],
4069 }),
4070 ),
4071 ]);
4072
4073 assert_ne!(first, changed_fingerprint);
4074 assert_ne!(first, changed_absence);
4075 assert!(matches!(
4076 first,
4077 ExpectedAcceptedHead::Exact { revision: 3, .. }
4078 ));
4079 }
4080
4081 #[test]
4082 #[expect(
4083 clippy::too_many_lines,
4084 reason = "the end-to-end catalog assertion is clearer as one lifecycle test"
4085 )]
4086 fn generated_check_abort_retires_source_identity_and_allows_fresh_reproposal() {
4087 let database = TargetDatabaseIdentity::from_bytes([0x71; 32]);
4088 let store = TargetStoreIdentity::from_bytes([0x72; 32]);
4089 let (initial, entity_source, _) = generated_check_proposal(
4090 ExpectedAcceptedHead::Empty,
4091 "abort-initial",
4092 false,
4093 database,
4094 store,
4095 );
4096 let initial_candidate = lower_initial_schema_proposal(
4097 &initial,
4098 &[ProposalStoreTarget {
4099 path: "abort::Store",
4100 identity: store,
4101 }],
4102 )
4103 .expect("initial proposal should lower")
4104 .pop()
4105 .expect("initial proposal should produce one candidate");
4106 let (with_check, _, check_source) = generated_check_proposal(
4107 ExpectedAcceptedHead::Exact {
4108 revision: 1,
4109 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x73; 32]),
4110 },
4111 "abort-add-check",
4112 true,
4113 database,
4114 store,
4115 );
4116 let pending_candidate = lower_existing_schema_proposal(
4117 &with_check,
4118 &[ExistingProposalStore {
4119 path: "abort::Store",
4120 identity: store,
4121 bundle: initial_candidate.bundle(),
4122 }],
4123 )
4124 .expect("generated check should lower")
4125 .pop()
4126 .expect("generated check should produce one candidate");
4127 let entity_tag = pending_candidate
4128 .bundle()
4129 .source_bindings_for_tests()
4130 .entity(&entity_source)
4131 .expect("entity source should remain bound");
4132 let constraint_id = pending_candidate
4133 .bundle()
4134 .source_bindings_for_tests()
4135 .constraint(entity_tag, &check_source)
4136 .expect("generated check source should bind");
4137 let pending_snapshot = pending_candidate
4138 .bundle()
4139 .entity_snapshots()
4140 .get(&entity_tag)
4141 .expect("pending entity should exist");
4142 let activation = pending_snapshot
4143 .constraint_catalog()
4144 .activation(constraint_id)
4145 .expect("generated check should remain an activation");
4146 assert_eq!(activation.origin(), ConstraintOrigin::Generated);
4147
4148 let aborted = aborted_generated_row_local_candidate(
4149 pending_candidate.bundle(),
4150 entity_tag,
4151 constraint_id,
4152 )
4153 .expect("generated check abort should build one catalog-native candidate");
4154 let aborted_snapshot = aborted
4155 .bundle()
4156 .entity_snapshots()
4157 .get(&entity_tag)
4158 .expect("aborted entity should remain");
4159 assert!(
4160 aborted_snapshot
4161 .constraint_catalog()
4162 .activation(constraint_id)
4163 .is_none(),
4164 );
4165 assert_eq!(aborted_snapshot.row_layout(), pending_snapshot.row_layout());
4166 assert!(
4167 aborted
4168 .bundle()
4169 .source_bindings_for_tests()
4170 .constraint(entity_tag, &check_source)
4171 .is_none(),
4172 );
4173
4174 let reproposed = lower_existing_schema_proposal(
4175 &with_check,
4176 &[ExistingProposalStore {
4177 path: "abort::Store",
4178 identity: store,
4179 bundle: aborted.bundle(),
4180 }],
4181 )
4182 .expect("aborted generated check should be independently reproposable")
4183 .pop()
4184 .expect("reproposal should produce one candidate");
4185 let replacement_id = reproposed
4186 .bundle()
4187 .source_bindings_for_tests()
4188 .constraint(entity_tag, &check_source)
4189 .expect("reproposal should bind a fresh constraint identity");
4190 assert!(
4191 replacement_id > constraint_id,
4192 "aborted accepted IDs must remain retired",
4193 );
4194 }
4195
4196 #[test]
4197 fn targeted_rule_edit_abort_keeps_prior_accepted_semantics_and_source_identity() {
4198 let database = TargetDatabaseIdentity::from_bytes([0x81; 32]);
4199 let store = TargetStoreIdentity::from_bytes([0x82; 32]);
4200 let (initial, entity_source, constraint_source) = targeted_rule_proposal(
4201 ExpectedAcceptedHead::Empty,
4202 "targeted-abort-initial",
4203 SourceRuleOperation::NumericRangeInclusive {
4204 min: ScalarLiteral::Nat(0),
4205 max: ScalarLiteral::Nat(10),
4206 },
4207 database,
4208 store,
4209 );
4210 let initial_candidate = lower_initial_schema_proposal(
4211 &initial,
4212 &[ProposalStoreTarget {
4213 path: "abort::TargetedStore",
4214 identity: store,
4215 }],
4216 )
4217 .expect("initial targeted proposal should lower")
4218 .pop()
4219 .expect("initial targeted proposal should produce one candidate");
4220 let initial_bundle = initial_candidate.bundle();
4221 let entity_tag = initial_bundle
4222 .source_bindings_for_tests()
4223 .entity(&entity_source)
4224 .expect("entity source should bind");
4225 let constraint_id = initial_bundle
4226 .source_bindings_for_tests()
4227 .constraint(entity_tag, &constraint_source)
4228 .expect("targeted source should bind");
4229 let high_water = initial_bundle.entity_snapshots()[&entity_tag]
4230 .constraint_id_allocator()
4231 .high_water();
4232 let (edited, _, _) = targeted_rule_proposal(
4233 ExpectedAcceptedHead::Exact {
4234 revision: initial_bundle.revision().get(),
4235 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x83; 32]),
4236 },
4237 "targeted-abort-edit",
4238 SourceRuleOperation::NumericMaximumInclusive {
4239 value: ScalarLiteral::Nat(8),
4240 },
4241 database,
4242 store,
4243 );
4244 let staged = lower_existing_schema_proposal(
4245 &edited,
4246 &[ExistingProposalStore {
4247 path: "abort::TargetedStore",
4248 identity: store,
4249 bundle: initial_bundle,
4250 }],
4251 )
4252 .expect("targeted semantic edit should stage")
4253 .pop()
4254 .expect("targeted semantic edit should produce one candidate");
4255 let aborted =
4256 aborted_generated_row_local_candidate(staged.bundle(), entity_tag, constraint_id)
4257 .expect("targeted semantic edit should abort through catalog authority");
4258 let snapshot = &aborted.bundle().entity_snapshots()[&entity_tag];
4259
4260 assert!(
4261 snapshot
4262 .constraint_catalog()
4263 .activation(constraint_id)
4264 .is_none()
4265 );
4266 assert_eq!(snapshot.constraint_id_allocator().high_water(), high_water);
4267 assert_eq!(
4268 aborted
4269 .bundle()
4270 .source_bindings_for_tests()
4271 .constraint(entity_tag, &constraint_source),
4272 Some(constraint_id),
4273 );
4274 assert!(snapshot.constraints().iter().any(|constraint| {
4275 constraint.id() == constraint_id
4276 && matches!(
4277 constraint.kind(),
4278 AcceptedConstraintKind::TargetedRule { operation, .. }
4279 if matches!(
4280 operation.as_ref(),
4281 AcceptedRuleOperation::NumericRangeInclusive { .. }
4282 )
4283 )
4284 }));
4285 }
4286
4287 #[test]
4288 #[expect(
4289 clippy::too_many_lines,
4290 reason = "the staged publication, recovery, and promotion assertions form one lifecycle"
4291 )]
4292 fn generated_startup_driver_resumes_pending_activation_and_promotes_without_source_model() {
4293 let db = Db::<EvolutionCanister>::new(
4294 &EVOLUTION_REGISTRY,
4295 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4296 );
4297 drive_startup_recovery_to_completion(&db);
4298 let empty_target =
4299 schema_application_target(&db).expect("empty evolution target should issue");
4300 let store_identity = empty_target
4301 .stores()
4302 .first()
4303 .expect("evolution store should register")
4304 .identity();
4305 let (initial, entity_source, constraint_source) = targeted_rule_proposal(
4306 empty_target.accepted_head().clone(),
4307 "targeted-recovery-initial",
4308 SourceRuleOperation::NumericRangeInclusive {
4309 min: ScalarLiteral::Nat(0),
4310 max: ScalarLiteral::Nat(10),
4311 },
4312 empty_target.database_identity(),
4313 store_identity,
4314 );
4315 assert!(matches!(
4316 apply_schema(&db, &initial)
4317 .expect("initial targeted proposal should publish")
4318 .outcome(),
4319 SchemaChangeOutcome::Applied { .. },
4320 ));
4321 drive_startup_recovery_to_completion(&db);
4322 drive_cardinality_to_ready(
4323 db.store_handle(EVOLUTION_STORE_PATH)
4324 .expect("evolution store should resolve"),
4325 );
4326
4327 let direct_target =
4328 schema_application_target(&db).expect("direct evolution target should issue");
4329 let (direct_edit, _, _) = targeted_rule_proposal(
4330 direct_target.accepted_head().clone(),
4331 "targeted-direct-edit",
4332 SourceRuleOperation::NumericMaximumInclusive {
4333 value: ScalarLiteral::Nat(8),
4334 },
4335 direct_target.database_identity(),
4336 store_identity,
4337 );
4338 assert!(matches!(
4339 apply_schema(&db, &direct_edit)
4340 .expect("empty-domain semantic edit should publish directly")
4341 .outcome(),
4342 SchemaChangeOutcome::Applied { .. },
4343 ));
4344 let store = db
4345 .store_handle(EVOLUTION_STORE_PATH)
4346 .expect("evolution store should resolve");
4347 let direct = store
4348 .with_schema(SchemaStore::current_accepted_schema_bundle)
4349 .expect("directly edited bundle should remain readable")
4350 .expect("directly edited bundle should exist");
4351 let entity_tag = direct
4352 .source_bindings_for_tests()
4353 .entity(&entity_source)
4354 .expect("entity source should remain bound");
4355 let constraint_id = direct
4356 .source_bindings_for_tests()
4357 .constraint(entity_tag, &constraint_source)
4358 .expect("direct edit should preserve constraint identity");
4359 assert!(
4360 direct.entity_snapshots()[&entity_tag]
4361 .constraint_catalog()
4362 .activation(constraint_id)
4363 .is_none()
4364 );
4365 assert!(
4366 direct.entity_snapshots()[&entity_tag]
4367 .constraints()
4368 .iter()
4369 .any(|constraint| {
4370 constraint.id() == constraint_id
4371 && matches!(
4372 constraint.kind(),
4373 AcceptedConstraintKind::TargetedRule { operation, .. }
4374 if matches!(
4375 operation.as_ref(),
4376 AcceptedRuleOperation::NumericMaximumInclusive { .. }
4377 )
4378 )
4379 })
4380 );
4381
4382 let target = schema_application_target(&db).expect("staged evolution target should issue");
4383 let (edited, _, _) = targeted_rule_proposal(
4384 target.accepted_head().clone(),
4385 "targeted-recovery-edit",
4386 SourceRuleOperation::MultipleOf {
4387 divisor: ScalarLiteral::Nat(2),
4388 },
4389 target.database_identity(),
4390 store_identity,
4391 );
4392 let current = store
4393 .with_schema(SchemaStore::current_accepted_schema_bundle)
4394 .expect("accepted evolution bundle should remain readable")
4395 .expect("directly edited evolution bundle should exist");
4396 let staged = lower_existing_schema_proposal(
4397 &edited,
4398 &[ExistingProposalStore {
4399 path: EVOLUTION_STORE_PATH,
4400 identity: store_identity,
4401 bundle: ¤t,
4402 }],
4403 )
4404 .expect("targeted edit should stage")
4405 .pop()
4406 .expect("targeted edit should produce one candidate");
4407 assert_eq!(
4408 staged
4409 .bundle()
4410 .source_bindings_for_tests()
4411 .constraint(entity_tag, &constraint_source),
4412 Some(constraint_id),
4413 );
4414 let proof = DirectGeneratedRowLocalProof {
4415 candidate_index: 0,
4416 store,
4417 store_path: EVOLUTION_STORE_PATH,
4418 entity_tag,
4419 entity_path: staged.bundle().entity_snapshots()[&entity_tag]
4420 .entity_path()
4421 .to_string(),
4422 constraint_id,
4423 has_rows: false,
4424 };
4425 let final_candidates = final_candidates_for_pending_row_local_constraint(
4426 std::slice::from_ref(&staged),
4427 &PendingGeneratedRowLocalConstraint { proof },
4428 )
4429 .expect("final semantic replacement should derive without source input");
4430 let authorities = application_authorities(&db);
4431 let candidate_head =
4432 accepted_head_after_candidates(authorities.as_slice(), &final_candidates)
4433 .expect("final candidate head should derive");
4434 let digest = edited.digest().expect("proposal digest should derive");
4435 let job_id = derive_schema_change_job_id(
4436 target.database_identity(),
4437 edited.submission_key(),
4438 digest,
4439 target.accepted_head(),
4440 )
4441 .expect("job identity should derive");
4442 let receipt = crate::db::schema::SchemaChangeReceipt::new(
4443 target.database_identity(),
4444 edited.submission_key().clone(),
4445 digest,
4446 target.accepted_head().clone(),
4447 SchemaChangeOutcome::Pending {
4448 job: SchemaChangeJob::new(job_id),
4449 candidate_head,
4450 },
4451 )
4452 .expect("pending replacement receipt should admit");
4453 let record = SchemaApplicationRecord::new(
4454 receipt,
4455 vec![
4456 SchemaChangeActivation::new(
4457 store_identity,
4458 entity_tag.value(),
4459 constraint_id.get(),
4460 )
4461 .expect("replacement activation should admit"),
4462 ],
4463 )
4464 .expect("pending replacement record should admit");
4465 let operation =
4466 SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4467 publish_accepted_schema_candidates_with_application_record(
4468 vec![AcceptedSchemaPublication::new(
4469 EVOLUTION_STORE_PATH,
4470 store,
4471 current.revision(),
4472 &staged,
4473 )],
4474 operation,
4475 )
4476 .expect("staged replacement and record should publish atomically");
4477
4478 forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4479 drive_startup_recovery_to_completion(&db);
4480
4481 let recovered = store
4482 .with_schema(SchemaStore::current_accepted_schema_bundle)
4483 .expect("recovered staged bundle should decode")
4484 .expect("recovered staged bundle should exist");
4485 let recovered_snapshot = recovered.entity_snapshots()[&entity_tag].clone();
4486 let validating_catalog = recovered_snapshot
4487 .constraint_catalog()
4488 .clone()
4489 .with_validation_started(constraint_id)
4490 .expect("recovered replacement should enter validation");
4491 let mut validating_snapshots = recovered.entity_snapshots().clone();
4492 validating_snapshots.insert(
4493 entity_tag,
4494 recovered_snapshot.with_constraint_catalog(validating_catalog),
4495 );
4496 let validating_bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
4497 recovered
4498 .revision()
4499 .checked_next()
4500 .expect("validation revision should remain available"),
4501 recovered.store_path(),
4502 recovered.enum_catalog().clone(),
4503 recovered.composite_catalog().clone(),
4504 recovered.source_bindings_for_tests().clone(),
4505 validating_snapshots,
4506 )
4507 .expect("validating replacement bundle should close");
4508 let validating_candidate = CandidateSchemaRevision::new(validating_bundle)
4509 .expect("validating replacement candidate should encode");
4510 let validating_activation = validating_candidate.bundle().entity_snapshots()[&entity_tag]
4511 .constraint_catalog()
4512 .activation(constraint_id)
4513 .expect("validating replacement activation should remain present");
4514 let validation_job = ConstraintValidationJob::start(
4515 entity_tag,
4516 validating_candidate.bundle().entity_snapshots()[&entity_tag]
4517 .entity_path()
4518 .to_string(),
4519 validating_activation,
4520 None,
4521 )
4522 .expect("validating replacement job should derive from accepted state");
4523 store
4524 .with_schema(|schema| {
4525 schema.validate_live_activation_transition(validating_candidate.bundle())?;
4526 schema.validate_constraint_validation_job_closure_with_change(
4527 validating_candidate.bundle(),
4528 Some(&validation_job),
4529 None,
4530 )
4531 })
4532 .expect("validating replacement transition and job should close");
4533 let startup_root = crate::db::RequestExecutionRoot::__new_runtime_root();
4534 let startup_session =
4535 crate::db::DbSession::<EvolutionCanister>::new(&EVOLUTION_REGISTRY, &startup_root);
4536
4537 assert_eq!(
4538 drive_generated_startup_recovery_page(
4539 &startup_session,
4540 &EVOLUTION_REGISTRY,
4541 edited.submission_key().as_str(),
4542 )
4543 .expect("generated startup should begin pending validation"),
4544 GeneratedStartupDriverStep::Recovering,
4545 );
4546 let mut terminal = false;
4547 for _ in 0..8 {
4548 match drive_generated_startup_recovery_page(
4549 &startup_session,
4550 &EVOLUTION_REGISTRY,
4551 edited.submission_key().as_str(),
4552 )
4553 .expect("generated startup should advance pending validation")
4554 {
4555 GeneratedStartupDriverStep::Recovering => {}
4556 GeneratedStartupDriverStep::Terminal => {
4557 terminal = true;
4558 break;
4559 }
4560 GeneratedStartupDriverStep::ApplyGeneratedSchema => {
4561 panic!("an exact pending receipt must resume instead of being resubmitted")
4562 }
4563 }
4564 }
4565 assert!(
4566 terminal,
4567 "empty historical domain should promote within bounded startup steps"
4568 );
4569 assert_eq!(
4570 observe_generated_startup_state::<EvolutionCanister>(
4571 &EVOLUTION_REGISTRY,
4572 edited.submission_key().as_str(),
4573 ),
4574 Ok(DatabaseStartupState::Ready),
4575 );
4576 let applied = super::exact_schema_application_receipt(
4577 &edited,
4578 edited
4579 .digest()
4580 .expect("proposal digest should remain stable"),
4581 )
4582 .expect("terminal generated receipt should remain readable")
4583 .expect("terminal generated receipt should remain present");
4584 assert!(matches!(
4585 applied.outcome(),
4586 SchemaChangeOutcome::Applied { .. }
4587 ));
4588 let promoted = store
4589 .with_schema(SchemaStore::current_accepted_schema_bundle)
4590 .expect("promoted bundle should remain readable")
4591 .expect("promoted bundle should exist");
4592 let snapshot = &promoted.entity_snapshots()[&entity_tag];
4593 assert!(
4594 snapshot
4595 .constraint_catalog()
4596 .activation(constraint_id)
4597 .is_none()
4598 );
4599 assert_eq!(
4600 promoted
4601 .source_bindings_for_tests()
4602 .constraint(entity_tag, &constraint_source),
4603 Some(constraint_id),
4604 );
4605 assert!(snapshot.constraints().iter().any(|constraint| {
4606 constraint.id() == constraint_id
4607 && matches!(
4608 constraint.kind(),
4609 AcceptedConstraintKind::TargetedRule { operation, .. }
4610 if matches!(
4611 operation.as_ref(),
4612 AcceptedRuleOperation::MultipleOf { .. }
4613 )
4614 )
4615 }));
4616 }
4617
4618 #[test]
4619 #[expect(
4620 clippy::too_many_lines,
4621 reason = "the durable pending job, startup failure, and retained finding assertions form one scenario"
4622 )]
4623 fn generated_startup_driver_persists_e223_for_a_retained_historical_finding() {
4624 let db = Db::<AbortCanister>::new(
4625 &ABORT_REGISTRY,
4626 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4627 );
4628 drive_startup_recovery_to_completion(&db);
4629 let empty_target =
4630 schema_application_target(&db).expect("empty application target should issue");
4631 let store_identity = empty_target
4632 .stores()
4633 .first()
4634 .expect("abort store should be registered")
4635 .identity();
4636 let (initial, _, _) = generated_check_proposal(
4637 empty_target.accepted_head().clone(),
4638 "startup-finding-initial",
4639 false,
4640 empty_target.database_identity(),
4641 store_identity,
4642 );
4643 apply_schema(&db, &initial).expect("initial generated schema should publish");
4644
4645 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
4646 let session = DbSession::<AbortCanister>::new(&ABORT_REGISTRY, &root);
4647 let rows = (1..=257)
4648 .map(|id| DynamicMutation::Insert {
4649 entity: "Item".to_string(),
4650 patch: DynamicStructuralPatch::new(vec![
4651 (
4652 "id".to_string(),
4653 DynamicWriteCell::Value(InputValue::nat64(id)),
4654 ),
4655 (
4656 "score".to_string(),
4657 DynamicWriteCell::Value(InputValue::int64(if id == 257 { -1 } else { 1 })),
4658 ),
4659 ]),
4660 })
4661 .collect();
4662 session
4663 .execute_trusted_dynamic_mutation_batch(rows)
4664 .expect("historical finding fixture rows should commit as one legal batch");
4665 drive_startup_recovery_to_completion(&db);
4666 drive_cardinality_to_ready(
4667 db.store_handle(ABORT_STORE_PATH)
4668 .expect("abort store should resolve"),
4669 );
4670
4671 let target =
4672 schema_application_target(&db).expect("existing application target should issue");
4673 let (with_check, _, _) = generated_check_proposal(
4674 target.accepted_head().clone(),
4675 "startup-finding-pending",
4676 true,
4677 target.database_identity(),
4678 store_identity,
4679 );
4680 let pending = apply_schema(&db, &with_check)
4681 .expect("the first clean page should admit durable continuation");
4682 let SchemaChangeOutcome::Pending { job, .. } = pending.outcome() else {
4683 panic!("a 257-row domain must exceed the 256-row direct proof page")
4684 };
4685
4686 let mut terminal = false;
4687 for _ in 0..8 {
4688 match drive_generated_startup_recovery_page(
4689 &session,
4690 &ABORT_REGISTRY,
4691 with_check.submission_key().as_str(),
4692 )
4693 .expect("generated startup should retain a typed finding failure")
4694 {
4695 GeneratedStartupDriverStep::Recovering => {}
4696 GeneratedStartupDriverStep::Terminal => {
4697 terminal = true;
4698 break;
4699 }
4700 GeneratedStartupDriverStep::ApplyGeneratedSchema => {
4701 panic!("an exact pending receipt must not be resubmitted")
4702 }
4703 }
4704 }
4705 assert!(terminal, "the retained finding should become terminal");
4706 let failure = observe_generated_startup_state::<AbortCanister>(
4707 &ABORT_REGISTRY,
4708 with_check.submission_key().as_str(),
4709 )
4710 .expect_err("the retained finding must remain durably observable");
4711 assert_eq!(
4712 failure.kind(),
4713 crate::db::StartupFailureKind::SchemaReconciliation,
4714 );
4715 assert_eq!(
4716 failure.diagnostic().error_code(),
4717 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_CONSTRAINT_VIOLATION,
4718 );
4719 assert_eq!(
4720 ABORT_DATA.with(|store| store.borrow().len()),
4721 257,
4722 "terminal startup publication must not change historical rows",
4723 );
4724 assert!(matches!(
4725 continue_schema_application(&db, job.id(), None)
4726 .expect("the retained finding page should replay exactly")
4727 .status(),
4728 SchemaChangeProgressStatus::Findings { findings, .. } if !findings.is_empty(),
4729 ));
4730 }
4731
4732 #[test]
4733 #[expect(
4734 clippy::too_many_lines,
4735 reason = "the journaled abort, replay, and recovery assertions form one scenario"
4736 )]
4737 fn pending_generated_check_abort_is_atomic_terminal_and_replayable() {
4738 let db = Db::<AbortCanister>::new(
4739 &ABORT_REGISTRY,
4740 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4741 );
4742 drive_startup_recovery_to_completion(&db);
4743 let empty_target =
4744 schema_application_target(&db).expect("empty application target should issue");
4745 let store_identity = empty_target
4746 .stores()
4747 .first()
4748 .expect("abort store should be registered")
4749 .identity();
4750 let (initial, entity_source, _) = generated_check_proposal(
4751 empty_target.accepted_head().clone(),
4752 "abort-runtime-initial",
4753 false,
4754 empty_target.database_identity(),
4755 store_identity,
4756 );
4757 assert!(matches!(
4758 apply_schema(&db, &initial)
4759 .expect("initial application should publish")
4760 .outcome(),
4761 SchemaChangeOutcome::Applied { .. },
4762 ));
4763
4764 let target =
4765 schema_application_target(&db).expect("existing application target should issue");
4766 let (with_check, _, check_source) = generated_check_proposal(
4767 target.accepted_head().clone(),
4768 "abort-runtime-pending",
4769 true,
4770 target.database_identity(),
4771 store_identity,
4772 );
4773 let store = db
4774 .store_handle(ABORT_STORE_PATH)
4775 .expect("abort store should resolve");
4776 let current = store
4777 .with_schema(SchemaStore::current_accepted_schema_bundle)
4778 .expect("accepted bundle should remain readable")
4779 .expect("initial accepted bundle should exist");
4780 let pending_candidate = lower_existing_schema_proposal(
4781 &with_check,
4782 &[ExistingProposalStore {
4783 path: ABORT_STORE_PATH,
4784 identity: store_identity,
4785 bundle: ¤t,
4786 }],
4787 )
4788 .expect("pending generated check should lower")
4789 .pop()
4790 .expect("pending generated check should produce one candidate");
4791 let entity_tag = pending_candidate
4792 .bundle()
4793 .source_bindings_for_tests()
4794 .entity(&entity_source)
4795 .expect("entity source should bind");
4796 let constraint_id = pending_candidate
4797 .bundle()
4798 .source_bindings_for_tests()
4799 .constraint(entity_tag, &check_source)
4800 .expect("generated check source should bind");
4801 let digest = with_check.digest().expect("proposal digest should derive");
4802 let job_id = derive_schema_change_job_id(
4803 target.database_identity(),
4804 with_check.submission_key(),
4805 digest,
4806 target.accepted_head(),
4807 )
4808 .expect("job identity should derive");
4809 let receipt = crate::db::schema::SchemaChangeReceipt::new(
4810 target.database_identity(),
4811 with_check.submission_key().clone(),
4812 digest,
4813 target.accepted_head().clone(),
4814 SchemaChangeOutcome::Pending {
4815 job: SchemaChangeJob::new(job_id),
4816 candidate_head: ExpectedAcceptedHead::Exact {
4817 revision: pending_candidate.revision().get().saturating_add(2),
4818 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x76; 32]),
4819 },
4820 },
4821 )
4822 .expect("pending receipt should admit");
4823 let record = SchemaApplicationRecord::new(
4824 receipt,
4825 vec![
4826 SchemaChangeActivation::new(
4827 store_identity,
4828 entity_tag.value(),
4829 constraint_id.get(),
4830 )
4831 .expect("application activation should admit"),
4832 ],
4833 )
4834 .expect("pending application record should admit");
4835 let operation =
4836 SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4837 publish_accepted_schema_candidates_with_application_record(
4838 vec![AcceptedSchemaPublication::new(
4839 ABORT_STORE_PATH,
4840 store,
4841 current.revision(),
4842 &pending_candidate,
4843 )],
4844 operation,
4845 )
4846 .expect("pending candidate and record should publish atomically");
4847
4848 let started = continue_schema_application(&db, job_id, None)
4849 .expect("first continuation should durably start validation");
4850 assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
4851 let progress =
4852 abort_schema_application(&db, job_id, None).expect("pending application should abort");
4853 assert_eq!(progress.status(), &SchemaChangeProgressStatus::Aborted);
4854 assert!(matches!(
4855 progress.receipt().outcome(),
4856 SchemaChangeOutcome::Aborted { .. },
4857 ));
4858 let replay =
4859 abort_schema_application(&db, job_id, None).expect("terminal abort should replay");
4860 assert_eq!(replay, progress);
4861 assert_eq!(
4862 continue_schema_application(&db, job_id, None)
4863 .expect("continuation after abort should replay terminal state"),
4864 progress,
4865 );
4866
4867 let aborted = store
4868 .with_schema(SchemaStore::current_accepted_schema_bundle)
4869 .expect("accepted bundle should remain readable")
4870 .expect("aborted accepted bundle should exist");
4871 assert!(
4872 aborted
4873 .entity_snapshots()
4874 .get(&entity_tag)
4875 .expect("entity should remain after abort")
4876 .constraint_catalog()
4877 .activation(constraint_id)
4878 .is_none(),
4879 );
4880 assert!(
4881 aborted
4882 .source_bindings_for_tests()
4883 .constraint(entity_tag, &check_source)
4884 .is_none(),
4885 );
4886 assert!(
4887 store
4888 .with_schema(|schema| {
4889 schema.constraint_validation_job(entity_tag, constraint_id)
4890 })
4891 .expect("validation-job storage should remain readable")
4892 .is_none(),
4893 );
4894
4895 ABORT_DATA.with(|store| {
4896 ABORT_DATA_MEMORY.with(|memory| {
4897 *store.borrow_mut() = DataStore::init_journaled(memory.clone());
4898 });
4899 });
4900 ABORT_INDEX.with(|store| {
4901 ABORT_INDEX_MEMORY.with(|memory| {
4902 *store.borrow_mut() = IndexStore::init_journaled(memory.clone());
4903 });
4904 });
4905 ABORT_SCHEMA.with(|store| {
4906 ABORT_SCHEMA_MEMORY.with(|memory| {
4907 *store.borrow_mut() = SchemaStore::init_journaled(memory.clone());
4908 });
4909 });
4910 ABORT_JOURNAL.with(|store| {
4911 ABORT_JOURNAL_MEMORY.with(|memory| {
4912 *store.borrow_mut() = JournalTailStore::init(memory.clone());
4913 });
4914 });
4915 forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4916 drive_startup_recovery_to_completion(&db);
4917 assert_eq!(
4918 abort_schema_application(&db, job_id, None)
4919 .expect("recovered terminal abort should replay"),
4920 progress,
4921 );
4922 assert!(
4923 store
4924 .with_schema(|schema| {
4925 schema.constraint_validation_job(entity_tag, constraint_id)
4926 })
4927 .expect("recovered validation-job storage should remain readable")
4928 .is_none(),
4929 );
4930 let startup_root = crate::db::RequestExecutionRoot::__new_runtime_root();
4931 let startup_session =
4932 crate::db::DbSession::<AbortCanister>::new(&ABORT_REGISTRY, &startup_root);
4933 ABORT_JOURNAL.with(|journal| {
4934 let journal = journal.borrow();
4935 assert!(
4936 journal
4937 .validate_current_tail_authority()
4938 .expect("recovered abort tail control should remain readable")
4939 .is_empty(),
4940 "recovered abort tail control should close exactly",
4941 );
4942 });
4943 assert_eq!(
4944 drive_generated_startup_recovery_page(
4945 &startup_session,
4946 &ABORT_REGISTRY,
4947 with_check.submission_key().as_str(),
4948 )
4949 .expect("an exact aborted generated submission should publish terminal startup state"),
4950 GeneratedStartupDriverStep::Terminal,
4951 );
4952 let failure = observe_generated_startup_state::<AbortCanister>(
4953 &ABORT_REGISTRY,
4954 with_check.submission_key().as_str(),
4955 )
4956 .expect_err("an aborted generated submission must not remain retryable forever");
4957 assert_eq!(
4958 failure.kind(),
4959 crate::db::StartupFailureKind::SchemaReconciliation,
4960 );
4961 assert_eq!(
4962 failure.diagnostic().error_code(),
4963 icydb_diagnostic_code::ErrorCode::RUNTIME_CONFLICT,
4964 );
4965 }
4966
4967 #[cfg(feature = "migration")]
4968 #[test]
4969 fn migration_planning_failures_retain_typed_public_classification() {
4970 use super::schema_migration_planning_error;
4971 use crate::db::schema::migration_planner::SchemaMigrationPlanningError;
4972 use icydb_diagnostic_code::{DiagnosticDetail, SchemaMigrationCode};
4973
4974 for (error, reason) in [
4975 (
4976 SchemaMigrationPlanningError::Unadopted,
4977 SchemaMigrationCode::Unadopted,
4978 ),
4979 (
4980 SchemaMigrationPlanningError::MissingMigration,
4981 SchemaMigrationCode::MissingMigration,
4982 ),
4983 (
4984 SchemaMigrationPlanningError::VersionGap,
4985 SchemaMigrationCode::VersionGap,
4986 ),
4987 (
4988 SchemaMigrationPlanningError::Downgrade,
4989 SchemaMigrationCode::Downgrade,
4990 ),
4991 (
4992 SchemaMigrationPlanningError::EmptyEntityVersionBump,
4993 SchemaMigrationCode::EmptyEntityVersionBump,
4994 ),
4995 (
4996 SchemaMigrationPlanningError::UnknownFromObject,
4997 SchemaMigrationCode::UnknownFromObject,
4998 ),
4999 (
5000 SchemaMigrationPlanningError::UnknownToObject,
5001 SchemaMigrationCode::UnknownToObject,
5002 ),
5003 (
5004 SchemaMigrationPlanningError::KindMismatch,
5005 SchemaMigrationCode::KindMismatch,
5006 ),
5007 (
5008 SchemaMigrationPlanningError::IdentityConflict,
5009 SchemaMigrationCode::IdentityConflict,
5010 ),
5011 (
5012 SchemaMigrationPlanningError::UnexplainedSchemaDifference,
5013 SchemaMigrationCode::UnexplainedSchemaDifference,
5014 ),
5015 (
5016 SchemaMigrationPlanningError::UnsupportedTransform,
5017 SchemaMigrationCode::UnsupportedTransform,
5018 ),
5019 (
5020 SchemaMigrationPlanningError::RekeyedCatalogInvalid,
5021 SchemaMigrationCode::CandidateMismatch,
5022 ),
5023 (
5024 SchemaMigrationPlanningError::CandidateMismatch,
5025 SchemaMigrationCode::CandidateMismatch,
5026 ),
5027 (
5028 SchemaMigrationPlanningError::CorruptLineage,
5029 SchemaMigrationCode::ProgressCorrupt,
5030 ),
5031 ] {
5032 let diagnostic = schema_migration_planning_error(error).diagnostic();
5033 assert_eq!(
5034 diagnostic.detail(),
5035 Some(&DiagnosticDetail::SchemaMigration { reason }),
5036 );
5037 assert_eq!(diagnostic.code(), reason.diagnostic_code());
5038 }
5039 }
5040
5041 #[cfg(feature = "migration")]
5042 #[test]
5043 fn migration_preparation_failure_retains_original_budget_diagnostic() {
5044 use crate::db::{
5045 resource::budget::MaintenanceConstructionBudget,
5046 resource::construction::ConstructionBudget,
5047 schema::migration_planner::SchemaMigrationPlanningError,
5048 };
5049 use icydb_diagnostic_code::DiagnosticExecutionBudgetResource as Resource;
5050
5051 let budget =
5052 MaintenanceConstructionBudget::with_limit_for_tests(Resource::TemporaryBytes, 0);
5053 let error = budget.charge(Resource::TemporaryBytes, 1).unwrap_err();
5054 let expected = error.diagnostic();
5055 let result = super::schema_migration_planning_error(
5056 SchemaMigrationPlanningError::Preparation(error),
5057 );
5058 assert_eq!(result.diagnostic(), expected);
5059 }
5060
5061 #[cfg(feature = "migration")]
5062 #[test]
5063 #[expect(
5064 clippy::too_many_lines,
5065 reason = "the validation replay, staging, and unchanged-row assertions form one scenario"
5066 )]
5067 fn physical_migration_validation_is_bounded_staged_and_does_not_rewrite_rows() {
5068 use std::convert::Infallible;
5069
5070 use super::{ensure_generated_schema_application_admitted, migrate_schema};
5071 use crate::db::{
5072 data::StoreVisit,
5073 index::{IndexEntryValue, IndexId, IndexKey, IndexKeyKind},
5074 key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
5075 schema::{SchemaMigrationCommand, SchemaMigrationPhase},
5076 };
5077 use crate::types::EntityTag;
5078
5079 let db = Db::<MigrationCanister>::new(
5080 &MIGRATION_REGISTRY,
5081 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5082 );
5083 drive_startup_recovery_to_completion(&db);
5084 let initial_target = schema_application_target(&db).expect("initial target should issue");
5085 let store_identity = initial_target
5086 .stores()
5087 .first()
5088 .expect("migration store should exist")
5089 .identity();
5090 let initial = validation_migration_proposal(
5091 ValidationMigrationShape::Clean,
5092 false,
5093 initial_target.accepted_head().clone(),
5094 initial_target.database_identity(),
5095 store_identity,
5096 );
5097 apply_schema(&db, &initial).expect("initial schema should publish");
5098
5099 let session = DbSession::<MigrationCanister>::new(
5100 &MIGRATION_REGISTRY,
5101 &crate::db::RequestExecutionRoot::__new_runtime_root(),
5102 );
5103 for (id, value) in [(1, 7), (2, 8)] {
5104 session
5105 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5106 entity: "MigratingItem".to_string(),
5107 patch: DynamicStructuralPatch::new(vec![
5108 (
5109 "id".to_string(),
5110 DynamicWriteCell::Value(InputValue::nat64(id)),
5111 ),
5112 (
5113 "old_value".to_string(),
5114 DynamicWriteCell::Value(InputValue::int64(value)),
5115 ),
5116 ]),
5117 })
5118 .expect("predecessor row should insert");
5119 }
5120 let store = db
5121 .store_handle(MIGRATION_STORE_PATH)
5122 .expect("migration store should resolve");
5123 let row_bytes = || {
5124 store.with_data(|data| {
5125 let mut rows = Vec::new();
5126 let result: Result<(), Infallible> = data.visit_entries(|key, row| {
5127 rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
5128 Ok(StoreVisit::Continue)
5129 });
5130 result.expect("infallible row visit should complete");
5131 rows
5132 })
5133 };
5134 let before_rows = row_bytes();
5135
5136 let target = schema_application_target(&db).expect("migration target should issue");
5137 let proposal = validation_migration_proposal(
5138 ValidationMigrationShape::Clean,
5139 true,
5140 target.accepted_head().clone(),
5141 target.database_identity(),
5142 store_identity,
5143 );
5144 let plan = proposal
5145 .migration()
5146 .expect("migration plan should exist")
5147 .digest();
5148 let pending = apply_schema(&db, &proposal)
5149 .expect_err("valid adjacent migration requires explicit advancement");
5150 assert_eq!(
5151 pending.diagnostic().error_code(),
5152 icydb_diagnostic_code::ErrorCode::SCHEMA_MIGRATION_IN_PROGRESS,
5153 );
5154 assert_eq!(
5155 schema_application_target(&db).unwrap().accepted_head(),
5156 target.accepted_head(),
5157 "ordinary startup must not publish the pending successor",
5158 );
5159 let command = || SchemaMigrationCommand::Advance {
5160 expected_database: target.database_identity(),
5161 expected_head: target.accepted_head().clone(),
5162 expected_plan: plan,
5163 acknowledged_finding_page: None,
5164 };
5165 assert_eq!(
5166 migrate_schema(&db, &proposal, command())
5167 .unwrap_or_else(|error| {
5168 panic!(
5169 "physical migration should prepare: {:?}",
5170 error.diagnostic()
5171 )
5172 })
5173 .phase(),
5174 SchemaMigrationPhase::Prepared,
5175 );
5176 let pending = ensure_generated_schema_application_admitted(&db, &proposal)
5177 .expect_err("prepared migration still owns generated schema publication");
5178 assert_eq!(
5179 pending.diagnostic().error_code(),
5180 icydb_diagnostic_code::ErrorCode::SCHEMA_MIGRATION_IN_PROGRESS,
5181 );
5182 let mismatched = ensure_generated_schema_application_admitted(&db, &initial)
5183 .expect_err("pending must not hide a mismatched deployed proposal");
5184 assert_eq!(
5185 mismatched.diagnostic().error_code(),
5186 icydb_diagnostic_code::ErrorCode::SCHEMA_MIGRATION_PLAN_CHANGED,
5187 );
5188 assert_eq!(
5189 migrate_schema(&db, &proposal, command())
5190 .expect("physical migration should enter validation")
5191 .phase(),
5192 SchemaMigrationPhase::Validating,
5193 );
5194 let record = super::load_schema_migration_record()
5195 .expect("migration record should remain readable")
5196 .expect("validating migration record should exist");
5197 let planned = super::recompile_active_physical_migration(&db, &proposal, &record)
5198 .expect("the exact active plan should recompile");
5199 for _ in 0..2 {
5200 let page = super::validate_migration_page(
5201 &db,
5202 &planned,
5203 record.progress(),
5204 record.plan_digest(),
5205 )
5206 .expect("the same validation page should remain replayable");
5207 let (progress, staged, exhausted) = page.into_parts();
5208 assert!(progress.findings().is_empty());
5209 assert!(exhausted);
5210 super::stage_migration_index_entries(staged)
5211 .expect("staging before a cursor marker should be idempotent");
5212 }
5213 assert_eq!(
5214 store.with_index(IndexStore::len),
5215 2,
5216 "replaying an uncheckpointed page must retain one exact staged key per row",
5217 );
5218 let ready =
5219 migrate_schema(&db, &proposal, command()).expect("bounded validation should complete");
5220 assert_eq!(ready.phase(), SchemaMigrationPhase::ReadyToRewrite);
5221 assert_eq!(ready.rows_validated(), 2);
5222 assert!(ready.findings().is_empty());
5223 assert_eq!(row_bytes(), before_rows, "validation must not rewrite rows");
5224 assert_eq!(
5225 store.with_index(IndexStore::len),
5226 2,
5227 "the isolated candidate unique generation should be durably staged",
5228 );
5229 store.with_index_mut(|index| {
5230 for ordinal in 0..513_u64 {
5231 let component = ordinal.to_be_bytes();
5232 let key = IndexKey::new_from_components_with_primary_key_value(
5233 &IndexId::new(EntityTag::new(2), 0),
5234 IndexKeyKind::User,
5235 &[component],
5236 &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(ordinal)),
5237 )
5238 .expect("unrelated abort-scan key should build")
5239 .to_raw()
5240 .expect("unrelated abort-scan key should encode");
5241 index.insert(key, IndexEntryValue::presence());
5242 }
5243 });
5244 let abort = || SchemaMigrationCommand::Abort {
5245 expected_database: target.database_identity(),
5246 expected_head: target.accepted_head().clone(),
5247 expected_plan: plan,
5248 };
5249 let cleaning = migrate_schema(&db, &proposal, abort())
5250 .expect("the first bounded abort cleanup page should publish");
5251 assert_eq!(cleaning.phase(), SchemaMigrationPhase::ReadyToRewrite);
5252 assert_eq!(store.with_index(IndexStore::len), 513);
5253 let aborted =
5254 migrate_schema(&db, &proposal, abort()).expect("pre-rewrite migration should abort");
5255 assert_eq!(aborted.phase(), SchemaMigrationPhase::Aborted);
5256 assert_eq!(
5257 store.with_index(IndexStore::len),
5258 513,
5259 "abort must remove only planner-invisible candidate generations",
5260 );
5261 assert_eq!(
5262 row_bytes(),
5263 before_rows,
5264 "abort must retain predecessor rows"
5265 );
5266 ensure_generated_schema_application_admitted(&db, &proposal)
5267 .expect("terminal aborted record must not block generated startup");
5268 }
5269
5270 #[cfg(feature = "migration")]
5271 #[test]
5272 #[expect(
5273 clippy::too_many_lines,
5274 reason = "the interrupted rewrite, recovery, final proof, and publication form one scenario"
5275 )]
5276 fn physical_migration_rewrite_recovers_and_publishes_one_complete_candidate() {
5277 use super::{
5278 ensure_generated_schema_application_admitted, migrate_schema,
5279 schema_migration_status_for_target,
5280 };
5281 use crate::db::{
5282 data::{CanonicalSlotReader, DecodedDataStoreKey, StoreVisit, StructuralSlotReader},
5283 schema::{
5284 MigrationRewriteInterruption, SchemaMigrationCommand, SchemaMigrationPhase,
5285 ensure_schema_migration_ready_for_ordinary_operations,
5286 interrupt_next_migration_rewrite_at,
5287 },
5288 };
5289 use crate::error::InternalError;
5290
5291 let db = Db::<MigrationExecutionCanister>::new(
5292 &MIGRATION_EXECUTION_REGISTRY,
5293 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5294 );
5295 drive_startup_recovery_to_completion(&db);
5296 let initial_target = schema_application_target(&db).expect("initial target should issue");
5297 let store_identity = initial_target
5298 .stores()
5299 .first()
5300 .expect("migration execution store should exist")
5301 .identity();
5302 let initial = validation_migration_proposal(
5303 ValidationMigrationShape::Clean,
5304 false,
5305 initial_target.accepted_head().clone(),
5306 initial_target.database_identity(),
5307 store_identity,
5308 );
5309 apply_schema(&db, &initial).expect("initial schema should publish");
5310 let session = DbSession::<MigrationExecutionCanister>::new(
5311 &MIGRATION_EXECUTION_REGISTRY,
5312 &crate::db::RequestExecutionRoot::__new_runtime_root(),
5313 );
5314 for (id, value) in [(1, 7), (2, 8), (3, 9)] {
5315 session
5316 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5317 entity: "MigratingItem".to_string(),
5318 patch: DynamicStructuralPatch::new(vec![
5319 (
5320 "id".to_string(),
5321 DynamicWriteCell::Value(InputValue::nat64(id)),
5322 ),
5323 (
5324 "old_value".to_string(),
5325 DynamicWriteCell::Value(InputValue::int64(value)),
5326 ),
5327 ]),
5328 })
5329 .expect("predecessor row should insert");
5330 }
5331 let target = schema_application_target(&db).expect("migration target should issue");
5332 let proposal = validation_migration_proposal(
5333 ValidationMigrationShape::Clean,
5334 true,
5335 target.accepted_head().clone(),
5336 target.database_identity(),
5337 store_identity,
5338 );
5339 let plan = proposal
5340 .migration()
5341 .expect("migration plan should exist")
5342 .digest();
5343 let command = || SchemaMigrationCommand::Advance {
5344 expected_database: target.database_identity(),
5345 expected_head: target.accepted_head().clone(),
5346 expected_plan: plan,
5347 acknowledged_finding_page: None,
5348 };
5349 for expected in [
5350 SchemaMigrationPhase::Prepared,
5351 SchemaMigrationPhase::Validating,
5352 SchemaMigrationPhase::ReadyToRewrite,
5353 SchemaMigrationPhase::RewritingRows,
5354 ] {
5355 assert_eq!(
5356 migrate_schema(&db, &proposal, command())
5357 .expect("migration phase should advance")
5358 .phase(),
5359 expected,
5360 );
5361 }
5362
5363 for interruption in [
5364 MigrationRewriteInterruption::MarkerPersisted,
5365 MigrationRewriteInterruption::JournalPublished,
5366 MigrationRewriteInterruption::PhysicalApplied,
5367 ] {
5368 interrupt_next_migration_rewrite_at(interruption);
5369 migrate_schema(&db, &proposal, command())
5370 .expect_err("injected interruption should retain the rewrite marker");
5371
5372 forget_recovered_domain_for_tests(&db)
5373 .expect("upgrade should reset recovery ownership");
5374 drive_startup_recovery_to_completion(&db);
5375 }
5376
5377 let rebuilding = schema_migration_status_for_target(
5378 &db,
5379 &proposal,
5380 &schema_application_target(&db).expect("recovered target should issue"),
5381 )
5382 .expect("recovered status should remain readable");
5383 assert_eq!(rebuilding.phase(), SchemaMigrationPhase::RebuildingIndexes);
5384 assert_eq!(rebuilding.rows_rewritten(), 3);
5385 assert_eq!(
5386 migrate_schema(&db, &proposal, command())
5387 .expect("derived generations should complete")
5388 .phase(),
5389 SchemaMigrationPhase::FinalValidation,
5390 );
5391 assert_eq!(
5392 migrate_schema(&db, &proposal, command())
5393 .expect("final validation should complete")
5394 .phase(),
5395 SchemaMigrationPhase::Publishing,
5396 );
5397 let applied = migrate_schema(&db, &proposal, command())
5398 .expect("candidate publication should complete atomically");
5399 assert_eq!(applied.phase(), SchemaMigrationPhase::Applied);
5400 assert_eq!(applied.rows_rewritten(), 3);
5401 assert_eq!(applied.indexes_rebuilt(), 1);
5402 assert_ne!(applied.accepted_head(), target.accepted_head());
5403 let terminal_target = schema_application_target(&db).expect("terminal target should issue");
5404 let terminal_proposal = validation_migration_proposal(
5405 ValidationMigrationShape::Clean,
5406 true,
5407 terminal_target.accepted_head().clone(),
5408 terminal_target.database_identity(),
5409 store_identity,
5410 );
5411 ensure_generated_schema_application_admitted(&db, &terminal_proposal)
5412 .expect("terminal record must not block generated startup");
5413
5414 let store = db
5415 .store_handle(MIGRATION_EXECUTION_STORE_PATH)
5416 .expect("migration execution store should resolve");
5417 let runtime = db
5418 .accepted_runtime_entity_for_path("MigratingItem")
5419 .expect("published candidate entity should resolve");
5420 let selection = store
5421 .with_schema(|schema| {
5422 schema.current_accepted_catalog_selection(
5423 runtime.entity_tag(),
5424 runtime.entity_path(),
5425 runtime.store_path(),
5426 )
5427 })
5428 .expect("candidate selection should remain readable")
5429 .expect("candidate selection should exist");
5430 let contract = crate::db::data::AcceptedStructuralRowAuthority::from_catalog_selection(
5431 runtime.entity_path(),
5432 &selection,
5433 )
5434 .expect("candidate row authority should compile")
5435 .into_row_contract();
5436 let mut values = Vec::new();
5437 store
5438 .with_data(|data| {
5439 data.visit_entries(|key, row| {
5440 let decoded = DecodedDataStoreKey::try_from_raw(key)
5441 .expect("rewritten key should decode");
5442 let reader =
5443 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
5444 row, &contract,
5445 )
5446 .expect("rewritten row should use the candidate layout");
5447 reader
5448 .validate_primary_key(&decoded)
5449 .expect("rewritten row and key should remain bound");
5450 values.push(
5451 reader
5452 .required_value_by_contract(1)
5453 .expect("candidate value slot should decode"),
5454 );
5455 Ok::<StoreVisit, InternalError>(StoreVisit::Continue)
5456 })
5457 })
5458 .expect("rewritten row scan should complete");
5459 assert_eq!(
5460 values,
5461 vec![
5462 crate::value::Value::Nat64(7),
5463 crate::value::Value::Nat64(8),
5464 crate::value::Value::Nat64(9),
5465 ],
5466 );
5467 assert_eq!(store.with_index(IndexStore::len), 3);
5468 let accepted = store
5469 .with_schema(SchemaStore::current_accepted_schema_bundle)
5470 .expect("published candidate bundle should remain readable")
5471 .expect("published candidate bundle should exist");
5472 let entity_source = EntitySourceKey::try_new("MigratingItem")
5473 .expect("migration entity source should admit");
5474 let entity_tag = accepted
5475 .source_bindings_for_tests()
5476 .entity(&entity_source)
5477 .expect("candidate entity source should remain bound");
5478 let old_value =
5479 FieldSourceKey::try_new("old_value").expect("predecessor source should admit");
5480 let current_value =
5481 FieldSourceKey::try_new("value").expect("candidate source should admit");
5482 assert_eq!(
5483 accepted
5484 .source_bindings_for_tests()
5485 .field(entity_tag, &old_value),
5486 None,
5487 );
5488 assert!(
5489 accepted
5490 .source_bindings_for_tests()
5491 .field(entity_tag, ¤t_value)
5492 .is_some(),
5493 );
5494 ensure_schema_migration_ready_for_ordinary_operations()
5495 .expect("terminal publication must clear the database-wide gate");
5496 }
5497
5498 #[cfg(feature = "migration")]
5499 #[test]
5500 #[expect(
5501 clippy::too_many_lines,
5502 reason = "all four finding families share one ordered historical scan fixture"
5503 )]
5504 fn physical_migration_validation_reports_every_typed_finding_family_without_writes() {
5505 use std::convert::Infallible;
5506
5507 use super::migrate_schema;
5508 use crate::db::{
5509 data::StoreVisit,
5510 schema::{SchemaMigrationCommand, SchemaMigrationFindingKind, SchemaMigrationPhase},
5511 };
5512
5513 let db = Db::<MigrationFindingCanister>::new(
5514 &MIGRATION_FINDING_REGISTRY,
5515 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5516 );
5517 drive_startup_recovery_to_completion(&db);
5518 let initial_target = schema_application_target(&db).expect("initial target should issue");
5519 let store_identity = initial_target
5520 .stores()
5521 .first()
5522 .expect("migration finding store should exist")
5523 .identity();
5524 let initial = validation_migration_proposal(
5525 ValidationMigrationShape::AllFindingFamilies,
5526 false,
5527 initial_target.accepted_head().clone(),
5528 initial_target.database_identity(),
5529 store_identity,
5530 );
5531 apply_schema(&db, &initial).expect("initial finding schema should publish");
5532
5533 let session = DbSession::<MigrationFindingCanister>::new(
5534 &MIGRATION_FINDING_REGISTRY,
5535 &crate::db::RequestExecutionRoot::__new_runtime_root(),
5536 );
5537 session
5538 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5539 entity: "MigrationTarget".to_string(),
5540 patch: DynamicStructuralPatch::new(vec![(
5541 "id".to_string(),
5542 DynamicWriteCell::Value(InputValue::nat64(7)),
5543 )]),
5544 })
5545 .expect("relation target should insert");
5546 for (id, value) in [(1, 9), (2, 8), (3, 7), (4, 7), (5, 300)] {
5547 session
5548 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5549 entity: "MigratingItem".to_string(),
5550 patch: DynamicStructuralPatch::new(vec![
5551 (
5552 "id".to_string(),
5553 DynamicWriteCell::Value(InputValue::nat64(id)),
5554 ),
5555 (
5556 "old_value".to_string(),
5557 DynamicWriteCell::Value(InputValue::int64(value)),
5558 ),
5559 ]),
5560 })
5561 .expect("predecessor finding row should insert");
5562 }
5563 let store = db
5564 .store_handle(MIGRATION_FINDING_STORE_PATH)
5565 .expect("migration finding store should resolve");
5566 let row_bytes = || {
5567 store.with_data(|data| {
5568 let mut rows = Vec::new();
5569 let result: Result<(), Infallible> = data.visit_entries(|key, row| {
5570 rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
5571 Ok(StoreVisit::Continue)
5572 });
5573 result.expect("infallible row visit should complete");
5574 rows
5575 })
5576 };
5577 let before_rows = row_bytes();
5578
5579 let target = schema_application_target(&db).expect("migration target should issue");
5580 let proposal = validation_migration_proposal(
5581 ValidationMigrationShape::AllFindingFamilies,
5582 true,
5583 target.accepted_head().clone(),
5584 target.database_identity(),
5585 store_identity,
5586 );
5587 let plan = proposal
5588 .migration()
5589 .expect("migration plan should exist")
5590 .digest();
5591 let command = || SchemaMigrationCommand::Advance {
5592 expected_database: target.database_identity(),
5593 expected_head: target.accepted_head().clone(),
5594 expected_plan: plan,
5595 acknowledged_finding_page: None,
5596 };
5597 assert_eq!(
5598 migrate_schema(&db, &proposal, command())
5599 .expect("finding migration should prepare")
5600 .phase(),
5601 SchemaMigrationPhase::Prepared,
5602 );
5603 assert_eq!(
5604 migrate_schema(&db, &proposal, command())
5605 .expect("finding migration should enter validation")
5606 .phase(),
5607 SchemaMigrationPhase::Validating,
5608 );
5609 let rejected =
5610 migrate_schema(&db, &proposal, command()).expect("validation should report findings");
5611 assert_eq!(rejected.phase(), SchemaMigrationPhase::Rejected);
5612 assert_eq!(rejected.rows_validated(), 5);
5613 assert_eq!(
5614 rejected
5615 .findings()
5616 .iter()
5617 .map(crate::db::schema::SchemaMigrationFinding::kind)
5618 .collect::<Vec<_>>(),
5619 vec![
5620 SchemaMigrationFindingKind::Constraint,
5621 SchemaMigrationFindingKind::Relation,
5622 SchemaMigrationFindingKind::UniqueIndex,
5623 SchemaMigrationFindingKind::Transform,
5624 ],
5625 );
5626 assert_eq!(
5627 row_bytes(),
5628 before_rows,
5629 "rejected validation must not rewrite accepted rows"
5630 );
5631 assert_eq!(
5632 store.with_index(IndexStore::len),
5633 0,
5634 "a rejected page must not publish any staged generation"
5635 );
5636 }
5637
5638 #[cfg(feature = "migration")]
5639 #[test]
5640 fn exact_migration_retry_binds_the_terminal_head_not_the_predecessor_head() {
5641 use super::exact_migration_replay_target;
5642
5643 let db = Db::<EvolutionCanister>::new(
5644 &EVOLUTION_REGISTRY,
5645 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5646 );
5647 drive_startup_recovery_to_completion(&db);
5648 let initial_target = schema_application_target(&db).expect("initial target should issue");
5649 let (proposal, _, _) = generated_check_proposal(
5650 initial_target.accepted_head().clone(),
5651 "migration-retry-initial",
5652 false,
5653 initial_target.database_identity(),
5654 initial_target
5655 .stores()
5656 .first()
5657 .expect("test store should exist")
5658 .identity(),
5659 );
5660 apply_schema(&db, &proposal).expect("initial schema should publish");
5661 let current_target = schema_application_target(&db).expect("current target should issue");
5662 assert_ne!(
5663 current_target.accepted_head(),
5664 initial_target.accepted_head(),
5665 );
5666
5667 let receipt = SchemaChangeReceipt::new(
5668 current_target.database_identity(),
5669 SchemaSubmissionKey::try_new("migration/retry")
5670 .expect("migration submission should admit"),
5671 SchemaProposalDigest::from_bytes([0x77; 32]),
5672 initial_target.accepted_head().clone(),
5673 SchemaChangeOutcome::Applied {
5674 accepted_head: current_target.accepted_head().clone(),
5675 },
5676 )
5677 .expect("terminal migration receipt should admit");
5678 let record = SchemaApplicationRecord::new(receipt, Vec::new())
5679 .expect("terminal migration record should admit");
5680
5681 assert_eq!(
5682 exact_migration_replay_target(&db, current_target.database_identity(), &record,)
5683 .expect("exact retry should resolve the terminal target"),
5684 current_target,
5685 );
5686 }
5687}