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