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