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::Completed)
436 && (self.state.phase != MutationJobPhase::Verify
437 || !self.engine_continuation.is_empty())
438 || matches!(self.state.status, MutationJobStatus::RestartRequired(_))
439 && !self.engine_continuation.is_empty()
440 {
441 return Err(MutationJobError::CorruptProgressStore);
442 }
443 match &self.last_receipt {
444 None => {
445 if self.state.sequence != 0
446 || self.state.status != MutationJobStatus::Active
447 || self.state.phase != MutationJobPhase::Forward
448 || self.state.keys_scanned_total != 0
449 || self.state.rows_updated_total != 0
450 || self.state.verify_restarts_total != 0
451 {
452 return Err(MutationJobError::CorruptProgressStore);
453 }
454 }
455 Some(retained) => {
456 retained.idempotency_key.validate()?;
457 validate_receipt(&retained.receipt)?;
458 if retained.receipt.committed_sequence != self.state.sequence
459 || retained.receipt.request_sequence.checked_add(1)
460 != Some(retained.receipt.committed_sequence)
461 || retained.receipt.status != self.state.status
462 || retained.receipt.phase != self.state.phase
463 || retained.receipt.keys_scanned_total != self.state.keys_scanned_total
464 || retained.receipt.rows_updated_total != self.state.rows_updated_total
465 || retained.receipt.verify_restarts_total != self.state.verify_restarts_total
466 {
467 return Err(MutationJobError::CorruptProgressStore);
468 }
469 let receipt_bytes = retained_receipt_encoded_len(retained)?;
470 if receipt_bytes > MAX_MUTATION_JOB_RECEIPT_BYTES {
471 return Err(payload_too_large(
472 MutationJobPayloadKind::Receipt,
473 MAX_MUTATION_JOB_RECEIPT_BYTES,
474 receipt_bytes,
475 ));
476 }
477 }
478 }
479 Ok(())
480 }
481}
482
483#[derive(Clone, Debug, Eq, PartialEq)]
484pub(in crate::db) struct MutationJobTransition {
485 status: MutationJobStatus,
486 phase: MutationJobPhase,
487 engine_continuation: Vec<u8>,
488 keys_scanned: u64,
489 rows_updated: u64,
490 verify_restarts: u64,
491}
492
493impl MutationJobTransition {
494 pub(in crate::db) const fn new(
495 status: MutationJobStatus,
496 phase: MutationJobPhase,
497 engine_continuation: Vec<u8>,
498 keys_scanned: u64,
499 rows_updated: u64,
500 verify_restarts: u64,
501 ) -> Self {
502 Self {
503 status,
504 phase,
505 engine_continuation,
506 keys_scanned,
507 rows_updated,
508 verify_restarts,
509 }
510 }
511
512 fn validate(&self, previous_phase: MutationJobPhase) -> Result<(), MutationJobError> {
513 validate_bytes(
514 &self.engine_continuation,
515 MAX_MUTATION_JOB_CONTINUATION_BYTES,
516 MutationJobPayloadKind::Continuation,
517 )?;
518 let expected_verify_restarts = u64::from(
519 previous_phase == MutationJobPhase::Verify
520 && self.phase == MutationJobPhase::Forward
521 && self.status == MutationJobStatus::Active,
522 );
523 if self.keys_scanned > MAX_MUTATION_JOB_STEP_KEYS_SCANNED
524 || self.rows_updated > MAX_MUTATION_JOB_STEP_ROWS_UPDATED
525 || self.rows_updated > self.keys_scanned
526 || previous_phase == MutationJobPhase::Verify && self.rows_updated != 0
527 || self.verify_restarts != expected_verify_restarts
528 || matches!(self.status, MutationJobStatus::Completed)
529 && (previous_phase != MutationJobPhase::Verify
530 || self.phase != MutationJobPhase::Verify
531 || self.rows_updated != 0
532 || !self.engine_continuation.is_empty())
533 || matches!(self.status, MutationJobStatus::RestartRequired(_))
534 && (self.phase != previous_phase
535 || !self.engine_continuation.is_empty()
536 || self.keys_scanned != 0
537 || self.rows_updated != 0)
538 {
539 return Err(MutationJobError::CorruptProgressStore);
540 }
541 Ok(())
542 }
543}
544
545pub(in crate::db) fn encode_mutation_job_payload(
546 record: &MutationJobRecord,
547) -> Result<Vec<u8>, MutationJobError> {
548 record.validate()?;
549 let mut bytes = Vec::new();
550 bytes.extend_from_slice(&record.state.job_id.to_bytes());
551 bytes.extend_from_slice(&record.state.sequence.to_be_bytes());
552 write_status(&mut bytes, record.state.status);
553 write_phase(&mut bytes, record.state.phase);
554 bytes.extend_from_slice(&record.state.keys_scanned_total.to_be_bytes());
555 bytes.extend_from_slice(&record.state.rows_updated_total.to_be_bytes());
556 bytes.extend_from_slice(&record.state.verify_restarts_total.to_be_bytes());
557 write_bytes(&mut bytes, &record.canonical_intent)?;
558 write_bytes(&mut bytes, &record.engine_continuation)?;
559 match &record.last_receipt {
560 None => bytes.push(0),
561 Some(retained) => {
562 bytes.push(1);
563 let receipt = &retained.receipt;
564 bytes.extend_from_slice(&receipt.request_sequence.to_be_bytes());
565 bytes.extend_from_slice(&receipt.committed_sequence.to_be_bytes());
566 write_status(&mut bytes, receipt.status);
567 write_phase(&mut bytes, receipt.phase);
568 bytes.extend_from_slice(&receipt.keys_scanned.to_be_bytes());
569 bytes.extend_from_slice(&receipt.rows_updated.to_be_bytes());
570 bytes.extend_from_slice(&receipt.keys_scanned_total.to_be_bytes());
571 bytes.extend_from_slice(&receipt.rows_updated_total.to_be_bytes());
572 bytes.extend_from_slice(&receipt.verify_restarts_total.to_be_bytes());
573 write_bytes(&mut bytes, retained.idempotency_key.as_str().as_bytes())?;
574 }
575 }
576 Ok(bytes)
577}
578
579pub(in crate::db) fn decode_mutation_job_payload(
580 bytes: &[u8],
581) -> Result<MutationJobRecord, MutationJobError> {
582 if bytes.len() > MAX_MUTATION_JOB_RECORD_BYTES {
583 return Err(MutationJobError::CorruptProgressStore);
584 }
585 let mut reader = Reader::new(bytes);
586 let job_id = MutationJobId::try_from_bytes(reader.array()?)
587 .map_err(|_| MutationJobError::CorruptProgressStore)?;
588 let state = MutationJobState {
589 job_id,
590 sequence: reader.u64()?,
591 status: read_status(&mut reader)?,
592 phase: read_phase(&mut reader)?,
593 keys_scanned_total: reader.u64()?,
594 rows_updated_total: reader.u64()?,
595 verify_restarts_total: reader.u64()?,
596 };
597 let canonical_intent = reader.bytes(MAX_MUTATION_JOB_INTENT_BYTES)?.to_vec();
598 let engine_continuation = reader.bytes(MAX_MUTATION_JOB_CONTINUATION_BYTES)?.to_vec();
599 let last_receipt = match reader.u8()? {
600 0 => None,
601 1 => {
602 let receipt = MutationJobAdvanceReceipt {
603 request_sequence: reader.u64()?,
604 committed_sequence: reader.u64()?,
605 status: read_status(&mut reader)?,
606 phase: read_phase(&mut reader)?,
607 keys_scanned: reader.u64()?,
608 rows_updated: reader.u64()?,
609 keys_scanned_total: reader.u64()?,
610 rows_updated_total: reader.u64()?,
611 verify_restarts_total: reader.u64()?,
612 };
613 let idempotency_key = MutationJobIdempotencyKey::new(
614 reader.string(MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES)?,
615 )
616 .map_err(|_| MutationJobError::CorruptProgressStore)?;
617 Some(RetainedMutationJobReceipt {
618 receipt,
619 idempotency_key,
620 })
621 }
622 _ => return Err(MutationJobError::CorruptProgressStore),
623 };
624 if !reader.is_empty() {
625 return Err(MutationJobError::CorruptProgressStore);
626 }
627 let record = MutationJobRecord {
628 state,
629 canonical_intent,
630 engine_continuation,
631 last_receipt,
632 };
633 record
634 .validate()
635 .map_err(|_| MutationJobError::CorruptProgressStore)?;
636 Ok(record)
637}
638
639fn validate_receipt(receipt: &MutationJobAdvanceReceipt) -> Result<(), MutationJobError> {
640 if receipt.keys_scanned > MAX_MUTATION_JOB_STEP_KEYS_SCANNED
641 || receipt.rows_updated > MAX_MUTATION_JOB_STEP_ROWS_UPDATED
642 || receipt.rows_updated > receipt.keys_scanned
643 || receipt.rows_updated_total > receipt.keys_scanned_total
644 || receipt.keys_scanned > receipt.keys_scanned_total
645 || receipt.rows_updated > receipt.rows_updated_total
646 || matches!(receipt.status, MutationJobStatus::Completed)
647 && (receipt.phase != MutationJobPhase::Verify || receipt.rows_updated != 0)
648 || matches!(receipt.status, MutationJobStatus::RestartRequired(_))
649 && (receipt.keys_scanned != 0 || receipt.rows_updated != 0)
650 {
651 return Err(MutationJobError::CorruptProgressStore);
652 }
653 Ok(())
654}
655
656fn retained_receipt_encoded_len(
657 retained: &RetainedMutationJobReceipt,
658) -> Result<usize, MutationJobError> {
659 let status_bytes = match retained.receipt.status {
660 MutationJobStatus::RestartRequired(_) => 2,
661 MutationJobStatus::Active | MutationJobStatus::Completed => 1,
662 };
663 8_usize
664 .checked_add(8)
665 .and_then(|value| value.checked_add(status_bytes))
666 .and_then(|value| value.checked_add(1))
667 .and_then(|value| value.checked_add(5 * 8))
668 .and_then(|value| value.checked_add(4))
669 .and_then(|value| value.checked_add(retained.idempotency_key.as_str().len()))
670 .ok_or(MutationJobError::CounterOverflow)
671}
672
673fn validate_nonempty_bytes(
674 value: &[u8],
675 limit: usize,
676 kind: MutationJobPayloadKind,
677) -> Result<(), MutationJobError> {
678 if value.is_empty() {
679 return Err(MutationJobError::CorruptProgressStore);
680 }
681 validate_bytes(value, limit, kind)
682}
683
684fn validate_bytes(
685 value: &[u8],
686 limit: usize,
687 kind: MutationJobPayloadKind,
688) -> Result<(), MutationJobError> {
689 if value.len() > limit {
690 return Err(payload_too_large(kind, limit, value.len()));
691 }
692 Ok(())
693}
694
695fn payload_too_large(
696 kind: MutationJobPayloadKind,
697 limit: usize,
698 observed: usize,
699) -> MutationJobError {
700 MutationJobError::PayloadTooLarge {
701 kind,
702 limit: u64::try_from(limit).map_or(u64::MAX, |value| value),
703 observed: u64::try_from(observed).map_or(u64::MAX, |value| value),
704 }
705}
706
707fn write_status(bytes: &mut Vec<u8>, status: MutationJobStatus) {
708 match status {
709 MutationJobStatus::Active => bytes.push(0),
710 MutationJobStatus::Completed => bytes.push(1),
711 MutationJobStatus::RestartRequired(reason) => {
712 bytes.push(2);
713 bytes.push(match reason {
714 MutationJobRestartReason::AcceptedSchemaChanged => 0,
715 MutationJobRestartReason::TargetAllocationChanged => 1,
716 MutationJobRestartReason::IntentIneligible => 2,
717 MutationJobRestartReason::BatchPolicyChanged => 3,
718 MutationJobRestartReason::UnsupportedContinuation => 4,
719 });
720 }
721 }
722}
723
724fn read_status(reader: &mut Reader<'_>) -> Result<MutationJobStatus, MutationJobError> {
725 match reader.u8()? {
726 0 => Ok(MutationJobStatus::Active),
727 1 => Ok(MutationJobStatus::Completed),
728 2 => Ok(MutationJobStatus::RestartRequired(match reader.u8()? {
729 0 => MutationJobRestartReason::AcceptedSchemaChanged,
730 1 => MutationJobRestartReason::TargetAllocationChanged,
731 2 => MutationJobRestartReason::IntentIneligible,
732 3 => MutationJobRestartReason::BatchPolicyChanged,
733 4 => MutationJobRestartReason::UnsupportedContinuation,
734 _ => return Err(MutationJobError::CorruptProgressStore),
735 })),
736 _ => Err(MutationJobError::CorruptProgressStore),
737 }
738}
739
740fn write_phase(bytes: &mut Vec<u8>, phase: MutationJobPhase) {
741 bytes.push(match phase {
742 MutationJobPhase::Forward => 0,
743 MutationJobPhase::Verify => 1,
744 });
745}
746
747fn read_phase(reader: &mut Reader<'_>) -> Result<MutationJobPhase, MutationJobError> {
748 match reader.u8()? {
749 0 => Ok(MutationJobPhase::Forward),
750 1 => Ok(MutationJobPhase::Verify),
751 _ => Err(MutationJobError::CorruptProgressStore),
752 }
753}
754
755fn write_bytes(bytes: &mut Vec<u8>, value: &[u8]) -> Result<(), MutationJobError> {
756 let len = u32::try_from(value.len()).map_err(|_| MutationJobError::Internal)?;
757 bytes.extend_from_slice(&len.to_be_bytes());
758 bytes.extend_from_slice(value);
759 Ok(())
760}
761
762struct Reader<'a> {
763 bytes: &'a [u8],
764 offset: usize,
765}
766
767impl<'a> Reader<'a> {
768 const fn new(bytes: &'a [u8]) -> Self {
769 Self { bytes, offset: 0 }
770 }
771
772 fn u8(&mut self) -> Result<u8, MutationJobError> {
773 let value = *self
774 .bytes
775 .get(self.offset)
776 .ok_or(MutationJobError::CorruptProgressStore)?;
777 self.offset += 1;
778 Ok(value)
779 }
780
781 fn u32(&mut self) -> Result<u32, MutationJobError> {
782 Ok(u32::from_be_bytes(self.array()?))
783 }
784
785 fn u64(&mut self) -> Result<u64, MutationJobError> {
786 Ok(u64::from_be_bytes(self.array()?))
787 }
788
789 fn array<const N: usize>(&mut self) -> Result<[u8; N], MutationJobError> {
790 self.take(N)?
791 .try_into()
792 .map_err(|_| MutationJobError::CorruptProgressStore)
793 }
794
795 fn bytes(&mut self, max: usize) -> Result<&'a [u8], MutationJobError> {
796 let len = self.u32()? as usize;
797 if len > max {
798 return Err(MutationJobError::CorruptProgressStore);
799 }
800 self.take(len)
801 }
802
803 fn string(&mut self, max: usize) -> Result<String, MutationJobError> {
804 let bytes = self.bytes(max)?;
805 std::str::from_utf8(bytes)
806 .map(str::to_string)
807 .map_err(|_| MutationJobError::CorruptProgressStore)
808 }
809
810 fn take(&mut self, len: usize) -> Result<&'a [u8], MutationJobError> {
811 let end = self
812 .offset
813 .checked_add(len)
814 .ok_or(MutationJobError::CorruptProgressStore)?;
815 let bytes = self
816 .bytes
817 .get(self.offset..end)
818 .ok_or(MutationJobError::CorruptProgressStore)?;
819 self.offset = end;
820 Ok(bytes)
821 }
822
823 const fn is_empty(&self) -> bool {
824 self.offset == self.bytes.len()
825 }
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831
832 fn job_id() -> MutationJobId {
833 MutationJobId::try_from_bytes([7; 32]).expect("nonzero mutation job id should admit")
834 }
835
836 fn request(sequence: u64, key: &str) -> MutationJobAdvanceRequest {
837 MutationJobAdvanceRequest::new(
838 job_id(),
839 sequence,
840 MutationJobIdempotencyKey::new(key).expect("bounded replay key should admit"),
841 )
842 }
843
844 fn initial_record() -> MutationJobRecord {
845 MutationJobRecord::new(job_id(), vec![1, 2, 3], vec![4, 5])
846 .expect("bounded mutation record should admit")
847 }
848
849 #[test]
850 fn identities_and_variable_components_enforce_current_bounds() {
851 assert_eq!(
852 MutationJobId::try_from_bytes([0; 32]),
853 Err(MutationJobError::InvalidJobId),
854 );
855 assert_eq!(
856 MutationJobIdempotencyKey::new(""),
857 Err(MutationJobError::InvalidIdempotencyKey),
858 );
859 assert!(MutationJobIdempotencyKey::new("k".repeat(256)).is_ok());
860 assert_eq!(
861 MutationJobIdempotencyKey::new("k".repeat(257)),
862 Err(MutationJobError::InvalidIdempotencyKey),
863 );
864
865 assert!(MutationJobRecord::new(job_id(), vec![1; 16 * 1024], vec![2; 2 * 1024]).is_ok());
866 assert!(matches!(
867 MutationJobRecord::new(job_id(), vec![1; 16 * 1024 + 1], Vec::new()),
868 Err(MutationJobError::PayloadTooLarge {
869 kind: MutationJobPayloadKind::Intent,
870 ..
871 }),
872 ));
873 assert!(matches!(
874 MutationJobRecord::new(job_id(), vec![1], vec![2; 2 * 1024 + 1]),
875 Err(MutationJobError::PayloadTooLarge {
876 kind: MutationJobPayloadKind::Continuation,
877 ..
878 }),
879 ));
880
881 let maximum_key =
882 MutationJobIdempotencyKey::new("k".repeat(MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES))
883 .expect("maximum replay key should admit");
884 let request = MutationJobAdvanceRequest::new(job_id(), 0, maximum_key);
885 let (record, _) = initial_record()
886 .apply_transition(
887 &request,
888 MutationJobTransition::new(
889 MutationJobStatus::Active,
890 MutationJobPhase::Forward,
891 Vec::new(),
892 1,
893 0,
894 0,
895 ),
896 )
897 .expect("maximum replay identity should retain");
898 assert_eq!(
899 record
900 .last_receipt
901 .as_ref()
902 .map(retained_receipt_encoded_len),
903 Some(Ok(318)),
904 );
905
906 let maximum_key =
907 MutationJobIdempotencyKey::new("k".repeat(MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES))
908 .expect("maximum replay key should admit");
909 let (restart, _) = initial_record()
910 .apply_transition(
911 &MutationJobAdvanceRequest::new(job_id(), 0, maximum_key),
912 MutationJobTransition::new(
913 MutationJobStatus::RestartRequired(
914 MutationJobRestartReason::BatchPolicyChanged,
915 ),
916 MutationJobPhase::Forward,
917 Vec::new(),
918 0,
919 0,
920 0,
921 ),
922 )
923 .expect("maximum restart receipt should retain");
924 assert_eq!(
925 restart
926 .last_receipt
927 .as_ref()
928 .map(retained_receipt_encoded_len),
929 Some(Ok(319)),
930 );
931 }
932
933 #[test]
934 fn current_payload_round_trips_every_lifecycle() {
935 let initial = initial_record();
936 let (active, _) = initial
937 .apply_transition(
938 &request(0, "forward-0"),
939 MutationJobTransition::new(
940 MutationJobStatus::Active,
941 MutationJobPhase::Verify,
942 vec![6],
943 13,
944 4,
945 0,
946 ),
947 )
948 .expect("bounded active transition should admit");
949 let (completed, _) = active
950 .apply_transition(
951 &request(1, "verify-0"),
952 MutationJobTransition::new(
953 MutationJobStatus::Completed,
954 MutationJobPhase::Verify,
955 Vec::new(),
956 9,
957 0,
958 0,
959 ),
960 )
961 .expect("clean terminal transition should admit");
962 let (restart, _) = initial
963 .apply_transition(
964 &request(0, "restart"),
965 MutationJobTransition::new(
966 MutationJobStatus::RestartRequired(
967 MutationJobRestartReason::AcceptedSchemaChanged,
968 ),
969 MutationJobPhase::Forward,
970 Vec::new(),
971 0,
972 0,
973 0,
974 ),
975 )
976 .expect("typed restart transition should admit");
977
978 for record in [initial, active, completed, restart] {
979 let bytes = encode_mutation_job_payload(&record)
980 .expect("current mutation payload should encode");
981 assert!(!bytes.starts_with(b"DIDL"));
982 assert_eq!(
983 decode_mutation_job_payload(&bytes)
984 .expect("current mutation payload should decode"),
985 record,
986 );
987 }
988 }
989
990 #[test]
991 fn public_state_request_receipt_and_error_are_candid_compatible() {
992 let state = initial_record().state().clone();
993 let request = request(0, "candid-request");
994 let receipt = MutationJobAdvanceReceipt {
995 request_sequence: 0,
996 committed_sequence: 1,
997 status: MutationJobStatus::Active,
998 phase: MutationJobPhase::Forward,
999 keys_scanned: 8,
1000 rows_updated: 3,
1001 keys_scanned_total: 8,
1002 rows_updated_total: 3,
1003 verify_restarts_total: 0,
1004 };
1005 let error = MutationJobError::StaleSequence {
1006 expected: 0,
1007 actual: 1,
1008 };
1009
1010 let state_bytes = candid::encode_one(&state).expect("mutation state should encode");
1011 let request_bytes = candid::encode_one(&request).expect("mutation request should encode");
1012 let receipt_bytes = candid::encode_one(&receipt).expect("mutation receipt should encode");
1013 let error_bytes = candid::encode_one(&error).expect("mutation error should encode");
1014 assert_eq!(
1015 candid::decode_one::<MutationJobState>(&state_bytes)
1016 .expect("mutation state should decode"),
1017 state,
1018 );
1019 assert_eq!(
1020 candid::decode_one::<MutationJobAdvanceRequest>(&request_bytes)
1021 .expect("mutation request should decode"),
1022 request,
1023 );
1024 assert_eq!(
1025 candid::decode_one::<MutationJobAdvanceReceipt>(&receipt_bytes)
1026 .expect("mutation receipt should decode"),
1027 receipt,
1028 );
1029 assert_eq!(
1030 candid::decode_one::<MutationJobError>(&error_bytes)
1031 .expect("mutation error should decode"),
1032 error,
1033 );
1034 }
1035
1036 #[test]
1037 fn exact_replay_precedes_stale_and_terminal_rejection() {
1038 let initial = initial_record();
1039 let (verifying, _) = initial
1040 .apply_transition(
1041 &request(0, "forward-0"),
1042 MutationJobTransition::new(
1043 MutationJobStatus::Active,
1044 MutationJobPhase::Verify,
1045 Vec::new(),
1046 8,
1047 3,
1048 0,
1049 ),
1050 )
1051 .expect("Forward exhaustion should enter Verify");
1052 let terminal_request = request(1, "verify-0");
1053 let (completed, receipt) = verifying
1054 .apply_transition(
1055 &terminal_request,
1056 MutationJobTransition::new(
1057 MutationJobStatus::Completed,
1058 MutationJobPhase::Verify,
1059 Vec::new(),
1060 8,
1061 0,
1062 0,
1063 ),
1064 )
1065 .expect("terminal transition should admit");
1066
1067 assert_eq!(
1068 completed
1069 .exact_replay(&terminal_request)
1070 .expect("exact replay lookup should succeed"),
1071 Some(&receipt),
1072 );
1073 assert_eq!(
1074 completed.ensure_can_advance(&request(1, "different")),
1075 Err(MutationJobError::StaleSequence {
1076 expected: 1,
1077 actual: 2,
1078 }),
1079 );
1080 assert_eq!(
1081 completed.ensure_can_advance(&request(2, "next")),
1082 Err(MutationJobError::Completed),
1083 );
1084 }
1085
1086 #[test]
1087 fn payload_decode_is_bounded_fallible_and_rejects_trailing_bytes() {
1088 let bytes = encode_mutation_job_payload(&initial_record())
1089 .expect("current mutation payload should encode");
1090 assert_eq!(
1091 decode_mutation_job_payload(&bytes[..bytes.len() - 1]),
1092 Err(MutationJobError::CorruptProgressStore),
1093 );
1094 let mut trailing = bytes;
1095 trailing.push(0);
1096 assert_eq!(
1097 decode_mutation_job_payload(&trailing),
1098 Err(MutationJobError::CorruptProgressStore),
1099 );
1100 let mut unknown_status = encode_mutation_job_payload(&initial_record())
1101 .expect("current mutation payload should encode");
1102 unknown_status[32 + 8] = u8::MAX;
1103 assert_eq!(
1104 decode_mutation_job_payload(&unknown_status),
1105 Err(MutationJobError::CorruptProgressStore),
1106 );
1107 let mut zero_job_id = encode_mutation_job_payload(&initial_record())
1108 .expect("current mutation payload should encode");
1109 zero_job_id[..32].fill(0);
1110 assert_eq!(
1111 decode_mutation_job_payload(&zero_job_id),
1112 Err(MutationJobError::CorruptProgressStore),
1113 );
1114 }
1115
1116 #[test]
1117 fn transition_totals_fail_closed_on_overflow() {
1118 let mut record = initial_record();
1119 record.state.keys_scanned_total = u64::MAX;
1120 record.state.rows_updated_total = u64::MAX;
1121 record.last_receipt = Some(RetainedMutationJobReceipt {
1122 receipt: MutationJobAdvanceReceipt {
1123 request_sequence: 0,
1124 committed_sequence: 1,
1125 status: MutationJobStatus::Active,
1126 phase: MutationJobPhase::Forward,
1127 keys_scanned: 1,
1128 rows_updated: 1,
1129 keys_scanned_total: u64::MAX,
1130 rows_updated_total: u64::MAX,
1131 verify_restarts_total: 0,
1132 },
1133 idempotency_key: MutationJobIdempotencyKey::new("prior")
1134 .expect("bounded replay key should admit"),
1135 });
1136 record.state.sequence = 1;
1137 assert_eq!(
1138 record.apply_transition(
1139 &request(1, "overflow"),
1140 MutationJobTransition::new(
1141 MutationJobStatus::Active,
1142 MutationJobPhase::Forward,
1143 Vec::new(),
1144 1,
1145 1,
1146 0,
1147 ),
1148 ),
1149 Err(MutationJobError::CounterOverflow),
1150 );
1151
1152 let mut sequence_record = initial_record();
1153 sequence_record.state.sequence = u64::MAX;
1154 sequence_record.last_receipt = Some(RetainedMutationJobReceipt {
1155 receipt: MutationJobAdvanceReceipt {
1156 request_sequence: u64::MAX - 1,
1157 committed_sequence: u64::MAX,
1158 status: MutationJobStatus::Active,
1159 phase: MutationJobPhase::Forward,
1160 keys_scanned: 0,
1161 rows_updated: 0,
1162 keys_scanned_total: 0,
1163 rows_updated_total: 0,
1164 verify_restarts_total: 0,
1165 },
1166 idempotency_key: MutationJobIdempotencyKey::new("prior-sequence")
1167 .expect("bounded replay key should admit"),
1168 });
1169 assert_eq!(
1170 sequence_record.apply_transition(
1171 &request(u64::MAX, "sequence-overflow"),
1172 MutationJobTransition::new(
1173 MutationJobStatus::Active,
1174 MutationJobPhase::Forward,
1175 Vec::new(),
1176 0,
1177 0,
1178 0,
1179 ),
1180 ),
1181 Err(MutationJobError::CounterOverflow),
1182 );
1183 }
1184}