1#[cfg(feature = "sql")]
7mod intent;
8
9use crate::db::codec::{ByteDecodeError, ByteReader};
10
11use serde::Deserialize;
12use std::{error::Error as StdError, fmt};
13
14#[cfg(feature = "sql")]
15pub(in crate::db) use intent::CanonicalMutationIntent;
16
17pub const MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES: usize = 256;
19pub const MAX_MUTATION_JOB_CONTINUATION_BYTES: usize = 2 * 1024;
21pub const MAX_MUTATION_JOB_INTENT_BYTES: usize = 16 * 1024;
23pub const MAX_MUTATION_JOB_RECEIPT_BYTES: usize = 8 * 1024;
25pub const MAX_MUTATION_JOB_RECORD_BYTES: usize = 64 * 1024;
27
28pub const MAX_MUTATION_JOB_STEP_KEYS_SCANNED: u64 = 4_096;
30pub const MAX_MUTATION_JOB_STEP_ROWS_UPDATED: u64 =
32 crate::db::executor::MAX_MUTATION_PROGRESS_BATCH_ROWS_AT_MAX_INDEX_FANOUT as u64;
33
34#[derive(
40 serde::Serialize, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd,
41)]
42pub struct MutationJobId([u8; 32]);
43
44impl MutationJobId {
45 pub fn try_from_bytes(bytes: [u8; 32]) -> Result<Self, MutationJobError> {
47 if bytes == [0; 32] {
48 return Err(MutationJobError::InvalidJobId);
49 }
50 Ok(Self(bytes))
51 }
52
53 #[must_use]
55 pub const fn to_bytes(self) -> [u8; 32] {
56 self.0
57 }
58
59 pub(in crate::db) fn validate(self) -> Result<(), MutationJobError> {
60 if self.0 == [0; 32] {
61 return Err(MutationJobError::InvalidJobId);
62 }
63 Ok(())
64 }
65}
66
67#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
69pub struct MutationJobIdempotencyKey(String);
70
71impl MutationJobIdempotencyKey {
72 pub fn new(value: impl Into<String>) -> Result<Self, MutationJobError> {
74 let value = value.into();
75 if value.is_empty() || value.len() > MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES {
76 return Err(MutationJobError::InvalidIdempotencyKey);
77 }
78 Ok(Self(value))
79 }
80
81 #[must_use]
83 pub const fn as_str(&self) -> &str {
84 self.0.as_str()
85 }
86
87 const fn validate(&self) -> Result<(), MutationJobError> {
88 if self.0.is_empty() || self.0.len() > MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES {
89 return Err(MutationJobError::InvalidIdempotencyKey);
90 }
91 Ok(())
92 }
93}
94
95#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
97pub enum MutationJobRestartReason {
98 AcceptedSchemaChanged,
100 TargetAllocationChanged,
102 IntentIneligible,
104 BatchPolicyChanged,
106 UnsupportedContinuation,
108 ManagedTimestampRegression,
110 CandidateExceedsBatchPolicy,
112 ExecutionBudgetPolicyExceeded,
114}
115
116#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
118pub enum MutationJobTargetFailureReason {
119 StagingByteBudgetExceeded,
121 Other,
123}
124
125#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
127pub enum MutationJobStatus {
128 Active,
130 Completed,
132 RestartRequired(MutationJobRestartReason),
134}
135
136#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
138pub enum MutationJobPhase {
139 Forward,
141 Verify,
143}
144
145#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
147pub struct MutationJobState {
148 pub job_id: MutationJobId,
150 pub sequence: u64,
152 pub status: MutationJobStatus,
154 pub phase: MutationJobPhase,
156 pub keys_scanned_total: u64,
158 pub rows_updated_total: u64,
160 pub verify_restarts_total: u64,
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
166pub struct MutationJobAdvanceRequest {
167 pub job_id: MutationJobId,
169 pub expected_sequence: u64,
171 pub idempotency_key: MutationJobIdempotencyKey,
173}
174
175impl MutationJobAdvanceRequest {
176 #[must_use]
178 pub const fn new(
179 job_id: MutationJobId,
180 expected_sequence: u64,
181 idempotency_key: MutationJobIdempotencyKey,
182 ) -> Self {
183 Self {
184 job_id,
185 expected_sequence,
186 idempotency_key,
187 }
188 }
189
190 fn validate(&self) -> Result<(), MutationJobError> {
191 self.job_id.validate()?;
192 self.idempotency_key.validate()
193 }
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
198pub struct MutationJobAdvanceReceipt {
199 pub request_sequence: u64,
201 pub committed_sequence: u64,
203 pub status: MutationJobStatus,
205 pub phase: MutationJobPhase,
207 pub keys_scanned: u64,
209 pub rows_updated: u64,
211 pub keys_scanned_total: u64,
213 pub rows_updated_total: u64,
215 pub verify_restarts_total: u64,
217}
218
219#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
221pub enum MutationJobPayloadKind {
222 Intent,
224 Continuation,
226 Receipt,
228 Record,
230}
231
232#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
234pub enum MutationJobError {
235 InvalidJobId,
237 InvalidIdempotencyKey,
239 IdentityConflict,
241 NotFound,
243 StaleSequence { expected: u64, actual: u64 },
245 Active,
247 Completed,
249 RestartRequired(MutationJobRestartReason),
251 PayloadTooLarge {
253 kind: MutationJobPayloadKind,
254 limit: u64,
255 observed: u64,
256 },
257 AuthorityMismatch,
259 IneligibleIntent,
261 CapacityExceeded,
263 CounterOverflow,
265 CorruptProgressStore,
267 IncompatibleProgressFormat,
269 CommitCorruption,
271 TargetMutationFailed(MutationJobTargetFailureReason),
273 TargetQueryFailed,
275 Internal,
277 ExecutionBudgetExceeded {
279 resource: u64,
280 limit: u64,
281 observed: u64,
282 scope: u64,
283 lane: u64,
284 normalized_shape_fingerprint_prefix: u64,
285 },
286}
287
288#[cfg(feature = "sql")]
289impl MutationJobError {
290 pub(in crate::db) fn from_internal_error(error: &crate::error::InternalError) -> Self {
293 use icydb_diagnostic_code::{
294 DiagnosticDetail, DiagnosticFactTag as Fact, RuntimeBoundaryCode,
295 };
296
297 if !matches!(
298 error.diagnostic().detail(),
299 Some(DiagnosticDetail::RuntimeBoundary {
300 boundary: RuntimeBoundaryCode::ExecutionBudgetExceeded,
301 })
302 ) {
303 return Self::Internal;
304 }
305 let facts = error.diagnostic_facts();
306 let [
307 Some(resource),
308 Some(limit),
309 Some(observed),
310 Some(scope),
311 Some(lane),
312 Some(prefix),
313 ] = [
314 Fact::BudgetResource,
315 Fact::Limit,
316 Fact::Actual,
317 Fact::ExecutionBudgetScope,
318 Fact::ExecutionLane,
319 Fact::QueryShapeFingerprintPrefix,
320 ]
321 .map(|tag| {
322 facts
323 .iter()
324 .find(|(fact, _)| *fact == tag)
325 .map(|(_, value)| *value)
326 })
327 else {
328 return Self::Internal;
329 };
330 Self::ExecutionBudgetExceeded {
331 resource,
332 limit,
333 observed,
334 scope,
335 lane,
336 normalized_shape_fingerprint_prefix: prefix,
337 }
338 }
339
340 pub(in crate::db) fn from_query_error(error: crate::db::query::intent::QueryError) -> Self {
341 match error {
342 crate::db::query::intent::QueryError::Execute(error) => {
343 if matches!(
344 error.diagnostic().detail(),
345 Some(icydb_diagnostic_code::DiagnosticDetail::SqlWriteBoundary { .. })
346 ) {
347 Self::IneligibleIntent
348 } else {
349 Self::from_internal_error(error.as_internal())
350 }
351 }
352 _ => Self::IneligibleIntent,
353 }
354 }
355}
356
357impl fmt::Display for MutationJobError {
358 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
359 formatter.write_str("mutation job operation failed")
360 }
361}
362
363impl From<ByteDecodeError> for MutationJobError {
364 fn from(_: ByteDecodeError) -> Self {
365 Self::CorruptProgressStore
366 }
367}
368
369impl StdError for MutationJobError {}
370
371#[derive(Clone, Debug, Eq, PartialEq)]
372struct RetainedMutationJobReceipt {
373 receipt: MutationJobAdvanceReceipt,
374 idempotency_key: MutationJobIdempotencyKey,
375}
376
377#[derive(Clone, Debug, Eq, PartialEq)]
378pub(in crate::db) struct MutationJobRecord {
379 state: MutationJobState,
380 canonical_intent: Vec<u8>,
381 engine_continuation: Vec<u8>,
382 last_receipt: Option<RetainedMutationJobReceipt>,
383}
384
385impl MutationJobRecord {
386 pub(in crate::db) fn new(
387 job_id: MutationJobId,
388 canonical_intent: Vec<u8>,
389 engine_continuation: Vec<u8>,
390 ) -> Result<Self, MutationJobError> {
391 let record = Self {
392 state: MutationJobState {
393 job_id,
394 sequence: 0,
395 status: MutationJobStatus::Active,
396 phase: MutationJobPhase::Forward,
397 keys_scanned_total: 0,
398 rows_updated_total: 0,
399 verify_restarts_total: 0,
400 },
401 canonical_intent,
402 engine_continuation,
403 last_receipt: None,
404 };
405 record.validate()?;
406 Ok(record)
407 }
408
409 pub(in crate::db) const fn state(&self) -> &MutationJobState {
410 &self.state
411 }
412
413 pub(in crate::db) const fn canonical_intent(&self) -> &[u8] {
414 self.canonical_intent.as_slice()
415 }
416
417 pub(in crate::db) const fn engine_continuation(&self) -> &[u8] {
418 self.engine_continuation.as_slice()
419 }
420
421 pub(in crate::db) fn ensure_cancelable_at_sequence(
423 &self,
424 expected_sequence: u64,
425 ) -> Result<&[u8], MutationJobError> {
426 self.validate()?;
427 if self.state.sequence != expected_sequence {
428 return Err(MutationJobError::StaleSequence {
429 expected: expected_sequence,
430 actual: self.state.sequence,
431 });
432 }
433 if self.state.sequence != 0 {
434 return Err(MutationJobError::StaleSequence {
435 expected: 0,
436 actual: self.state.sequence,
437 });
438 }
439 if self.state.status != MutationJobStatus::Active
440 || self.state.phase != MutationJobPhase::Forward
441 || self.state.keys_scanned_total != 0
442 || self.state.rows_updated_total != 0
443 || self.state.verify_restarts_total != 0
444 || self.last_receipt.is_some()
445 {
446 return Err(MutationJobError::CorruptProgressStore);
447 }
448 Ok(self.engine_continuation())
449 }
450
451 pub(in crate::db) fn exact_replay(
452 &self,
453 request: &MutationJobAdvanceRequest,
454 ) -> Result<Option<&MutationJobAdvanceReceipt>, MutationJobError> {
455 self.validate()?;
456 request.validate()?;
457 if request.job_id != self.state.job_id {
458 return Err(MutationJobError::NotFound);
459 }
460 Ok(self.last_receipt.as_ref().and_then(|retained| {
461 (retained.receipt.request_sequence == request.expected_sequence
462 && retained.idempotency_key == request.idempotency_key)
463 .then_some(&retained.receipt)
464 }))
465 }
466
467 pub(in crate::db) fn ensure_can_advance(
468 &self,
469 request: &MutationJobAdvanceRequest,
470 ) -> Result<(), MutationJobError> {
471 self.validate()?;
472 request.validate()?;
473 if request.job_id != self.state.job_id {
474 return Err(MutationJobError::NotFound);
475 }
476 if self.state.sequence != request.expected_sequence {
477 return Err(MutationJobError::StaleSequence {
478 expected: request.expected_sequence,
479 actual: self.state.sequence,
480 });
481 }
482 match self.state.status {
483 MutationJobStatus::Active => Ok(()),
484 MutationJobStatus::Completed => Err(MutationJobError::Completed),
485 MutationJobStatus::RestartRequired(reason) => {
486 Err(MutationJobError::RestartRequired(reason))
487 }
488 }
489 }
490
491 pub(in crate::db) fn apply_transition(
492 &self,
493 request: &MutationJobAdvanceRequest,
494 transition: MutationJobTransition,
495 ) -> Result<(Self, MutationJobAdvanceReceipt), MutationJobError> {
496 self.ensure_can_advance(request)?;
497 transition.validate(self.state.phase)?;
498 let committed_sequence = self
499 .state
500 .sequence
501 .checked_add(1)
502 .ok_or(MutationJobError::CounterOverflow)?;
503 let keys_scanned_total = self
504 .state
505 .keys_scanned_total
506 .checked_add(transition.keys_scanned)
507 .ok_or(MutationJobError::CounterOverflow)?;
508 let rows_updated_total = self
509 .state
510 .rows_updated_total
511 .checked_add(transition.rows_updated)
512 .ok_or(MutationJobError::CounterOverflow)?;
513 let verify_restarts_total = self
514 .state
515 .verify_restarts_total
516 .checked_add(transition.verify_restarts)
517 .ok_or(MutationJobError::CounterOverflow)?;
518 let receipt = MutationJobAdvanceReceipt {
519 request_sequence: request.expected_sequence,
520 committed_sequence,
521 status: transition.status,
522 phase: transition.phase,
523 keys_scanned: transition.keys_scanned,
524 rows_updated: transition.rows_updated,
525 keys_scanned_total,
526 rows_updated_total,
527 verify_restarts_total,
528 };
529 let record = Self {
530 state: MutationJobState {
531 job_id: self.state.job_id,
532 sequence: committed_sequence,
533 status: transition.status,
534 phase: transition.phase,
535 keys_scanned_total,
536 rows_updated_total,
537 verify_restarts_total,
538 },
539 canonical_intent: self.canonical_intent.clone(),
540 engine_continuation: transition.engine_continuation,
541 last_receipt: Some(RetainedMutationJobReceipt {
542 receipt: receipt.clone(),
543 idempotency_key: request.idempotency_key.clone(),
544 }),
545 };
546 record.validate()?;
547 Ok((record, receipt))
548 }
549
550 pub(in crate::db) fn validate(&self) -> Result<(), MutationJobError> {
551 self.state.job_id.validate()?;
552 validate_nonempty_bytes(
553 &self.canonical_intent,
554 MAX_MUTATION_JOB_INTENT_BYTES,
555 MutationJobPayloadKind::Intent,
556 )?;
557 validate_bytes(
558 &self.engine_continuation,
559 MAX_MUTATION_JOB_CONTINUATION_BYTES,
560 MutationJobPayloadKind::Continuation,
561 )?;
562 if self.state.rows_updated_total > self.state.keys_scanned_total
563 || matches!(self.state.status, MutationJobStatus::Active)
564 && self.engine_continuation.is_empty()
565 || matches!(self.state.status, MutationJobStatus::Completed)
566 && (self.state.phase != MutationJobPhase::Verify
567 || !self.engine_continuation.is_empty())
568 || matches!(self.state.status, MutationJobStatus::RestartRequired(_))
569 && !self.engine_continuation.is_empty()
570 {
571 return Err(MutationJobError::CorruptProgressStore);
572 }
573 match &self.last_receipt {
574 None => {
575 if self.state.sequence != 0
576 || self.state.status != MutationJobStatus::Active
577 || self.state.phase != MutationJobPhase::Forward
578 || self.state.keys_scanned_total != 0
579 || self.state.rows_updated_total != 0
580 || self.state.verify_restarts_total != 0
581 {
582 return Err(MutationJobError::CorruptProgressStore);
583 }
584 }
585 Some(retained) => {
586 retained.idempotency_key.validate()?;
587 validate_receipt(&retained.receipt)?;
588 if retained.receipt.committed_sequence != self.state.sequence
589 || retained.receipt.request_sequence.checked_add(1)
590 != Some(retained.receipt.committed_sequence)
591 || retained.receipt.status != self.state.status
592 || retained.receipt.phase != self.state.phase
593 || retained.receipt.keys_scanned_total != self.state.keys_scanned_total
594 || retained.receipt.rows_updated_total != self.state.rows_updated_total
595 || retained.receipt.verify_restarts_total != self.state.verify_restarts_total
596 {
597 return Err(MutationJobError::CorruptProgressStore);
598 }
599 let receipt_bytes = retained_receipt_encoded_len(retained)?;
600 if receipt_bytes > MAX_MUTATION_JOB_RECEIPT_BYTES {
601 return Err(payload_too_large(
602 MutationJobPayloadKind::Receipt,
603 MAX_MUTATION_JOB_RECEIPT_BYTES,
604 receipt_bytes,
605 ));
606 }
607 }
608 }
609 Ok(())
610 }
611}
612
613#[derive(Clone, Debug, Eq, PartialEq)]
614pub(in crate::db) struct MutationJobTransition {
615 status: MutationJobStatus,
616 phase: MutationJobPhase,
617 engine_continuation: Vec<u8>,
618 keys_scanned: u64,
619 rows_updated: u64,
620 verify_restarts: u64,
621}
622
623impl MutationJobTransition {
624 pub(in crate::db) const fn new(
625 status: MutationJobStatus,
626 phase: MutationJobPhase,
627 engine_continuation: Vec<u8>,
628 keys_scanned: u64,
629 rows_updated: u64,
630 verify_restarts: u64,
631 ) -> Self {
632 Self {
633 status,
634 phase,
635 engine_continuation,
636 keys_scanned,
637 rows_updated,
638 verify_restarts,
639 }
640 }
641
642 fn validate(&self, previous_phase: MutationJobPhase) -> Result<(), MutationJobError> {
643 validate_bytes(
644 &self.engine_continuation,
645 MAX_MUTATION_JOB_CONTINUATION_BYTES,
646 MutationJobPayloadKind::Continuation,
647 )?;
648 let expected_verify_restarts = u64::from(
649 previous_phase == MutationJobPhase::Verify
650 && self.phase == MutationJobPhase::Forward
651 && self.status == MutationJobStatus::Active,
652 );
653 if self.keys_scanned > MAX_MUTATION_JOB_STEP_KEYS_SCANNED
654 || self.rows_updated > MAX_MUTATION_JOB_STEP_ROWS_UPDATED
655 || self.rows_updated > self.keys_scanned
656 || matches!(self.status, MutationJobStatus::Active)
657 && self.engine_continuation.is_empty()
658 || previous_phase == MutationJobPhase::Verify && self.rows_updated != 0
659 || self.verify_restarts != expected_verify_restarts
660 || matches!(self.status, MutationJobStatus::Completed)
661 && (previous_phase != MutationJobPhase::Verify
662 || self.phase != MutationJobPhase::Verify
663 || self.rows_updated != 0
664 || !self.engine_continuation.is_empty())
665 || matches!(self.status, MutationJobStatus::RestartRequired(_))
666 && (self.phase != previous_phase
667 || !self.engine_continuation.is_empty()
668 || self.keys_scanned != 0
669 || self.rows_updated != 0)
670 {
671 return Err(MutationJobError::CorruptProgressStore);
672 }
673 Ok(())
674 }
675}
676
677pub(in crate::db) fn encode_mutation_job_payload(
678 record: &MutationJobRecord,
679) -> Result<Vec<u8>, MutationJobError> {
680 record.validate()?;
681 let mut bytes = Vec::new();
682 bytes.extend_from_slice(&record.state.job_id.to_bytes());
683 bytes.extend_from_slice(&record.state.sequence.to_be_bytes());
684 write_status(&mut bytes, record.state.status);
685 write_phase(&mut bytes, record.state.phase);
686 bytes.extend_from_slice(&record.state.keys_scanned_total.to_be_bytes());
687 bytes.extend_from_slice(&record.state.rows_updated_total.to_be_bytes());
688 bytes.extend_from_slice(&record.state.verify_restarts_total.to_be_bytes());
689 write_bytes(&mut bytes, &record.canonical_intent)?;
690 write_bytes(&mut bytes, &record.engine_continuation)?;
691 match &record.last_receipt {
692 None => bytes.push(0),
693 Some(retained) => {
694 bytes.push(1);
695 let receipt = &retained.receipt;
696 bytes.extend_from_slice(&receipt.request_sequence.to_be_bytes());
697 bytes.extend_from_slice(&receipt.committed_sequence.to_be_bytes());
698 write_status(&mut bytes, receipt.status);
699 write_phase(&mut bytes, receipt.phase);
700 bytes.extend_from_slice(&receipt.keys_scanned.to_be_bytes());
701 bytes.extend_from_slice(&receipt.rows_updated.to_be_bytes());
702 bytes.extend_from_slice(&receipt.keys_scanned_total.to_be_bytes());
703 bytes.extend_from_slice(&receipt.rows_updated_total.to_be_bytes());
704 bytes.extend_from_slice(&receipt.verify_restarts_total.to_be_bytes());
705 write_bytes(&mut bytes, retained.idempotency_key.as_str().as_bytes())?;
706 }
707 }
708 Ok(bytes)
709}
710
711pub(in crate::db) fn decode_mutation_job_payload(
712 bytes: &[u8],
713) -> Result<MutationJobRecord, MutationJobError> {
714 if bytes.len() > MAX_MUTATION_JOB_RECORD_BYTES {
715 return Err(MutationJobError::CorruptProgressStore);
716 }
717 let mut reader = ByteReader::new(bytes);
718 let job_id = MutationJobId::try_from_bytes(reader.read_array()?)
719 .map_err(|_| MutationJobError::CorruptProgressStore)?;
720 let state = MutationJobState {
721 job_id,
722 sequence: reader.read_u64()?,
723 status: read_status(&mut reader)?,
724 phase: read_phase(&mut reader)?,
725 keys_scanned_total: reader.read_u64()?,
726 rows_updated_total: reader.read_u64()?,
727 verify_restarts_total: reader.read_u64()?,
728 };
729 let canonical_intent = reader
730 .read_bounded_len_prefixed_bytes(MAX_MUTATION_JOB_INTENT_BYTES)?
731 .to_vec();
732 let engine_continuation = reader
733 .read_bounded_len_prefixed_bytes(MAX_MUTATION_JOB_CONTINUATION_BYTES)?
734 .to_vec();
735 let last_receipt = match reader.read_u8()? {
736 0 => None,
737 1 => {
738 let receipt = MutationJobAdvanceReceipt {
739 request_sequence: reader.read_u64()?,
740 committed_sequence: reader.read_u64()?,
741 status: read_status(&mut reader)?,
742 phase: read_phase(&mut reader)?,
743 keys_scanned: reader.read_u64()?,
744 rows_updated: reader.read_u64()?,
745 keys_scanned_total: reader.read_u64()?,
746 rows_updated_total: reader.read_u64()?,
747 verify_restarts_total: reader.read_u64()?,
748 };
749 let idempotency_key = MutationJobIdempotencyKey::new(
750 reader.read_bounded_string(MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES)?,
751 )
752 .map_err(|_| MutationJobError::CorruptProgressStore)?;
753 Some(RetainedMutationJobReceipt {
754 receipt,
755 idempotency_key,
756 })
757 }
758 _ => return Err(MutationJobError::CorruptProgressStore),
759 };
760 reader.finish()?;
761 let record = MutationJobRecord {
762 state,
763 canonical_intent,
764 engine_continuation,
765 last_receipt,
766 };
767 record
768 .validate()
769 .map_err(|_| MutationJobError::CorruptProgressStore)?;
770 Ok(record)
771}
772
773fn validate_receipt(receipt: &MutationJobAdvanceReceipt) -> Result<(), MutationJobError> {
774 if receipt.keys_scanned > MAX_MUTATION_JOB_STEP_KEYS_SCANNED
775 || receipt.rows_updated > MAX_MUTATION_JOB_STEP_ROWS_UPDATED
776 || receipt.rows_updated > receipt.keys_scanned
777 || receipt.rows_updated_total > receipt.keys_scanned_total
778 || receipt.keys_scanned > receipt.keys_scanned_total
779 || receipt.rows_updated > receipt.rows_updated_total
780 || matches!(receipt.status, MutationJobStatus::Completed)
781 && (receipt.phase != MutationJobPhase::Verify || receipt.rows_updated != 0)
782 || matches!(receipt.status, MutationJobStatus::RestartRequired(_))
783 && (receipt.keys_scanned != 0 || receipt.rows_updated != 0)
784 {
785 return Err(MutationJobError::CorruptProgressStore);
786 }
787 Ok(())
788}
789
790fn retained_receipt_encoded_len(
791 retained: &RetainedMutationJobReceipt,
792) -> Result<usize, MutationJobError> {
793 let status_bytes = match retained.receipt.status {
794 MutationJobStatus::RestartRequired(_) => 2,
795 MutationJobStatus::Active | MutationJobStatus::Completed => 1,
796 };
797 8_usize
798 .checked_add(8)
799 .and_then(|value| value.checked_add(status_bytes))
800 .and_then(|value| value.checked_add(1))
801 .and_then(|value| value.checked_add(5 * 8))
802 .and_then(|value| value.checked_add(4))
803 .and_then(|value| value.checked_add(retained.idempotency_key.as_str().len()))
804 .ok_or(MutationJobError::CounterOverflow)
805}
806
807fn validate_nonempty_bytes(
808 value: &[u8],
809 limit: usize,
810 kind: MutationJobPayloadKind,
811) -> Result<(), MutationJobError> {
812 if value.is_empty() {
813 return Err(MutationJobError::CorruptProgressStore);
814 }
815 validate_bytes(value, limit, kind)
816}
817
818fn validate_bytes(
819 value: &[u8],
820 limit: usize,
821 kind: MutationJobPayloadKind,
822) -> Result<(), MutationJobError> {
823 if value.len() > limit {
824 return Err(payload_too_large(kind, limit, value.len()));
825 }
826 Ok(())
827}
828
829fn payload_too_large(
830 kind: MutationJobPayloadKind,
831 limit: usize,
832 observed: usize,
833) -> MutationJobError {
834 MutationJobError::PayloadTooLarge {
835 kind,
836 limit: u64::try_from(limit).unwrap_or(u64::MAX),
837 observed: u64::try_from(observed).unwrap_or(u64::MAX),
838 }
839}
840
841fn write_status(bytes: &mut Vec<u8>, status: MutationJobStatus) {
842 match status {
843 MutationJobStatus::Active => bytes.push(0),
844 MutationJobStatus::Completed => bytes.push(1),
845 MutationJobStatus::RestartRequired(reason) => {
846 bytes.push(2);
847 bytes.push(match reason {
848 MutationJobRestartReason::AcceptedSchemaChanged => 0,
849 MutationJobRestartReason::TargetAllocationChanged => 1,
850 MutationJobRestartReason::IntentIneligible => 2,
851 MutationJobRestartReason::BatchPolicyChanged => 3,
852 MutationJobRestartReason::UnsupportedContinuation => 4,
853 MutationJobRestartReason::ManagedTimestampRegression => 5,
854 MutationJobRestartReason::CandidateExceedsBatchPolicy => 6,
855 MutationJobRestartReason::ExecutionBudgetPolicyExceeded => 7,
856 });
857 }
858 }
859}
860
861fn read_status(reader: &mut ByteReader<'_>) -> Result<MutationJobStatus, MutationJobError> {
862 match reader.read_u8()? {
863 0 => Ok(MutationJobStatus::Active),
864 1 => Ok(MutationJobStatus::Completed),
865 2 => Ok(MutationJobStatus::RestartRequired(
866 match reader.read_u8()? {
867 0 => MutationJobRestartReason::AcceptedSchemaChanged,
868 1 => MutationJobRestartReason::TargetAllocationChanged,
869 2 => MutationJobRestartReason::IntentIneligible,
870 3 => MutationJobRestartReason::BatchPolicyChanged,
871 4 => MutationJobRestartReason::UnsupportedContinuation,
872 5 => MutationJobRestartReason::ManagedTimestampRegression,
873 6 => MutationJobRestartReason::CandidateExceedsBatchPolicy,
874 7 => MutationJobRestartReason::ExecutionBudgetPolicyExceeded,
875 _ => return Err(MutationJobError::CorruptProgressStore),
876 },
877 )),
878 _ => Err(MutationJobError::CorruptProgressStore),
879 }
880}
881
882fn write_phase(bytes: &mut Vec<u8>, phase: MutationJobPhase) {
883 bytes.push(match phase {
884 MutationJobPhase::Forward => 0,
885 MutationJobPhase::Verify => 1,
886 });
887}
888
889fn read_phase(reader: &mut ByteReader<'_>) -> Result<MutationJobPhase, MutationJobError> {
890 match reader.read_u8()? {
891 0 => Ok(MutationJobPhase::Forward),
892 1 => Ok(MutationJobPhase::Verify),
893 _ => Err(MutationJobError::CorruptProgressStore),
894 }
895}
896
897fn write_bytes(bytes: &mut Vec<u8>, value: &[u8]) -> Result<(), MutationJobError> {
898 let len = u32::try_from(value.len()).map_err(|_| MutationJobError::Internal)?;
899 bytes.extend_from_slice(&len.to_be_bytes());
900 bytes.extend_from_slice(value);
901 Ok(())
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907
908 fn job_id() -> MutationJobId {
909 MutationJobId::try_from_bytes([7; 32]).expect("nonzero mutation job id should admit")
910 }
911
912 fn request(sequence: u64, key: &str) -> MutationJobAdvanceRequest {
913 MutationJobAdvanceRequest::new(
914 job_id(),
915 sequence,
916 MutationJobIdempotencyKey::new(key).expect("bounded replay key should admit"),
917 )
918 }
919
920 fn initial_record() -> MutationJobRecord {
921 MutationJobRecord::new(job_id(), vec![1, 2, 3], vec![4, 5])
922 .expect("bounded mutation record should admit")
923 }
924
925 #[test]
926 fn identities_and_variable_components_enforce_current_bounds() {
927 assert_eq!(
928 MutationJobId::try_from_bytes([0; 32]),
929 Err(MutationJobError::InvalidJobId),
930 );
931 assert_eq!(
932 MutationJobIdempotencyKey::new(""),
933 Err(MutationJobError::InvalidIdempotencyKey),
934 );
935 assert!(MutationJobIdempotencyKey::new("k".repeat(256)).is_ok());
936 assert_eq!(
937 MutationJobIdempotencyKey::new("k".repeat(257)),
938 Err(MutationJobError::InvalidIdempotencyKey),
939 );
940 assert_eq!(
941 MutationJobRecord::new(job_id(), vec![1], Vec::new()),
942 Err(MutationJobError::CorruptProgressStore),
943 );
944
945 assert!(MutationJobRecord::new(job_id(), vec![1; 16 * 1024], vec![2; 2 * 1024]).is_ok());
946 assert!(matches!(
947 MutationJobRecord::new(job_id(), vec![1; 16 * 1024 + 1], Vec::new()),
948 Err(MutationJobError::PayloadTooLarge {
949 kind: MutationJobPayloadKind::Intent,
950 ..
951 }),
952 ));
953 assert!(matches!(
954 MutationJobRecord::new(job_id(), vec![1], vec![2; 2 * 1024 + 1]),
955 Err(MutationJobError::PayloadTooLarge {
956 kind: MutationJobPayloadKind::Continuation,
957 ..
958 }),
959 ));
960
961 let maximum_key =
962 MutationJobIdempotencyKey::new("k".repeat(MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES))
963 .expect("maximum replay key should admit");
964 let request = MutationJobAdvanceRequest::new(job_id(), 0, maximum_key);
965 let (record, _) = initial_record()
966 .apply_transition(
967 &request,
968 MutationJobTransition::new(
969 MutationJobStatus::Active,
970 MutationJobPhase::Forward,
971 vec![7],
972 1,
973 0,
974 0,
975 ),
976 )
977 .expect("maximum replay identity should retain");
978 assert_eq!(
979 record
980 .last_receipt
981 .as_ref()
982 .map(retained_receipt_encoded_len),
983 Some(Ok(318)),
984 );
985
986 let maximum_key =
987 MutationJobIdempotencyKey::new("k".repeat(MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES))
988 .expect("maximum replay key should admit");
989 let (restart, _) = initial_record()
990 .apply_transition(
991 &MutationJobAdvanceRequest::new(job_id(), 0, maximum_key),
992 MutationJobTransition::new(
993 MutationJobStatus::RestartRequired(
994 MutationJobRestartReason::BatchPolicyChanged,
995 ),
996 MutationJobPhase::Forward,
997 Vec::new(),
998 0,
999 0,
1000 0,
1001 ),
1002 )
1003 .expect("maximum restart receipt should retain");
1004 assert_eq!(
1005 restart
1006 .last_receipt
1007 .as_ref()
1008 .map(retained_receipt_encoded_len),
1009 Some(Ok(319)),
1010 );
1011 }
1012
1013 #[test]
1014 fn current_payload_round_trips_every_lifecycle() {
1015 let initial = initial_record();
1016 let (active, _) = initial
1017 .apply_transition(
1018 &request(0, "forward-0"),
1019 MutationJobTransition::new(
1020 MutationJobStatus::Active,
1021 MutationJobPhase::Verify,
1022 vec![6],
1023 13,
1024 4,
1025 0,
1026 ),
1027 )
1028 .expect("bounded active transition should admit");
1029 let (completed, _) = active
1030 .apply_transition(
1031 &request(1, "verify-0"),
1032 MutationJobTransition::new(
1033 MutationJobStatus::Completed,
1034 MutationJobPhase::Verify,
1035 Vec::new(),
1036 9,
1037 0,
1038 0,
1039 ),
1040 )
1041 .expect("clean terminal transition should admit");
1042 let (restart, _) = initial
1043 .apply_transition(
1044 &request(0, "restart"),
1045 MutationJobTransition::new(
1046 MutationJobStatus::RestartRequired(
1047 MutationJobRestartReason::ManagedTimestampRegression,
1048 ),
1049 MutationJobPhase::Forward,
1050 Vec::new(),
1051 0,
1052 0,
1053 0,
1054 ),
1055 )
1056 .expect("typed restart transition should admit");
1057 let (oversized_candidate, _) = initial
1058 .apply_transition(
1059 &request(0, "candidate-exceeds-policy"),
1060 MutationJobTransition::new(
1061 MutationJobStatus::RestartRequired(
1062 MutationJobRestartReason::CandidateExceedsBatchPolicy,
1063 ),
1064 MutationJobPhase::Forward,
1065 Vec::new(),
1066 0,
1067 0,
1068 0,
1069 ),
1070 )
1071 .expect("candidate policy restart should admit");
1072 let (execution_budget, _) = initial
1073 .apply_transition(
1074 &request(0, "execution-budget-policy"),
1075 MutationJobTransition::new(
1076 MutationJobStatus::RestartRequired(
1077 MutationJobRestartReason::ExecutionBudgetPolicyExceeded,
1078 ),
1079 MutationJobPhase::Forward,
1080 Vec::new(),
1081 0,
1082 0,
1083 0,
1084 ),
1085 )
1086 .expect("execution budget policy restart should admit");
1087
1088 for record in [
1089 initial,
1090 active,
1091 completed,
1092 restart,
1093 oversized_candidate,
1094 execution_budget,
1095 ] {
1096 let bytes = encode_mutation_job_payload(&record)
1097 .expect("current mutation payload should encode");
1098 assert!(!bytes.starts_with(b"DIDL"));
1099 assert_eq!(
1100 decode_mutation_job_payload(&bytes)
1101 .expect("current mutation payload should decode"),
1102 record,
1103 );
1104 }
1105 }
1106
1107 #[test]
1108 fn public_state_request_receipt_and_error_are_wire_compatible() {
1109 let state = initial_record().state().clone();
1110 let request = request(0, "wire-request");
1111 let receipt = MutationJobAdvanceReceipt {
1112 request_sequence: 0,
1113 committed_sequence: 1,
1114 status: MutationJobStatus::Active,
1115 phase: MutationJobPhase::Forward,
1116 keys_scanned: 8,
1117 rows_updated: 3,
1118 keys_scanned_total: 8,
1119 rows_updated_total: 3,
1120 verify_restarts_total: 0,
1121 };
1122 let error = MutationJobError::TargetMutationFailed(
1123 MutationJobTargetFailureReason::StagingByteBudgetExceeded,
1124 );
1125
1126 let state_bytes =
1127 icydb_schema::wire::encode_one(&state).expect("mutation state should encode");
1128 let request_bytes =
1129 icydb_schema::wire::encode_one(&request).expect("mutation request should encode");
1130 let receipt_bytes =
1131 icydb_schema::wire::encode_one(&receipt).expect("mutation receipt should encode");
1132 let error_bytes =
1133 icydb_schema::wire::encode_one(&error).expect("mutation error should encode");
1134 assert_eq!(
1135 icydb_schema::wire::decode_one::<MutationJobState>(&state_bytes)
1136 .expect("mutation state should decode"),
1137 state,
1138 );
1139 assert_eq!(
1140 icydb_schema::wire::decode_one::<MutationJobAdvanceRequest>(&request_bytes)
1141 .expect("mutation request should decode"),
1142 request,
1143 );
1144 assert_eq!(
1145 icydb_schema::wire::decode_one::<MutationJobAdvanceReceipt>(&receipt_bytes)
1146 .expect("mutation receipt should decode"),
1147 receipt,
1148 );
1149 assert_eq!(
1150 icydb_schema::wire::decode_one::<MutationJobError>(&error_bytes)
1151 .expect("mutation error should decode"),
1152 error,
1153 );
1154 }
1155
1156 #[test]
1157 fn exact_replay_precedes_stale_and_terminal_rejection() {
1158 let initial = initial_record();
1159 let (verifying, _) = initial
1160 .apply_transition(
1161 &request(0, "forward-0"),
1162 MutationJobTransition::new(
1163 MutationJobStatus::Active,
1164 MutationJobPhase::Verify,
1165 vec![7],
1166 8,
1167 3,
1168 0,
1169 ),
1170 )
1171 .expect("Forward exhaustion should enter Verify");
1172 let terminal_request = request(1, "verify-0");
1173 let (completed, receipt) = verifying
1174 .apply_transition(
1175 &terminal_request,
1176 MutationJobTransition::new(
1177 MutationJobStatus::Completed,
1178 MutationJobPhase::Verify,
1179 Vec::new(),
1180 8,
1181 0,
1182 0,
1183 ),
1184 )
1185 .expect("terminal transition should admit");
1186
1187 assert_eq!(
1188 completed
1189 .exact_replay(&terminal_request)
1190 .expect("exact replay lookup should succeed"),
1191 Some(&receipt),
1192 );
1193 assert_eq!(
1194 completed.ensure_can_advance(&request(1, "different")),
1195 Err(MutationJobError::StaleSequence {
1196 expected: 1,
1197 actual: 2,
1198 }),
1199 );
1200 assert_eq!(
1201 completed.ensure_can_advance(&request(2, "next")),
1202 Err(MutationJobError::Completed),
1203 );
1204 }
1205
1206 #[test]
1207 fn payload_decode_is_bounded_fallible_and_rejects_trailing_bytes() {
1208 let bytes = encode_mutation_job_payload(&initial_record())
1209 .expect("current mutation payload should encode");
1210 for end in 0..bytes.len() {
1211 assert_eq!(
1212 decode_mutation_job_payload(&bytes[..end]),
1213 Err(MutationJobError::CorruptProgressStore),
1214 "truncation at byte {end} must retain corruption classification",
1215 );
1216 }
1217 let mut trailing = bytes;
1218 trailing.push(0);
1219 assert_eq!(
1220 decode_mutation_job_payload(&trailing),
1221 Err(MutationJobError::CorruptProgressStore),
1222 );
1223 let mut unknown_status = encode_mutation_job_payload(&initial_record())
1224 .expect("current mutation payload should encode");
1225 unknown_status[32 + 8] = u8::MAX;
1226 assert_eq!(
1227 decode_mutation_job_payload(&unknown_status),
1228 Err(MutationJobError::CorruptProgressStore),
1229 );
1230 let mut zero_job_id = encode_mutation_job_payload(&initial_record())
1231 .expect("current mutation payload should encode");
1232 zero_job_id[..32].fill(0);
1233 assert_eq!(
1234 decode_mutation_job_payload(&zero_job_id),
1235 Err(MutationJobError::CorruptProgressStore),
1236 );
1237
1238 let initial = initial_record();
1239 let mut bytes =
1240 encode_mutation_job_payload(&initial).expect("current mutation payload should encode");
1241 let intent_len_offset = 32 + 8 + 1 + 1 + 3 * 8;
1242 let continuation_len_offset = intent_len_offset + 4 + initial.canonical_intent.len();
1243 let continuation_offset = continuation_len_offset + 4;
1244 let continuation_end = continuation_offset + initial.engine_continuation.len();
1245 bytes[continuation_len_offset..continuation_offset].fill(0);
1246 bytes.drain(continuation_offset..continuation_end);
1247 assert_eq!(
1248 decode_mutation_job_payload(&bytes),
1249 Err(MutationJobError::CorruptProgressStore),
1250 );
1251 }
1252
1253 #[test]
1254 fn transition_totals_fail_closed_on_overflow() {
1255 assert_eq!(
1256 initial_record().apply_transition(
1257 &request(0, "empty-active-continuation"),
1258 MutationJobTransition::new(
1259 MutationJobStatus::Active,
1260 MutationJobPhase::Forward,
1261 Vec::new(),
1262 1,
1263 0,
1264 0,
1265 ),
1266 ),
1267 Err(MutationJobError::CorruptProgressStore),
1268 );
1269
1270 let mut record = initial_record();
1271 record.state.keys_scanned_total = u64::MAX;
1272 record.state.rows_updated_total = u64::MAX;
1273 record.last_receipt = Some(RetainedMutationJobReceipt {
1274 receipt: MutationJobAdvanceReceipt {
1275 request_sequence: 0,
1276 committed_sequence: 1,
1277 status: MutationJobStatus::Active,
1278 phase: MutationJobPhase::Forward,
1279 keys_scanned: 1,
1280 rows_updated: 1,
1281 keys_scanned_total: u64::MAX,
1282 rows_updated_total: u64::MAX,
1283 verify_restarts_total: 0,
1284 },
1285 idempotency_key: MutationJobIdempotencyKey::new("prior")
1286 .expect("bounded replay key should admit"),
1287 });
1288 record.state.sequence = 1;
1289 assert_eq!(
1290 record.apply_transition(
1291 &request(1, "overflow"),
1292 MutationJobTransition::new(
1293 MutationJobStatus::Active,
1294 MutationJobPhase::Forward,
1295 vec![7],
1296 1,
1297 1,
1298 0,
1299 ),
1300 ),
1301 Err(MutationJobError::CounterOverflow),
1302 );
1303
1304 let mut sequence_record = initial_record();
1305 sequence_record.state.sequence = u64::MAX;
1306 sequence_record.last_receipt = Some(RetainedMutationJobReceipt {
1307 receipt: MutationJobAdvanceReceipt {
1308 request_sequence: u64::MAX - 1,
1309 committed_sequence: u64::MAX,
1310 status: MutationJobStatus::Active,
1311 phase: MutationJobPhase::Forward,
1312 keys_scanned: 0,
1313 rows_updated: 0,
1314 keys_scanned_total: 0,
1315 rows_updated_total: 0,
1316 verify_restarts_total: 0,
1317 },
1318 idempotency_key: MutationJobIdempotencyKey::new("prior-sequence")
1319 .expect("bounded replay key should admit"),
1320 });
1321 assert_eq!(
1322 sequence_record.apply_transition(
1323 &request(u64::MAX, "sequence-overflow"),
1324 MutationJobTransition::new(
1325 MutationJobStatus::Active,
1326 MutationJobPhase::Forward,
1327 vec![7],
1328 0,
1329 0,
1330 0,
1331 ),
1332 ),
1333 Err(MutationJobError::CounterOverflow),
1334 );
1335 }
1336}