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