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