1use crate::db::codec::{
7 finalize_hash_sha256, new_hash_sha256_prefixed, write_hash_str_u32, write_hash_tag_u8,
8 write_hash_u64,
9};
10use crate::error::{ConstraintDiagnostic, InternalError};
11use candid::CandidType;
12use icydb_schema::{
13 ExpectedAcceptedHead, SchemaProposalDigest, SchemaSubmissionKey, TargetDatabaseIdentity,
14 TargetStoreIdentity,
15};
16use serde::Deserialize;
17use sha2::Digest;
18
19const SCHEMA_CHANGE_JOB_ID_PROFILE: &[u8] = b"icydb.schema-application.job-id.v1";
20const MAX_SCHEMA_CHANGE_ACTIVATIONS: usize = 512;
21
22#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
29#[serde(transparent)]
30pub struct SchemaChangeJobId([u8; 32]);
31
32impl SchemaChangeJobId {
33 fn from_bytes(bytes: [u8; 32]) -> Result<Self, InternalError> {
34 if bytes == [0; 32] {
35 return Err(InternalError::store_corruption());
36 }
37 Ok(Self(bytes))
38 }
39
40 #[must_use]
42 pub const fn to_bytes(self) -> [u8; 32] {
43 self.0
44 }
45
46 fn validate(self) -> Result<(), InternalError> {
47 if self.0 == [0; 32] {
48 return Err(InternalError::store_corruption());
49 }
50 Ok(())
51 }
52}
53
54#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
61pub struct SchemaChangeJob {
62 id: SchemaChangeJobId,
63}
64
65impl SchemaChangeJob {
66 pub(in crate::db) const fn new(id: SchemaChangeJobId) -> Self {
67 Self { id }
68 }
69
70 #[must_use]
72 pub const fn id(self) -> SchemaChangeJobId {
73 self.id
74 }
75}
76
77#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
84pub enum SchemaChangeValidationPhase {
85 Forward,
87 Verify,
89}
90
91#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
98pub enum SchemaChangeProgressStatus {
99 Started,
101 Advanced {
103 phase: SchemaChangeValidationPhase,
105 rows_scanned: u64,
107 },
108 Findings {
110 phase: SchemaChangeValidationPhase,
112 rows_scanned: u64,
114 page_sequence: u64,
116 findings: Vec<ConstraintDiagnostic>,
118 },
119 Restarted {
121 rows_scanned: u64,
123 },
124 Applied,
126 Aborted,
128}
129
130#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
137pub struct SchemaChangeProgress {
138 receipt: SchemaChangeReceipt,
139 status: SchemaChangeProgressStatus,
140}
141
142impl SchemaChangeProgress {
143 pub(in crate::db) const fn new(
144 receipt: SchemaChangeReceipt,
145 status: SchemaChangeProgressStatus,
146 ) -> Self {
147 Self { receipt, status }
148 }
149
150 #[must_use]
152 pub const fn receipt(&self) -> &SchemaChangeReceipt {
153 &self.receipt
154 }
155
156 #[must_use]
158 pub const fn status(&self) -> &SchemaChangeProgressStatus {
159 &self.status
160 }
161}
162
163#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
170pub enum SchemaChangeOutcome {
171 NoOp {
173 accepted_head: ExpectedAcceptedHead,
175 },
176 Applied {
178 accepted_head: ExpectedAcceptedHead,
180 },
181 Pending {
183 job: SchemaChangeJob,
185 candidate_head: ExpectedAcceptedHead,
187 },
188 Aborted {
190 accepted_head: ExpectedAcceptedHead,
192 },
193}
194
195#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
202pub struct SchemaChangeReceipt {
203 database_identity: TargetDatabaseIdentity,
204 submission_key: SchemaSubmissionKey,
205 proposal_digest: SchemaProposalDigest,
206 prior_head: ExpectedAcceptedHead,
207 outcome: SchemaChangeOutcome,
208}
209
210impl SchemaChangeReceipt {
211 pub(in crate::db) fn new(
212 database_identity: TargetDatabaseIdentity,
213 submission_key: SchemaSubmissionKey,
214 proposal_digest: SchemaProposalDigest,
215 prior_head: ExpectedAcceptedHead,
216 outcome: SchemaChangeOutcome,
217 ) -> Result<Self, InternalError> {
218 let receipt = Self {
219 database_identity,
220 submission_key,
221 proposal_digest,
222 prior_head,
223 outcome,
224 };
225 receipt.validate()?;
226 Ok(receipt)
227 }
228
229 #[must_use]
231 pub const fn database_identity(&self) -> TargetDatabaseIdentity {
232 self.database_identity
233 }
234
235 #[must_use]
237 pub const fn submission_key(&self) -> &SchemaSubmissionKey {
238 &self.submission_key
239 }
240
241 #[must_use]
243 pub const fn proposal_digest(&self) -> SchemaProposalDigest {
244 self.proposal_digest
245 }
246
247 #[must_use]
249 pub const fn prior_head(&self) -> &ExpectedAcceptedHead {
250 &self.prior_head
251 }
252
253 #[must_use]
255 pub const fn outcome(&self) -> &SchemaChangeOutcome {
256 &self.outcome
257 }
258
259 pub(in crate::db) fn is_exact_submission(
260 &self,
261 database_identity: TargetDatabaseIdentity,
262 submission_key: &SchemaSubmissionKey,
263 proposal_digest: SchemaProposalDigest,
264 prior_head: &ExpectedAcceptedHead,
265 ) -> bool {
266 self.database_identity == database_identity
267 && &self.submission_key == submission_key
268 && self.proposal_digest == proposal_digest
269 && &self.prior_head == prior_head
270 }
271
272 pub(in crate::db) fn validate(&self) -> Result<(), InternalError> {
273 if self.database_identity.to_bytes() == [0; 32]
274 || self.proposal_digest.to_bytes() == [0; 32]
275 {
276 return Err(InternalError::store_corruption());
277 }
278 validate_head(&self.prior_head, true)?;
279 match &self.outcome {
280 SchemaChangeOutcome::NoOp { accepted_head } => {
281 validate_head(accepted_head, true)?;
282 if accepted_head != &self.prior_head {
283 return Err(InternalError::store_corruption());
284 }
285 }
286 SchemaChangeOutcome::Applied { accepted_head }
287 | SchemaChangeOutcome::Aborted { accepted_head } => {
288 validate_head(accepted_head, false)?;
289 if accepted_head == &self.prior_head {
290 return Err(InternalError::store_corruption());
291 }
292 }
293 SchemaChangeOutcome::Pending {
294 job,
295 candidate_head,
296 } => {
297 job.id.validate()?;
298 validate_head(candidate_head, false)?;
299 if candidate_head == &self.prior_head
300 || job.id
301 != derive_schema_change_job_id(
302 self.database_identity,
303 &self.submission_key,
304 self.proposal_digest,
305 &self.prior_head,
306 )?
307 {
308 return Err(InternalError::store_corruption());
309 }
310 }
311 }
312 Ok(())
313 }
314}
315
316#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
323pub(in crate::db) struct SchemaChangeActivation {
324 store: TargetStoreIdentity,
325 entity_tag: u64,
326 constraint_id: u32,
327}
328
329impl SchemaChangeActivation {
330 pub(in crate::db) fn new(
331 store: TargetStoreIdentity,
332 entity_tag: u64,
333 constraint_id: u32,
334 ) -> Result<Self, InternalError> {
335 if entity_tag == 0 || constraint_id == 0 {
336 return Err(InternalError::store_invariant());
337 }
338 Ok(Self {
339 store,
340 entity_tag,
341 constraint_id,
342 })
343 }
344
345 pub(in crate::db) const fn store(&self) -> TargetStoreIdentity {
346 self.store
347 }
348
349 pub(in crate::db) const fn entity_tag(&self) -> u64 {
350 self.entity_tag
351 }
352
353 pub(in crate::db) const fn constraint_id(&self) -> u32 {
354 self.constraint_id
355 }
356
357 fn validate(&self) -> Result<(), InternalError> {
358 if self.store.to_bytes() == [0; 32] || self.entity_tag == 0 || self.constraint_id == 0 {
359 return Err(InternalError::store_corruption());
360 }
361 Ok(())
362 }
363}
364
365#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
373pub(in crate::db) struct SchemaApplicationRecord {
374 receipt: SchemaChangeReceipt,
375 activations: Vec<SchemaChangeActivation>,
376}
377
378impl SchemaApplicationRecord {
379 pub(in crate::db) fn new(
380 receipt: SchemaChangeReceipt,
381 activations: Vec<SchemaChangeActivation>,
382 ) -> Result<Self, InternalError> {
383 let record = Self {
384 receipt,
385 activations,
386 };
387 record.validate()?;
388 Ok(record)
389 }
390
391 pub(in crate::db) const fn receipt(&self) -> &SchemaChangeReceipt {
392 &self.receipt
393 }
394
395 pub(in crate::db) const fn activations(&self) -> &[SchemaChangeActivation] {
396 self.activations.as_slice()
397 }
398
399 pub(in crate::db) fn validate(&self) -> Result<(), InternalError> {
400 self.receipt.validate()?;
401 if self.activations.len() > MAX_SCHEMA_CHANGE_ACTIVATIONS
402 || self.activations.windows(2).any(|pair| {
403 (pair[0].store, pair[0].entity_tag, pair[0].constraint_id)
404 >= (pair[1].store, pair[1].entity_tag, pair[1].constraint_id)
405 })
406 || self
407 .activations
408 .iter()
409 .any(|activation| activation.validate().is_err())
410 {
411 return Err(InternalError::store_corruption());
412 }
413
414 let pending = matches!(self.receipt.outcome(), SchemaChangeOutcome::Pending { .. });
415 if pending == self.activations.is_empty() {
416 return Err(InternalError::store_corruption());
417 }
418 Ok(())
419 }
420}
421
422pub(in crate::db) fn derive_schema_change_job_id(
423 database_identity: TargetDatabaseIdentity,
424 submission_key: &SchemaSubmissionKey,
425 proposal_digest: SchemaProposalDigest,
426 prior_head: &ExpectedAcceptedHead,
427) -> Result<SchemaChangeJobId, InternalError> {
428 validate_head(prior_head, true)?;
429 let mut hasher = new_hash_sha256_prefixed(SCHEMA_CHANGE_JOB_ID_PROFILE);
430 hasher.update(database_identity.to_bytes());
431 write_hash_str_u32(&mut hasher, submission_key.as_str());
432 hasher.update(proposal_digest.to_bytes());
433 write_head(&mut hasher, prior_head);
434 SchemaChangeJobId::from_bytes(finalize_hash_sha256(hasher))
435}
436
437fn validate_head(head: &ExpectedAcceptedHead, empty_allowed: bool) -> Result<(), InternalError> {
438 match head {
439 ExpectedAcceptedHead::Empty if empty_allowed => Ok(()),
440 ExpectedAcceptedHead::Exact { revision: 0, .. } | ExpectedAcceptedHead::Empty => {
441 Err(InternalError::store_corruption())
442 }
443 ExpectedAcceptedHead::Exact { fingerprint, .. } if fingerprint.to_bytes() == [0; 32] => {
444 Err(InternalError::store_corruption())
445 }
446 ExpectedAcceptedHead::Exact { .. } => Ok(()),
447 }
448}
449
450fn write_head(hasher: &mut sha2::Sha256, head: &ExpectedAcceptedHead) {
451 match head {
452 ExpectedAcceptedHead::Empty => write_hash_tag_u8(hasher, 0),
453 ExpectedAcceptedHead::Exact {
454 revision,
455 fingerprint,
456 } => {
457 write_hash_tag_u8(hasher, 1);
458 write_hash_u64(hasher, *revision);
459 hasher.update(fingerprint.to_bytes());
460 }
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::{
467 SchemaApplicationRecord, SchemaChangeActivation, SchemaChangeJob, SchemaChangeOutcome,
468 SchemaChangeReceipt, derive_schema_change_job_id,
469 };
470 use icydb_schema::{
471 ExpectedAcceptedHead, ExpectedSchemaFingerprint, SchemaProposalDigest, SchemaSubmissionKey,
472 TargetDatabaseIdentity, TargetStoreIdentity,
473 };
474
475 #[test]
476 fn schema_change_job_identity_covers_the_complete_idempotency_tuple() {
477 let database = TargetDatabaseIdentity::from_bytes([0x11; 32]);
478 let submission =
479 SchemaSubmissionKey::try_new("job-id").expect("submission key should admit");
480 let digest = SchemaProposalDigest::from_bytes([0x22; 32]);
481 let empty = derive_schema_change_job_id(
482 database,
483 &submission,
484 digest,
485 &ExpectedAcceptedHead::Empty,
486 )
487 .expect("empty-head identity should derive");
488 let exact = derive_schema_change_job_id(
489 database,
490 &submission,
491 digest,
492 &ExpectedAcceptedHead::Exact {
493 revision: 1,
494 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x33; 32]),
495 },
496 )
497 .expect("exact-head identity should derive");
498
499 assert_ne!(empty, exact);
500 }
501
502 #[test]
503 fn pending_and_terminal_record_state_is_exact() {
504 let database = TargetDatabaseIdentity::from_bytes([0x11; 32]);
505 let submission =
506 SchemaSubmissionKey::try_new("state").expect("submission key should admit");
507 let digest = SchemaProposalDigest::from_bytes([0x22; 32]);
508 let head = ExpectedAcceptedHead::Empty;
509 let job = SchemaChangeJob::new(
510 derive_schema_change_job_id(database, &submission, digest, &head)
511 .expect("job identity should derive"),
512 );
513 let pending = SchemaChangeReceipt::new(
514 database,
515 submission.clone(),
516 digest,
517 head.clone(),
518 SchemaChangeOutcome::Pending {
519 job,
520 candidate_head: ExpectedAcceptedHead::Exact {
521 revision: 1,
522 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x33; 32]),
523 },
524 },
525 )
526 .expect("pending receipt should admit");
527 assert!(
528 SchemaApplicationRecord::new(pending.clone(), Vec::new()).is_err(),
529 "pending records require exact activation ownership",
530 );
531 let activation =
532 SchemaChangeActivation::new(TargetStoreIdentity::from_bytes([0x44; 32]), 1, 1)
533 .expect("activation should admit");
534 SchemaApplicationRecord::new(pending, vec![activation])
535 .expect("pending record with activation should admit");
536
537 let terminal = SchemaChangeReceipt::new(
538 database,
539 submission,
540 digest,
541 head,
542 SchemaChangeOutcome::Aborted {
543 accepted_head: ExpectedAcceptedHead::Exact {
544 revision: 1,
545 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x55; 32]),
546 },
547 },
548 )
549 .expect("terminal receipt should admit");
550 assert!(
551 SchemaApplicationRecord::new(
552 terminal,
553 vec![
554 SchemaChangeActivation::new(TargetStoreIdentity::from_bytes([0x44; 32]), 1, 1,)
555 .expect("activation should admit"),
556 ],
557 )
558 .is_err(),
559 "terminal records cannot retain activation state",
560 );
561 }
562
563 #[test]
564 fn schema_change_receipt_outcome_heads_have_exact_temporal_closure() {
565 let database = TargetDatabaseIdentity::from_bytes([0x11; 32]);
566 let digest = SchemaProposalDigest::from_bytes([0x22; 32]);
567 let prior = ExpectedAcceptedHead::Exact {
568 revision: 7,
569 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x33; 32]),
570 };
571 let changed = ExpectedAcceptedHead::Exact {
572 revision: 8,
573 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x44; 32]),
574 };
575
576 SchemaChangeReceipt::new(
577 database,
578 SchemaSubmissionKey::try_new("noop").expect("submission key should admit"),
579 digest,
580 prior.clone(),
581 SchemaChangeOutcome::NoOp {
582 accepted_head: prior.clone(),
583 },
584 )
585 .expect("no-op must retain the exact prior head");
586 assert!(
587 SchemaChangeReceipt::new(
588 database,
589 SchemaSubmissionKey::try_new("invalid-noop").expect("submission key should admit"),
590 digest,
591 prior.clone(),
592 SchemaChangeOutcome::NoOp {
593 accepted_head: changed.clone(),
594 },
595 )
596 .is_err(),
597 );
598 assert!(
599 SchemaChangeReceipt::new(
600 database,
601 SchemaSubmissionKey::try_new("invalid-applied")
602 .expect("submission key should admit"),
603 digest,
604 prior.clone(),
605 SchemaChangeOutcome::Applied {
606 accepted_head: prior.clone(),
607 },
608 )
609 .is_err(),
610 );
611 SchemaChangeReceipt::new(
612 database,
613 SchemaSubmissionKey::try_new("applied").expect("submission key should admit"),
614 digest,
615 prior.clone(),
616 SchemaChangeOutcome::Applied {
617 accepted_head: changed.clone(),
618 },
619 )
620 .expect("applied receipt must identify a different exact head");
621 assert!(
622 SchemaChangeReceipt::new(
623 database,
624 SchemaSubmissionKey::try_new("invalid-abort").expect("submission key should admit"),
625 digest,
626 prior.clone(),
627 SchemaChangeOutcome::Aborted {
628 accepted_head: prior.clone(),
629 },
630 )
631 .is_err(),
632 );
633 SchemaChangeReceipt::new(
634 database,
635 SchemaSubmissionKey::try_new("aborted").expect("submission key should admit"),
636 digest,
637 prior,
638 SchemaChangeOutcome::Aborted {
639 accepted_head: changed,
640 },
641 )
642 .expect("aborted receipt must retain the post-abort accepted head");
643 }
644}