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