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