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