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.
33#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
34pub struct MutationJobId([u8; 32]);
35
36impl MutationJobId {
37    /// Admit one nonzero application-owned identity.
38    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    /// Return the application-owned identity bytes.
46    #[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/// Bounded request identity reused exactly after a lost response.
60#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
61pub struct MutationJobIdempotencyKey(String);
62
63impl MutationJobIdempotencyKey {
64    /// Admit one nonempty bounded UTF-8 request identity.
65    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    /// Borrow the request identity.
74    #[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/// Terminal reason why a valid retained mutation job cannot continue safely.
88#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
89pub enum MutationJobRestartReason {
90    /// The accepted schema identity changed after the job started.
91    AcceptedSchemaChanged,
92    /// The target allocation or database incarnation changed.
93    TargetAllocationChanged,
94    /// The frozen canonical intent is no longer eligible.
95    IntentIneligible,
96    /// The engine-owned batch-policy identity changed.
97    BatchPolicyChanged,
98    /// The retained current record names an unsupported internal continuation.
99    UnsupportedContinuation,
100    /// The current managed-write time would move a target row backward.
101    ManagedTimestampRegression,
102    /// One valid mutation candidate cannot fit the current fixed page policy.
103    CandidateExceedsBatchPolicy,
104}
105
106/// Durable lifecycle of one mutation job.
107#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
108pub enum MutationJobStatus {
109    /// One bounded Forward or Verify step may still run.
110    Active,
111    /// Stable clean Verify exhaustion committed.
112    Completed,
113    /// Authority or policy drift requires a new job.
114    RestartRequired(MutationJobRestartReason),
115}
116
117/// Current convergence phase of one mutation job.
118#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
119pub enum MutationJobPhase {
120    /// Scan authoritative keys and converge stale rows.
121    Forward,
122    /// Prove a clean scan at one unchanged durable target revision.
123    Verify,
124}
125
126/// Public bounded state for one retained mutation job.
127#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
128pub struct MutationJobState {
129    /// Application-owned job identity.
130    pub job_id: MutationJobId,
131    /// Sequence expected by the next non-replay advance.
132    pub sequence: u64,
133    /// Current durable lifecycle.
134    pub status: MutationJobStatus,
135    /// Current convergence phase.
136    pub phase: MutationJobPhase,
137    /// Authoritative keys examined across committed advances.
138    pub keys_scanned_total: u64,
139    /// Rows changed across committed advances.
140    pub rows_updated_total: u64,
141    /// Verify passes restarted because stable convergence was not proven.
142    pub verify_restarts_total: u64,
143}
144
145/// Identity and expected sequence for one idempotent bounded advance.
146#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
147pub struct MutationJobAdvanceRequest {
148    /// Target mutation job.
149    pub job_id: MutationJobId,
150    /// Exact sequence observed before issuing this request.
151    pub expected_sequence: u64,
152    /// Stable request identity reused after a lost reply.
153    pub idempotency_key: MutationJobIdempotencyKey,
154}
155
156impl MutationJobAdvanceRequest {
157    /// Construct one request from already admitted identities.
158    #[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/// Replayable result of one committed bounded advance.
178#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
179pub struct MutationJobAdvanceReceipt {
180    /// Sequence named by the request.
181    pub request_sequence: u64,
182    /// Durable sequence after the advance committed.
183    pub committed_sequence: u64,
184    /// Durable lifecycle after the advance committed.
185    pub status: MutationJobStatus,
186    /// Durable convergence phase after the advance committed.
187    pub phase: MutationJobPhase,
188    /// Authoritative keys examined by this advance.
189    pub keys_scanned: u64,
190    /// Rows changed by this advance.
191    pub rows_updated: u64,
192    /// Authoritative keys examined across committed advances.
193    pub keys_scanned_total: u64,
194    /// Rows changed across committed advances.
195    pub rows_updated_total: u64,
196    /// Verify restarts across committed advances.
197    pub verify_restarts_total: u64,
198}
199
200/// Variable-sized component constrained by the durable record protocol.
201#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
202pub enum MutationJobPayloadKind {
203    /// Canonical accepted mutation intent.
204    Intent,
205    /// Private engine continuation.
206    Continuation,
207    /// Retained replay receipt and request identity.
208    Receipt,
209    /// Complete progress-store record envelope.
210    Record,
211}
212
213/// Typed mutation-job protocol, lifecycle, or persistence failure.
214#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
215pub enum MutationJobError {
216    /// Job identity was all zeroes.
217    InvalidJobId,
218    /// Idempotency key was empty or exceeded its byte bound.
219    InvalidIdempotencyKey,
220    /// A retained job id was reused for different canonical meaning.
221    IdentityConflict,
222    /// The requested job does not exist.
223    NotFound,
224    /// The request did not name the job's current sequence.
225    StaleSequence { expected: u64, actual: u64 },
226    /// Acknowledgement targeted an active job.
227    Active,
228    /// A non-replay advance targeted a completed job.
229    Completed,
230    /// A non-replay advance targeted a job that must restart.
231    RestartRequired(MutationJobRestartReason),
232    /// A bounded persisted component exceeded its engine-owned limit.
233    PayloadTooLarge {
234        kind: MutationJobPayloadKind,
235        limit: u64,
236        observed: u64,
237    },
238    /// Current accepted authority no longer matches the frozen intent.
239    AuthorityMismatch,
240    /// The requested mutation cannot be represented by the fixed-intent engine.
241    IneligibleIntent,
242    /// The shared excluded progress store reached its hard capacity.
243    CapacityExceeded,
244    /// A checked sequence or cumulative counter would overflow.
245    CounterOverflow,
246    /// Retained progress bytes or their state closure were corrupt.
247    CorruptProgressStore,
248    /// Retained progress bytes use an unsupported format version.
249    IncompatibleProgressFormat,
250    /// Commit or recovery evidence cannot prove one exact transition.
251    CommitCorruption,
252    /// Target mutation execution failed before progress committed.
253    TargetMutationFailed,
254    /// Target traversal failed before progress committed.
255    TargetQueryFailed,
256    /// An internal database invariant prevented the operation.
257    Internal,
258    /// The enclosing request exhausted aggregate IcyDB work allowance.
259    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}