Skip to main content

icydb_core/db/
mutation_job.rs

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