1#![cfg_attr(
7 not(test),
8 expect(
9 dead_code,
10 reason = "record construction and transitions are private mutation-coordinator inputs"
11 )
12)]
13
14#[cfg(feature = "sql")]
15mod intent;
16
17use candid::CandidType;
18use serde::Deserialize;
19use std::{error::Error as StdError, fmt};
20
21#[cfg(feature = "sql")]
22pub(in crate::db) use intent::CanonicalMutationIntent;
23
24pub const MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES: usize = 256;
26pub const MAX_MUTATION_JOB_CONTINUATION_BYTES: usize = 2 * 1024;
28pub const MAX_MUTATION_JOB_INTENT_BYTES: usize = 16 * 1024;
30pub const MAX_MUTATION_JOB_RECEIPT_BYTES: usize = 8 * 1024;
32pub const MAX_MUTATION_JOB_RECORD_BYTES: usize = 64 * 1024;
34
35pub const MAX_MUTATION_JOB_STEP_KEYS_SCANNED: u64 = 256;
37pub const MAX_MUTATION_JOB_STEP_ROWS_UPDATED: u64 = 64;
39
40#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
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(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
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(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
97pub enum MutationJobRestartReason {
98 AcceptedSchemaChanged,
100 TargetAllocationChanged,
102 IntentIneligible,
104 BatchPolicyChanged,
106 UnsupportedContinuation,
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) fn exact_replay(
328 &self,
329 request: &MutationJobAdvanceRequest,
330 ) -> Result<Option<&MutationJobAdvanceReceipt>, MutationJobError> {
331 self.validate()?;
332 request.validate()?;
333 if request.job_id != self.state.job_id {
334 return Err(MutationJobError::NotFound);
335 }
336 Ok(self.last_receipt.as_ref().and_then(|retained| {
337 (retained.receipt.request_sequence == request.expected_sequence
338 && retained.idempotency_key == request.idempotency_key)
339 .then_some(&retained.receipt)
340 }))
341 }
342
343 pub(in crate::db) fn ensure_can_advance(
344 &self,
345 request: &MutationJobAdvanceRequest,
346 ) -> Result<(), MutationJobError> {
347 self.validate()?;
348 request.validate()?;
349 if request.job_id != self.state.job_id {
350 return Err(MutationJobError::NotFound);
351 }
352 if self.state.sequence != request.expected_sequence {
353 return Err(MutationJobError::StaleSequence {
354 expected: request.expected_sequence,
355 actual: self.state.sequence,
356 });
357 }
358 match self.state.status {
359 MutationJobStatus::Active => Ok(()),
360 MutationJobStatus::Completed => Err(MutationJobError::Completed),
361 MutationJobStatus::RestartRequired(reason) => {
362 Err(MutationJobError::RestartRequired(reason))
363 }
364 }
365 }
366
367 pub(in crate::db) fn apply_transition(
368 &self,
369 request: &MutationJobAdvanceRequest,
370 transition: MutationJobTransition,
371 ) -> Result<(Self, MutationJobAdvanceReceipt), MutationJobError> {
372 self.ensure_can_advance(request)?;
373 transition.validate(self.state.phase)?;
374 let committed_sequence = self
375 .state
376 .sequence
377 .checked_add(1)
378 .ok_or(MutationJobError::CounterOverflow)?;
379 let keys_scanned_total = self
380 .state
381 .keys_scanned_total
382 .checked_add(transition.keys_scanned)
383 .ok_or(MutationJobError::CounterOverflow)?;
384 let rows_updated_total = self
385 .state
386 .rows_updated_total
387 .checked_add(transition.rows_updated)
388 .ok_or(MutationJobError::CounterOverflow)?;
389 let verify_restarts_total = self
390 .state
391 .verify_restarts_total
392 .checked_add(transition.verify_restarts)
393 .ok_or(MutationJobError::CounterOverflow)?;
394 let receipt = MutationJobAdvanceReceipt {
395 request_sequence: request.expected_sequence,
396 committed_sequence,
397 status: transition.status,
398 phase: transition.phase,
399 keys_scanned: transition.keys_scanned,
400 rows_updated: transition.rows_updated,
401 keys_scanned_total,
402 rows_updated_total,
403 verify_restarts_total,
404 };
405 let record = Self {
406 state: MutationJobState {
407 job_id: self.state.job_id,
408 sequence: committed_sequence,
409 status: transition.status,
410 phase: transition.phase,
411 keys_scanned_total,
412 rows_updated_total,
413 verify_restarts_total,
414 },
415 canonical_intent: self.canonical_intent.clone(),
416 engine_continuation: transition.engine_continuation,
417 last_receipt: Some(RetainedMutationJobReceipt {
418 receipt: receipt.clone(),
419 idempotency_key: request.idempotency_key.clone(),
420 }),
421 };
422 record.validate()?;
423 Ok((record, receipt))
424 }
425
426 pub(in crate::db) fn validate(&self) -> Result<(), MutationJobError> {
427 self.state.job_id.validate()?;
428 validate_nonempty_bytes(
429 &self.canonical_intent,
430 MAX_MUTATION_JOB_INTENT_BYTES,
431 MutationJobPayloadKind::Intent,
432 )?;
433 validate_bytes(
434 &self.engine_continuation,
435 MAX_MUTATION_JOB_CONTINUATION_BYTES,
436 MutationJobPayloadKind::Continuation,
437 )?;
438 if self.state.rows_updated_total > self.state.keys_scanned_total
439 || matches!(self.state.status, MutationJobStatus::Completed)
440 && (self.state.phase != MutationJobPhase::Verify
441 || !self.engine_continuation.is_empty())
442 || matches!(self.state.status, MutationJobStatus::RestartRequired(_))
443 && !self.engine_continuation.is_empty()
444 {
445 return Err(MutationJobError::CorruptProgressStore);
446 }
447 match &self.last_receipt {
448 None => {
449 if self.state.sequence != 0
450 || self.state.status != MutationJobStatus::Active
451 || self.state.phase != MutationJobPhase::Forward
452 || self.state.keys_scanned_total != 0
453 || self.state.rows_updated_total != 0
454 || self.state.verify_restarts_total != 0
455 {
456 return Err(MutationJobError::CorruptProgressStore);
457 }
458 }
459 Some(retained) => {
460 retained.idempotency_key.validate()?;
461 validate_receipt(&retained.receipt)?;
462 if retained.receipt.committed_sequence != self.state.sequence
463 || retained.receipt.request_sequence.checked_add(1)
464 != Some(retained.receipt.committed_sequence)
465 || retained.receipt.status != self.state.status
466 || retained.receipt.phase != self.state.phase
467 || retained.receipt.keys_scanned_total != self.state.keys_scanned_total
468 || retained.receipt.rows_updated_total != self.state.rows_updated_total
469 || retained.receipt.verify_restarts_total != self.state.verify_restarts_total
470 {
471 return Err(MutationJobError::CorruptProgressStore);
472 }
473 let receipt_bytes = retained_receipt_encoded_len(retained)?;
474 if receipt_bytes > MAX_MUTATION_JOB_RECEIPT_BYTES {
475 return Err(payload_too_large(
476 MutationJobPayloadKind::Receipt,
477 MAX_MUTATION_JOB_RECEIPT_BYTES,
478 receipt_bytes,
479 ));
480 }
481 }
482 }
483 Ok(())
484 }
485}
486
487#[derive(Clone, Debug, Eq, PartialEq)]
488pub(in crate::db) struct MutationJobTransition {
489 status: MutationJobStatus,
490 phase: MutationJobPhase,
491 engine_continuation: Vec<u8>,
492 keys_scanned: u64,
493 rows_updated: u64,
494 verify_restarts: u64,
495}
496
497impl MutationJobTransition {
498 pub(in crate::db) const fn new(
499 status: MutationJobStatus,
500 phase: MutationJobPhase,
501 engine_continuation: Vec<u8>,
502 keys_scanned: u64,
503 rows_updated: u64,
504 verify_restarts: u64,
505 ) -> Self {
506 Self {
507 status,
508 phase,
509 engine_continuation,
510 keys_scanned,
511 rows_updated,
512 verify_restarts,
513 }
514 }
515
516 fn validate(&self, previous_phase: MutationJobPhase) -> Result<(), MutationJobError> {
517 validate_bytes(
518 &self.engine_continuation,
519 MAX_MUTATION_JOB_CONTINUATION_BYTES,
520 MutationJobPayloadKind::Continuation,
521 )?;
522 let expected_verify_restarts = u64::from(
523 previous_phase == MutationJobPhase::Verify
524 && self.phase == MutationJobPhase::Forward
525 && self.status == MutationJobStatus::Active,
526 );
527 if self.keys_scanned > MAX_MUTATION_JOB_STEP_KEYS_SCANNED
528 || self.rows_updated > MAX_MUTATION_JOB_STEP_ROWS_UPDATED
529 || self.rows_updated > self.keys_scanned
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
869 assert!(MutationJobRecord::new(job_id(), vec![1; 16 * 1024], vec![2; 2 * 1024]).is_ok());
870 assert!(matches!(
871 MutationJobRecord::new(job_id(), vec![1; 16 * 1024 + 1], Vec::new()),
872 Err(MutationJobError::PayloadTooLarge {
873 kind: MutationJobPayloadKind::Intent,
874 ..
875 }),
876 ));
877 assert!(matches!(
878 MutationJobRecord::new(job_id(), vec![1], vec![2; 2 * 1024 + 1]),
879 Err(MutationJobError::PayloadTooLarge {
880 kind: MutationJobPayloadKind::Continuation,
881 ..
882 }),
883 ));
884
885 let maximum_key =
886 MutationJobIdempotencyKey::new("k".repeat(MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES))
887 .expect("maximum replay key should admit");
888 let request = MutationJobAdvanceRequest::new(job_id(), 0, maximum_key);
889 let (record, _) = initial_record()
890 .apply_transition(
891 &request,
892 MutationJobTransition::new(
893 MutationJobStatus::Active,
894 MutationJobPhase::Forward,
895 Vec::new(),
896 1,
897 0,
898 0,
899 ),
900 )
901 .expect("maximum replay identity should retain");
902 assert_eq!(
903 record
904 .last_receipt
905 .as_ref()
906 .map(retained_receipt_encoded_len),
907 Some(Ok(318)),
908 );
909
910 let maximum_key =
911 MutationJobIdempotencyKey::new("k".repeat(MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES))
912 .expect("maximum replay key should admit");
913 let (restart, _) = initial_record()
914 .apply_transition(
915 &MutationJobAdvanceRequest::new(job_id(), 0, maximum_key),
916 MutationJobTransition::new(
917 MutationJobStatus::RestartRequired(
918 MutationJobRestartReason::BatchPolicyChanged,
919 ),
920 MutationJobPhase::Forward,
921 Vec::new(),
922 0,
923 0,
924 0,
925 ),
926 )
927 .expect("maximum restart receipt should retain");
928 assert_eq!(
929 restart
930 .last_receipt
931 .as_ref()
932 .map(retained_receipt_encoded_len),
933 Some(Ok(319)),
934 );
935 }
936
937 #[test]
938 fn current_payload_round_trips_every_lifecycle() {
939 let initial = initial_record();
940 let (active, _) = initial
941 .apply_transition(
942 &request(0, "forward-0"),
943 MutationJobTransition::new(
944 MutationJobStatus::Active,
945 MutationJobPhase::Verify,
946 vec![6],
947 13,
948 4,
949 0,
950 ),
951 )
952 .expect("bounded active transition should admit");
953 let (completed, _) = active
954 .apply_transition(
955 &request(1, "verify-0"),
956 MutationJobTransition::new(
957 MutationJobStatus::Completed,
958 MutationJobPhase::Verify,
959 Vec::new(),
960 9,
961 0,
962 0,
963 ),
964 )
965 .expect("clean terminal transition should admit");
966 let (restart, _) = initial
967 .apply_transition(
968 &request(0, "restart"),
969 MutationJobTransition::new(
970 MutationJobStatus::RestartRequired(
971 MutationJobRestartReason::AcceptedSchemaChanged,
972 ),
973 MutationJobPhase::Forward,
974 Vec::new(),
975 0,
976 0,
977 0,
978 ),
979 )
980 .expect("typed restart transition should admit");
981
982 for record in [initial, active, completed, restart] {
983 let bytes = encode_mutation_job_payload(&record)
984 .expect("current mutation payload should encode");
985 assert!(!bytes.starts_with(b"DIDL"));
986 assert_eq!(
987 decode_mutation_job_payload(&bytes)
988 .expect("current mutation payload should decode"),
989 record,
990 );
991 }
992 }
993
994 #[test]
995 fn public_state_request_receipt_and_error_are_candid_compatible() {
996 let state = initial_record().state().clone();
997 let request = request(0, "candid-request");
998 let receipt = MutationJobAdvanceReceipt {
999 request_sequence: 0,
1000 committed_sequence: 1,
1001 status: MutationJobStatus::Active,
1002 phase: MutationJobPhase::Forward,
1003 keys_scanned: 8,
1004 rows_updated: 3,
1005 keys_scanned_total: 8,
1006 rows_updated_total: 3,
1007 verify_restarts_total: 0,
1008 };
1009 let error = MutationJobError::StaleSequence {
1010 expected: 0,
1011 actual: 1,
1012 };
1013
1014 let state_bytes = candid::encode_one(&state).expect("mutation state should encode");
1015 let request_bytes = candid::encode_one(&request).expect("mutation request should encode");
1016 let receipt_bytes = candid::encode_one(&receipt).expect("mutation receipt should encode");
1017 let error_bytes = candid::encode_one(&error).expect("mutation error should encode");
1018 assert_eq!(
1019 candid::decode_one::<MutationJobState>(&state_bytes)
1020 .expect("mutation state should decode"),
1021 state,
1022 );
1023 assert_eq!(
1024 candid::decode_one::<MutationJobAdvanceRequest>(&request_bytes)
1025 .expect("mutation request should decode"),
1026 request,
1027 );
1028 assert_eq!(
1029 candid::decode_one::<MutationJobAdvanceReceipt>(&receipt_bytes)
1030 .expect("mutation receipt should decode"),
1031 receipt,
1032 );
1033 assert_eq!(
1034 candid::decode_one::<MutationJobError>(&error_bytes)
1035 .expect("mutation error should decode"),
1036 error,
1037 );
1038 }
1039
1040 #[test]
1041 fn exact_replay_precedes_stale_and_terminal_rejection() {
1042 let initial = initial_record();
1043 let (verifying, _) = initial
1044 .apply_transition(
1045 &request(0, "forward-0"),
1046 MutationJobTransition::new(
1047 MutationJobStatus::Active,
1048 MutationJobPhase::Verify,
1049 Vec::new(),
1050 8,
1051 3,
1052 0,
1053 ),
1054 )
1055 .expect("Forward exhaustion should enter Verify");
1056 let terminal_request = request(1, "verify-0");
1057 let (completed, receipt) = verifying
1058 .apply_transition(
1059 &terminal_request,
1060 MutationJobTransition::new(
1061 MutationJobStatus::Completed,
1062 MutationJobPhase::Verify,
1063 Vec::new(),
1064 8,
1065 0,
1066 0,
1067 ),
1068 )
1069 .expect("terminal transition should admit");
1070
1071 assert_eq!(
1072 completed
1073 .exact_replay(&terminal_request)
1074 .expect("exact replay lookup should succeed"),
1075 Some(&receipt),
1076 );
1077 assert_eq!(
1078 completed.ensure_can_advance(&request(1, "different")),
1079 Err(MutationJobError::StaleSequence {
1080 expected: 1,
1081 actual: 2,
1082 }),
1083 );
1084 assert_eq!(
1085 completed.ensure_can_advance(&request(2, "next")),
1086 Err(MutationJobError::Completed),
1087 );
1088 }
1089
1090 #[test]
1091 fn payload_decode_is_bounded_fallible_and_rejects_trailing_bytes() {
1092 let bytes = encode_mutation_job_payload(&initial_record())
1093 .expect("current mutation payload should encode");
1094 assert_eq!(
1095 decode_mutation_job_payload(&bytes[..bytes.len() - 1]),
1096 Err(MutationJobError::CorruptProgressStore),
1097 );
1098 let mut trailing = bytes;
1099 trailing.push(0);
1100 assert_eq!(
1101 decode_mutation_job_payload(&trailing),
1102 Err(MutationJobError::CorruptProgressStore),
1103 );
1104 let mut unknown_status = encode_mutation_job_payload(&initial_record())
1105 .expect("current mutation payload should encode");
1106 unknown_status[32 + 8] = u8::MAX;
1107 assert_eq!(
1108 decode_mutation_job_payload(&unknown_status),
1109 Err(MutationJobError::CorruptProgressStore),
1110 );
1111 let mut zero_job_id = encode_mutation_job_payload(&initial_record())
1112 .expect("current mutation payload should encode");
1113 zero_job_id[..32].fill(0);
1114 assert_eq!(
1115 decode_mutation_job_payload(&zero_job_id),
1116 Err(MutationJobError::CorruptProgressStore),
1117 );
1118 }
1119
1120 #[test]
1121 fn transition_totals_fail_closed_on_overflow() {
1122 let mut record = initial_record();
1123 record.state.keys_scanned_total = u64::MAX;
1124 record.state.rows_updated_total = u64::MAX;
1125 record.last_receipt = Some(RetainedMutationJobReceipt {
1126 receipt: MutationJobAdvanceReceipt {
1127 request_sequence: 0,
1128 committed_sequence: 1,
1129 status: MutationJobStatus::Active,
1130 phase: MutationJobPhase::Forward,
1131 keys_scanned: 1,
1132 rows_updated: 1,
1133 keys_scanned_total: u64::MAX,
1134 rows_updated_total: u64::MAX,
1135 verify_restarts_total: 0,
1136 },
1137 idempotency_key: MutationJobIdempotencyKey::new("prior")
1138 .expect("bounded replay key should admit"),
1139 });
1140 record.state.sequence = 1;
1141 assert_eq!(
1142 record.apply_transition(
1143 &request(1, "overflow"),
1144 MutationJobTransition::new(
1145 MutationJobStatus::Active,
1146 MutationJobPhase::Forward,
1147 Vec::new(),
1148 1,
1149 1,
1150 0,
1151 ),
1152 ),
1153 Err(MutationJobError::CounterOverflow),
1154 );
1155
1156 let mut sequence_record = initial_record();
1157 sequence_record.state.sequence = u64::MAX;
1158 sequence_record.last_receipt = Some(RetainedMutationJobReceipt {
1159 receipt: MutationJobAdvanceReceipt {
1160 request_sequence: u64::MAX - 1,
1161 committed_sequence: u64::MAX,
1162 status: MutationJobStatus::Active,
1163 phase: MutationJobPhase::Forward,
1164 keys_scanned: 0,
1165 rows_updated: 0,
1166 keys_scanned_total: 0,
1167 rows_updated_total: 0,
1168 verify_restarts_total: 0,
1169 },
1170 idempotency_key: MutationJobIdempotencyKey::new("prior-sequence")
1171 .expect("bounded replay key should admit"),
1172 });
1173 assert_eq!(
1174 sequence_record.apply_transition(
1175 &request(u64::MAX, "sequence-overflow"),
1176 MutationJobTransition::new(
1177 MutationJobStatus::Active,
1178 MutationJobPhase::Forward,
1179 Vec::new(),
1180 0,
1181 0,
1182 0,
1183 ),
1184 ),
1185 Err(MutationJobError::CounterOverflow),
1186 );
1187 }
1188}