Skip to main content

icydb_core/db/integrity/
progress_store.rs

1//! Module: db::integrity::progress_store
2//! Responsibility: independently persist one bounded record per Deep job.
3//! Does not own: inspected database state, commit markers, journals, or advancement semantics.
4//! Boundary: current-form job codec -> physically separate stable BTreeMap allocation.
5
6#[cfg(not(test))]
7use crate::db::open_memory;
8use crate::{
9    db::{
10        codec::{finalize_hash_sha256, new_hash_sha256_prefixed},
11        database_format::crc32c,
12        integrity::{
13            IntegrityJob, IntegrityJobError, IntegrityJobId, IntegrityJobOwner, IntegrityJobState,
14            progress_codec::{
15                MAX_INTEGRITY_JOB_PAYLOAD_BYTES, decode_integrity_job_payload,
16                encode_integrity_job_payload,
17            },
18        },
19        mutation_job::{
20            MAX_MUTATION_JOB_RECORD_BYTES, MutationJobError, MutationJobId, MutationJobRecord,
21            MutationJobStatus, decode_mutation_job_payload, encode_mutation_job_payload,
22        },
23        resumable_job::{
24            ResumableJobError, ResumableJobId, ResumableJobRecord, ResumableJobStatus,
25            decode_resumable_job_payload, encode_resumable_job_payload,
26        },
27    },
28    error::InternalError,
29    traits::CanisterKind,
30};
31
32use ic_memory::{DefaultMemoryImpl, RuntimeMemory};
33use ic_storage::adapter::{StableBTreeMap, Storable, storable::Bound};
34use serde::Deserialize;
35use sha2::Digest;
36use std::borrow::Cow;
37#[cfg(test)]
38use std::cell::RefCell;
39use std::ops::Bound::{Excluded, Unbounded};
40
41const PROGRESS_HEADER_KEY: ProgressRecordKey = ProgressRecordKey([0; 32]);
42const PROGRESS_HEADER_MAGIC: &[u8; 8] = b"ICYIPROG";
43const PROGRESS_HEADER_VERSION: u8 = 1;
44const PROGRESS_HEADER_BYTES: usize = 8 + 1 + 4;
45const JOB_RECORD_MAGIC: &[u8; 8] = b"ICYIJPTH";
46const PROGRESS_JOB_RECORD_VERSION: u8 = 1;
47const PROGRESS_JOB_RECORD_HEADER_BYTES: usize = 8 + 1 + 4 + 4;
48const RESUMABLE_JOB_KEY_DOMAIN: &[u8] = b"icydb.resumable-job.progress-key.v1";
49const RESUMABLE_JOB_RECORD_MAGIC: &[u8; 8] = b"ICYRJOB1";
50const MUTATION_JOB_KEY_DOMAIN: &[u8] = b"icydb.mutation-job.progress-key.v1";
51const MUTATION_JOB_RECORD_MAGIC: &[u8; 8] = b"ICYMJOB1";
52const MUTATION_PROGRESS_BEFORE_DIGEST_DOMAIN: &[u8] = b"icydb.mutation-job.progress-before.v1";
53const MAX_PROGRESS_RECORD_BYTES: u32 = 512 * 1024;
54const MAX_PROGRESS_JOBS_GLOBAL: u64 = 64;
55const MAX_PROGRESS_JOBS_NON_INTEGRITY: u64 = 56;
56const PROGRESS_JOBS_INTEGRITY_RESERVATION: u64 =
57    MAX_PROGRESS_JOBS_GLOBAL - MAX_PROGRESS_JOBS_NON_INTEGRITY;
58const MAX_PROGRESS_JOBS_PER_OWNER: u64 = 8;
59
60/// Stable family of one retained record in the shared progress allocation.
61#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
62pub enum ProgressJobFamily {
63    /// Engine-owned Deep integrity inspection.
64    Integrity,
65    /// Application-owned generic resumable job.
66    Resumable,
67    /// Engine-owned fixed SQL mutation job.
68    Mutation,
69}
70
71/// Bounded normalized lifecycle of one retained progress record.
72#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
73pub enum ProgressJobLifecycle {
74    /// More work may advance.
75    Active,
76    /// Integrity advancement is frozen before its terminal receipt is published.
77    TerminalPending,
78    /// Successful exhaustion is retained.
79    Completed,
80    /// Protected source authority changed.
81    Invalidated,
82    /// Mutation authority or policy requires a fresh job.
83    RestartRequired,
84    /// Another integrity terminal outcome is retained.
85    Terminal,
86}
87
88/// One privacy-bounded retained-record inventory entry.
89#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
90pub struct ProgressJobInventoryRecord {
91    /// Durable progress family.
92    pub family: ProgressJobFamily,
93    /// Family-owned job identity bytes.
94    pub job_id: [u8; 32],
95    /// Normalized retained lifecycle.
96    pub lifecycle: ProgressJobLifecycle,
97    /// Family sequence when its current record carries one.
98    pub sequence: Option<u64>,
99}
100
101/// Complete bounded inventory of the shared excluded progress allocation.
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
103pub struct ProgressJobInventory {
104    /// Total retained records across all families.
105    pub retained_count: u64,
106    /// Hard retained-record capacity.
107    pub hard_limit: u64,
108    /// Slots protected for integrity work from non-integrity starts.
109    pub reserved_integrity_headroom: u64,
110    /// Retained Deep integrity records.
111    pub integrity_count: u64,
112    /// Retained generic resumable records.
113    pub resumable_count: u64,
114    /// Retained fixed SQL mutation records.
115    pub mutation_count: u64,
116    /// Complete encoded bytes across every retained record.
117    pub retained_record_bytes: u64,
118    /// Every validated retained record in stable key order.
119    pub records: Vec<ProgressJobInventoryRecord>,
120}
121
122#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
123struct ProgressRecordKey([u8; 32]);
124
125impl ProgressRecordKey {
126    const fn from_job_id(job_id: IntegrityJobId) -> Self {
127        Self(job_id.to_bytes())
128    }
129
130    fn from_resumable_job_id(job_id: ResumableJobId) -> Result<Self, ResumableJobError> {
131        let mut hasher = new_hash_sha256_prefixed(RESUMABLE_JOB_KEY_DOMAIN);
132        hasher.update(job_id.to_bytes());
133        let key = finalize_hash_sha256(hasher);
134        if key == PROGRESS_HEADER_KEY.0 {
135            return Err(ResumableJobError::InvalidJobId);
136        }
137        Ok(Self(key))
138    }
139
140    fn from_mutation_job_id(job_id: MutationJobId) -> Result<Self, MutationJobError> {
141        job_id.validate()?;
142        let mut hasher = new_hash_sha256_prefixed(MUTATION_JOB_KEY_DOMAIN);
143        hasher.update(job_id.to_bytes());
144        let key = finalize_hash_sha256(hasher);
145        if key == PROGRESS_HEADER_KEY.0 {
146            return Err(MutationJobError::InvalidJobId);
147        }
148        Ok(Self(key))
149    }
150
151    const fn to_bytes(self) -> [u8; 32] {
152        self.0
153    }
154}
155
156impl Storable for ProgressRecordKey {
157    fn to_bytes(&self) -> Cow<'_, [u8]> {
158        Cow::Borrowed(&self.0)
159    }
160
161    fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
162        let mut key = [0; 32];
163        if bytes.len() == key.len() {
164            key.copy_from_slice(bytes.as_ref());
165        }
166        Self(key)
167    }
168
169    fn into_bytes(self) -> Vec<u8> {
170        self.0.to_vec()
171    }
172
173    const BOUND: Bound = Bound::Bounded {
174        max_size: 32,
175        is_fixed_size: true,
176    };
177}
178
179#[derive(Clone, Debug, Eq, PartialEq)]
180struct ProgressRecordBytes(Vec<u8>);
181
182impl Storable for ProgressRecordBytes {
183    fn to_bytes(&self) -> Cow<'_, [u8]> {
184        Cow::Borrowed(self.0.as_slice())
185    }
186
187    fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
188        Self(bytes.into_owned())
189    }
190
191    fn into_bytes(self) -> Vec<u8> {
192        self.0
193    }
194
195    const BOUND: Bound = Bound::Bounded {
196        max_size: MAX_PROGRESS_RECORD_BYTES,
197        is_fixed_size: false,
198    };
199}
200
201pub(super) enum InsertJobResult {
202    Inserted,
203    Occupied(Box<IntegrityJob>),
204}
205
206pub(in crate::db) enum InsertMutationJobResult {
207    Inserted,
208    Occupied(Box<MutationJobRecord>),
209}
210
211/// Exact mutation-progress compare-and-replace effect carried by one commit marker.
212///
213/// Every constructor validates the complete before/after proof. Store
214/// preflight and apply therefore compare only the already-canonical bytes and
215/// do not repeat bounded record decoding on the commit hot path.
216#[derive(Clone, Debug)]
217pub(in crate::db) struct MutationProgressRecordOp {
218    key: ProgressRecordKey,
219    job_id: MutationJobId,
220    expected_sequence: u64,
221    expected_before_digest: [u8; 32],
222    before: Vec<u8>,
223    after: Vec<u8>,
224}
225
226impl MutationProgressRecordOp {
227    /// Build one exact successor replacement from two validated current records.
228    pub(in crate::db) fn replace(
229        before: &MutationJobRecord,
230        after: &MutationJobRecord,
231    ) -> Result<Self, MutationJobError> {
232        let job_id = before.state().job_id;
233        let expected_sequence = before.state().sequence;
234        let key = ProgressRecordKey::from_mutation_job_id(job_id)?;
235        let before = encode_mutation_job_record(before)?;
236        Self::from_encoded(
237            key.to_bytes(),
238            job_id,
239            expected_sequence,
240            mutation_progress_before_digest(&before),
241            before,
242            encode_mutation_job_record(after)?,
243        )
244    }
245
246    /// Reconstruct and validate one current marker-owned replacement.
247    pub(in crate::db) fn from_encoded(
248        key: [u8; 32],
249        job_id: MutationJobId,
250        expected_sequence: u64,
251        expected_before_digest: [u8; 32],
252        before: Vec<u8>,
253        after: Vec<u8>,
254    ) -> Result<Self, MutationJobError> {
255        let operation = Self {
256            key: ProgressRecordKey(key),
257            job_id,
258            expected_sequence,
259            expected_before_digest,
260            before,
261            after,
262        };
263        operation.validate()?;
264        Ok(operation)
265    }
266
267    pub(in crate::db) const fn key(&self) -> [u8; 32] {
268        self.key.to_bytes()
269    }
270
271    pub(in crate::db) const fn job_id(&self) -> MutationJobId {
272        self.job_id
273    }
274
275    pub(in crate::db) const fn expected_sequence(&self) -> u64 {
276        self.expected_sequence
277    }
278
279    pub(in crate::db) const fn expected_before_digest(&self) -> [u8; 32] {
280        self.expected_before_digest
281    }
282
283    pub(in crate::db) const fn before_bytes(&self) -> &[u8] {
284        self.before.as_slice()
285    }
286
287    pub(in crate::db) const fn after_bytes(&self) -> &[u8] {
288        self.after.as_slice()
289    }
290
291    pub(in crate::db) fn validate(&self) -> Result<(), MutationJobError> {
292        self.job_id.validate()?;
293        if ProgressRecordKey::from_mutation_job_id(self.job_id)? != self.key
294            || mutation_progress_before_digest(&self.before) != self.expected_before_digest
295        {
296            return Err(MutationJobError::CorruptProgressStore);
297        }
298        let before = decode_mutation_job_record(&self.before, self.job_id)?;
299        let after = decode_mutation_job_record(&self.after, self.job_id)?;
300        let Some(expected_after_sequence) = self.expected_sequence.checked_add(1) else {
301            return Err(MutationJobError::CounterOverflow);
302        };
303        if before.state().sequence != self.expected_sequence
304            || after.state().sequence != expected_after_sequence
305            || before.canonical_intent() != after.canonical_intent()
306        {
307            return Err(MutationJobError::CorruptProgressStore);
308        }
309        Ok(())
310    }
311}
312
313pub(super) struct ProgressScanPage {
314    pub(super) job_ids: Vec<IntegrityJobId>,
315    pub(super) next_checkpoint: Option<IntegrityJobId>,
316    pub(super) records_scanned: usize,
317    pub(super) exhausted: bool,
318}
319
320pub(in crate::db) struct InspectionProgressStore {
321    map: StableBTreeMap<ProgressRecordKey, ProgressRecordBytes, DefaultMemoryImpl>,
322}
323
324impl InspectionProgressStore {
325    fn open(memory: RuntimeMemory<DefaultMemoryImpl>) -> Result<Self, IntegrityJobError> {
326        let mut store = Self {
327            map: StableBTreeMap::init(memory),
328        };
329        if store.map.is_empty() {
330            store.map.insert(
331                PROGRESS_HEADER_KEY,
332                ProgressRecordBytes(encode_progress_header()),
333            );
334        } else {
335            let header = store
336                .map
337                .get(&PROGRESS_HEADER_KEY)
338                .ok_or(IntegrityJobError::CorruptProgressHeader)?;
339            decode_progress_header(&header.0)?;
340            if store.job_count()? > MAX_PROGRESS_JOBS_GLOBAL {
341                return Err(IntegrityJobError::CorruptProgressHeader);
342            }
343        }
344        Ok(store)
345    }
346
347    pub(super) fn load(&self, job_id: IntegrityJobId) -> Result<IntegrityJob, IntegrityJobError> {
348        let raw = self
349            .map
350            .get(&ProgressRecordKey::from_job_id(job_id))
351            .ok_or(IntegrityJobError::JobNotFound)?;
352        decode_job_record(&raw.0, job_id)
353    }
354
355    pub(super) fn insert_new(
356        &mut self,
357        job: &IntegrityJob,
358    ) -> Result<InsertJobResult, IntegrityJobError> {
359        job.validate()?;
360        let key = ProgressRecordKey::from_job_id(job.id);
361        if key == PROGRESS_HEADER_KEY {
362            return Err(IntegrityJobError::CorruptProgressRecord);
363        }
364        if let Some(raw) = self.map.get(&key) {
365            return decode_job_record(&raw.0, job.id)
366                .map(Box::new)
367                .map(InsertJobResult::Occupied);
368        }
369        if self.job_count()? >= MAX_PROGRESS_JOBS_GLOBAL
370            || self.owner_job_count(&job.owner)? >= MAX_PROGRESS_JOBS_PER_OWNER
371        {
372            return Err(IntegrityJobError::CapacityExceeded);
373        }
374        self.map
375            .insert(key, ProgressRecordBytes(encode_job_record(job)?));
376        Ok(InsertJobResult::Inserted)
377    }
378
379    pub(super) fn replace(&mut self, job: &IntegrityJob) -> Result<(), IntegrityJobError> {
380        job.validate()?;
381        let key = ProgressRecordKey::from_job_id(job.id);
382        if !self.map.contains_key(&key) {
383            return Err(IntegrityJobError::JobNotFound);
384        }
385        self.map
386            .insert(key, ProgressRecordBytes(encode_job_record(job)?));
387        Ok(())
388    }
389
390    pub(in crate::db) fn load_resumable(
391        &self,
392        job_id: ResumableJobId,
393    ) -> Result<ResumableJobRecord, ResumableJobError> {
394        let key = ProgressRecordKey::from_resumable_job_id(job_id)?;
395        let raw = self.map.get(&key).ok_or(ResumableJobError::NotFound)?;
396        decode_resumable_job_record(&raw.0, job_id)
397    }
398
399    pub(in crate::db) fn insert_resumable(
400        &mut self,
401        record: &ResumableJobRecord,
402    ) -> Result<(), ResumableJobError> {
403        record.validate()?;
404        let key = ProgressRecordKey::from_resumable_job_id(record.state().job_id)?;
405        if self.map.contains_key(&key) {
406            return Err(ResumableJobError::AlreadyExists);
407        }
408        if self.job_count().map_err(map_integrity_store_error)? >= MAX_PROGRESS_JOBS_NON_INTEGRITY {
409            return Err(ResumableJobError::CapacityExceeded);
410        }
411        self.map.insert(
412            key,
413            ProgressRecordBytes(encode_resumable_job_record(record)?),
414        );
415        Ok(())
416    }
417
418    pub(in crate::db) fn replace_resumable(
419        &mut self,
420        record: &ResumableJobRecord,
421    ) -> Result<(), ResumableJobError> {
422        record.validate()?;
423        let key = ProgressRecordKey::from_resumable_job_id(record.state().job_id)?;
424        if !self.map.contains_key(&key) {
425            return Err(ResumableJobError::NotFound);
426        }
427        self.map.insert(
428            key,
429            ProgressRecordBytes(encode_resumable_job_record(record)?),
430        );
431        Ok(())
432    }
433
434    pub(in crate::db) fn remove_resumable(
435        &mut self,
436        job_id: ResumableJobId,
437    ) -> Result<(), ResumableJobError> {
438        let key = ProgressRecordKey::from_resumable_job_id(job_id)?;
439        let Some(raw) = self.map.get(&key) else {
440            return Ok(());
441        };
442        decode_resumable_job_record(&raw.0, job_id)?;
443        let _ = self.map.remove(&key);
444        Ok(())
445    }
446
447    pub(in crate::db) fn load_mutation(
448        &self,
449        job_id: MutationJobId,
450    ) -> Result<MutationJobRecord, MutationJobError> {
451        let key = ProgressRecordKey::from_mutation_job_id(job_id)?;
452        let raw = self.map.get(&key).ok_or(MutationJobError::NotFound)?;
453        decode_mutation_job_record(&raw.0, job_id)
454    }
455
456    pub(in crate::db) fn insert_mutation(
457        &mut self,
458        record: &MutationJobRecord,
459    ) -> Result<InsertMutationJobResult, MutationJobError> {
460        record.validate()?;
461        let key = ProgressRecordKey::from_mutation_job_id(record.state().job_id)?;
462        if let Some(raw) = self.map.get(&key) {
463            return decode_mutation_job_record(&raw.0, record.state().job_id)
464                .map(Box::new)
465                .map(InsertMutationJobResult::Occupied);
466        }
467        if self.job_count().map_err(map_mutation_store_error)? >= MAX_PROGRESS_JOBS_NON_INTEGRITY {
468            return Err(MutationJobError::CapacityExceeded);
469        }
470        self.map.insert(
471            key,
472            ProgressRecordBytes(encode_mutation_job_record(record)?),
473        );
474        Ok(InsertMutationJobResult::Inserted)
475    }
476
477    // Install fixture states outside the production before/after replacement proof.
478    #[cfg(test)]
479    pub(in crate::db) fn replace_mutation(
480        &mut self,
481        record: &MutationJobRecord,
482    ) -> Result<(), MutationJobError> {
483        record.validate()?;
484        let key = ProgressRecordKey::from_mutation_job_id(record.state().job_id)?;
485        if !self.map.contains_key(&key) {
486            return Err(MutationJobError::NotFound);
487        }
488        self.map.insert(
489            key,
490            ProgressRecordBytes(encode_mutation_job_record(record)?),
491        );
492        Ok(())
493    }
494
495    fn preflight_mutation_progress(
496        &self,
497        operation: &MutationProgressRecordOp,
498    ) -> Result<(), MutationJobError> {
499        let current = self
500            .map
501            .get(&operation.key)
502            .ok_or(MutationJobError::CorruptProgressStore)?;
503        if current.0 != operation.before {
504            return Err(MutationJobError::CorruptProgressStore);
505        }
506        Ok(())
507    }
508
509    fn apply_mutation_progress(
510        &mut self,
511        operation: &MutationProgressRecordOp,
512    ) -> Result<(), MutationJobError> {
513        let current = self
514            .map
515            .get(&operation.key)
516            .ok_or(MutationJobError::CorruptProgressStore)?;
517        if current.0 == operation.after {
518            return Ok(());
519        }
520        if current.0 != operation.before {
521            return Err(MutationJobError::CorruptProgressStore);
522        }
523        self.map
524            .insert(operation.key, ProgressRecordBytes(operation.after.clone()));
525        Ok(())
526    }
527
528    fn apply_preflighted_mutation_progress(&mut self, operation: &MutationProgressRecordOp) {
529        self.map
530            .insert(operation.key, ProgressRecordBytes(operation.after.clone()));
531    }
532
533    /// Replace one mutation record without target-row work using the same exact
534    /// before/after proof as marker-owned mutation progress.
535    pub(in crate::db) fn replace_mutation_progress(
536        &mut self,
537        operation: &MutationProgressRecordOp,
538    ) -> Result<(), MutationJobError> {
539        self.apply_mutation_progress(operation)
540    }
541
542    fn verify_mutation_progress(
543        &self,
544        operation: &MutationProgressRecordOp,
545    ) -> Result<(), MutationJobError> {
546        operation.validate()?;
547        let current = self
548            .map
549            .get(&operation.key)
550            .ok_or(MutationJobError::CorruptProgressStore)?;
551        if current.0 != operation.after {
552            return Err(MutationJobError::CorruptProgressStore);
553        }
554        Ok(())
555    }
556
557    pub(in crate::db) fn acknowledge_mutation(
558        &mut self,
559        job_id: MutationJobId,
560        expected_sequence: u64,
561    ) -> Result<(), MutationJobError> {
562        let key = ProgressRecordKey::from_mutation_job_id(job_id)?;
563        let Some(raw) = self.map.get(&key) else {
564            return Ok(());
565        };
566        let record = decode_mutation_job_record(&raw.0, job_id)?;
567        if record.state().sequence != expected_sequence {
568            return Err(MutationJobError::StaleSequence {
569                expected: expected_sequence,
570                actual: record.state().sequence,
571            });
572        }
573        if record.state().status == crate::db::MutationJobStatus::Active {
574            return Err(MutationJobError::Active);
575        }
576        let _ = self.map.remove(&key);
577        Ok(())
578    }
579
580    /// Remove exactly one current initial mutation record.
581    ///
582    /// The caller supplies the engine-format validator so the shared store
583    /// remains independent of SQL continuation semantics while still making
584    /// validation and removal one indivisible store borrow.
585    #[cfg(feature = "sql")]
586    pub(in crate::db) fn cancel_unadvanced_mutation(
587        &mut self,
588        job_id: MutationJobId,
589        expected_sequence: u64,
590        validate_initial_continuation: impl FnOnce(&[u8]) -> Result<(), MutationJobError>,
591    ) -> Result<(), MutationJobError> {
592        let key = ProgressRecordKey::from_mutation_job_id(job_id)?;
593        let Some(raw) = self.map.get(&key) else {
594            return Ok(());
595        };
596        let record = decode_mutation_job_record(&raw.0, job_id)?;
597        let continuation = record.ensure_cancelable_at_sequence(expected_sequence)?;
598        validate_initial_continuation(continuation)?;
599        let _ = self.map.remove(&key);
600        Ok(())
601    }
602
603    /// Decode every retained slot before returning one complete bounded inventory.
604    pub(in crate::db) fn inventory(&self) -> Result<ProgressJobInventory, MutationJobError> {
605        let retained_count = self.job_count().map_err(map_mutation_store_error)?;
606        let record_capacity =
607            usize::try_from(retained_count).map_err(|_| MutationJobError::CorruptProgressStore)?;
608        let mut records = Vec::with_capacity(record_capacity);
609        let mut integrity_count = 0_u64;
610        let mut resumable_count = 0_u64;
611        let mut mutation_count = 0_u64;
612        let mut retained_record_bytes = 0_u64;
613
614        for entry in &self.map {
615            let key = *entry.key();
616            if key == PROGRESS_HEADER_KEY {
617                continue;
618            }
619            let bytes = &entry.value().0;
620            retained_record_bytes = retained_record_bytes
621                .checked_add(
622                    u64::try_from(bytes.len())
623                        .map_err(|_| MutationJobError::CorruptProgressStore)?,
624                )
625                .ok_or(MutationJobError::CorruptProgressStore)?;
626            let record = if bytes.starts_with(JOB_RECORD_MAGIC) {
627                let job_id = IntegrityJobId::try_from_bytes(key.to_bytes())
628                    .map_err(|_| MutationJobError::CorruptProgressStore)?;
629                let job = decode_job_record(bytes, job_id)
630                    .map_err(|_| MutationJobError::CorruptProgressStore)?;
631                integrity_count = integrity_count
632                    .checked_add(1)
633                    .ok_or(MutationJobError::CorruptProgressStore)?;
634                ProgressJobInventoryRecord {
635                    family: ProgressJobFamily::Integrity,
636                    job_id: job.id.to_bytes(),
637                    lifecycle: match job.state {
638                        IntegrityJobState::InProgress => ProgressJobLifecycle::Active,
639                        IntegrityJobState::TerminalPending(_) => {
640                            ProgressJobLifecycle::TerminalPending
641                        }
642                        IntegrityJobState::Terminal { .. } => ProgressJobLifecycle::Terminal,
643                    },
644                    sequence: Some(job.pages_completed),
645                }
646            } else if bytes.starts_with(RESUMABLE_JOB_RECORD_MAGIC) {
647                let job = decode_resumable_job_record_for_inventory(bytes, key)?;
648                resumable_count = resumable_count
649                    .checked_add(1)
650                    .ok_or(MutationJobError::CorruptProgressStore)?;
651                ProgressJobInventoryRecord {
652                    family: ProgressJobFamily::Resumable,
653                    job_id: job.state().job_id.to_bytes(),
654                    lifecycle: match job.state().status {
655                        ResumableJobStatus::Active => ProgressJobLifecycle::Active,
656                        ResumableJobStatus::Completed => ProgressJobLifecycle::Completed,
657                        ResumableJobStatus::Invalidated => ProgressJobLifecycle::Invalidated,
658                    },
659                    sequence: Some(job.state().sequence),
660                }
661            } else if bytes.starts_with(MUTATION_JOB_RECORD_MAGIC) {
662                let job = decode_mutation_job_record_for_inventory(bytes, key)?;
663                mutation_count = mutation_count
664                    .checked_add(1)
665                    .ok_or(MutationJobError::CorruptProgressStore)?;
666                ProgressJobInventoryRecord {
667                    family: ProgressJobFamily::Mutation,
668                    job_id: job.state().job_id.to_bytes(),
669                    lifecycle: match job.state().status {
670                        MutationJobStatus::Active => ProgressJobLifecycle::Active,
671                        MutationJobStatus::Completed => ProgressJobLifecycle::Completed,
672                        MutationJobStatus::RestartRequired(_) => {
673                            ProgressJobLifecycle::RestartRequired
674                        }
675                    },
676                    sequence: Some(job.state().sequence),
677                }
678            } else {
679                return Err(MutationJobError::CorruptProgressStore);
680            };
681            records.push(record);
682        }
683
684        let decoded_count = integrity_count
685            .checked_add(resumable_count)
686            .and_then(|count| count.checked_add(mutation_count))
687            .ok_or(MutationJobError::CorruptProgressStore)?;
688        if decoded_count != retained_count || u64::try_from(records.len()) != Ok(retained_count) {
689            return Err(MutationJobError::CorruptProgressStore);
690        }
691
692        Ok(ProgressJobInventory {
693            retained_count,
694            hard_limit: MAX_PROGRESS_JOBS_GLOBAL,
695            reserved_integrity_headroom: PROGRESS_JOBS_INTEGRITY_RESERVATION,
696            integrity_count,
697            resumable_count,
698            mutation_count,
699            retained_record_bytes,
700            records,
701        })
702    }
703
704    pub(super) fn remove(&mut self, job_id: IntegrityJobId) -> Result<(), IntegrityJobError> {
705        if self
706            .map
707            .remove(&ProgressRecordKey::from_job_id(job_id))
708            .is_none()
709        {
710            return Err(IntegrityJobError::JobNotFound);
711        }
712        Ok(())
713    }
714
715    pub(super) fn scan_after(
716        &self,
717        checkpoint: Option<IntegrityJobId>,
718        limit: usize,
719    ) -> Result<ProgressScanPage, IntegrityJobError> {
720        if limit == 0 {
721            return Err(IntegrityJobError::CapacityExceeded);
722        }
723        let lower = checkpoint.map_or(PROGRESS_HEADER_KEY, ProgressRecordKey::from_job_id);
724        let mut job_ids = Vec::with_capacity(limit);
725        let mut next_checkpoint = None;
726        let mut records_scanned = 0;
727        let mut entries = self.map.range((Excluded(lower), Unbounded));
728        for entry in entries.by_ref().take(limit) {
729            // The physical key owns lookup and pagination even when the payload
730            // is corrupt. Every family consumes page work; only its owner may
731            // interpret or retire its records.
732            let job_id = IntegrityJobId::try_from_bytes(entry.key().0)?;
733            next_checkpoint = Some(job_id);
734            records_scanned += 1;
735            let bytes = entry.value();
736            if !bytes.0.starts_with(RESUMABLE_JOB_RECORD_MAGIC)
737                && !bytes.0.starts_with(MUTATION_JOB_RECORD_MAGIC)
738            {
739                job_ids.push(job_id);
740            }
741        }
742        Ok(ProgressScanPage {
743            job_ids,
744            next_checkpoint,
745            records_scanned,
746            exhausted: entries.next().is_none(),
747        })
748    }
749
750    fn job_count(&self) -> Result<u64, IntegrityJobError> {
751        self.map
752            .len()
753            .checked_sub(1)
754            .ok_or(IntegrityJobError::CorruptProgressHeader)
755    }
756
757    fn owner_job_count(&self, owner: &IntegrityJobOwner) -> Result<u64, IntegrityJobError> {
758        let mut count = 0_u64;
759        for entry in &self.map {
760            if *entry.key() == PROGRESS_HEADER_KEY {
761                continue;
762            }
763            // Corrupt records already consume one slot from the global hard
764            // capacity, but their owner cannot be trusted. Skipping them here
765            // isolates the failed job without allowing unbounded progress
766            // growth or blocking every other owner from starting work.
767            let Ok(job_id) = IntegrityJobId::try_from_bytes(entry.key().0) else {
768                continue;
769            };
770            let Ok(job) = decode_job_record(&entry.value().0, job_id) else {
771                continue;
772            };
773            if job.owner == *owner {
774                count = count
775                    .checked_add(1)
776                    .ok_or(IntegrityJobError::CapacityExceeded)?;
777            }
778        }
779        Ok(count)
780    }
781}
782
783fn encode_progress_header() -> Vec<u8> {
784    let mut bytes = Vec::with_capacity(PROGRESS_HEADER_BYTES);
785    bytes.extend_from_slice(PROGRESS_HEADER_MAGIC);
786    bytes.push(PROGRESS_HEADER_VERSION);
787    let checksum = crc32c(bytes.as_slice());
788    bytes.extend_from_slice(&checksum.to_be_bytes());
789    bytes
790}
791
792fn decode_progress_header(bytes: &[u8]) -> Result<(), IntegrityJobError> {
793    if bytes.len() != PROGRESS_HEADER_BYTES
794        || !bytes.starts_with(PROGRESS_HEADER_MAGIC)
795        || bytes[PROGRESS_HEADER_MAGIC.len()] != PROGRESS_HEADER_VERSION
796    {
797        return Err(IntegrityJobError::IncompatibleProgressFormat);
798    }
799    let checksum_offset = PROGRESS_HEADER_MAGIC.len() + 1;
800    let mut checksum = [0; 4];
801    checksum.copy_from_slice(&bytes[checksum_offset..]);
802    if u32::from_be_bytes(checksum) != crc32c(&bytes[..checksum_offset]) {
803        return Err(IntegrityJobError::CorruptProgressHeader);
804    }
805    Ok(())
806}
807
808fn encode_job_record(job: &IntegrityJob) -> Result<Vec<u8>, IntegrityJobError> {
809    let payload =
810        encode_integrity_job_payload(job).map_err(|_| IntegrityJobError::CapacityExceeded)?;
811    encode_progress_record_payload(
812        &payload,
813        *JOB_RECORD_MAGIC,
814        MAX_PROGRESS_RECORD_BYTES as usize,
815        IntegrityJobError::CapacityExceeded,
816        |_| IntegrityJobError::CapacityExceeded,
817    )
818}
819
820// The envelope grammar is shared; each family retains its payload codec,
821// record ceiling and distinct arithmetic/size error taxonomy.
822fn encode_progress_record_payload<E>(
823    payload: &[u8],
824    magic: [u8; 8],
825    max_bytes: usize,
826    capacity_error: E,
827    size_error: impl FnOnce(usize) -> E,
828) -> Result<Vec<u8>, E> {
829    let Some(total_len) = PROGRESS_JOB_RECORD_HEADER_BYTES.checked_add(payload.len()) else {
830        return Err(capacity_error);
831    };
832    if total_len > max_bytes {
833        return Err(size_error(total_len));
834    }
835    let payload_len = u32::try_from(payload.len()).map_err(|_| capacity_error)?;
836    let mut bytes = Vec::with_capacity(total_len);
837    bytes.extend_from_slice(&magic);
838    bytes.push(PROGRESS_JOB_RECORD_VERSION);
839    bytes.extend_from_slice(&payload_len.to_be_bytes());
840    bytes.extend_from_slice(&crc32c(payload).to_be_bytes());
841    bytes.extend_from_slice(payload);
842    Ok(bytes)
843}
844
845// All job families use the same envelope. Callers retain payload semantics,
846// family size ceilings and public error mapping; validate framing before decoding.
847fn decode_progress_record_payload(
848    bytes: &[u8],
849    magic: [u8; 8],
850    max_bytes: usize,
851) -> Result<&[u8], IntegrityJobError> {
852    if bytes.len() < PROGRESS_JOB_RECORD_HEADER_BYTES
853        || !bytes.starts_with(&magic)
854        || bytes[magic.len()] != PROGRESS_JOB_RECORD_VERSION
855    {
856        return Err(IntegrityJobError::IncompatibleProgressFormat);
857    }
858    if bytes.len() > max_bytes {
859        return Err(IntegrityJobError::CorruptProgressRecord);
860    }
861    let payload_len_offset = magic.len() + 1;
862    let checksum_offset = payload_len_offset + 4;
863    let payload_offset = checksum_offset + 4;
864    let mut payload_len = [0; 4];
865    payload_len.copy_from_slice(&bytes[payload_len_offset..checksum_offset]);
866    if u32::from_be_bytes(payload_len) as usize != bytes.len() - payload_offset {
867        return Err(IntegrityJobError::CorruptProgressRecord);
868    }
869    let payload = &bytes[payload_offset..];
870    let mut checksum = [0; 4];
871    checksum.copy_from_slice(&bytes[checksum_offset..payload_offset]);
872    if u32::from_be_bytes(checksum) != crc32c(payload) {
873        return Err(IntegrityJobError::CorruptProgressRecord);
874    }
875    Ok(payload)
876}
877
878fn decode_job_record(
879    bytes: &[u8],
880    expected_id: IntegrityJobId,
881) -> Result<IntegrityJob, IntegrityJobError> {
882    let job = decode_job_record_unbound(bytes)?;
883    if job.id != expected_id {
884        return Err(IntegrityJobError::CorruptProgressRecord);
885    }
886    Ok(job)
887}
888
889fn decode_job_record_unbound(bytes: &[u8]) -> Result<IntegrityJob, IntegrityJobError> {
890    let payload = decode_progress_record_payload(
891        bytes,
892        *JOB_RECORD_MAGIC,
893        MAX_PROGRESS_RECORD_BYTES as usize,
894    )?;
895    if payload.len() > MAX_INTEGRITY_JOB_PAYLOAD_BYTES {
896        return Err(IntegrityJobError::CorruptProgressRecord);
897    }
898    decode_integrity_job_payload(payload).map_err(|_| IntegrityJobError::CorruptProgressRecord)
899}
900
901fn encode_resumable_job_record(record: &ResumableJobRecord) -> Result<Vec<u8>, ResumableJobError> {
902    let payload = encode_resumable_job_payload(record)?;
903    encode_progress_record_payload(
904        &payload,
905        *RESUMABLE_JOB_RECORD_MAGIC,
906        MAX_PROGRESS_RECORD_BYTES as usize,
907        ResumableJobError::PayloadTooLarge,
908        |_| ResumableJobError::PayloadTooLarge,
909    )
910}
911
912fn decode_resumable_job_record(
913    bytes: &[u8],
914    expected_id: ResumableJobId,
915) -> Result<ResumableJobRecord, ResumableJobError> {
916    let record = decode_resumable_job_record_unbound(bytes)?;
917    if record.state().job_id != expected_id {
918        return Err(ResumableJobError::CorruptProgressStore);
919    }
920    Ok(record)
921}
922
923fn decode_resumable_job_record_unbound(
924    bytes: &[u8],
925) -> Result<ResumableJobRecord, ResumableJobError> {
926    let payload = decode_progress_record_payload(
927        bytes,
928        *RESUMABLE_JOB_RECORD_MAGIC,
929        MAX_PROGRESS_RECORD_BYTES as usize,
930    )
931    .map_err(map_integrity_store_error)?;
932    decode_resumable_job_payload(payload)
933}
934
935fn decode_resumable_job_record_for_inventory(
936    bytes: &[u8],
937    key: ProgressRecordKey,
938) -> Result<ResumableJobRecord, MutationJobError> {
939    let record = decode_resumable_job_record_unbound(bytes)
940        .map_err(|_| MutationJobError::CorruptProgressStore)?;
941    let expected_key = ProgressRecordKey::from_resumable_job_id(record.state().job_id)
942        .map_err(|_| MutationJobError::CorruptProgressStore)?;
943    if key != expected_key {
944        return Err(MutationJobError::CorruptProgressStore);
945    }
946    Ok(record)
947}
948
949fn encode_mutation_job_record(record: &MutationJobRecord) -> Result<Vec<u8>, MutationJobError> {
950    let payload = encode_mutation_job_payload(record)?;
951    encode_progress_record_payload(
952        &payload,
953        *MUTATION_JOB_RECORD_MAGIC,
954        MAX_MUTATION_JOB_RECORD_BYTES,
955        MutationJobError::CapacityExceeded,
956        mutation_record_size_error,
957    )
958}
959
960fn decode_mutation_job_record(
961    bytes: &[u8],
962    expected_id: MutationJobId,
963) -> Result<MutationJobRecord, MutationJobError> {
964    let record = decode_mutation_job_record_unbound(bytes)?;
965    if record.state().job_id != expected_id {
966        return Err(MutationJobError::CorruptProgressStore);
967    }
968    Ok(record)
969}
970
971fn decode_mutation_job_record_unbound(bytes: &[u8]) -> Result<MutationJobRecord, MutationJobError> {
972    let payload = decode_progress_record_payload(
973        bytes,
974        *MUTATION_JOB_RECORD_MAGIC,
975        MAX_MUTATION_JOB_RECORD_BYTES,
976    )
977    .map_err(map_mutation_store_error)?;
978    decode_mutation_job_payload(payload)
979}
980
981fn decode_mutation_job_record_for_inventory(
982    bytes: &[u8],
983    key: ProgressRecordKey,
984) -> Result<MutationJobRecord, MutationJobError> {
985    let record = decode_mutation_job_record_unbound(bytes)
986        .map_err(|_| MutationJobError::CorruptProgressStore)?;
987    let expected_key = ProgressRecordKey::from_mutation_job_id(record.state().job_id)
988        .map_err(|_| MutationJobError::CorruptProgressStore)?;
989    if key != expected_key {
990        return Err(MutationJobError::CorruptProgressStore);
991    }
992    Ok(record)
993}
994
995fn mutation_progress_before_digest(bytes: &[u8]) -> [u8; 32] {
996    let mut hasher = new_hash_sha256_prefixed(MUTATION_PROGRESS_BEFORE_DIGEST_DOMAIN);
997    hasher.update(bytes);
998    finalize_hash_sha256(hasher)
999}
1000
1001fn mutation_record_size_error(observed: usize) -> MutationJobError {
1002    MutationJobError::PayloadTooLarge {
1003        kind: crate::db::MutationJobPayloadKind::Record,
1004        limit: u64::try_from(MAX_MUTATION_JOB_RECORD_BYTES).unwrap_or(u64::MAX),
1005        observed: u64::try_from(observed).unwrap_or(u64::MAX),
1006    }
1007}
1008
1009const fn map_integrity_store_error(error: IntegrityJobError) -> ResumableJobError {
1010    match error {
1011        IntegrityJobError::IncompatibleProgressFormat => {
1012            ResumableJobError::IncompatibleProgressFormat
1013        }
1014        IntegrityJobError::CapacityExceeded => ResumableJobError::CapacityExceeded,
1015        _ => ResumableJobError::CorruptProgressStore,
1016    }
1017}
1018
1019const fn map_mutation_store_error(error: IntegrityJobError) -> MutationJobError {
1020    match error {
1021        IntegrityJobError::IncompatibleProgressFormat => {
1022            MutationJobError::IncompatibleProgressFormat
1023        }
1024        IntegrityJobError::CapacityExceeded => MutationJobError::CapacityExceeded,
1025        _ => MutationJobError::CorruptProgressStore,
1026    }
1027}
1028
1029pub(in crate::db) fn with_progress_store<C: CanisterKind, R>(
1030    f: impl FnOnce(&mut InspectionProgressStore) -> Result<R, IntegrityJobError>,
1031) -> Result<R, IntegrityJobError> {
1032    let memory = progress_memory::<C>()?;
1033    let mut store = InspectionProgressStore::open(memory)?;
1034    f(&mut store)
1035}
1036
1037pub(in crate::db) fn with_resumable_progress_store<C: CanisterKind, R>(
1038    f: impl FnOnce(&mut InspectionProgressStore) -> Result<R, ResumableJobError>,
1039) -> Result<R, ResumableJobError> {
1040    let memory = progress_memory::<C>().map_err(map_integrity_store_error)?;
1041    let mut store = InspectionProgressStore::open(memory).map_err(map_integrity_store_error)?;
1042    f(&mut store)
1043}
1044
1045pub(in crate::db) fn with_mutation_progress_store<C: CanisterKind, R>(
1046    f: impl FnOnce(&mut InspectionProgressStore) -> Result<R, MutationJobError>,
1047) -> Result<R, MutationJobError> {
1048    let memory = progress_memory::<C>().map_err(map_mutation_store_error)?;
1049    let mut store = InspectionProgressStore::open(memory).map_err(map_mutation_store_error)?;
1050    f(&mut store)
1051}
1052
1053pub(in crate::db) fn preflight_mutation_progress_record_op<C: CanisterKind>(
1054    operation: &MutationProgressRecordOp,
1055) -> Result<(), InternalError> {
1056    with_mutation_progress_store::<C, _>(|store| store.preflight_mutation_progress(operation))
1057        .map_err(|_| InternalError::commit_corruption())
1058}
1059
1060pub(in crate::db) fn apply_mutation_progress_record_op<C: CanisterKind>(
1061    operation: &MutationProgressRecordOp,
1062) -> Result<(), InternalError> {
1063    with_mutation_progress_store::<C, _>(|store| store.apply_mutation_progress(operation))
1064        .map_err(|_| InternalError::commit_corruption())
1065}
1066
1067/// Mechanically publish one mutation-progress replacement already proved by preflight.
1068pub(in crate::db) fn apply_preflighted_mutation_progress_record_op<C: CanisterKind>(
1069    operation: &MutationProgressRecordOp,
1070) -> Result<(), InternalError> {
1071    with_mutation_progress_store::<C, _>(|store| {
1072        store.apply_preflighted_mutation_progress(operation);
1073        Ok(())
1074    })
1075    .map_err(|_| InternalError::commit_corruption())
1076}
1077
1078pub(in crate::db) fn replace_mutation_progress_record_op<C: CanisterKind>(
1079    operation: &MutationProgressRecordOp,
1080) -> Result<(), MutationJobError> {
1081    with_mutation_progress_store::<C, _>(|store| store.replace_mutation_progress(operation))
1082}
1083
1084pub(in crate::db) fn verify_mutation_progress_record_op<C: CanisterKind>(
1085    operation: &MutationProgressRecordOp,
1086) -> Result<(), InternalError> {
1087    with_mutation_progress_store::<C, _>(|store| store.verify_mutation_progress(operation))
1088        .map_err(|_| InternalError::recovery_effect_verification_failed())
1089}
1090
1091// Corrupt physical bytes at the progress owner for end-to-end request tests.
1092#[cfg(all(test, feature = "sql"))]
1093pub(in crate::db) fn replace_progress_record_for_test<C: CanisterKind>(
1094    key: [u8; 32],
1095    bytes: Vec<u8>,
1096) -> Result<(), IntegrityJobError> {
1097    with_progress_store::<C, _>(|store| {
1098        store
1099            .map
1100            .insert(ProgressRecordKey(key), ProgressRecordBytes(bytes));
1101        Ok(())
1102    })
1103}
1104
1105// Preserve a valid payload/CRC while removing its matching physical key.
1106#[cfg(all(test, feature = "sql"))]
1107pub(in crate::db) fn miskey_progress_record_for_test<C: CanisterKind>(
1108    job_id: IntegrityJobId,
1109    stored_id: IntegrityJobId,
1110) -> Result<(), IntegrityJobError> {
1111    with_progress_store::<C, _>(|store| {
1112        let bytes = store
1113            .map
1114            .remove(&ProgressRecordKey::from_job_id(job_id))
1115            .ok_or(IntegrityJobError::JobNotFound)?;
1116        store
1117            .map
1118            .insert(ProgressRecordKey::from_job_id(stored_id), bytes);
1119        Ok(())
1120    })
1121}
1122
1123#[cfg(test)]
1124fn progress_memory<C: CanisterKind>() -> Result<RuntimeMemory<DefaultMemoryImpl>, IntegrityJobError>
1125{
1126    let resolved_id = C::integrity_progress_memory_id().map_err(|_| IntegrityJobError::Internal)?;
1127    thread_local! {
1128        static MEMORIES: RefCell<
1129            Vec<(u8, &'static str, crate::testing::TestMemoryOwner)>
1130        > = const { RefCell::new(Vec::new()) };
1131    }
1132
1133    MEMORIES.with(|memories| {
1134        let mut memories = memories.borrow_mut();
1135        if let Some((_, _, memory)) = memories
1136            .iter()
1137            .find(|(id, key, _)| *id == resolved_id && *key == C::INTEGRITY_PROGRESS_STABLE_KEY)
1138        {
1139            return Ok(memory.open());
1140        }
1141        let owner = crate::testing::test_memory_owner(resolved_id);
1142        let memory = owner.open();
1143        memories.push((resolved_id, C::INTEGRITY_PROGRESS_STABLE_KEY, owner));
1144        Ok(memory)
1145    })
1146}
1147
1148#[cfg(not(test))]
1149fn progress_memory<C: CanisterKind>() -> Result<RuntimeMemory<DefaultMemoryImpl>, IntegrityJobError>
1150{
1151    open_memory(C::INTEGRITY_PROGRESS_STABLE_KEY).map_err(|_| IntegrityJobError::Internal)
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157    use crate::{
1158        db::{
1159            MutationJobAdvanceRequest, MutationJobIdempotencyKey, MutationJobPhase,
1160            MutationJobRestartReason, MutationJobStatus, ReadSetRevisionProof,
1161            ReadSetStoreIdentity, ReadSetStoreRevision,
1162            integrity::progress_codec::current_job_codec_fixture,
1163            mutation_job::MutationJobTransition,
1164        },
1165        testing::test_memory,
1166        traits::Path,
1167    };
1168
1169    struct RetentionCanister;
1170
1171    impl CanisterKind for RetentionCanister {
1172        const COMMIT_STABLE_KEY: &'static str = "tests::retention::commit";
1173        const STARTUP_STABLE_KEY: &'static str = "tests::retention::startup";
1174        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str = "tests::retention::progress";
1175
1176        fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
1177            Ok(253)
1178        }
1179    }
1180
1181    impl Path for RetentionCanister {
1182        const PATH: &'static str = "tests::RetentionCanister";
1183    }
1184
1185    fn current_resumable_record() -> ResumableJobRecord {
1186        let proof = ReadSetRevisionProof::from_parts(
1187            [1; 16],
1188            7,
1189            1,
1190            [2; 32],
1191            vec![ReadSetStoreRevision::new(
1192                ReadSetStoreIdentity::from_bytes([3; 32]),
1193                11,
1194                13,
1195            )],
1196        )
1197        .expect("bounded canonical proof should admit");
1198        ResumableJobRecord::new(
1199            ResumableJobId::try_from_bytes([4; 32])
1200                .expect("nonzero resumable job identity should admit"),
1201            proof,
1202            vec![5, 6],
1203        )
1204        .expect("current resumable record should admit")
1205    }
1206
1207    fn mutation_job_id(byte: u8) -> MutationJobId {
1208        MutationJobId::try_from_bytes([byte; 32]).expect("nonzero mutation job id should admit")
1209    }
1210
1211    fn current_mutation_record(byte: u8) -> MutationJobRecord {
1212        MutationJobRecord::new(mutation_job_id(byte), vec![1, 2, 3], vec![4, 5])
1213            .expect("current mutation record should admit")
1214    }
1215
1216    fn current_integrity_record(byte: u8) -> IntegrityJob {
1217        let mut job = current_job_codec_fixture();
1218        let job_id = IntegrityJobId::try_from_bytes([byte; 32])
1219            .expect("nonzero integrity job id should admit");
1220        job.id = job_id;
1221        match &mut job.last_receipt.receipt {
1222            crate::db::IntegrityJobReceipt::Page(page) => page.job_id = job_id,
1223            crate::db::IntegrityJobReceipt::Abort(receipt) => receipt.job_id = job_id,
1224        }
1225        job.validate()
1226            .expect("rewritten integrity job should admit");
1227        job
1228    }
1229
1230    fn insert_mutation_record(store: &mut InspectionProgressStore, record: &MutationJobRecord) {
1231        assert!(matches!(
1232            store
1233                .insert_mutation(record)
1234                .expect("mutation record should insert"),
1235            InsertMutationJobResult::Inserted,
1236        ));
1237    }
1238
1239    fn mutation_request(byte: u8, sequence: u64, key: &str) -> MutationJobAdvanceRequest {
1240        MutationJobAdvanceRequest::new(
1241            mutation_job_id(byte),
1242            sequence,
1243            MutationJobIdempotencyKey::new(key).expect("bounded replay key should admit"),
1244        )
1245    }
1246
1247    #[test]
1248    fn progress_header_rejects_future_version_and_checksum_corruption() {
1249        let mut future = encode_progress_header();
1250        future[PROGRESS_HEADER_MAGIC.len()] = PROGRESS_HEADER_VERSION + 1;
1251        assert_eq!(
1252            decode_progress_header(&future),
1253            Err(IntegrityJobError::IncompatibleProgressFormat),
1254        );
1255
1256        let mut corrupt = encode_progress_header();
1257        let last = corrupt
1258            .last_mut()
1259            .expect("current progress header has a checksum");
1260        *last ^= 0xff;
1261        assert_eq!(
1262            decode_progress_header(&corrupt),
1263            Err(IntegrityJobError::CorruptProgressHeader),
1264        );
1265    }
1266
1267    #[test]
1268    fn current_job_record_uses_the_direct_bounded_payload() {
1269        let job = current_job_codec_fixture();
1270        let encoded = encode_job_record(&job).expect("current job should encode");
1271
1272        assert_eq!(encoded[JOB_RECORD_MAGIC.len()], 1);
1273        assert!(!encoded[PROGRESS_JOB_RECORD_HEADER_BYTES..].starts_with(b"DIDL"));
1274        assert_eq!(
1275            decode_job_record(&encoded, job.id).expect("current job should decode"),
1276            job,
1277        );
1278
1279        let mut corrupt = encoded;
1280        let last = corrupt
1281            .last_mut()
1282            .expect("current job record has a payload");
1283        *last ^= 0xff;
1284        assert_eq!(
1285            decode_job_record(&corrupt, job.id),
1286            Err(IntegrityJobError::CorruptProgressRecord),
1287        );
1288    }
1289
1290    #[test]
1291    fn current_resumable_record_is_direct_bounded_and_checksum_protected() {
1292        let record = current_resumable_record();
1293        let encoded =
1294            encode_resumable_job_record(&record).expect("current resumable record should encode");
1295
1296        assert_eq!(encoded.len(), 175);
1297        assert_eq!(encoded[RESUMABLE_JOB_RECORD_MAGIC.len()], 1);
1298        assert!(!encoded[PROGRESS_JOB_RECORD_HEADER_BYTES..].starts_with(b"DIDL"));
1299        assert_eq!(
1300            decode_resumable_job_record(&encoded, record.state().job_id)
1301                .expect("current resumable record should decode"),
1302            record,
1303        );
1304
1305        let mut future = encoded.clone();
1306        future[RESUMABLE_JOB_RECORD_MAGIC.len()] = PROGRESS_JOB_RECORD_VERSION + 1;
1307        assert_eq!(
1308            decode_resumable_job_record(&future, record.state().job_id),
1309            Err(ResumableJobError::IncompatibleProgressFormat),
1310        );
1311
1312        let mut corrupt = encoded;
1313        let last = corrupt
1314            .last_mut()
1315            .expect("current resumable record has a payload");
1316        *last ^= 0xff;
1317        assert_eq!(
1318            decode_resumable_job_record(&corrupt, record.state().job_id),
1319            Err(ResumableJobError::CorruptProgressStore),
1320        );
1321    }
1322
1323    #[test]
1324    fn current_mutation_record_is_distinct_bounded_and_checksum_protected() {
1325        let record = current_mutation_record(7);
1326        let encoded =
1327            encode_mutation_job_record(&record).expect("current mutation record should encode");
1328
1329        assert_eq!(encoded.len(), 97);
1330        assert_eq!(encoded[MUTATION_JOB_RECORD_MAGIC.len()], 1);
1331        assert!(!encoded[PROGRESS_JOB_RECORD_HEADER_BYTES..].starts_with(b"DIDL"));
1332        assert_eq!(
1333            decode_mutation_job_record(&encoded, record.state().job_id)
1334                .expect("current mutation record should decode"),
1335            record,
1336        );
1337
1338        let mut future = encoded.clone();
1339        future[MUTATION_JOB_RECORD_MAGIC.len()] = PROGRESS_JOB_RECORD_VERSION + 1;
1340        assert_eq!(
1341            decode_mutation_job_record(&future, record.state().job_id),
1342            Err(MutationJobError::IncompatibleProgressFormat),
1343        );
1344
1345        let mut corrupt = encoded;
1346        let last = corrupt
1347            .last_mut()
1348            .expect("current mutation record has a payload");
1349        *last ^= 0xff;
1350        assert_eq!(
1351            decode_mutation_job_record(&corrupt, record.state().job_id),
1352            Err(MutationJobError::CorruptProgressStore),
1353        );
1354
1355        let mut oversized = vec![0; MAX_MUTATION_JOB_RECORD_BYTES + 1];
1356        oversized[..MUTATION_JOB_RECORD_MAGIC.len()].copy_from_slice(MUTATION_JOB_RECORD_MAGIC);
1357        oversized[MUTATION_JOB_RECORD_MAGIC.len()] = PROGRESS_JOB_RECORD_VERSION;
1358        assert_eq!(
1359            decode_mutation_job_record(&oversized, record.state().job_id),
1360            Err(MutationJobError::CorruptProgressStore),
1361        );
1362    }
1363
1364    #[test]
1365    fn job_envelopes_preserve_family_rejections() {
1366        fn check<E: std::fmt::Debug + PartialEq>(
1367            encoded: &[u8],
1368            max_bytes: usize,
1369            decode: impl Fn(&[u8]) -> Result<(), E>,
1370            incompatible: E,
1371            corrupt: E,
1372        ) {
1373            assert_eq!(decode(encoded), Ok(()));
1374            for end in 0..encoded.len() {
1375                let expected = if end < PROGRESS_JOB_RECORD_HEADER_BYTES {
1376                    &incompatible
1377                } else {
1378                    &corrupt
1379                };
1380                assert_eq!(&decode(&encoded[..end]).unwrap_err(), expected);
1381            }
1382            for offset in [0, 8, 9, 13, encoded.len() - 1] {
1383                let mut invalid = encoded.to_vec();
1384                invalid[offset] ^= 0xff;
1385                let expected = if offset < 9 { &incompatible } else { &corrupt };
1386                assert_eq!(&decode(&invalid).unwrap_err(), expected);
1387            }
1388            let mut trailing = encoded.to_vec();
1389            trailing.push(0);
1390            assert_eq!(decode(&trailing).unwrap_err(), corrupt);
1391            // A consistent length and checksum cannot bypass the family ceiling.
1392            let mut oversized = encoded.to_vec();
1393            oversized.resize(max_bytes + 1, 0);
1394            let payload = &oversized[PROGRESS_JOB_RECORD_HEADER_BYTES..];
1395            let len = u32::try_from(payload.len()).unwrap();
1396            let checksum = crc32c(payload);
1397            oversized[9..13].copy_from_slice(&len.to_be_bytes());
1398            oversized[13..17].copy_from_slice(&checksum.to_be_bytes());
1399            assert_eq!(decode(&oversized).unwrap_err(), corrupt);
1400        }
1401
1402        let integrity = current_job_codec_fixture();
1403        check(
1404            &encode_job_record(&integrity).unwrap(),
1405            MAX_PROGRESS_RECORD_BYTES as usize,
1406            |bytes| decode_job_record(bytes, integrity.id).map(|_| ()),
1407            IntegrityJobError::IncompatibleProgressFormat,
1408            IntegrityJobError::CorruptProgressRecord,
1409        );
1410        let resumable = current_resumable_record();
1411        check(
1412            &encode_resumable_job_record(&resumable).unwrap(),
1413            MAX_PROGRESS_RECORD_BYTES as usize,
1414            |bytes| decode_resumable_job_record(bytes, resumable.state().job_id).map(|_| ()),
1415            ResumableJobError::IncompatibleProgressFormat,
1416            ResumableJobError::CorruptProgressStore,
1417        );
1418        let mutation = current_mutation_record(7);
1419        check(
1420            &encode_mutation_job_record(&mutation).unwrap(),
1421            MAX_MUTATION_JOB_RECORD_BYTES,
1422            |bytes| decode_mutation_job_record(bytes, mutation.state().job_id).map(|_| ()),
1423            MutationJobError::IncompatibleProgressFormat,
1424            MutationJobError::CorruptProgressStore,
1425        );
1426    }
1427
1428    #[test]
1429    fn mutation_progress_replacement_is_exact_idempotent_and_fail_closed() {
1430        let before = current_mutation_record(21);
1431        let (after, _) = before
1432            .apply_transition(
1433                &mutation_request(21, 0, "atomic-forward"),
1434                MutationJobTransition::new(
1435                    MutationJobStatus::Active,
1436                    MutationJobPhase::Forward,
1437                    vec![9],
1438                    8,
1439                    3,
1440                    0,
1441                ),
1442            )
1443            .expect("bounded atomic successor should admit");
1444        let operation = MutationProgressRecordOp::replace(&before, &after)
1445            .expect("exact mutation progress replacement should admit");
1446        let mut store = InspectionProgressStore::open(test_memory(252))
1447            .expect("isolated progress store should open");
1448        assert!(matches!(
1449            store
1450                .insert_mutation(&before)
1451                .expect("before record should insert"),
1452            InsertMutationJobResult::Inserted,
1453        ));
1454
1455        store
1456            .preflight_mutation_progress(&operation)
1457            .expect("exact before bytes should preflight");
1458        store
1459            .apply_mutation_progress(&operation)
1460            .expect("exact before bytes should advance");
1461        store
1462            .apply_mutation_progress(&operation)
1463            .expect("exact after bytes should replay idempotently");
1464        store
1465            .verify_mutation_progress(&operation)
1466            .expect("exact after bytes should verify");
1467        assert_eq!(
1468            store
1469                .load_mutation(before.state().job_id)
1470                .expect("advanced record should load"),
1471            after,
1472        );
1473        assert_eq!(
1474            store.preflight_mutation_progress(&operation),
1475            Err(MutationJobError::CorruptProgressStore),
1476            "opening a new marker against after-state must not reset progress",
1477        );
1478
1479        let (unexpected, _) = after
1480            .apply_transition(
1481                &mutation_request(21, 1, "unexpected"),
1482                MutationJobTransition::new(
1483                    MutationJobStatus::Active,
1484                    MutationJobPhase::Forward,
1485                    vec![10],
1486                    1,
1487                    0,
1488                    0,
1489                ),
1490            )
1491            .expect("third valid state should admit");
1492        store
1493            .replace_mutation(&unexpected)
1494            .expect("test should install neither-side state");
1495        assert_eq!(
1496            store.apply_mutation_progress(&operation),
1497            Err(MutationJobError::CorruptProgressStore),
1498        );
1499        assert_eq!(
1500            store.verify_mutation_progress(&operation),
1501            Err(MutationJobError::CorruptProgressStore),
1502        );
1503    }
1504
1505    #[test]
1506    fn mutation_record_sizes_are_fixed_for_current_and_maximal_states() {
1507        let initial = current_mutation_record(8);
1508        let (active, _) = initial
1509            .apply_transition(
1510                &mutation_request(8, 0, "forward-0"),
1511                MutationJobTransition::new(
1512                    MutationJobStatus::Active,
1513                    MutationJobPhase::Verify,
1514                    vec![6],
1515                    13,
1516                    4,
1517                    0,
1518                ),
1519            )
1520            .expect("bounded active transition should admit");
1521        let (completed, _) = active
1522            .apply_transition(
1523                &mutation_request(8, 1, "verify-0"),
1524                MutationJobTransition::new(
1525                    MutationJobStatus::Completed,
1526                    MutationJobPhase::Verify,
1527                    Vec::new(),
1528                    9,
1529                    0,
1530                    0,
1531                ),
1532            )
1533            .expect("bounded completion should admit");
1534        let (restart, _) = initial
1535            .apply_transition(
1536                &mutation_request(8, 0, "restart"),
1537                MutationJobTransition::new(
1538                    MutationJobStatus::RestartRequired(
1539                        MutationJobRestartReason::AcceptedSchemaChanged,
1540                    ),
1541                    MutationJobPhase::Forward,
1542                    Vec::new(),
1543                    0,
1544                    0,
1545                    0,
1546                ),
1547            )
1548            .expect("bounded restart should admit");
1549        let maximal_initial = MutationJobRecord::new(
1550            mutation_job_id(9),
1551            vec![1; crate::db::MAX_MUTATION_JOB_INTENT_BYTES],
1552            vec![2; crate::db::MAX_MUTATION_JOB_CONTINUATION_BYTES],
1553        )
1554        .expect("maximum initial record should admit");
1555        let (maximal_active, _) = maximal_initial
1556            .apply_transition(
1557                &MutationJobAdvanceRequest::new(
1558                    mutation_job_id(9),
1559                    0,
1560                    MutationJobIdempotencyKey::new(
1561                        "k".repeat(crate::db::MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES),
1562                    )
1563                    .expect("maximum replay key should admit"),
1564                ),
1565                MutationJobTransition::new(
1566                    MutationJobStatus::Active,
1567                    MutationJobPhase::Forward,
1568                    vec![2; crate::db::MAX_MUTATION_JOB_CONTINUATION_BYTES],
1569                    crate::db::MAX_MUTATION_JOB_STEP_KEYS_SCANNED,
1570                    crate::db::MAX_MUTATION_JOB_STEP_ROWS_UPDATED,
1571                    0,
1572                ),
1573            )
1574            .expect("maximum active record should admit");
1575
1576        assert_eq!(
1577            encode_mutation_job_record(&initial).map(|bytes| bytes.len()),
1578            Ok(97)
1579        );
1580        assert_eq!(
1581            encode_mutation_job_record(&active).map(|bytes| bytes.len()),
1582            Ok(167)
1583        );
1584        assert_eq!(
1585            encode_mutation_job_record(&completed).map(|bytes| bytes.len()),
1586            Ok(165),
1587        );
1588        assert_eq!(
1589            encode_mutation_job_record(&restart).map(|bytes| bytes.len()),
1590            Ok(166)
1591        );
1592        assert_eq!(
1593            encode_mutation_job_record(&maximal_initial).map(|bytes| bytes.len()),
1594            Ok(18_524),
1595        );
1596        assert_eq!(
1597            encode_mutation_job_record(&maximal_active).map(|bytes| bytes.len()),
1598            Ok(18_842),
1599        );
1600    }
1601
1602    #[test]
1603    fn mutation_key_domain_and_shared_capacity_reservation_are_enforced() {
1604        let shared_bytes = [11; 32];
1605        let mutation_key = ProgressRecordKey::from_mutation_job_id(
1606            MutationJobId::try_from_bytes(shared_bytes).expect("mutation id should admit"),
1607        )
1608        .expect("mutation progress key should derive");
1609        let resumable_key = ProgressRecordKey::from_resumable_job_id(
1610            ResumableJobId::try_from_bytes(shared_bytes).expect("resumable id should admit"),
1611        )
1612        .expect("resumable progress key should derive");
1613        let integrity_key = ProgressRecordKey::from_job_id(
1614            IntegrityJobId::try_from_bytes(shared_bytes).expect("integrity id should admit"),
1615        );
1616        assert_ne!(mutation_key, resumable_key);
1617        assert_ne!(mutation_key, integrity_key);
1618
1619        let mut store = InspectionProgressStore::open(test_memory(251))
1620            .expect("isolated progress store should open");
1621        store
1622            .insert_resumable(&current_resumable_record())
1623            .expect("generic job should consume one shared slot");
1624        for byte in 1..=54 {
1625            assert!(matches!(
1626                store
1627                    .insert_mutation(&current_mutation_record(byte))
1628                    .expect("record inside shared capacity should insert"),
1629                InsertMutationJobResult::Inserted,
1630            ));
1631        }
1632        assert_eq!(
1633            store
1634                .inventory()
1635                .expect("55 current records should inventory")
1636                .retained_count,
1637            55,
1638        );
1639        assert!(matches!(
1640            store
1641                .insert_mutation(&current_mutation_record(55))
1642                .expect("the 56th non-integrity record should insert"),
1643            InsertMutationJobResult::Inserted,
1644        ));
1645        assert_eq!(
1646            store
1647                .inventory()
1648                .expect("56 current records should inventory")
1649                .retained_count,
1650            56,
1651        );
1652        assert!(matches!(
1653            store.insert_mutation(&current_mutation_record(56)),
1654            Err(MutationJobError::CapacityExceeded),
1655        ));
1656
1657        for byte in 200..=206 {
1658            assert!(matches!(
1659                store
1660                    .insert_new(&current_integrity_record(byte))
1661                    .expect("reserved integrity record should insert"),
1662                InsertJobResult::Inserted,
1663            ));
1664        }
1665        assert_eq!(
1666            store
1667                .inventory()
1668                .expect("63 current records should inventory")
1669                .retained_count,
1670            63,
1671        );
1672        assert!(matches!(
1673            store
1674                .insert_new(&current_integrity_record(207))
1675                .expect("the 64th integrity record should insert"),
1676            InsertJobResult::Inserted,
1677        ));
1678        let full = store.inventory().expect("full store should inventory");
1679        assert_eq!(full.retained_count, 64);
1680        assert_eq!(full.hard_limit, 64);
1681        assert_eq!(full.reserved_integrity_headroom, 8);
1682        assert_eq!(full.integrity_count, 8);
1683        assert_eq!(full.resumable_count, 1);
1684        assert_eq!(full.mutation_count, 55);
1685        assert!(matches!(
1686            store.insert_new(&current_integrity_record(208)),
1687            Err(IntegrityJobError::CapacityExceeded),
1688        ));
1689    }
1690
1691    #[test]
1692    fn progress_stable_growth_is_measured_at_reservation_boundaries() {
1693        const STABLE_PAGE_BYTES: u64 = 65_536;
1694
1695        let memory = crate::testing::test_memory_owner(249);
1696        let mut store = InspectionProgressStore::open(memory.open())
1697            .expect("isolated progress store should open");
1698        let mut bytes_at_fifty_five = 0;
1699        let mut bytes_at_fifty_six = 0;
1700        for byte in 1..=56 {
1701            assert!(matches!(
1702                store
1703                    .insert_mutation(&current_mutation_record(byte))
1704                    .expect("record inside shared capacity should insert"),
1705                InsertMutationJobResult::Inserted,
1706            ));
1707            if byte == 55 {
1708                bytes_at_fifty_five = memory.size() * STABLE_PAGE_BYTES;
1709            } else if byte == 56 {
1710                bytes_at_fifty_six = memory.size() * STABLE_PAGE_BYTES;
1711            }
1712        }
1713        let mut bytes_at_sixty_three = 0;
1714        for byte in 200..=207 {
1715            assert!(matches!(
1716                store
1717                    .insert_new(&current_integrity_record(byte))
1718                    .expect("record inside integrity reservation should insert"),
1719                InsertJobResult::Inserted,
1720            ));
1721            if byte == 206 {
1722                bytes_at_sixty_three = memory.size() * STABLE_PAGE_BYTES;
1723            }
1724        }
1725        let bytes_at_sixty_four = memory.size() * STABLE_PAGE_BYTES;
1726
1727        assert_eq!(
1728            (
1729                bytes_at_fifty_five,
1730                bytes_at_fifty_six,
1731                bytes_at_sixty_three,
1732                bytes_at_sixty_four,
1733            ),
1734            (38_993_920, 38_993_920, 43_319_296, 43_319_296),
1735        );
1736    }
1737
1738    #[test]
1739    fn mutation_store_load_replay_replace_and_acknowledge_are_exact() {
1740        let mut store = InspectionProgressStore::open(test_memory(250))
1741            .expect("isolated progress store should open");
1742        let initial = current_mutation_record(10);
1743        assert!(matches!(
1744            store
1745                .insert_mutation(&initial)
1746                .expect("initial mutation record should insert"),
1747            InsertMutationJobResult::Inserted,
1748        ));
1749        assert!(matches!(
1750            store
1751                .insert_mutation(&initial)
1752                .expect("duplicate identity should load retained record"),
1753            InsertMutationJobResult::Occupied(record) if *record == initial,
1754        ));
1755        assert_eq!(
1756            store.load_mutation(mutation_job_id(10)),
1757            Ok(initial.clone())
1758        );
1759        assert_eq!(
1760            store.acknowledge_mutation(mutation_job_id(10), 0),
1761            Err(MutationJobError::Active),
1762        );
1763
1764        let request = mutation_request(10, 0, "restart");
1765        let (terminal, receipt) = initial
1766            .apply_transition(
1767                &request,
1768                MutationJobTransition::new(
1769                    MutationJobStatus::RestartRequired(
1770                        MutationJobRestartReason::BatchPolicyChanged,
1771                    ),
1772                    MutationJobPhase::Forward,
1773                    Vec::new(),
1774                    0,
1775                    0,
1776                    0,
1777                ),
1778            )
1779            .expect("terminal transition should admit");
1780        store
1781            .replace_mutation(&terminal)
1782            .expect("terminal replacement should persist");
1783        assert_eq!(
1784            store.load_mutation(mutation_job_id(10)).and_then(|record| {
1785                let replay = record.exact_replay(&request)?;
1786                Ok(replay.cloned())
1787            }),
1788            Ok(Some(receipt)),
1789        );
1790        assert_eq!(
1791            store.acknowledge_mutation(mutation_job_id(10), 0),
1792            Err(MutationJobError::StaleSequence {
1793                expected: 0,
1794                actual: 1,
1795            }),
1796        );
1797        assert_eq!(store.acknowledge_mutation(mutation_job_id(10), 1), Ok(()));
1798        assert_eq!(store.acknowledge_mutation(mutation_job_id(10), 1), Ok(()));
1799        assert_eq!(
1800            store.load_mutation(mutation_job_id(10)),
1801            Err(MutationJobError::NotFound),
1802        );
1803    }
1804
1805    #[cfg(feature = "sql")]
1806    #[test]
1807    fn mutation_cancellation_is_exact_zero_state_and_absent_idempotent() {
1808        let mut store = InspectionProgressStore::open(test_memory(248))
1809            .expect("isolated progress store should open");
1810        let initial = current_mutation_record(31);
1811        insert_mutation_record(&mut store, &initial);
1812        assert_eq!(
1813            store.cancel_unadvanced_mutation(mutation_job_id(31), 1, |_| Ok(())),
1814            Err(MutationJobError::StaleSequence {
1815                expected: 1,
1816                actual: 0,
1817            }),
1818        );
1819        assert_eq!(
1820            store.cancel_unadvanced_mutation(mutation_job_id(31), 0, |_| Ok(())),
1821            Ok(()),
1822        );
1823        assert_eq!(
1824            store.cancel_unadvanced_mutation(mutation_job_id(31), 0, |_| {
1825                Err(MutationJobError::CorruptProgressStore)
1826            }),
1827            Ok(()),
1828            "an absent retry must not invoke continuation validation",
1829        );
1830
1831        let advanced_initial = current_mutation_record(32);
1832        let (advanced, _) = advanced_initial
1833            .apply_transition(
1834                &mutation_request(32, 0, "advanced"),
1835                MutationJobTransition::new(
1836                    MutationJobStatus::Active,
1837                    MutationJobPhase::Forward,
1838                    vec![6],
1839                    1,
1840                    0,
1841                    0,
1842                ),
1843            )
1844            .expect("advanced state should admit");
1845        insert_mutation_record(&mut store, &advanced);
1846        for expected_sequence in [0, 1] {
1847            assert_eq!(
1848                store.cancel_unadvanced_mutation(
1849                    mutation_job_id(32),
1850                    expected_sequence,
1851                    |_| Ok(()),
1852                ),
1853                Err(MutationJobError::StaleSequence {
1854                    expected: 0,
1855                    actual: 1,
1856                }),
1857            );
1858        }
1859
1860        let terminal_initial = current_mutation_record(33);
1861        let (terminal, _) = terminal_initial
1862            .apply_transition(
1863                &mutation_request(33, 0, "terminal"),
1864                MutationJobTransition::new(
1865                    MutationJobStatus::RestartRequired(
1866                        MutationJobRestartReason::BatchPolicyChanged,
1867                    ),
1868                    MutationJobPhase::Forward,
1869                    Vec::new(),
1870                    0,
1871                    0,
1872                    0,
1873                ),
1874            )
1875            .expect("terminal state should admit");
1876        insert_mutation_record(&mut store, &terminal);
1877        assert_eq!(
1878            store.cancel_unadvanced_mutation(mutation_job_id(33), 1, |_| Ok(())),
1879            Err(MutationJobError::StaleSequence {
1880                expected: 0,
1881                actual: 1,
1882            }),
1883        );
1884
1885        let malformed_continuation = current_mutation_record(34);
1886        insert_mutation_record(&mut store, &malformed_continuation);
1887        assert_eq!(
1888            store.cancel_unadvanced_mutation(mutation_job_id(34), 0, |_| {
1889                Err(MutationJobError::CorruptProgressStore)
1890            }),
1891            Err(MutationJobError::CorruptProgressStore),
1892        );
1893        assert_eq!(
1894            store.load_mutation(mutation_job_id(34)),
1895            Ok(malformed_continuation),
1896            "failed validation must retain the record",
1897        );
1898    }
1899
1900    #[test]
1901    fn progress_inventory_is_complete_family_bounded_and_fail_closed() {
1902        let mut store = InspectionProgressStore::open(test_memory(247))
1903            .expect("isolated progress store should open");
1904        let integrity = current_integrity_record(201);
1905        let resumable = current_resumable_record();
1906        let mutation = current_mutation_record(35);
1907        assert!(matches!(
1908            store
1909                .insert_new(&integrity)
1910                .expect("integrity record should insert"),
1911            InsertJobResult::Inserted,
1912        ));
1913        store
1914            .insert_resumable(&resumable)
1915            .expect("resumable record should insert");
1916        assert!(matches!(
1917            store
1918                .insert_mutation(&mutation)
1919                .expect("mutation record should insert"),
1920            InsertMutationJobResult::Inserted,
1921        ));
1922
1923        let inventory = store.inventory().expect("valid records should inventory");
1924        assert_eq!(inventory.retained_count, 3);
1925        assert_eq!(inventory.hard_limit, 64);
1926        assert_eq!(inventory.reserved_integrity_headroom, 8);
1927        assert_eq!(inventory.integrity_count, 1);
1928        assert_eq!(inventory.resumable_count, 1);
1929        assert_eq!(inventory.mutation_count, 1);
1930        let expected_record_bytes = encode_job_record(&integrity)
1931            .expect("integrity record should encode")
1932            .len()
1933            .saturating_add(
1934                encode_resumable_job_record(&resumable)
1935                    .expect("resumable record should encode")
1936                    .len(),
1937            )
1938            .saturating_add(
1939                encode_mutation_job_record(&mutation)
1940                    .expect("mutation record should encode")
1941                    .len(),
1942            );
1943        assert_eq!(
1944            inventory.retained_record_bytes,
1945            u64::try_from(expected_record_bytes).expect("bounded records fit u64")
1946        );
1947        assert_eq!(inventory.records.len(), 3);
1948        assert!(inventory.records.iter().all(|record| {
1949            record.lifecycle == ProgressJobLifecycle::Active && record.sequence == Some(0)
1950        }));
1951        assert!(inventory.records.iter().any(|record| {
1952            record.family == ProgressJobFamily::Integrity
1953                && record.job_id == integrity.id.to_bytes()
1954        }));
1955        assert!(inventory.records.iter().any(|record| {
1956            record.family == ProgressJobFamily::Resumable
1957                && record.job_id == resumable.state().job_id.to_bytes()
1958        }));
1959        assert!(inventory.records.iter().any(|record| {
1960            record.family == ProgressJobFamily::Mutation
1961                && record.job_id == mutation.state().job_id.to_bytes()
1962        }));
1963
1964        store.map.insert(
1965            ProgressRecordKey([202; 32]),
1966            ProgressRecordBytes(b"undecodable-retained-slot".to_vec()),
1967        );
1968        assert_eq!(
1969            store.inventory(),
1970            Err(MutationJobError::CorruptProgressStore),
1971            "one undecodable slot must fail the whole inventory",
1972        );
1973    }
1974
1975    #[test]
1976    fn retention_passes_corrupt_keys_without_losing_expiration_or_receipts() {
1977        use crate::db::integrity::{
1978            DeepIntegrityPageStatus, IntegrityJobReceipt, IntegrityPendingTerminal,
1979            IntegrityReceiptReplayKey, IntegrityTerminalOutcome, run_next_integrity_retention_page,
1980        };
1981
1982        with_progress_store::<RetentionCanister, _>(|store| {
1983            let mismatched = encode_job_record(&current_integrity_record(99)).unwrap();
1984            for byte in 1..=20 {
1985                store.map.insert(
1986                    ProgressRecordKey([byte; 32]),
1987                    ProgressRecordBytes(if byte % 2 == 0 {
1988                        mismatched.clone()
1989                    } else {
1990                        vec![0]
1991                    }),
1992                );
1993            }
1994            let first = store.scan_after(None, 16)?;
1995            assert_eq!(first.records_scanned, 16);
1996            assert_eq!(first.job_ids.len(), 16);
1997            assert_eq!(first.next_checkpoint.unwrap().to_bytes(), [16; 32]);
1998            assert!(!first.exhausted);
1999            for id in first.job_ids {
2000                assert_eq!(id.to_bytes()[0], id.to_bytes()[31]);
2001                assert!(matches!(
2002                    store.load(id),
2003                    Err(IntegrityJobError::CorruptProgressRecord
2004                        | IntegrityJobError::IncompatibleProgressFormat)
2005                ));
2006            }
2007            let mut expired = current_integrity_record(21);
2008            expired.lease_deadline_nanos = 1;
2009            store.map.insert(
2010                ProgressRecordKey::from_job_id(expired.id),
2011                ProgressRecordBytes(encode_job_record(&expired)?),
2012            );
2013            for (byte, acknowledged) in [(22, true), (23, false)] {
2014                let mut terminal = current_integrity_record(byte);
2015                terminal.lease_deadline_nanos = 1;
2016                terminal.state = IntegrityJobState::Terminal {
2017                    outcome: IntegrityTerminalOutcome::DeepCompleteClean,
2018                    receipt_acknowledged: acknowledged,
2019                };
2020                terminal.pages_completed = 1;
2021                terminal.last_receipt.replay_key = IntegrityReceiptReplayKey::Continue {
2022                    acknowledged_sequence: 0,
2023                };
2024                let IntegrityJobReceipt::Page(page) = &mut terminal.last_receipt.receipt else {
2025                    panic!("fixture should have a page");
2026                };
2027                page.status =
2028                    DeepIntegrityPageStatus::Terminal(IntegrityTerminalOutcome::DeepCompleteClean);
2029                page.page_sequence = 1;
2030                page.pages_completed = 1;
2031                terminal.validate()?;
2032                store.map.insert(
2033                    ProgressRecordKey::from_job_id(terminal.id),
2034                    ProgressRecordBytes(encode_job_record(&terminal)?),
2035                );
2036            }
2037            Ok(())
2038        })
2039        .unwrap();
2040
2041        run_next_integrity_retention_page::<RetentionCanister>();
2042        with_progress_store::<RetentionCanister, _>(|store| {
2043            assert_eq!(
2044                store.load(current_integrity_record(21).id)?.state,
2045                IntegrityJobState::InProgress
2046            );
2047            Ok(())
2048        })
2049        .unwrap();
2050        run_next_integrity_retention_page::<RetentionCanister>();
2051        with_progress_store::<RetentionCanister, _>(|store| {
2052            assert_eq!(
2053                store.load(current_integrity_record(21).id)?.state,
2054                IntegrityJobState::TerminalPending(IntegrityPendingTerminal::Expired),
2055            );
2056            assert_eq!(
2057                store.load(current_integrity_record(22).id),
2058                Err(IntegrityJobError::JobNotFound)
2059            );
2060            assert!(matches!(
2061                store.load(current_integrity_record(23).id)?.state,
2062                IntegrityJobState::Terminal {
2063                    receipt_acknowledged: false,
2064                    ..
2065                }
2066            ));
2067            assert_eq!(
2068                store.job_count()?,
2069                22,
2070                "corrupt records and outstanding receipts stay retained"
2071            );
2072            Ok(())
2073        })
2074        .unwrap();
2075        // Wrapping revisits the corrupt prefix without stalling or deleting it.
2076        run_next_integrity_retention_page::<RetentionCanister>();
2077    }
2078
2079    #[test]
2080    fn integrity_scan_skips_other_progress_record_families() {
2081        let mut store = InspectionProgressStore::open(test_memory(252))
2082            .expect("isolated progress store should open");
2083        let integrity = current_job_codec_fixture();
2084        assert!(matches!(
2085            store
2086                .insert_new(&integrity)
2087                .expect("integrity job should insert"),
2088            InsertJobResult::Inserted,
2089        ));
2090        store
2091            .insert_resumable(&current_resumable_record())
2092            .expect("generic resumable job should insert");
2093        assert!(matches!(
2094            store
2095                .insert_mutation(&current_mutation_record(12))
2096                .expect("mutation job should insert"),
2097            InsertMutationJobResult::Inserted,
2098        ));
2099
2100        let page = store
2101            .scan_after(None, 8)
2102            .expect("integrity scan should ignore other record families");
2103        assert_eq!(page.job_ids, vec![integrity.id]);
2104        assert_eq!(page.records_scanned, 3);
2105        assert!(page.exhausted);
2106
2107        let mut checkpoint = None;
2108        let mut scanned = 0;
2109        let mut ids = Vec::new();
2110        loop {
2111            let page = store.scan_after(checkpoint, 1).unwrap();
2112            assert_eq!(page.records_scanned, 1);
2113            assert!(page.next_checkpoint > checkpoint);
2114            scanned += page.records_scanned;
2115            ids.extend(page.job_ids);
2116            checkpoint = page.next_checkpoint;
2117            if page.exhausted {
2118                break;
2119            }
2120            assert!(scanned < 3);
2121        }
2122        assert_eq!(scanned, 3);
2123        assert_eq!(ids, vec![integrity.id]);
2124        assert!(matches!(
2125            store.scan_after(None, 0),
2126            Err(IntegrityJobError::CapacityExceeded)
2127        ));
2128    }
2129}