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