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