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