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