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) exhausted: bool,
316}
317
318pub(in crate::db) struct InspectionProgressStore {
319    map: StableBTreeMap<ProgressRecordKey, ProgressRecordBytes, DefaultMemoryImpl>,
320}
321
322impl InspectionProgressStore {
323    fn open(memory: RuntimeMemory<DefaultMemoryImpl>) -> Result<Self, IntegrityJobError> {
324        let mut store = Self {
325            map: StableBTreeMap::init(memory),
326        };
327        if store.map.is_empty() {
328            store.map.insert(
329                PROGRESS_HEADER_KEY,
330                ProgressRecordBytes(encode_progress_header()),
331            );
332        } else {
333            let header = store
334                .map
335                .get(&PROGRESS_HEADER_KEY)
336                .ok_or(IntegrityJobError::CorruptProgressHeader)?;
337            decode_progress_header(&header.0)?;
338            if store.job_count()? > MAX_PROGRESS_JOBS_GLOBAL {
339                return Err(IntegrityJobError::CorruptProgressHeader);
340            }
341        }
342        Ok(store)
343    }
344
345    pub(super) fn load(&self, job_id: IntegrityJobId) -> Result<IntegrityJob, IntegrityJobError> {
346        let raw = self
347            .map
348            .get(&ProgressRecordKey::from_job_id(job_id))
349            .ok_or(IntegrityJobError::JobNotFound)?;
350        decode_job_record(&raw.0, job_id)
351    }
352
353    pub(super) fn insert_new(
354        &mut self,
355        job: &IntegrityJob,
356    ) -> Result<InsertJobResult, IntegrityJobError> {
357        job.validate()?;
358        let key = ProgressRecordKey::from_job_id(job.id);
359        if key == PROGRESS_HEADER_KEY {
360            return Err(IntegrityJobError::CorruptProgressRecord);
361        }
362        if let Some(raw) = self.map.get(&key) {
363            return decode_job_record(&raw.0, job.id)
364                .map(Box::new)
365                .map(InsertJobResult::Occupied);
366        }
367        if self.job_count()? >= MAX_PROGRESS_JOBS_GLOBAL
368            || self.owner_job_count(&job.owner)? >= MAX_PROGRESS_JOBS_PER_OWNER
369        {
370            return Err(IntegrityJobError::CapacityExceeded);
371        }
372        self.map
373            .insert(key, ProgressRecordBytes(encode_job_record(job)?));
374        Ok(InsertJobResult::Inserted)
375    }
376
377    pub(super) fn replace(&mut self, job: &IntegrityJob) -> Result<(), IntegrityJobError> {
378        job.validate()?;
379        let key = ProgressRecordKey::from_job_id(job.id);
380        if !self.map.contains_key(&key) {
381            return Err(IntegrityJobError::JobNotFound);
382        }
383        self.map
384            .insert(key, ProgressRecordBytes(encode_job_record(job)?));
385        Ok(())
386    }
387
388    pub(in crate::db) fn load_resumable(
389        &self,
390        job_id: ResumableJobId,
391    ) -> Result<ResumableJobRecord, ResumableJobError> {
392        let key = ProgressRecordKey::from_resumable_job_id(job_id)?;
393        let raw = self.map.get(&key).ok_or(ResumableJobError::NotFound)?;
394        decode_resumable_job_record(&raw.0, job_id)
395    }
396
397    pub(in crate::db) fn insert_resumable(
398        &mut self,
399        record: &ResumableJobRecord,
400    ) -> Result<(), ResumableJobError> {
401        record.validate()?;
402        let key = ProgressRecordKey::from_resumable_job_id(record.state().job_id)?;
403        if self.map.contains_key(&key) {
404            return Err(ResumableJobError::AlreadyExists);
405        }
406        if self.job_count().map_err(map_integrity_store_error)? >= MAX_PROGRESS_JOBS_NON_INTEGRITY {
407            return Err(ResumableJobError::CapacityExceeded);
408        }
409        self.map.insert(
410            key,
411            ProgressRecordBytes(encode_resumable_job_record(record)?),
412        );
413        Ok(())
414    }
415
416    pub(in crate::db) fn replace_resumable(
417        &mut self,
418        record: &ResumableJobRecord,
419    ) -> Result<(), ResumableJobError> {
420        record.validate()?;
421        let key = ProgressRecordKey::from_resumable_job_id(record.state().job_id)?;
422        if !self.map.contains_key(&key) {
423            return Err(ResumableJobError::NotFound);
424        }
425        self.map.insert(
426            key,
427            ProgressRecordBytes(encode_resumable_job_record(record)?),
428        );
429        Ok(())
430    }
431
432    pub(in crate::db) fn remove_resumable(
433        &mut self,
434        job_id: ResumableJobId,
435    ) -> Result<(), ResumableJobError> {
436        let key = ProgressRecordKey::from_resumable_job_id(job_id)?;
437        let Some(raw) = self.map.get(&key) else {
438            return Ok(());
439        };
440        decode_resumable_job_record(&raw.0, job_id)?;
441        let _ = self.map.remove(&key);
442        Ok(())
443    }
444
445    pub(in crate::db) fn load_mutation(
446        &self,
447        job_id: MutationJobId,
448    ) -> Result<MutationJobRecord, MutationJobError> {
449        let key = ProgressRecordKey::from_mutation_job_id(job_id)?;
450        let raw = self.map.get(&key).ok_or(MutationJobError::NotFound)?;
451        decode_mutation_job_record(&raw.0, job_id)
452    }
453
454    pub(in crate::db) fn insert_mutation(
455        &mut self,
456        record: &MutationJobRecord,
457    ) -> Result<InsertMutationJobResult, MutationJobError> {
458        record.validate()?;
459        let key = ProgressRecordKey::from_mutation_job_id(record.state().job_id)?;
460        if let Some(raw) = self.map.get(&key) {
461            return decode_mutation_job_record(&raw.0, record.state().job_id)
462                .map(Box::new)
463                .map(InsertMutationJobResult::Occupied);
464        }
465        if self.job_count().map_err(map_mutation_store_error)? >= MAX_PROGRESS_JOBS_NON_INTEGRITY {
466            return Err(MutationJobError::CapacityExceeded);
467        }
468        self.map.insert(
469            key,
470            ProgressRecordBytes(encode_mutation_job_record(record)?),
471        );
472        Ok(InsertMutationJobResult::Inserted)
473    }
474
475    // Install fixture states outside the production before/after replacement proof.
476    #[cfg(test)]
477    pub(in crate::db) fn replace_mutation(
478        &mut self,
479        record: &MutationJobRecord,
480    ) -> Result<(), MutationJobError> {
481        record.validate()?;
482        let key = ProgressRecordKey::from_mutation_job_id(record.state().job_id)?;
483        if !self.map.contains_key(&key) {
484            return Err(MutationJobError::NotFound);
485        }
486        self.map.insert(
487            key,
488            ProgressRecordBytes(encode_mutation_job_record(record)?),
489        );
490        Ok(())
491    }
492
493    fn preflight_mutation_progress(
494        &self,
495        operation: &MutationProgressRecordOp,
496    ) -> Result<(), MutationJobError> {
497        let current = self
498            .map
499            .get(&operation.key)
500            .ok_or(MutationJobError::CorruptProgressStore)?;
501        if current.0 != operation.before {
502            return Err(MutationJobError::CorruptProgressStore);
503        }
504        Ok(())
505    }
506
507    fn apply_mutation_progress(
508        &mut self,
509        operation: &MutationProgressRecordOp,
510    ) -> Result<(), MutationJobError> {
511        let current = self
512            .map
513            .get(&operation.key)
514            .ok_or(MutationJobError::CorruptProgressStore)?;
515        if current.0 == operation.after {
516            return Ok(());
517        }
518        if current.0 != operation.before {
519            return Err(MutationJobError::CorruptProgressStore);
520        }
521        self.map
522            .insert(operation.key, ProgressRecordBytes(operation.after.clone()));
523        Ok(())
524    }
525
526    fn apply_preflighted_mutation_progress(&mut self, operation: &MutationProgressRecordOp) {
527        self.map
528            .insert(operation.key, ProgressRecordBytes(operation.after.clone()));
529    }
530
531    /// Replace one mutation record without target-row work using the same exact
532    /// before/after proof as marker-owned mutation progress.
533    pub(in crate::db) fn replace_mutation_progress(
534        &mut self,
535        operation: &MutationProgressRecordOp,
536    ) -> Result<(), MutationJobError> {
537        self.apply_mutation_progress(operation)
538    }
539
540    fn verify_mutation_progress(
541        &self,
542        operation: &MutationProgressRecordOp,
543    ) -> Result<(), MutationJobError> {
544        operation.validate()?;
545        let current = self
546            .map
547            .get(&operation.key)
548            .ok_or(MutationJobError::CorruptProgressStore)?;
549        if current.0 != operation.after {
550            return Err(MutationJobError::CorruptProgressStore);
551        }
552        Ok(())
553    }
554
555    pub(in crate::db) fn acknowledge_mutation(
556        &mut self,
557        job_id: MutationJobId,
558        expected_sequence: u64,
559    ) -> Result<(), MutationJobError> {
560        let key = ProgressRecordKey::from_mutation_job_id(job_id)?;
561        let Some(raw) = self.map.get(&key) else {
562            return Ok(());
563        };
564        let record = decode_mutation_job_record(&raw.0, job_id)?;
565        if record.state().sequence != expected_sequence {
566            return Err(MutationJobError::StaleSequence {
567                expected: expected_sequence,
568                actual: record.state().sequence,
569            });
570        }
571        if record.state().status == crate::db::MutationJobStatus::Active {
572            return Err(MutationJobError::Active);
573        }
574        let _ = self.map.remove(&key);
575        Ok(())
576    }
577
578    /// Remove exactly one current initial mutation record.
579    ///
580    /// The caller supplies the engine-format validator so the shared store
581    /// remains independent of SQL continuation semantics while still making
582    /// validation and removal one indivisible store borrow.
583    #[cfg(feature = "sql")]
584    pub(in crate::db) fn cancel_unadvanced_mutation(
585        &mut self,
586        job_id: MutationJobId,
587        expected_sequence: u64,
588        validate_initial_continuation: impl FnOnce(&[u8]) -> Result<(), MutationJobError>,
589    ) -> Result<(), MutationJobError> {
590        let key = ProgressRecordKey::from_mutation_job_id(job_id)?;
591        let Some(raw) = self.map.get(&key) else {
592            return Ok(());
593        };
594        let record = decode_mutation_job_record(&raw.0, job_id)?;
595        let continuation = record.ensure_cancelable_at_sequence(expected_sequence)?;
596        validate_initial_continuation(continuation)?;
597        let _ = self.map.remove(&key);
598        Ok(())
599    }
600
601    /// Decode every retained slot before returning one complete bounded inventory.
602    pub(in crate::db) fn inventory(&self) -> Result<ProgressJobInventory, MutationJobError> {
603        let retained_count = self.job_count().map_err(map_mutation_store_error)?;
604        let record_capacity =
605            usize::try_from(retained_count).map_err(|_| MutationJobError::CorruptProgressStore)?;
606        let mut records = Vec::with_capacity(record_capacity);
607        let mut integrity_count = 0_u64;
608        let mut resumable_count = 0_u64;
609        let mut mutation_count = 0_u64;
610        let mut retained_record_bytes = 0_u64;
611
612        for entry in &self.map {
613            let key = *entry.key();
614            if key == PROGRESS_HEADER_KEY {
615                continue;
616            }
617            let bytes = &entry.value().0;
618            retained_record_bytes = retained_record_bytes
619                .checked_add(
620                    u64::try_from(bytes.len())
621                        .map_err(|_| MutationJobError::CorruptProgressStore)?,
622                )
623                .ok_or(MutationJobError::CorruptProgressStore)?;
624            let record = if bytes.starts_with(JOB_RECORD_MAGIC) {
625                let job_id = IntegrityJobId::try_from_bytes(key.to_bytes())
626                    .map_err(|_| MutationJobError::CorruptProgressStore)?;
627                let job = decode_job_record(bytes, job_id)
628                    .map_err(|_| MutationJobError::CorruptProgressStore)?;
629                integrity_count = integrity_count
630                    .checked_add(1)
631                    .ok_or(MutationJobError::CorruptProgressStore)?;
632                ProgressJobInventoryRecord {
633                    family: ProgressJobFamily::Integrity,
634                    job_id: job.id.to_bytes(),
635                    lifecycle: match job.state {
636                        IntegrityJobState::InProgress => ProgressJobLifecycle::Active,
637                        IntegrityJobState::TerminalPending(_) => {
638                            ProgressJobLifecycle::TerminalPending
639                        }
640                        IntegrityJobState::Terminal { .. } => ProgressJobLifecycle::Terminal,
641                    },
642                    sequence: Some(job.pages_completed),
643                }
644            } else if bytes.starts_with(RESUMABLE_JOB_RECORD_MAGIC) {
645                let job = decode_resumable_job_record_for_inventory(bytes, key)?;
646                resumable_count = resumable_count
647                    .checked_add(1)
648                    .ok_or(MutationJobError::CorruptProgressStore)?;
649                ProgressJobInventoryRecord {
650                    family: ProgressJobFamily::Resumable,
651                    job_id: job.state().job_id.to_bytes(),
652                    lifecycle: match job.state().status {
653                        ResumableJobStatus::Active => ProgressJobLifecycle::Active,
654                        ResumableJobStatus::Completed => ProgressJobLifecycle::Completed,
655                        ResumableJobStatus::Invalidated => ProgressJobLifecycle::Invalidated,
656                    },
657                    sequence: Some(job.state().sequence),
658                }
659            } else if bytes.starts_with(MUTATION_JOB_RECORD_MAGIC) {
660                let job = decode_mutation_job_record_for_inventory(bytes, key)?;
661                mutation_count = mutation_count
662                    .checked_add(1)
663                    .ok_or(MutationJobError::CorruptProgressStore)?;
664                ProgressJobInventoryRecord {
665                    family: ProgressJobFamily::Mutation,
666                    job_id: job.state().job_id.to_bytes(),
667                    lifecycle: match job.state().status {
668                        MutationJobStatus::Active => ProgressJobLifecycle::Active,
669                        MutationJobStatus::Completed => ProgressJobLifecycle::Completed,
670                        MutationJobStatus::RestartRequired(_) => {
671                            ProgressJobLifecycle::RestartRequired
672                        }
673                    },
674                    sequence: Some(job.state().sequence),
675                }
676            } else {
677                return Err(MutationJobError::CorruptProgressStore);
678            };
679            records.push(record);
680        }
681
682        let decoded_count = integrity_count
683            .checked_add(resumable_count)
684            .and_then(|count| count.checked_add(mutation_count))
685            .ok_or(MutationJobError::CorruptProgressStore)?;
686        if decoded_count != retained_count || u64::try_from(records.len()) != Ok(retained_count) {
687            return Err(MutationJobError::CorruptProgressStore);
688        }
689
690        Ok(ProgressJobInventory {
691            retained_count,
692            hard_limit: MAX_PROGRESS_JOBS_GLOBAL,
693            reserved_integrity_headroom: PROGRESS_JOBS_INTEGRITY_RESERVATION,
694            integrity_count,
695            resumable_count,
696            mutation_count,
697            retained_record_bytes,
698            records,
699        })
700    }
701
702    pub(super) fn remove(&mut self, job_id: IntegrityJobId) -> Result<(), IntegrityJobError> {
703        if self
704            .map
705            .remove(&ProgressRecordKey::from_job_id(job_id))
706            .is_none()
707        {
708            return Err(IntegrityJobError::JobNotFound);
709        }
710        Ok(())
711    }
712
713    pub(super) fn scan_after(
714        &self,
715        checkpoint: Option<IntegrityJobId>,
716        limit: usize,
717    ) -> Result<ProgressScanPage, IntegrityJobError> {
718        if limit == 0 {
719            return Err(IntegrityJobError::CapacityExceeded);
720        }
721        let lower = checkpoint.map_or(PROGRESS_HEADER_KEY, ProgressRecordKey::from_job_id);
722        let mut job_ids = Vec::with_capacity(limit);
723        let mut has_more = false;
724        for entry in self.map.range((Excluded(lower), Unbounded)) {
725            let Ok(job) = decode_job_record_unbound(&entry.value().0) else {
726                continue;
727            };
728            if job_ids.len() == limit {
729                has_more = true;
730                break;
731            }
732            job_ids.push(job.id);
733        }
734        Ok(ProgressScanPage {
735            job_ids,
736            exhausted: !has_more,
737        })
738    }
739
740    fn job_count(&self) -> Result<u64, IntegrityJobError> {
741        self.map
742            .len()
743            .checked_sub(1)
744            .ok_or(IntegrityJobError::CorruptProgressHeader)
745    }
746
747    fn owner_job_count(&self, owner: &IntegrityJobOwner) -> Result<u64, IntegrityJobError> {
748        let mut count = 0_u64;
749        for entry in &self.map {
750            if *entry.key() == PROGRESS_HEADER_KEY {
751                continue;
752            }
753            // Corrupt records already consume one slot from the global hard
754            // capacity, but their owner cannot be trusted. Skipping them here
755            // isolates the failed job without allowing unbounded progress
756            // growth or blocking every other owner from starting work.
757            let Ok(job_id) = IntegrityJobId::try_from_bytes(entry.key().0) else {
758                continue;
759            };
760            let Ok(job) = decode_job_record(&entry.value().0, job_id) else {
761                continue;
762            };
763            if job.owner == *owner {
764                count = count
765                    .checked_add(1)
766                    .ok_or(IntegrityJobError::CapacityExceeded)?;
767            }
768        }
769        Ok(count)
770    }
771}
772
773fn encode_progress_header() -> Vec<u8> {
774    let mut bytes = Vec::with_capacity(PROGRESS_HEADER_BYTES);
775    bytes.extend_from_slice(PROGRESS_HEADER_MAGIC);
776    bytes.push(PROGRESS_HEADER_VERSION);
777    let checksum = crc32c(bytes.as_slice());
778    bytes.extend_from_slice(&checksum.to_be_bytes());
779    bytes
780}
781
782fn decode_progress_header(bytes: &[u8]) -> Result<(), IntegrityJobError> {
783    if bytes.len() != PROGRESS_HEADER_BYTES
784        || !bytes.starts_with(PROGRESS_HEADER_MAGIC)
785        || bytes[PROGRESS_HEADER_MAGIC.len()] != PROGRESS_HEADER_VERSION
786    {
787        return Err(IntegrityJobError::IncompatibleProgressFormat);
788    }
789    let checksum_offset = PROGRESS_HEADER_MAGIC.len() + 1;
790    let mut checksum = [0; 4];
791    checksum.copy_from_slice(&bytes[checksum_offset..]);
792    if u32::from_be_bytes(checksum) != crc32c(&bytes[..checksum_offset]) {
793        return Err(IntegrityJobError::CorruptProgressHeader);
794    }
795    Ok(())
796}
797
798fn encode_job_record(job: &IntegrityJob) -> Result<Vec<u8>, IntegrityJobError> {
799    let payload =
800        encode_integrity_job_payload(job).map_err(|_| IntegrityJobError::CapacityExceeded)?;
801    encode_progress_record_payload(
802        &payload,
803        *JOB_RECORD_MAGIC,
804        MAX_PROGRESS_RECORD_BYTES as usize,
805        IntegrityJobError::CapacityExceeded,
806        |_| IntegrityJobError::CapacityExceeded,
807    )
808}
809
810// The envelope grammar is shared; each family retains its payload codec,
811// record ceiling and distinct arithmetic/size error taxonomy.
812fn encode_progress_record_payload<E>(
813    payload: &[u8],
814    magic: [u8; 8],
815    max_bytes: usize,
816    capacity_error: E,
817    size_error: impl FnOnce(usize) -> E,
818) -> Result<Vec<u8>, E> {
819    let Some(total_len) = PROGRESS_JOB_RECORD_HEADER_BYTES.checked_add(payload.len()) else {
820        return Err(capacity_error);
821    };
822    if total_len > max_bytes {
823        return Err(size_error(total_len));
824    }
825    let payload_len = u32::try_from(payload.len()).map_err(|_| capacity_error)?;
826    let mut bytes = Vec::with_capacity(total_len);
827    bytes.extend_from_slice(&magic);
828    bytes.push(PROGRESS_JOB_RECORD_VERSION);
829    bytes.extend_from_slice(&payload_len.to_be_bytes());
830    bytes.extend_from_slice(&crc32c(payload).to_be_bytes());
831    bytes.extend_from_slice(payload);
832    Ok(bytes)
833}
834
835// All job families use the same envelope. Callers retain payload semantics,
836// family size ceilings and public error mapping; validate framing before decoding.
837fn decode_progress_record_payload(
838    bytes: &[u8],
839    magic: [u8; 8],
840    max_bytes: usize,
841) -> Result<&[u8], IntegrityJobError> {
842    if bytes.len() < PROGRESS_JOB_RECORD_HEADER_BYTES
843        || !bytes.starts_with(&magic)
844        || bytes[magic.len()] != PROGRESS_JOB_RECORD_VERSION
845    {
846        return Err(IntegrityJobError::IncompatibleProgressFormat);
847    }
848    if bytes.len() > max_bytes {
849        return Err(IntegrityJobError::CorruptProgressRecord);
850    }
851    let payload_len_offset = magic.len() + 1;
852    let checksum_offset = payload_len_offset + 4;
853    let payload_offset = checksum_offset + 4;
854    let mut payload_len = [0; 4];
855    payload_len.copy_from_slice(&bytes[payload_len_offset..checksum_offset]);
856    if u32::from_be_bytes(payload_len) as usize != bytes.len() - payload_offset {
857        return Err(IntegrityJobError::CorruptProgressRecord);
858    }
859    let payload = &bytes[payload_offset..];
860    let mut checksum = [0; 4];
861    checksum.copy_from_slice(&bytes[checksum_offset..payload_offset]);
862    if u32::from_be_bytes(checksum) != crc32c(payload) {
863        return Err(IntegrityJobError::CorruptProgressRecord);
864    }
865    Ok(payload)
866}
867
868fn decode_job_record(
869    bytes: &[u8],
870    expected_id: IntegrityJobId,
871) -> Result<IntegrityJob, IntegrityJobError> {
872    let job = decode_job_record_unbound(bytes)?;
873    if job.id != expected_id {
874        return Err(IntegrityJobError::CorruptProgressRecord);
875    }
876    Ok(job)
877}
878
879fn decode_job_record_unbound(bytes: &[u8]) -> Result<IntegrityJob, IntegrityJobError> {
880    let payload = decode_progress_record_payload(
881        bytes,
882        *JOB_RECORD_MAGIC,
883        MAX_PROGRESS_RECORD_BYTES as usize,
884    )?;
885    if payload.len() > MAX_INTEGRITY_JOB_PAYLOAD_BYTES {
886        return Err(IntegrityJobError::CorruptProgressRecord);
887    }
888    decode_integrity_job_payload(payload).map_err(|_| IntegrityJobError::CorruptProgressRecord)
889}
890
891fn encode_resumable_job_record(record: &ResumableJobRecord) -> Result<Vec<u8>, ResumableJobError> {
892    let payload = encode_resumable_job_payload(record)?;
893    encode_progress_record_payload(
894        &payload,
895        *RESUMABLE_JOB_RECORD_MAGIC,
896        MAX_PROGRESS_RECORD_BYTES as usize,
897        ResumableJobError::PayloadTooLarge,
898        |_| ResumableJobError::PayloadTooLarge,
899    )
900}
901
902fn decode_resumable_job_record(
903    bytes: &[u8],
904    expected_id: ResumableJobId,
905) -> Result<ResumableJobRecord, ResumableJobError> {
906    let record = decode_resumable_job_record_unbound(bytes)?;
907    if record.state().job_id != expected_id {
908        return Err(ResumableJobError::CorruptProgressStore);
909    }
910    Ok(record)
911}
912
913fn decode_resumable_job_record_unbound(
914    bytes: &[u8],
915) -> Result<ResumableJobRecord, ResumableJobError> {
916    let payload = decode_progress_record_payload(
917        bytes,
918        *RESUMABLE_JOB_RECORD_MAGIC,
919        MAX_PROGRESS_RECORD_BYTES as usize,
920    )
921    .map_err(map_integrity_store_error)?;
922    decode_resumable_job_payload(payload)
923}
924
925fn decode_resumable_job_record_for_inventory(
926    bytes: &[u8],
927    key: ProgressRecordKey,
928) -> Result<ResumableJobRecord, MutationJobError> {
929    let record = decode_resumable_job_record_unbound(bytes)
930        .map_err(|_| MutationJobError::CorruptProgressStore)?;
931    let expected_key = ProgressRecordKey::from_resumable_job_id(record.state().job_id)
932        .map_err(|_| MutationJobError::CorruptProgressStore)?;
933    if key != expected_key {
934        return Err(MutationJobError::CorruptProgressStore);
935    }
936    Ok(record)
937}
938
939fn encode_mutation_job_record(record: &MutationJobRecord) -> Result<Vec<u8>, MutationJobError> {
940    let payload = encode_mutation_job_payload(record)?;
941    encode_progress_record_payload(
942        &payload,
943        *MUTATION_JOB_RECORD_MAGIC,
944        MAX_MUTATION_JOB_RECORD_BYTES,
945        MutationJobError::CapacityExceeded,
946        mutation_record_size_error,
947    )
948}
949
950fn decode_mutation_job_record(
951    bytes: &[u8],
952    expected_id: MutationJobId,
953) -> Result<MutationJobRecord, MutationJobError> {
954    let record = decode_mutation_job_record_unbound(bytes)?;
955    if record.state().job_id != expected_id {
956        return Err(MutationJobError::CorruptProgressStore);
957    }
958    Ok(record)
959}
960
961fn decode_mutation_job_record_unbound(bytes: &[u8]) -> Result<MutationJobRecord, MutationJobError> {
962    let payload = decode_progress_record_payload(
963        bytes,
964        *MUTATION_JOB_RECORD_MAGIC,
965        MAX_MUTATION_JOB_RECORD_BYTES,
966    )
967    .map_err(map_mutation_store_error)?;
968    decode_mutation_job_payload(payload)
969}
970
971fn decode_mutation_job_record_for_inventory(
972    bytes: &[u8],
973    key: ProgressRecordKey,
974) -> Result<MutationJobRecord, MutationJobError> {
975    let record = decode_mutation_job_record_unbound(bytes)
976        .map_err(|_| MutationJobError::CorruptProgressStore)?;
977    let expected_key = ProgressRecordKey::from_mutation_job_id(record.state().job_id)
978        .map_err(|_| MutationJobError::CorruptProgressStore)?;
979    if key != expected_key {
980        return Err(MutationJobError::CorruptProgressStore);
981    }
982    Ok(record)
983}
984
985fn mutation_progress_before_digest(bytes: &[u8]) -> [u8; 32] {
986    let mut hasher = new_hash_sha256_prefixed(MUTATION_PROGRESS_BEFORE_DIGEST_DOMAIN);
987    hasher.update(bytes);
988    finalize_hash_sha256(hasher)
989}
990
991fn mutation_record_size_error(observed: usize) -> MutationJobError {
992    MutationJobError::PayloadTooLarge {
993        kind: crate::db::MutationJobPayloadKind::Record,
994        limit: u64::try_from(MAX_MUTATION_JOB_RECORD_BYTES).unwrap_or(u64::MAX),
995        observed: u64::try_from(observed).unwrap_or(u64::MAX),
996    }
997}
998
999const fn map_integrity_store_error(error: IntegrityJobError) -> ResumableJobError {
1000    match error {
1001        IntegrityJobError::IncompatibleProgressFormat => {
1002            ResumableJobError::IncompatibleProgressFormat
1003        }
1004        IntegrityJobError::CapacityExceeded => ResumableJobError::CapacityExceeded,
1005        _ => ResumableJobError::CorruptProgressStore,
1006    }
1007}
1008
1009const fn map_mutation_store_error(error: IntegrityJobError) -> MutationJobError {
1010    match error {
1011        IntegrityJobError::IncompatibleProgressFormat => {
1012            MutationJobError::IncompatibleProgressFormat
1013        }
1014        IntegrityJobError::CapacityExceeded => MutationJobError::CapacityExceeded,
1015        _ => MutationJobError::CorruptProgressStore,
1016    }
1017}
1018
1019pub(in crate::db) fn with_progress_store<C: CanisterKind, R>(
1020    f: impl FnOnce(&mut InspectionProgressStore) -> Result<R, IntegrityJobError>,
1021) -> Result<R, IntegrityJobError> {
1022    let memory = progress_memory::<C>()?;
1023    let mut store = InspectionProgressStore::open(memory)?;
1024    f(&mut store)
1025}
1026
1027pub(in crate::db) fn with_resumable_progress_store<C: CanisterKind, R>(
1028    f: impl FnOnce(&mut InspectionProgressStore) -> Result<R, ResumableJobError>,
1029) -> Result<R, ResumableJobError> {
1030    let memory = progress_memory::<C>().map_err(map_integrity_store_error)?;
1031    let mut store = InspectionProgressStore::open(memory).map_err(map_integrity_store_error)?;
1032    f(&mut store)
1033}
1034
1035pub(in crate::db) fn with_mutation_progress_store<C: CanisterKind, R>(
1036    f: impl FnOnce(&mut InspectionProgressStore) -> Result<R, MutationJobError>,
1037) -> Result<R, MutationJobError> {
1038    let memory = progress_memory::<C>().map_err(map_mutation_store_error)?;
1039    let mut store = InspectionProgressStore::open(memory).map_err(map_mutation_store_error)?;
1040    f(&mut store)
1041}
1042
1043pub(in crate::db) fn preflight_mutation_progress_record_op<C: CanisterKind>(
1044    operation: &MutationProgressRecordOp,
1045) -> Result<(), InternalError> {
1046    with_mutation_progress_store::<C, _>(|store| store.preflight_mutation_progress(operation))
1047        .map_err(|_| InternalError::commit_corruption())
1048}
1049
1050pub(in crate::db) fn apply_mutation_progress_record_op<C: CanisterKind>(
1051    operation: &MutationProgressRecordOp,
1052) -> Result<(), InternalError> {
1053    with_mutation_progress_store::<C, _>(|store| store.apply_mutation_progress(operation))
1054        .map_err(|_| InternalError::commit_corruption())
1055}
1056
1057/// Mechanically publish one mutation-progress replacement already proved by preflight.
1058pub(in crate::db) fn apply_preflighted_mutation_progress_record_op<C: CanisterKind>(
1059    operation: &MutationProgressRecordOp,
1060) -> Result<(), InternalError> {
1061    with_mutation_progress_store::<C, _>(|store| {
1062        store.apply_preflighted_mutation_progress(operation);
1063        Ok(())
1064    })
1065    .map_err(|_| InternalError::commit_corruption())
1066}
1067
1068pub(in crate::db) fn replace_mutation_progress_record_op<C: CanisterKind>(
1069    operation: &MutationProgressRecordOp,
1070) -> Result<(), MutationJobError> {
1071    with_mutation_progress_store::<C, _>(|store| store.replace_mutation_progress(operation))
1072}
1073
1074pub(in crate::db) fn verify_mutation_progress_record_op<C: CanisterKind>(
1075    operation: &MutationProgressRecordOp,
1076) -> Result<(), InternalError> {
1077    with_mutation_progress_store::<C, _>(|store| store.verify_mutation_progress(operation))
1078        .map_err(|_| InternalError::recovery_effect_verification_failed())
1079}
1080
1081#[cfg(test)]
1082fn progress_memory<C: CanisterKind>() -> Result<RuntimeMemory<DefaultMemoryImpl>, IntegrityJobError>
1083{
1084    let resolved_id = C::integrity_progress_memory_id().map_err(|_| IntegrityJobError::Internal)?;
1085    thread_local! {
1086        static MEMORIES: RefCell<
1087            Vec<(u8, &'static str, crate::testing::TestMemoryOwner)>
1088        > = const { RefCell::new(Vec::new()) };
1089    }
1090
1091    MEMORIES.with(|memories| {
1092        let mut memories = memories.borrow_mut();
1093        if let Some((_, _, memory)) = memories
1094            .iter()
1095            .find(|(id, key, _)| *id == resolved_id && *key == C::INTEGRITY_PROGRESS_STABLE_KEY)
1096        {
1097            return Ok(memory.open());
1098        }
1099        let owner = crate::testing::test_memory_owner(resolved_id);
1100        let memory = owner.open();
1101        memories.push((resolved_id, C::INTEGRITY_PROGRESS_STABLE_KEY, owner));
1102        Ok(memory)
1103    })
1104}
1105
1106#[cfg(not(test))]
1107fn progress_memory<C: CanisterKind>() -> Result<RuntimeMemory<DefaultMemoryImpl>, IntegrityJobError>
1108{
1109    open_memory(C::INTEGRITY_PROGRESS_STABLE_KEY).map_err(|_| IntegrityJobError::Internal)
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use crate::{
1116        db::{
1117            MutationJobAdvanceRequest, MutationJobIdempotencyKey, MutationJobPhase,
1118            MutationJobRestartReason, MutationJobStatus, ReadSetRevisionProof,
1119            ReadSetStoreIdentity, ReadSetStoreRevision,
1120            integrity::progress_codec::current_job_codec_fixture,
1121            mutation_job::MutationJobTransition,
1122        },
1123        testing::test_memory,
1124    };
1125
1126    fn current_resumable_record() -> ResumableJobRecord {
1127        let proof = ReadSetRevisionProof::from_parts(
1128            [1; 16],
1129            7,
1130            1,
1131            [2; 32],
1132            vec![ReadSetStoreRevision::new(
1133                ReadSetStoreIdentity::from_bytes([3; 32]),
1134                11,
1135                13,
1136            )],
1137        )
1138        .expect("bounded canonical proof should admit");
1139        ResumableJobRecord::new(
1140            ResumableJobId::try_from_bytes([4; 32])
1141                .expect("nonzero resumable job identity should admit"),
1142            proof,
1143            vec![5, 6],
1144        )
1145        .expect("current resumable record should admit")
1146    }
1147
1148    fn mutation_job_id(byte: u8) -> MutationJobId {
1149        MutationJobId::try_from_bytes([byte; 32]).expect("nonzero mutation job id should admit")
1150    }
1151
1152    fn current_mutation_record(byte: u8) -> MutationJobRecord {
1153        MutationJobRecord::new(mutation_job_id(byte), vec![1, 2, 3], vec![4, 5])
1154            .expect("current mutation record should admit")
1155    }
1156
1157    fn current_integrity_record(byte: u8) -> IntegrityJob {
1158        let mut job = current_job_codec_fixture();
1159        let job_id = IntegrityJobId::try_from_bytes([byte; 32])
1160            .expect("nonzero integrity job id should admit");
1161        job.id = job_id;
1162        match &mut job.last_receipt.receipt {
1163            crate::db::IntegrityJobReceipt::Page(page) => page.job_id = job_id,
1164            crate::db::IntegrityJobReceipt::Abort(receipt) => receipt.job_id = job_id,
1165        }
1166        job.validate()
1167            .expect("rewritten integrity job should admit");
1168        job
1169    }
1170
1171    fn insert_mutation_record(store: &mut InspectionProgressStore, record: &MutationJobRecord) {
1172        assert!(matches!(
1173            store
1174                .insert_mutation(record)
1175                .expect("mutation record should insert"),
1176            InsertMutationJobResult::Inserted,
1177        ));
1178    }
1179
1180    fn mutation_request(byte: u8, sequence: u64, key: &str) -> MutationJobAdvanceRequest {
1181        MutationJobAdvanceRequest::new(
1182            mutation_job_id(byte),
1183            sequence,
1184            MutationJobIdempotencyKey::new(key).expect("bounded replay key should admit"),
1185        )
1186    }
1187
1188    #[test]
1189    fn progress_header_rejects_future_version_and_checksum_corruption() {
1190        let mut future = encode_progress_header();
1191        future[PROGRESS_HEADER_MAGIC.len()] = PROGRESS_HEADER_VERSION + 1;
1192        assert_eq!(
1193            decode_progress_header(&future),
1194            Err(IntegrityJobError::IncompatibleProgressFormat),
1195        );
1196
1197        let mut corrupt = encode_progress_header();
1198        let last = corrupt
1199            .last_mut()
1200            .expect("current progress header has a checksum");
1201        *last ^= 0xff;
1202        assert_eq!(
1203            decode_progress_header(&corrupt),
1204            Err(IntegrityJobError::CorruptProgressHeader),
1205        );
1206    }
1207
1208    #[test]
1209    fn current_job_record_uses_the_direct_bounded_payload() {
1210        let job = current_job_codec_fixture();
1211        let encoded = encode_job_record(&job).expect("current job should encode");
1212
1213        assert_eq!(encoded[JOB_RECORD_MAGIC.len()], 1);
1214        assert!(!encoded[PROGRESS_JOB_RECORD_HEADER_BYTES..].starts_with(b"DIDL"));
1215        assert_eq!(
1216            decode_job_record(&encoded, job.id).expect("current job should decode"),
1217            job,
1218        );
1219
1220        let mut corrupt = encoded;
1221        let last = corrupt
1222            .last_mut()
1223            .expect("current job record has a payload");
1224        *last ^= 0xff;
1225        assert_eq!(
1226            decode_job_record(&corrupt, job.id),
1227            Err(IntegrityJobError::CorruptProgressRecord),
1228        );
1229    }
1230
1231    #[test]
1232    fn current_resumable_record_is_direct_bounded_and_checksum_protected() {
1233        let record = current_resumable_record();
1234        let encoded =
1235            encode_resumable_job_record(&record).expect("current resumable record should encode");
1236
1237        assert_eq!(encoded.len(), 175);
1238        assert_eq!(encoded[RESUMABLE_JOB_RECORD_MAGIC.len()], 1);
1239        assert!(!encoded[PROGRESS_JOB_RECORD_HEADER_BYTES..].starts_with(b"DIDL"));
1240        assert_eq!(
1241            decode_resumable_job_record(&encoded, record.state().job_id)
1242                .expect("current resumable record should decode"),
1243            record,
1244        );
1245
1246        let mut future = encoded.clone();
1247        future[RESUMABLE_JOB_RECORD_MAGIC.len()] = PROGRESS_JOB_RECORD_VERSION + 1;
1248        assert_eq!(
1249            decode_resumable_job_record(&future, record.state().job_id),
1250            Err(ResumableJobError::IncompatibleProgressFormat),
1251        );
1252
1253        let mut corrupt = encoded;
1254        let last = corrupt
1255            .last_mut()
1256            .expect("current resumable record has a payload");
1257        *last ^= 0xff;
1258        assert_eq!(
1259            decode_resumable_job_record(&corrupt, record.state().job_id),
1260            Err(ResumableJobError::CorruptProgressStore),
1261        );
1262    }
1263
1264    #[test]
1265    fn current_mutation_record_is_distinct_bounded_and_checksum_protected() {
1266        let record = current_mutation_record(7);
1267        let encoded =
1268            encode_mutation_job_record(&record).expect("current mutation record should encode");
1269
1270        assert_eq!(encoded.len(), 97);
1271        assert_eq!(encoded[MUTATION_JOB_RECORD_MAGIC.len()], 1);
1272        assert!(!encoded[PROGRESS_JOB_RECORD_HEADER_BYTES..].starts_with(b"DIDL"));
1273        assert_eq!(
1274            decode_mutation_job_record(&encoded, record.state().job_id)
1275                .expect("current mutation record should decode"),
1276            record,
1277        );
1278
1279        let mut future = encoded.clone();
1280        future[MUTATION_JOB_RECORD_MAGIC.len()] = PROGRESS_JOB_RECORD_VERSION + 1;
1281        assert_eq!(
1282            decode_mutation_job_record(&future, record.state().job_id),
1283            Err(MutationJobError::IncompatibleProgressFormat),
1284        );
1285
1286        let mut corrupt = encoded;
1287        let last = corrupt
1288            .last_mut()
1289            .expect("current mutation record has a payload");
1290        *last ^= 0xff;
1291        assert_eq!(
1292            decode_mutation_job_record(&corrupt, record.state().job_id),
1293            Err(MutationJobError::CorruptProgressStore),
1294        );
1295
1296        let mut oversized = vec![0; MAX_MUTATION_JOB_RECORD_BYTES + 1];
1297        oversized[..MUTATION_JOB_RECORD_MAGIC.len()].copy_from_slice(MUTATION_JOB_RECORD_MAGIC);
1298        oversized[MUTATION_JOB_RECORD_MAGIC.len()] = PROGRESS_JOB_RECORD_VERSION;
1299        assert_eq!(
1300            decode_mutation_job_record(&oversized, record.state().job_id),
1301            Err(MutationJobError::CorruptProgressStore),
1302        );
1303    }
1304
1305    #[test]
1306    fn job_envelopes_preserve_family_rejections() {
1307        fn check<E: std::fmt::Debug + PartialEq>(
1308            encoded: &[u8],
1309            max_bytes: usize,
1310            decode: impl Fn(&[u8]) -> Result<(), E>,
1311            incompatible: E,
1312            corrupt: E,
1313        ) {
1314            assert_eq!(decode(encoded), Ok(()));
1315            for end in 0..encoded.len() {
1316                let expected = if end < PROGRESS_JOB_RECORD_HEADER_BYTES {
1317                    &incompatible
1318                } else {
1319                    &corrupt
1320                };
1321                assert_eq!(&decode(&encoded[..end]).unwrap_err(), expected);
1322            }
1323            for offset in [0, 8, 9, 13, encoded.len() - 1] {
1324                let mut invalid = encoded.to_vec();
1325                invalid[offset] ^= 0xff;
1326                let expected = if offset < 9 { &incompatible } else { &corrupt };
1327                assert_eq!(&decode(&invalid).unwrap_err(), expected);
1328            }
1329            let mut trailing = encoded.to_vec();
1330            trailing.push(0);
1331            assert_eq!(decode(&trailing).unwrap_err(), corrupt);
1332            // A consistent length and checksum cannot bypass the family ceiling.
1333            let mut oversized = encoded.to_vec();
1334            oversized.resize(max_bytes + 1, 0);
1335            let payload = &oversized[PROGRESS_JOB_RECORD_HEADER_BYTES..];
1336            let len = u32::try_from(payload.len()).unwrap();
1337            let checksum = crc32c(payload);
1338            oversized[9..13].copy_from_slice(&len.to_be_bytes());
1339            oversized[13..17].copy_from_slice(&checksum.to_be_bytes());
1340            assert_eq!(decode(&oversized).unwrap_err(), corrupt);
1341        }
1342
1343        let integrity = current_job_codec_fixture();
1344        check(
1345            &encode_job_record(&integrity).unwrap(),
1346            MAX_PROGRESS_RECORD_BYTES as usize,
1347            |bytes| decode_job_record(bytes, integrity.id).map(|_| ()),
1348            IntegrityJobError::IncompatibleProgressFormat,
1349            IntegrityJobError::CorruptProgressRecord,
1350        );
1351        let resumable = current_resumable_record();
1352        check(
1353            &encode_resumable_job_record(&resumable).unwrap(),
1354            MAX_PROGRESS_RECORD_BYTES as usize,
1355            |bytes| decode_resumable_job_record(bytes, resumable.state().job_id).map(|_| ()),
1356            ResumableJobError::IncompatibleProgressFormat,
1357            ResumableJobError::CorruptProgressStore,
1358        );
1359        let mutation = current_mutation_record(7);
1360        check(
1361            &encode_mutation_job_record(&mutation).unwrap(),
1362            MAX_MUTATION_JOB_RECORD_BYTES,
1363            |bytes| decode_mutation_job_record(bytes, mutation.state().job_id).map(|_| ()),
1364            MutationJobError::IncompatibleProgressFormat,
1365            MutationJobError::CorruptProgressStore,
1366        );
1367    }
1368
1369    #[test]
1370    fn mutation_progress_replacement_is_exact_idempotent_and_fail_closed() {
1371        let before = current_mutation_record(21);
1372        let (after, _) = before
1373            .apply_transition(
1374                &mutation_request(21, 0, "atomic-forward"),
1375                MutationJobTransition::new(
1376                    MutationJobStatus::Active,
1377                    MutationJobPhase::Forward,
1378                    vec![9],
1379                    8,
1380                    3,
1381                    0,
1382                ),
1383            )
1384            .expect("bounded atomic successor should admit");
1385        let operation = MutationProgressRecordOp::replace(&before, &after)
1386            .expect("exact mutation progress replacement should admit");
1387        let mut store = InspectionProgressStore::open(test_memory(252))
1388            .expect("isolated progress store should open");
1389        assert!(matches!(
1390            store
1391                .insert_mutation(&before)
1392                .expect("before record should insert"),
1393            InsertMutationJobResult::Inserted,
1394        ));
1395
1396        store
1397            .preflight_mutation_progress(&operation)
1398            .expect("exact before bytes should preflight");
1399        store
1400            .apply_mutation_progress(&operation)
1401            .expect("exact before bytes should advance");
1402        store
1403            .apply_mutation_progress(&operation)
1404            .expect("exact after bytes should replay idempotently");
1405        store
1406            .verify_mutation_progress(&operation)
1407            .expect("exact after bytes should verify");
1408        assert_eq!(
1409            store
1410                .load_mutation(before.state().job_id)
1411                .expect("advanced record should load"),
1412            after,
1413        );
1414        assert_eq!(
1415            store.preflight_mutation_progress(&operation),
1416            Err(MutationJobError::CorruptProgressStore),
1417            "opening a new marker against after-state must not reset progress",
1418        );
1419
1420        let (unexpected, _) = after
1421            .apply_transition(
1422                &mutation_request(21, 1, "unexpected"),
1423                MutationJobTransition::new(
1424                    MutationJobStatus::Active,
1425                    MutationJobPhase::Forward,
1426                    vec![10],
1427                    1,
1428                    0,
1429                    0,
1430                ),
1431            )
1432            .expect("third valid state should admit");
1433        store
1434            .replace_mutation(&unexpected)
1435            .expect("test should install neither-side state");
1436        assert_eq!(
1437            store.apply_mutation_progress(&operation),
1438            Err(MutationJobError::CorruptProgressStore),
1439        );
1440        assert_eq!(
1441            store.verify_mutation_progress(&operation),
1442            Err(MutationJobError::CorruptProgressStore),
1443        );
1444    }
1445
1446    #[test]
1447    fn mutation_record_sizes_are_fixed_for_current_and_maximal_states() {
1448        let initial = current_mutation_record(8);
1449        let (active, _) = initial
1450            .apply_transition(
1451                &mutation_request(8, 0, "forward-0"),
1452                MutationJobTransition::new(
1453                    MutationJobStatus::Active,
1454                    MutationJobPhase::Verify,
1455                    vec![6],
1456                    13,
1457                    4,
1458                    0,
1459                ),
1460            )
1461            .expect("bounded active transition should admit");
1462        let (completed, _) = active
1463            .apply_transition(
1464                &mutation_request(8, 1, "verify-0"),
1465                MutationJobTransition::new(
1466                    MutationJobStatus::Completed,
1467                    MutationJobPhase::Verify,
1468                    Vec::new(),
1469                    9,
1470                    0,
1471                    0,
1472                ),
1473            )
1474            .expect("bounded completion should admit");
1475        let (restart, _) = initial
1476            .apply_transition(
1477                &mutation_request(8, 0, "restart"),
1478                MutationJobTransition::new(
1479                    MutationJobStatus::RestartRequired(
1480                        MutationJobRestartReason::AcceptedSchemaChanged,
1481                    ),
1482                    MutationJobPhase::Forward,
1483                    Vec::new(),
1484                    0,
1485                    0,
1486                    0,
1487                ),
1488            )
1489            .expect("bounded restart should admit");
1490        let maximal_initial = MutationJobRecord::new(
1491            mutation_job_id(9),
1492            vec![1; crate::db::MAX_MUTATION_JOB_INTENT_BYTES],
1493            vec![2; crate::db::MAX_MUTATION_JOB_CONTINUATION_BYTES],
1494        )
1495        .expect("maximum initial record should admit");
1496        let (maximal_active, _) = maximal_initial
1497            .apply_transition(
1498                &MutationJobAdvanceRequest::new(
1499                    mutation_job_id(9),
1500                    0,
1501                    MutationJobIdempotencyKey::new(
1502                        "k".repeat(crate::db::MAX_MUTATION_JOB_IDEMPOTENCY_KEY_BYTES),
1503                    )
1504                    .expect("maximum replay key should admit"),
1505                ),
1506                MutationJobTransition::new(
1507                    MutationJobStatus::Active,
1508                    MutationJobPhase::Forward,
1509                    vec![2; crate::db::MAX_MUTATION_JOB_CONTINUATION_BYTES],
1510                    crate::db::MAX_MUTATION_JOB_STEP_KEYS_SCANNED,
1511                    crate::db::MAX_MUTATION_JOB_STEP_ROWS_UPDATED,
1512                    0,
1513                ),
1514            )
1515            .expect("maximum active record should admit");
1516
1517        assert_eq!(
1518            encode_mutation_job_record(&initial).map(|bytes| bytes.len()),
1519            Ok(97)
1520        );
1521        assert_eq!(
1522            encode_mutation_job_record(&active).map(|bytes| bytes.len()),
1523            Ok(167)
1524        );
1525        assert_eq!(
1526            encode_mutation_job_record(&completed).map(|bytes| bytes.len()),
1527            Ok(165),
1528        );
1529        assert_eq!(
1530            encode_mutation_job_record(&restart).map(|bytes| bytes.len()),
1531            Ok(166)
1532        );
1533        assert_eq!(
1534            encode_mutation_job_record(&maximal_initial).map(|bytes| bytes.len()),
1535            Ok(18_524),
1536        );
1537        assert_eq!(
1538            encode_mutation_job_record(&maximal_active).map(|bytes| bytes.len()),
1539            Ok(18_842),
1540        );
1541    }
1542
1543    #[test]
1544    fn mutation_key_domain_and_shared_capacity_reservation_are_enforced() {
1545        let shared_bytes = [11; 32];
1546        let mutation_key = ProgressRecordKey::from_mutation_job_id(
1547            MutationJobId::try_from_bytes(shared_bytes).expect("mutation id should admit"),
1548        )
1549        .expect("mutation progress key should derive");
1550        let resumable_key = ProgressRecordKey::from_resumable_job_id(
1551            ResumableJobId::try_from_bytes(shared_bytes).expect("resumable id should admit"),
1552        )
1553        .expect("resumable progress key should derive");
1554        let integrity_key = ProgressRecordKey::from_job_id(
1555            IntegrityJobId::try_from_bytes(shared_bytes).expect("integrity id should admit"),
1556        );
1557        assert_ne!(mutation_key, resumable_key);
1558        assert_ne!(mutation_key, integrity_key);
1559
1560        let mut store = InspectionProgressStore::open(test_memory(251))
1561            .expect("isolated progress store should open");
1562        store
1563            .insert_resumable(&current_resumable_record())
1564            .expect("generic job should consume one shared slot");
1565        for byte in 1..=54 {
1566            assert!(matches!(
1567                store
1568                    .insert_mutation(&current_mutation_record(byte))
1569                    .expect("record inside shared capacity should insert"),
1570                InsertMutationJobResult::Inserted,
1571            ));
1572        }
1573        assert_eq!(
1574            store
1575                .inventory()
1576                .expect("55 current records should inventory")
1577                .retained_count,
1578            55,
1579        );
1580        assert!(matches!(
1581            store
1582                .insert_mutation(&current_mutation_record(55))
1583                .expect("the 56th non-integrity record should insert"),
1584            InsertMutationJobResult::Inserted,
1585        ));
1586        assert_eq!(
1587            store
1588                .inventory()
1589                .expect("56 current records should inventory")
1590                .retained_count,
1591            56,
1592        );
1593        assert!(matches!(
1594            store.insert_mutation(&current_mutation_record(56)),
1595            Err(MutationJobError::CapacityExceeded),
1596        ));
1597
1598        for byte in 200..=206 {
1599            assert!(matches!(
1600                store
1601                    .insert_new(&current_integrity_record(byte))
1602                    .expect("reserved integrity record should insert"),
1603                InsertJobResult::Inserted,
1604            ));
1605        }
1606        assert_eq!(
1607            store
1608                .inventory()
1609                .expect("63 current records should inventory")
1610                .retained_count,
1611            63,
1612        );
1613        assert!(matches!(
1614            store
1615                .insert_new(&current_integrity_record(207))
1616                .expect("the 64th integrity record should insert"),
1617            InsertJobResult::Inserted,
1618        ));
1619        let full = store.inventory().expect("full store should inventory");
1620        assert_eq!(full.retained_count, 64);
1621        assert_eq!(full.hard_limit, 64);
1622        assert_eq!(full.reserved_integrity_headroom, 8);
1623        assert_eq!(full.integrity_count, 8);
1624        assert_eq!(full.resumable_count, 1);
1625        assert_eq!(full.mutation_count, 55);
1626        assert!(matches!(
1627            store.insert_new(&current_integrity_record(208)),
1628            Err(IntegrityJobError::CapacityExceeded),
1629        ));
1630    }
1631
1632    #[test]
1633    fn progress_stable_growth_is_measured_at_reservation_boundaries() {
1634        const STABLE_PAGE_BYTES: u64 = 65_536;
1635
1636        let memory = crate::testing::test_memory_owner(249);
1637        let mut store = InspectionProgressStore::open(memory.open())
1638            .expect("isolated progress store should open");
1639        let mut bytes_at_fifty_five = 0;
1640        let mut bytes_at_fifty_six = 0;
1641        for byte in 1..=56 {
1642            assert!(matches!(
1643                store
1644                    .insert_mutation(&current_mutation_record(byte))
1645                    .expect("record inside shared capacity should insert"),
1646                InsertMutationJobResult::Inserted,
1647            ));
1648            if byte == 55 {
1649                bytes_at_fifty_five = memory.size() * STABLE_PAGE_BYTES;
1650            } else if byte == 56 {
1651                bytes_at_fifty_six = memory.size() * STABLE_PAGE_BYTES;
1652            }
1653        }
1654        let mut bytes_at_sixty_three = 0;
1655        for byte in 200..=207 {
1656            assert!(matches!(
1657                store
1658                    .insert_new(&current_integrity_record(byte))
1659                    .expect("record inside integrity reservation should insert"),
1660                InsertJobResult::Inserted,
1661            ));
1662            if byte == 206 {
1663                bytes_at_sixty_three = memory.size() * STABLE_PAGE_BYTES;
1664            }
1665        }
1666        let bytes_at_sixty_four = memory.size() * STABLE_PAGE_BYTES;
1667
1668        assert_eq!(
1669            (
1670                bytes_at_fifty_five,
1671                bytes_at_fifty_six,
1672                bytes_at_sixty_three,
1673                bytes_at_sixty_four,
1674            ),
1675            (38_993_920, 38_993_920, 43_319_296, 43_319_296),
1676        );
1677    }
1678
1679    #[test]
1680    fn mutation_store_load_replay_replace_and_acknowledge_are_exact() {
1681        let mut store = InspectionProgressStore::open(test_memory(250))
1682            .expect("isolated progress store should open");
1683        let initial = current_mutation_record(10);
1684        assert!(matches!(
1685            store
1686                .insert_mutation(&initial)
1687                .expect("initial mutation record should insert"),
1688            InsertMutationJobResult::Inserted,
1689        ));
1690        assert!(matches!(
1691            store
1692                .insert_mutation(&initial)
1693                .expect("duplicate identity should load retained record"),
1694            InsertMutationJobResult::Occupied(record) if *record == initial,
1695        ));
1696        assert_eq!(
1697            store.load_mutation(mutation_job_id(10)),
1698            Ok(initial.clone())
1699        );
1700        assert_eq!(
1701            store.acknowledge_mutation(mutation_job_id(10), 0),
1702            Err(MutationJobError::Active),
1703        );
1704
1705        let request = mutation_request(10, 0, "restart");
1706        let (terminal, receipt) = initial
1707            .apply_transition(
1708                &request,
1709                MutationJobTransition::new(
1710                    MutationJobStatus::RestartRequired(
1711                        MutationJobRestartReason::BatchPolicyChanged,
1712                    ),
1713                    MutationJobPhase::Forward,
1714                    Vec::new(),
1715                    0,
1716                    0,
1717                    0,
1718                ),
1719            )
1720            .expect("terminal transition should admit");
1721        store
1722            .replace_mutation(&terminal)
1723            .expect("terminal replacement should persist");
1724        assert_eq!(
1725            store.load_mutation(mutation_job_id(10)).and_then(|record| {
1726                let replay = record.exact_replay(&request)?;
1727                Ok(replay.cloned())
1728            }),
1729            Ok(Some(receipt)),
1730        );
1731        assert_eq!(
1732            store.acknowledge_mutation(mutation_job_id(10), 0),
1733            Err(MutationJobError::StaleSequence {
1734                expected: 0,
1735                actual: 1,
1736            }),
1737        );
1738        assert_eq!(store.acknowledge_mutation(mutation_job_id(10), 1), Ok(()));
1739        assert_eq!(store.acknowledge_mutation(mutation_job_id(10), 1), Ok(()));
1740        assert_eq!(
1741            store.load_mutation(mutation_job_id(10)),
1742            Err(MutationJobError::NotFound),
1743        );
1744    }
1745
1746    #[cfg(feature = "sql")]
1747    #[test]
1748    fn mutation_cancellation_is_exact_zero_state_and_absent_idempotent() {
1749        let mut store = InspectionProgressStore::open(test_memory(248))
1750            .expect("isolated progress store should open");
1751        let initial = current_mutation_record(31);
1752        insert_mutation_record(&mut store, &initial);
1753        assert_eq!(
1754            store.cancel_unadvanced_mutation(mutation_job_id(31), 1, |_| Ok(())),
1755            Err(MutationJobError::StaleSequence {
1756                expected: 1,
1757                actual: 0,
1758            }),
1759        );
1760        assert_eq!(
1761            store.cancel_unadvanced_mutation(mutation_job_id(31), 0, |_| Ok(())),
1762            Ok(()),
1763        );
1764        assert_eq!(
1765            store.cancel_unadvanced_mutation(mutation_job_id(31), 0, |_| {
1766                Err(MutationJobError::CorruptProgressStore)
1767            }),
1768            Ok(()),
1769            "an absent retry must not invoke continuation validation",
1770        );
1771
1772        let advanced_initial = current_mutation_record(32);
1773        let (advanced, _) = advanced_initial
1774            .apply_transition(
1775                &mutation_request(32, 0, "advanced"),
1776                MutationJobTransition::new(
1777                    MutationJobStatus::Active,
1778                    MutationJobPhase::Forward,
1779                    vec![6],
1780                    1,
1781                    0,
1782                    0,
1783                ),
1784            )
1785            .expect("advanced state should admit");
1786        insert_mutation_record(&mut store, &advanced);
1787        for expected_sequence in [0, 1] {
1788            assert_eq!(
1789                store.cancel_unadvanced_mutation(
1790                    mutation_job_id(32),
1791                    expected_sequence,
1792                    |_| Ok(()),
1793                ),
1794                Err(MutationJobError::StaleSequence {
1795                    expected: 0,
1796                    actual: 1,
1797                }),
1798            );
1799        }
1800
1801        let terminal_initial = current_mutation_record(33);
1802        let (terminal, _) = terminal_initial
1803            .apply_transition(
1804                &mutation_request(33, 0, "terminal"),
1805                MutationJobTransition::new(
1806                    MutationJobStatus::RestartRequired(
1807                        MutationJobRestartReason::BatchPolicyChanged,
1808                    ),
1809                    MutationJobPhase::Forward,
1810                    Vec::new(),
1811                    0,
1812                    0,
1813                    0,
1814                ),
1815            )
1816            .expect("terminal state should admit");
1817        insert_mutation_record(&mut store, &terminal);
1818        assert_eq!(
1819            store.cancel_unadvanced_mutation(mutation_job_id(33), 1, |_| Ok(())),
1820            Err(MutationJobError::StaleSequence {
1821                expected: 0,
1822                actual: 1,
1823            }),
1824        );
1825
1826        let malformed_continuation = current_mutation_record(34);
1827        insert_mutation_record(&mut store, &malformed_continuation);
1828        assert_eq!(
1829            store.cancel_unadvanced_mutation(mutation_job_id(34), 0, |_| {
1830                Err(MutationJobError::CorruptProgressStore)
1831            }),
1832            Err(MutationJobError::CorruptProgressStore),
1833        );
1834        assert_eq!(
1835            store.load_mutation(mutation_job_id(34)),
1836            Ok(malformed_continuation),
1837            "failed validation must retain the record",
1838        );
1839    }
1840
1841    #[test]
1842    fn progress_inventory_is_complete_family_bounded_and_fail_closed() {
1843        let mut store = InspectionProgressStore::open(test_memory(247))
1844            .expect("isolated progress store should open");
1845        let integrity = current_integrity_record(201);
1846        let resumable = current_resumable_record();
1847        let mutation = current_mutation_record(35);
1848        assert!(matches!(
1849            store
1850                .insert_new(&integrity)
1851                .expect("integrity record should insert"),
1852            InsertJobResult::Inserted,
1853        ));
1854        store
1855            .insert_resumable(&resumable)
1856            .expect("resumable record should insert");
1857        assert!(matches!(
1858            store
1859                .insert_mutation(&mutation)
1860                .expect("mutation record should insert"),
1861            InsertMutationJobResult::Inserted,
1862        ));
1863
1864        let inventory = store.inventory().expect("valid records should inventory");
1865        assert_eq!(inventory.retained_count, 3);
1866        assert_eq!(inventory.hard_limit, 64);
1867        assert_eq!(inventory.reserved_integrity_headroom, 8);
1868        assert_eq!(inventory.integrity_count, 1);
1869        assert_eq!(inventory.resumable_count, 1);
1870        assert_eq!(inventory.mutation_count, 1);
1871        let expected_record_bytes = encode_job_record(&integrity)
1872            .expect("integrity record should encode")
1873            .len()
1874            .saturating_add(
1875                encode_resumable_job_record(&resumable)
1876                    .expect("resumable record should encode")
1877                    .len(),
1878            )
1879            .saturating_add(
1880                encode_mutation_job_record(&mutation)
1881                    .expect("mutation record should encode")
1882                    .len(),
1883            );
1884        assert_eq!(
1885            inventory.retained_record_bytes,
1886            u64::try_from(expected_record_bytes).expect("bounded records fit u64")
1887        );
1888        assert_eq!(inventory.records.len(), 3);
1889        assert!(inventory.records.iter().all(|record| {
1890            record.lifecycle == ProgressJobLifecycle::Active && record.sequence == Some(0)
1891        }));
1892        assert!(inventory.records.iter().any(|record| {
1893            record.family == ProgressJobFamily::Integrity
1894                && record.job_id == integrity.id.to_bytes()
1895        }));
1896        assert!(inventory.records.iter().any(|record| {
1897            record.family == ProgressJobFamily::Resumable
1898                && record.job_id == resumable.state().job_id.to_bytes()
1899        }));
1900        assert!(inventory.records.iter().any(|record| {
1901            record.family == ProgressJobFamily::Mutation
1902                && record.job_id == mutation.state().job_id.to_bytes()
1903        }));
1904
1905        store.map.insert(
1906            ProgressRecordKey([202; 32]),
1907            ProgressRecordBytes(b"undecodable-retained-slot".to_vec()),
1908        );
1909        assert_eq!(
1910            store.inventory(),
1911            Err(MutationJobError::CorruptProgressStore),
1912            "one undecodable slot must fail the whole inventory",
1913        );
1914    }
1915
1916    #[test]
1917    fn integrity_scan_skips_other_progress_record_families() {
1918        let mut store = InspectionProgressStore::open(test_memory(252))
1919            .expect("isolated progress store should open");
1920        let integrity = current_job_codec_fixture();
1921        assert!(matches!(
1922            store
1923                .insert_new(&integrity)
1924                .expect("integrity job should insert"),
1925            InsertJobResult::Inserted,
1926        ));
1927        store
1928            .insert_resumable(&current_resumable_record())
1929            .expect("generic resumable job should insert");
1930        assert!(matches!(
1931            store
1932                .insert_mutation(&current_mutation_record(12))
1933                .expect("mutation job should insert"),
1934            InsertMutationJobResult::Inserted,
1935        ));
1936
1937        let page = store
1938            .scan_after(None, 8)
1939            .expect("integrity scan should ignore other record families");
1940        assert_eq!(page.job_ids, vec![integrity.id]);
1941        assert!(page.exhausted);
1942    }
1943}