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_attr(
7    not(test),
8    expect(
9        dead_code,
10        reason = "record construction and transitions are private mutation-coordinator inputs"
11    )
12)]
13
14#[cfg(feature = "sql")]
15mod intent;
16
17use candid::CandidType;
18use serde::Deserialize;
19use std::{error::Error as StdError, fmt};
20
21#[cfg(feature = "sql")]
22pub(in crate::db) use intent::CanonicalMutationIntent;
23
24/// Maximum UTF-8 bytes in one mutation-job idempotency key.
25pub const MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES: usize = 256;
26/// Maximum current engine-continuation bytes retained by one mutation job.
27pub const MAX_MUTATION_JOB_CONTINUATION_BYTES: usize = 2 * 1024;
28/// Maximum canonical accepted-intent bytes retained by one mutation job.
29pub const MAX_MUTATION_JOB_INTENT_BYTES: usize = 16 * 1024;
30/// Maximum encoded replay-receipt bytes retained by one mutation job.
31pub const MAX_MUTATION_JOB_RECEIPT_BYTES: usize = 8 * 1024;
32/// Maximum complete encoded mutation-job record, including its storage envelope.
33pub const MAX_MUTATION_JOB_RECORD_BYTES: usize = 64 * 1024;
34
35/// Maximum authoritative keys examined by one mutation-job advance.
36pub const MAX_MUTATION_JOB_STEP_KEYS_SCANNED: u64 = 256;
37/// Maximum target rows changed by one mutation-job advance.
38pub const MAX_MUTATION_JOB_STEP_ROWS_UPDATED: u64 = 64;
39
40/// Nonzero application-owned identity for one durable mutation job.
41#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
42pub struct MutationJobId([u8; 32]);
43
44impl MutationJobId {
45    /// Admit one nonzero application-owned identity.
46    pub fn try_from_bytes(bytes: [u8; 32]) -> Result<Self, MutationJobError> {
47        if bytes == [0; 32] {
48            return Err(MutationJobError::InvalidJobId);
49        }
50        Ok(Self(bytes))
51    }
52
53    /// Return the application-owned identity bytes.
54    #[must_use]
55    pub const fn to_bytes(self) -> [u8; 32] {
56        self.0
57    }
58
59    pub(in crate::db) fn validate(self) -> Result<(), MutationJobError> {
60        if self.0 == [0; 32] {
61            return Err(MutationJobError::InvalidJobId);
62        }
63        Ok(())
64    }
65}
66
67/// Bounded request identity reused exactly after a lost response.
68#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
69pub struct MutationJobIdempotencyKey(String);
70
71impl MutationJobIdempotencyKey {
72    /// Admit one nonempty bounded UTF-8 request identity.
73    pub fn new(value: impl Into<String>) -> Result<Self, MutationJobError> {
74        let value = value.into();
75        if value.is_empty() || value.len() > MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES {
76            return Err(MutationJobError::InvalidIdempotencyKey);
77        }
78        Ok(Self(value))
79    }
80
81    /// Borrow the request identity.
82    #[must_use]
83    pub const fn as_str(&self) -> &str {
84        self.0.as_str()
85    }
86
87    const fn validate(&self) -> Result<(), MutationJobError> {
88        if self.0.is_empty() || self.0.len() > MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES {
89            return Err(MutationJobError::InvalidIdempotencyKey);
90        }
91        Ok(())
92    }
93}
94
95/// Terminal reason why a valid retained mutation job cannot continue safely.
96#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
97pub enum MutationJobRestartReason {
98    /// The accepted schema identity changed after the job started.
99    AcceptedSchemaChanged,
100    /// The target allocation or database incarnation changed.
101    TargetAllocationChanged,
102    /// The frozen canonical intent is no longer eligible.
103    IntentIneligible,
104    /// The engine-owned batch-policy identity changed.
105    BatchPolicyChanged,
106    /// The retained current record names an unsupported internal continuation.
107    UnsupportedContinuation,
108}
109
110/// Durable lifecycle of one mutation job.
111#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
112pub enum MutationJobStatus {
113    /// One bounded Forward or Verify step may still run.
114    Active,
115    /// Stable clean Verify exhaustion committed.
116    Completed,
117    /// Authority or policy drift requires a new job.
118    RestartRequired(MutationJobRestartReason),
119}
120
121/// Current convergence phase of one mutation job.
122#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
123pub enum MutationJobPhase {
124    /// Scan authoritative keys and converge stale rows.
125    Forward,
126    /// Prove a clean scan at one unchanged durable target revision.
127    Verify,
128}
129
130/// Public bounded state for one retained mutation job.
131#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
132pub struct MutationJobState {
133    /// Application-owned job identity.
134    pub job_id: MutationJobId,
135    /// Sequence expected by the next non-replay advance.
136    pub sequence: u64,
137    /// Current durable lifecycle.
138    pub status: MutationJobStatus,
139    /// Current convergence phase.
140    pub phase: MutationJobPhase,
141    /// Authoritative keys examined across committed advances.
142    pub keys_scanned_total: u64,
143    /// Rows changed across committed advances.
144    pub rows_updated_total: u64,
145    /// Verify passes restarted because stable convergence was not proven.
146    pub verify_restarts_total: u64,
147}
148
149/// Identity and expected sequence for one idempotent bounded advance.
150#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
151pub struct MutationJobAdvanceRequest {
152    /// Target mutation job.
153    pub job_id: MutationJobId,
154    /// Exact sequence observed before issuing this request.
155    pub expected_sequence: u64,
156    /// Stable request identity reused after a lost reply.
157    pub idempotency_key: MutationJobIdempotencyKey,
158}
159
160impl MutationJobAdvanceRequest {
161    /// Construct one request from already admitted identities.
162    #[must_use]
163    pub const fn new(
164        job_id: MutationJobId,
165        expected_sequence: u64,
166        idempotency_key: MutationJobIdempotencyKey,
167    ) -> Self {
168        Self {
169            job_id,
170            expected_sequence,
171            idempotency_key,
172        }
173    }
174
175    fn validate(&self) -> Result<(), MutationJobError> {
176        self.job_id.validate()?;
177        self.idempotency_key.validate()
178    }
179}
180
181/// Replayable result of one committed bounded advance.
182#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
183pub struct MutationJobAdvanceReceipt {
184    /// Sequence named by the request.
185    pub request_sequence: u64,
186    /// Durable sequence after the advance committed.
187    pub committed_sequence: u64,
188    /// Durable lifecycle after the advance committed.
189    pub status: MutationJobStatus,
190    /// Durable convergence phase after the advance committed.
191    pub phase: MutationJobPhase,
192    /// Authoritative keys examined by this advance.
193    pub keys_scanned: u64,
194    /// Rows changed by this advance.
195    pub rows_updated: u64,
196    /// Authoritative keys examined across committed advances.
197    pub keys_scanned_total: u64,
198    /// Rows changed across committed advances.
199    pub rows_updated_total: u64,
200    /// Verify restarts across committed advances.
201    pub verify_restarts_total: u64,
202}
203
204/// Variable-sized component constrained by the durable record protocol.
205#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
206pub enum MutationJobPayloadKind {
207    /// Canonical accepted mutation intent.
208    Intent,
209    /// Private engine continuation.
210    Continuation,
211    /// Retained replay receipt and request identity.
212    Receipt,
213    /// Complete progress-store record envelope.
214    Record,
215}
216
217/// Typed mutation-job protocol, lifecycle, or persistence failure.
218#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
219pub enum MutationJobError {
220    /// Job identity was all zeroes.
221    InvalidJobId,
222    /// Idempotency key was empty or exceeded its byte bound.
223    InvalidIdempotencyKey,
224    /// A retained job id was reused for different canonical meaning.
225    IdentityConflict,
226    /// The requested job does not exist.
227    NotFound,
228    /// The request did not name the job's current sequence.
229    StaleSequence { expected: u64, actual: u64 },
230    /// Acknowledgement targeted an active job.
231    Active,
232    /// A non-replay advance targeted a completed job.
233    Completed,
234    /// A non-replay advance targeted a job that must restart.
235    RestartRequired(MutationJobRestartReason),
236    /// A bounded persisted component exceeded its engine-owned limit.
237    PayloadTooLarge {
238        kind: MutationJobPayloadKind,
239        limit: u64,
240        observed: u64,
241    },
242    /// Current accepted authority no longer matches the frozen intent.
243    AuthorityMismatch,
244    /// The requested mutation cannot be represented by the fixed-intent engine.
245    IneligibleIntent,
246    /// The shared excluded progress store reached its hard capacity.
247    CapacityExceeded,
248    /// A checked sequence or cumulative counter would overflow.
249    CounterOverflow,
250    /// Retained progress bytes or their state closure were corrupt.
251    CorruptProgressStore,
252    /// Retained progress bytes use an unsupported format version.
253    IncompatibleProgressFormat,
254    /// Commit or recovery evidence cannot prove one exact transition.
255    CommitCorruption,
256    /// Target mutation execution failed before progress committed.
257    TargetMutationFailed,
258    /// Target traversal failed before progress committed.
259    TargetQueryFailed,
260    /// An internal database invariant prevented the operation.
261    Internal,
262    /// The enclosing request exhausted aggregate IcyDB work allowance.
263    ExecutionBudgetExceeded {
264        resource: u64,
265        limit: u64,
266        observed: u64,
267        scope: u64,
268        lane: u64,
269        normalized_shape_fingerprint_prefix: u64,
270    },
271}
272
273impl fmt::Display for MutationJobError {
274    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
275        formatter.write_str("mutation job operation failed")
276    }
277}
278
279impl StdError for MutationJobError {}
280
281#[derive(Clone, Debug, Eq, PartialEq)]
282struct RetainedMutationJobReceipt {
283    receipt: MutationJobAdvanceReceipt,
284    idempotency_key: MutationJobIdempotencyKey,
285}
286
287#[derive(Clone, Debug, Eq, PartialEq)]
288pub(in crate::db) struct MutationJobRecord {
289    state: MutationJobState,
290    canonical_intent: Vec<u8>,
291    engine_continuation: Vec<u8>,
292    last_receipt: Option<RetainedMutationJobReceipt>,
293}
294
295impl MutationJobRecord {
296    pub(in crate::db) fn new(
297        job_id: MutationJobId,
298        canonical_intent: Vec<u8>,
299        engine_continuation: Vec<u8>,
300    ) -> Result<Self, MutationJobError> {
301        let record = Self {
302            state: MutationJobState {
303                job_id,
304                sequence: 0,
305                status: MutationJobStatus::Active,
306                phase: MutationJobPhase::Forward,
307                keys_scanned_total: 0,
308                rows_updated_total: 0,
309                verify_restarts_total: 0,
310            },
311            canonical_intent,
312            engine_continuation,
313            last_receipt: None,
314        };
315        record.validate()?;
316        Ok(record)
317    }
318
319    pub(in crate::db) const fn state(&self) -> &MutationJobState {
320        &self.state
321    }
322
323    pub(in crate::db) const fn canonical_intent(&self) -> &[u8] {
324        self.canonical_intent.as_slice()
325    }
326
327    pub(in crate::db) fn exact_replay(
328        &self,
329        request: &MutationJobAdvanceRequest,
330    ) -> Result<Option<&MutationJobAdvanceReceipt>, MutationJobError> {
331        self.validate()?;
332        request.validate()?;
333        if request.job_id != self.state.job_id {
334            return Err(MutationJobError::NotFound);
335        }
336        Ok(self.last_receipt.as_ref().and_then(|retained| {
337            (retained.receipt.request_sequence == request.expected_sequence
338                && retained.idempotency_key == request.idempotency_key)
339                .then_some(&retained.receipt)
340        }))
341    }
342
343    pub(in crate::db) fn ensure_can_advance(
344        &self,
345        request: &MutationJobAdvanceRequest,
346    ) -> Result<(), MutationJobError> {
347        self.validate()?;
348        request.validate()?;
349        if request.job_id != self.state.job_id {
350            return Err(MutationJobError::NotFound);
351        }
352        if self.state.sequence != request.expected_sequence {
353            return Err(MutationJobError::StaleSequence {
354                expected: request.expected_sequence,
355                actual: self.state.sequence,
356            });
357        }
358        match self.state.status {
359            MutationJobStatus::Active => Ok(()),
360            MutationJobStatus::Completed => Err(MutationJobError::Completed),
361            MutationJobStatus::RestartRequired(reason) => {
362                Err(MutationJobError::RestartRequired(reason))
363            }
364        }
365    }
366
367    pub(in crate::db) fn apply_transition(
368        &self,
369        request: &MutationJobAdvanceRequest,
370        transition: MutationJobTransition,
371    ) -> Result<(Self, MutationJobAdvanceReceipt), MutationJobError> {
372        self.ensure_can_advance(request)?;
373        transition.validate(self.state.phase)?;
374        let committed_sequence = self
375            .state
376            .sequence
377            .checked_add(1)
378            .ok_or(MutationJobError::CounterOverflow)?;
379        let keys_scanned_total = self
380            .state
381            .keys_scanned_total
382            .checked_add(transition.keys_scanned)
383            .ok_or(MutationJobError::CounterOverflow)?;
384        let rows_updated_total = self
385            .state
386            .rows_updated_total
387            .checked_add(transition.rows_updated)
388            .ok_or(MutationJobError::CounterOverflow)?;
389        let verify_restarts_total = self
390            .state
391            .verify_restarts_total
392            .checked_add(transition.verify_restarts)
393            .ok_or(MutationJobError::CounterOverflow)?;
394        let receipt = MutationJobAdvanceReceipt {
395            request_sequence: request.expected_sequence,
396            committed_sequence,
397            status: transition.status,
398            phase: transition.phase,
399            keys_scanned: transition.keys_scanned,
400            rows_updated: transition.rows_updated,
401            keys_scanned_total,
402            rows_updated_total,
403            verify_restarts_total,
404        };
405        let record = Self {
406            state: MutationJobState {
407                job_id: self.state.job_id,
408                sequence: committed_sequence,
409                status: transition.status,
410                phase: transition.phase,
411                keys_scanned_total,
412                rows_updated_total,
413                verify_restarts_total,
414            },
415            canonical_intent: self.canonical_intent.clone(),
416            engine_continuation: transition.engine_continuation,
417            last_receipt: Some(RetainedMutationJobReceipt {
418                receipt: receipt.clone(),
419                idempotency_key: request.idempotency_key.clone(),
420            }),
421        };
422        record.validate()?;
423        Ok((record, receipt))
424    }
425
426    pub(in crate::db) fn validate(&self) -> Result<(), MutationJobError> {
427        self.state.job_id.validate()?;
428        validate_nonempty_bytes(
429            &self.canonical_intent,
430            MAX_MUTATION_JOB_INTENT_BYTES,
431            MutationJobPayloadKind::Intent,
432        )?;
433        validate_bytes(
434            &self.engine_continuation,
435            MAX_MUTATION_JOB_CONTINUATION_BYTES,
436            MutationJobPayloadKind::Continuation,
437        )?;
438        if self.state.rows_updated_total > self.state.keys_scanned_total
439            || matches!(self.state.status, MutationJobStatus::Completed)
440                && (self.state.phase != MutationJobPhase::Verify
441                    || !self.engine_continuation.is_empty())
442            || matches!(self.state.status, MutationJobStatus::RestartRequired(_))
443                && !self.engine_continuation.is_empty()
444        {
445            return Err(MutationJobError::CorruptProgressStore);
446        }
447        match &self.last_receipt {
448            None => {
449                if self.state.sequence != 0
450                    || self.state.status != MutationJobStatus::Active
451                    || self.state.phase != MutationJobPhase::Forward
452                    || self.state.keys_scanned_total != 0
453                    || self.state.rows_updated_total != 0
454                    || self.state.verify_restarts_total != 0
455                {
456                    return Err(MutationJobError::CorruptProgressStore);
457                }
458            }
459            Some(retained) => {
460                retained.idempotency_key.validate()?;
461                validate_receipt(&retained.receipt)?;
462                if retained.receipt.committed_sequence != self.state.sequence
463                    || retained.receipt.request_sequence.checked_add(1)
464                        != Some(retained.receipt.committed_sequence)
465                    || retained.receipt.status != self.state.status
466                    || retained.receipt.phase != self.state.phase
467                    || retained.receipt.keys_scanned_total != self.state.keys_scanned_total
468                    || retained.receipt.rows_updated_total != self.state.rows_updated_total
469                    || retained.receipt.verify_restarts_total != self.state.verify_restarts_total
470                {
471                    return Err(MutationJobError::CorruptProgressStore);
472                }
473                let receipt_bytes = retained_receipt_encoded_len(retained)?;
474                if receipt_bytes > MAX_MUTATION_JOB_RECEIPT_BYTES {
475                    return Err(payload_too_large(
476                        MutationJobPayloadKind::Receipt,
477                        MAX_MUTATION_JOB_RECEIPT_BYTES,
478                        receipt_bytes,
479                    ));
480                }
481            }
482        }
483        Ok(())
484    }
485}
486
487#[derive(Clone, Debug, Eq, PartialEq)]
488pub(in crate::db) struct MutationJobTransition {
489    status: MutationJobStatus,
490    phase: MutationJobPhase,
491    engine_continuation: Vec<u8>,
492    keys_scanned: u64,
493    rows_updated: u64,
494    verify_restarts: u64,
495}
496
497impl MutationJobTransition {
498    pub(in crate::db) const fn new(
499        status: MutationJobStatus,
500        phase: MutationJobPhase,
501        engine_continuation: Vec<u8>,
502        keys_scanned: u64,
503        rows_updated: u64,
504        verify_restarts: u64,
505    ) -> Self {
506        Self {
507            status,
508            phase,
509            engine_continuation,
510            keys_scanned,
511            rows_updated,
512            verify_restarts,
513        }
514    }
515
516    fn validate(&self, previous_phase: MutationJobPhase) -> Result<(), MutationJobError> {
517        validate_bytes(
518            &self.engine_continuation,
519            MAX_MUTATION_JOB_CONTINUATION_BYTES,
520            MutationJobPayloadKind::Continuation,
521        )?;
522        let expected_verify_restarts = u64::from(
523            previous_phase == MutationJobPhase::Verify
524                && self.phase == MutationJobPhase::Forward
525                && self.status == MutationJobStatus::Active,
526        );
527        if self.keys_scanned > MAX_MUTATION_JOB_STEP_KEYS_SCANNED
528            || self.rows_updated > MAX_MUTATION_JOB_STEP_ROWS_UPDATED
529            || self.rows_updated > self.keys_scanned
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
869        assert!(MutationJobRecord::new(job_id(), vec![1; 16 * 1024], vec![2; 2 * 1024]).is_ok());
870        assert!(matches!(
871            MutationJobRecord::new(job_id(), vec![1; 16 * 1024 + 1], Vec::new()),
872            Err(MutationJobError::PayloadTooLarge {
873                kind: MutationJobPayloadKind::Intent,
874                ..
875            }),
876        ));
877        assert!(matches!(
878            MutationJobRecord::new(job_id(), vec![1], vec![2; 2 * 1024 + 1]),
879            Err(MutationJobError::PayloadTooLarge {
880                kind: MutationJobPayloadKind::Continuation,
881                ..
882            }),
883        ));
884
885        let maximum_key =
886            MutationJobIdempotencyKey::new("k".repeat(MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES))
887                .expect("maximum replay key should admit");
888        let request = MutationJobAdvanceRequest::new(job_id(), 0, maximum_key);
889        let (record, _) = initial_record()
890            .apply_transition(
891                &request,
892                MutationJobTransition::new(
893                    MutationJobStatus::Active,
894                    MutationJobPhase::Forward,
895                    Vec::new(),
896                    1,
897                    0,
898                    0,
899                ),
900            )
901            .expect("maximum replay identity should retain");
902        assert_eq!(
903            record
904                .last_receipt
905                .as_ref()
906                .map(retained_receipt_encoded_len),
907            Some(Ok(318)),
908        );
909
910        let maximum_key =
911            MutationJobIdempotencyKey::new("k".repeat(MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES))
912                .expect("maximum replay key should admit");
913        let (restart, _) = initial_record()
914            .apply_transition(
915                &MutationJobAdvanceRequest::new(job_id(), 0, maximum_key),
916                MutationJobTransition::new(
917                    MutationJobStatus::RestartRequired(
918                        MutationJobRestartReason::BatchPolicyChanged,
919                    ),
920                    MutationJobPhase::Forward,
921                    Vec::new(),
922                    0,
923                    0,
924                    0,
925                ),
926            )
927            .expect("maximum restart receipt should retain");
928        assert_eq!(
929            restart
930                .last_receipt
931                .as_ref()
932                .map(retained_receipt_encoded_len),
933            Some(Ok(319)),
934        );
935    }
936
937    #[test]
938    fn current_payload_round_trips_every_lifecycle() {
939        let initial = initial_record();
940        let (active, _) = initial
941            .apply_transition(
942                &request(0, "forward-0"),
943                MutationJobTransition::new(
944                    MutationJobStatus::Active,
945                    MutationJobPhase::Verify,
946                    vec![6],
947                    13,
948                    4,
949                    0,
950                ),
951            )
952            .expect("bounded active transition should admit");
953        let (completed, _) = active
954            .apply_transition(
955                &request(1, "verify-0"),
956                MutationJobTransition::new(
957                    MutationJobStatus::Completed,
958                    MutationJobPhase::Verify,
959                    Vec::new(),
960                    9,
961                    0,
962                    0,
963                ),
964            )
965            .expect("clean terminal transition should admit");
966        let (restart, _) = initial
967            .apply_transition(
968                &request(0, "restart"),
969                MutationJobTransition::new(
970                    MutationJobStatus::RestartRequired(
971                        MutationJobRestartReason::AcceptedSchemaChanged,
972                    ),
973                    MutationJobPhase::Forward,
974                    Vec::new(),
975                    0,
976                    0,
977                    0,
978                ),
979            )
980            .expect("typed restart transition should admit");
981
982        for record in [initial, active, completed, restart] {
983            let bytes = encode_mutation_job_payload(&record)
984                .expect("current mutation payload should encode");
985            assert!(!bytes.starts_with(b"DIDL"));
986            assert_eq!(
987                decode_mutation_job_payload(&bytes)
988                    .expect("current mutation payload should decode"),
989                record,
990            );
991        }
992    }
993
994    #[test]
995    fn public_state_request_receipt_and_error_are_candid_compatible() {
996        let state = initial_record().state().clone();
997        let request = request(0, "candid-request");
998        let receipt = MutationJobAdvanceReceipt {
999            request_sequence: 0,
1000            committed_sequence: 1,
1001            status: MutationJobStatus::Active,
1002            phase: MutationJobPhase::Forward,
1003            keys_scanned: 8,
1004            rows_updated: 3,
1005            keys_scanned_total: 8,
1006            rows_updated_total: 3,
1007            verify_restarts_total: 0,
1008        };
1009        let error = MutationJobError::StaleSequence {
1010            expected: 0,
1011            actual: 1,
1012        };
1013
1014        let state_bytes = candid::encode_one(&state).expect("mutation state should encode");
1015        let request_bytes = candid::encode_one(&request).expect("mutation request should encode");
1016        let receipt_bytes = candid::encode_one(&receipt).expect("mutation receipt should encode");
1017        let error_bytes = candid::encode_one(&error).expect("mutation error should encode");
1018        assert_eq!(
1019            candid::decode_one::<MutationJobState>(&state_bytes)
1020                .expect("mutation state should decode"),
1021            state,
1022        );
1023        assert_eq!(
1024            candid::decode_one::<MutationJobAdvanceRequest>(&request_bytes)
1025                .expect("mutation request should decode"),
1026            request,
1027        );
1028        assert_eq!(
1029            candid::decode_one::<MutationJobAdvanceReceipt>(&receipt_bytes)
1030                .expect("mutation receipt should decode"),
1031            receipt,
1032        );
1033        assert_eq!(
1034            candid::decode_one::<MutationJobError>(&error_bytes)
1035                .expect("mutation error should decode"),
1036            error,
1037        );
1038    }
1039
1040    #[test]
1041    fn exact_replay_precedes_stale_and_terminal_rejection() {
1042        let initial = initial_record();
1043        let (verifying, _) = initial
1044            .apply_transition(
1045                &request(0, "forward-0"),
1046                MutationJobTransition::new(
1047                    MutationJobStatus::Active,
1048                    MutationJobPhase::Verify,
1049                    Vec::new(),
1050                    8,
1051                    3,
1052                    0,
1053                ),
1054            )
1055            .expect("Forward exhaustion should enter Verify");
1056        let terminal_request = request(1, "verify-0");
1057        let (completed, receipt) = verifying
1058            .apply_transition(
1059                &terminal_request,
1060                MutationJobTransition::new(
1061                    MutationJobStatus::Completed,
1062                    MutationJobPhase::Verify,
1063                    Vec::new(),
1064                    8,
1065                    0,
1066                    0,
1067                ),
1068            )
1069            .expect("terminal transition should admit");
1070
1071        assert_eq!(
1072            completed
1073                .exact_replay(&terminal_request)
1074                .expect("exact replay lookup should succeed"),
1075            Some(&receipt),
1076        );
1077        assert_eq!(
1078            completed.ensure_can_advance(&request(1, "different")),
1079            Err(MutationJobError::StaleSequence {
1080                expected: 1,
1081                actual: 2,
1082            }),
1083        );
1084        assert_eq!(
1085            completed.ensure_can_advance(&request(2, "next")),
1086            Err(MutationJobError::Completed),
1087        );
1088    }
1089
1090    #[test]
1091    fn payload_decode_is_bounded_fallible_and_rejects_trailing_bytes() {
1092        let bytes = encode_mutation_job_payload(&initial_record())
1093            .expect("current mutation payload should encode");
1094        assert_eq!(
1095            decode_mutation_job_payload(&bytes[..bytes.len() - 1]),
1096            Err(MutationJobError::CorruptProgressStore),
1097        );
1098        let mut trailing = bytes;
1099        trailing.push(0);
1100        assert_eq!(
1101            decode_mutation_job_payload(&trailing),
1102            Err(MutationJobError::CorruptProgressStore),
1103        );
1104        let mut unknown_status = encode_mutation_job_payload(&initial_record())
1105            .expect("current mutation payload should encode");
1106        unknown_status[32 + 8] = u8::MAX;
1107        assert_eq!(
1108            decode_mutation_job_payload(&unknown_status),
1109            Err(MutationJobError::CorruptProgressStore),
1110        );
1111        let mut zero_job_id = encode_mutation_job_payload(&initial_record())
1112            .expect("current mutation payload should encode");
1113        zero_job_id[..32].fill(0);
1114        assert_eq!(
1115            decode_mutation_job_payload(&zero_job_id),
1116            Err(MutationJobError::CorruptProgressStore),
1117        );
1118    }
1119
1120    #[test]
1121    fn transition_totals_fail_closed_on_overflow() {
1122        let mut record = initial_record();
1123        record.state.keys_scanned_total = u64::MAX;
1124        record.state.rows_updated_total = u64::MAX;
1125        record.last_receipt = Some(RetainedMutationJobReceipt {
1126            receipt: MutationJobAdvanceReceipt {
1127                request_sequence: 0,
1128                committed_sequence: 1,
1129                status: MutationJobStatus::Active,
1130                phase: MutationJobPhase::Forward,
1131                keys_scanned: 1,
1132                rows_updated: 1,
1133                keys_scanned_total: u64::MAX,
1134                rows_updated_total: u64::MAX,
1135                verify_restarts_total: 0,
1136            },
1137            idempotency_key: MutationJobIdempotencyKey::new("prior")
1138                .expect("bounded replay key should admit"),
1139        });
1140        record.state.sequence = 1;
1141        assert_eq!(
1142            record.apply_transition(
1143                &request(1, "overflow"),
1144                MutationJobTransition::new(
1145                    MutationJobStatus::Active,
1146                    MutationJobPhase::Forward,
1147                    Vec::new(),
1148                    1,
1149                    1,
1150                    0,
1151                ),
1152            ),
1153            Err(MutationJobError::CounterOverflow),
1154        );
1155
1156        let mut sequence_record = initial_record();
1157        sequence_record.state.sequence = u64::MAX;
1158        sequence_record.last_receipt = Some(RetainedMutationJobReceipt {
1159            receipt: MutationJobAdvanceReceipt {
1160                request_sequence: u64::MAX - 1,
1161                committed_sequence: u64::MAX,
1162                status: MutationJobStatus::Active,
1163                phase: MutationJobPhase::Forward,
1164                keys_scanned: 0,
1165                rows_updated: 0,
1166                keys_scanned_total: 0,
1167                rows_updated_total: 0,
1168                verify_restarts_total: 0,
1169            },
1170            idempotency_key: MutationJobIdempotencyKey::new("prior-sequence")
1171                .expect("bounded replay key should admit"),
1172        });
1173        assert_eq!(
1174            sequence_record.apply_transition(
1175                &request(u64::MAX, "sequence-overflow"),
1176                MutationJobTransition::new(
1177                    MutationJobStatus::Active,
1178                    MutationJobPhase::Forward,
1179                    Vec::new(),
1180                    0,
1181                    0,
1182                    0,
1183                ),
1184            ),
1185            Err(MutationJobError::CounterOverflow),
1186        );
1187    }
1188}