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