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