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, 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#[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: 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 #[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 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 #[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 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 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 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
825fn 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
850fn 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
1072pub(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#[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#[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 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(¤t_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(¤t_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(¤t_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(¤t_mutation_record(56)),
1666 Err(MutationJobError::CapacityExceeded),
1667 ));
1668
1669 for byte in 200..=206 {
1670 assert!(matches!(
1671 store
1672 .insert_new(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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 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(¤t_resumable_record())
2104 .expect("generic resumable job should insert");
2105 assert!(matches!(
2106 store
2107 .insert_mutation(¤t_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}