1use parking_lot::Mutex;
130use serde::{Deserialize, Serialize};
131use sha2::{Digest, Sha256};
132use std::collections::{BTreeMap, HashMap, HashSet};
133use std::io::Read;
134use std::path::Path;
135use std::sync::atomic::{AtomicBool, Ordering};
136use std::sync::Arc;
137
138use crate::catalog::META_DEK_LEN;
139use crate::durable_file::DurableRoot;
140use crate::error::MongrelError;
141
142pub const JOBS_FILENAME: &str = "JOBS";
144const MAGIC: &[u8; 8] = b"MONGRJOB";
145const JOBS_FORMAT_VERSION: u16 = 1;
146const MAX_JOBS_BYTES: u64 = 64 * 1024 * 1024;
149const MAX_CHECKPOINT_BYTES: usize = 1024 * 1024;
151pub const DEFAULT_MAX_CONCURRENT_JOBS: usize = 2;
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159pub enum JobState {
160 Pending,
162 Running,
164 Paused,
167 Cancelling,
169 Succeeded,
171 Failed,
173 RollingBack,
175}
176
177impl JobState {
178 #[cfg(test)]
180 pub(crate) const ALL: [JobState; 7] = [
181 JobState::Pending,
182 JobState::Running,
183 JobState::Paused,
184 JobState::Cancelling,
185 JobState::Succeeded,
186 JobState::Failed,
187 JobState::RollingBack,
188 ];
189
190 pub fn is_terminal(self) -> bool {
192 matches!(self, JobState::Succeeded | JobState::Failed)
193 }
194
195 pub fn can_transition(self, next: JobState) -> bool {
199 use JobState::{Cancelling, Failed, Paused, Pending, RollingBack, Running, Succeeded};
200 matches!(
201 (self, next),
202 (Pending, Running)
203 | (Pending, Cancelling)
204 | (Running, Paused)
205 | (Running, Cancelling)
206 | (Running, RollingBack)
207 | (Running, Succeeded)
208 | (Paused, Pending)
209 | (Paused, Cancelling)
210 | (Cancelling, RollingBack)
211 | (Cancelling, Failed)
212 | (RollingBack, Failed)
213 )
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222pub enum JobKind {
223 IndexBuild,
225 ColumnBackfill,
227 SchemaValidation,
229 MaterializedViewRebuild,
231 KeyRotation,
233 LargeImport,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct JobTarget {
241 pub table: String,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub index: Option<String>,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct JobProgress {
252 pub fraction: f64,
254 pub done: u64,
256 pub total: u64,
258}
259
260impl Default for JobProgress {
261 fn default() -> Self {
262 Self {
263 fraction: 0.0,
264 done: 0,
265 total: 0,
266 }
267 }
268}
269
270impl JobProgress {
271 pub fn new(fraction: f64, done: u64, total: u64) -> Result<Self, JobError> {
274 if !(0.0..=1.0).contains(&fraction) {
275 return Err(JobError::InvalidProgress(format!(
276 "fraction {fraction} is outside [0.0, 1.0]"
277 )));
278 }
279 if total > 0 && done > total {
280 return Err(JobError::InvalidProgress(format!(
281 "done {done} exceeds total {total}"
282 )));
283 }
284 Ok(Self {
285 fraction,
286 done,
287 total,
288 })
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct JobRecord {
298 pub job_id: u64,
300 pub kind: JobKind,
301 pub state: JobState,
302 pub target: JobTarget,
303 pub progress: JobProgress,
304 pub created_at_micros: u64,
306 pub updated_at_micros: u64,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub error: Option<String>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub checkpoint: Option<Vec<u8>>,
315}
316
317#[derive(Debug, thiserror::Error)]
320pub enum JobError {
321 #[error("job {job_id} not found")]
323 NotFound {
324 job_id: u64,
326 },
327 #[error("illegal job state transition from {from:?} to {to:?}")]
329 IllegalTransition {
330 from: JobState,
332 to: JobState,
334 },
335 #[error("job {job_id} is {actual:?}, expected {expected:?}")]
337 UnexpectedState {
338 job_id: u64,
340 expected: JobState,
342 actual: JobState,
344 },
345 #[error("concurrent job limit reached: {active} active of {limit} allowed")]
347 ConcurrencyLimit {
348 active: usize,
350 limit: usize,
352 },
353 #[error("job {job_id} still has a live drive in this process")]
356 DriveActive {
357 job_id: u64,
359 },
360 #[error("invalid job progress: {0}")]
362 InvalidProgress(String),
363 #[error("job cancelled")]
365 Cancelled,
366 #[error("job phase failed: {0}")]
368 Phase(String),
369 #[error("injected fault: {0}")]
371 InjectedFault(String),
372 #[error("job checkpoint of {bytes} bytes exceeds the {limit}-byte limit")]
374 CheckpointTooLarge {
375 bytes: usize,
377 limit: usize,
379 },
380 #[error("job registry storage: {0}")]
383 Storage(String),
384 #[error("io error: {0}")]
386 Io(#[from] std::io::Error),
387}
388
389impl From<JobError> for MongrelError {
390 fn from(error: JobError) -> Self {
391 match error {
392 JobError::NotFound { job_id } => MongrelError::NotFound(format!("job {job_id}")),
393 JobError::Cancelled => MongrelError::Cancelled,
394 JobError::ConcurrencyLimit { active, limit } => MongrelError::ResourceLimitExceeded {
395 resource: "concurrent jobs",
396 requested: active,
397 limit,
398 },
399 JobError::InvalidProgress(message) => MongrelError::InvalidArgument(message),
400 JobError::Io(error) => MongrelError::Io(error),
401 other => MongrelError::Other(other.to_string()),
402 }
403 }
404}
405
406#[derive(Debug, Clone, Default)]
411pub struct CancellationToken {
412 flag: Arc<AtomicBool>,
413}
414
415impl CancellationToken {
416 pub fn is_cancelled(&self) -> bool {
418 self.flag.load(Ordering::Acquire)
419 }
420
421 pub fn cancel(&self) {
423 self.flag.store(true, Ordering::Release);
424 }
425
426 pub fn check(&self) -> Result<(), JobError> {
429 if self.is_cancelled() {
430 Err(JobError::Cancelled)
431 } else {
432 Ok(())
433 }
434 }
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439pub enum BuildPhase {
440 RecordPending,
442 PinSnapshot,
444 BuildHidden,
446 CatchUp,
448 Validate,
450 Publish,
452 ReleaseOld,
454}
455
456impl BuildPhase {
457 pub const ALL: [BuildPhase; 7] = [
459 BuildPhase::RecordPending,
460 BuildPhase::PinSnapshot,
461 BuildPhase::BuildHidden,
462 BuildPhase::CatchUp,
463 BuildPhase::Validate,
464 BuildPhase::Publish,
465 BuildPhase::ReleaseOld,
466 ];
467
468 pub fn label(self) -> &'static str {
470 match self {
471 BuildPhase::RecordPending => "record_pending",
472 BuildPhase::PinSnapshot => "pin_snapshot",
473 BuildPhase::BuildHidden => "build_hidden",
474 BuildPhase::CatchUp => "catch_up",
475 BuildPhase::Validate => "validate",
476 BuildPhase::Publish => "publish",
477 BuildPhase::ReleaseOld => "release_old",
478 }
479 }
480
481 pub fn before_hook(self) -> &'static str {
483 match self {
484 BuildPhase::RecordPending => "job.record_pending.before",
485 BuildPhase::PinSnapshot => "job.pin_snapshot.before",
486 BuildPhase::BuildHidden => "job.build_hidden.before",
487 BuildPhase::CatchUp => "job.catch_up.before",
488 BuildPhase::Validate => "job.validate.before",
489 BuildPhase::Publish => "job.publish.before",
490 BuildPhase::ReleaseOld => "job.release_old.before",
491 }
492 }
493
494 pub fn after_hook(self) -> &'static str {
497 match self {
498 BuildPhase::RecordPending => "job.record_pending.after",
499 BuildPhase::PinSnapshot => "job.pin_snapshot.after",
500 BuildPhase::BuildHidden => "job.build_hidden.after",
501 BuildPhase::CatchUp => "job.catch_up.after",
502 BuildPhase::Validate => "job.validate.after",
503 BuildPhase::Publish => "job.publish.after",
504 BuildPhase::ReleaseOld => "job.release_old.after",
505 }
506 }
507
508 fn invoke<J: BuildPublishJob + ?Sized>(
509 self,
510 job: &mut J,
511 context: &JobContext,
512 ) -> Result<(), JobError> {
513 match self {
514 BuildPhase::RecordPending => job.record_pending(context),
515 BuildPhase::PinSnapshot => job.pin_snapshot(context),
516 BuildPhase::BuildHidden => job.build_hidden(context),
517 BuildPhase::CatchUp => job.catch_up(context),
518 BuildPhase::Validate => job.validate(context),
519 BuildPhase::Publish => job.publish(context),
520 BuildPhase::ReleaseOld => job.release_old(context),
521 }
522 }
523}
524
525pub trait BuildPublishJob {
531 fn checkpoint_state(&self) -> Vec<u8> {
534 Vec::new()
535 }
536
537 fn restore_checkpoint(&mut self, _state: &[u8]) -> Result<(), JobError> {
540 Ok(())
541 }
542
543 fn record_pending(&mut self, _context: &JobContext) -> Result<(), JobError> {
545 Ok(())
546 }
547
548 fn pin_snapshot(&mut self, _context: &JobContext) -> Result<(), JobError> {
550 Ok(())
551 }
552
553 fn build_hidden(&mut self, _context: &JobContext) -> Result<(), JobError> {
556 Ok(())
557 }
558
559 fn catch_up(&mut self, _context: &JobContext) -> Result<(), JobError> {
562 Ok(())
563 }
564
565 fn validate(&mut self, _context: &JobContext) -> Result<(), JobError> {
568 Ok(())
569 }
570
571 fn publish(&mut self, _context: &JobContext) -> Result<(), JobError> {
574 Ok(())
575 }
576
577 fn release_old(&mut self, _context: &JobContext) -> Result<(), JobError> {
580 Ok(())
581 }
582
583 fn rollback(&mut self) -> Result<(), JobError> {
587 Ok(())
588 }
589}
590
591pub struct JobContext<'a> {
595 registry: &'a JobRegistry,
596 job_id: u64,
597 token: CancellationToken,
598}
599
600impl JobContext<'_> {
601 pub fn job_id(&self) -> u64 {
603 self.job_id
604 }
605
606 pub fn token(&self) -> &CancellationToken {
608 &self.token
609 }
610
611 pub fn check_cancelled(&self) -> Result<(), JobError> {
613 self.token.check()
614 }
615
616 pub fn report_progress(&self, done: u64, total: u64) -> Result<(), JobError> {
620 if total == 0 {
621 return Err(JobError::InvalidProgress(
622 "unit progress requires a nonzero total".to_string(),
623 ));
624 }
625 let fraction = done as f64 / total as f64;
626 self.registry
627 .update_progress(self.job_id, JobProgress::new(fraction, done, total)?)
628 }
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize)]
633#[serde(deny_unknown_fields)]
634struct JobsSnapshot {
635 next_job_id: u64,
637 jobs: Vec<JobRecord>,
638}
639
640#[derive(Serialize, Deserialize)]
641#[serde(deny_unknown_fields)]
642struct JobsEnvelope {
643 format_version: u16,
644 registry: JobsSnapshot,
645}
646
647#[derive(Debug, Clone, Default)]
648struct RegistryInner {
649 next_job_id: u64,
650 jobs: BTreeMap<u64, JobRecord>,
651}
652
653pub struct JobRegistry {
661 root: DurableRoot,
662 meta_dek: Option<[u8; META_DEK_LEN]>,
663 inner: Mutex<RegistryInner>,
664 tokens: Mutex<HashMap<u64, CancellationToken>>,
665 active_drives: Mutex<HashSet<u64>>,
669 max_concurrent_jobs: usize,
670}
671
672impl std::fmt::Debug for JobRegistry {
673 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
674 formatter
675 .debug_struct("JobRegistry")
676 .field("root", &self.root)
677 .field("max_concurrent_jobs", &self.max_concurrent_jobs)
678 .finish_non_exhaustive()
679 }
680}
681
682impl JobRegistry {
683 pub fn open(dir: &Path, meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Self, JobError> {
690 let root = DurableRoot::open(dir)?;
691 let mut inner = match read_durable(&root, meta_dek)? {
692 Some(snapshot) => validate_snapshot(snapshot)?,
693 None => RegistryInner {
694 next_job_id: 1,
695 jobs: BTreeMap::new(),
696 },
697 };
698 let recovered = recover_after_crash(&mut inner);
699 let registry = Self {
700 root,
701 meta_dek: meta_dek.copied(),
702 inner: Mutex::new(inner),
703 tokens: Mutex::new(HashMap::new()),
704 active_drives: Mutex::new(HashSet::new()),
705 max_concurrent_jobs: DEFAULT_MAX_CONCURRENT_JOBS,
706 };
707 if recovered {
708 registry.persist_locked(®istry.inner.lock())?;
709 }
710 Ok(registry)
711 }
712
713 pub fn with_max_concurrent_jobs(mut self, limit: usize) -> Self {
715 self.max_concurrent_jobs = limit.max(1);
716 self
717 }
718
719 pub fn submit(&self, kind: JobKind, target: JobTarget) -> Result<u64, JobError> {
721 if target.table.is_empty() {
722 return Err(JobError::InvalidProgress(
723 "job target table must not be empty".to_string(),
724 ));
725 }
726 if kind == JobKind::IndexBuild && target.index.is_none() {
727 return Err(JobError::InvalidProgress(
728 "an index-build job requires a target index".to_string(),
729 ));
730 }
731 let mut next = self.inner.lock().clone();
732 let now = unix_micros();
733 let job_id = next.next_job_id;
734 next.next_job_id = next
735 .next_job_id
736 .checked_add(1)
737 .ok_or_else(|| JobError::Storage("job id space exhausted".to_string()))?;
738 next.jobs.insert(
739 job_id,
740 JobRecord {
741 job_id,
742 kind,
743 state: JobState::Pending,
744 target,
745 progress: JobProgress::default(),
746 created_at_micros: now,
747 updated_at_micros: now,
748 error: None,
749 checkpoint: None,
750 },
751 );
752 self.persist_and_swap(next)?;
753 self.tokens
754 .lock()
755 .insert(job_id, CancellationToken::default());
756 Ok(job_id)
757 }
758
759 pub fn get(&self, job_id: u64) -> Option<JobRecord> {
761 self.inner.lock().jobs.get(&job_id).cloned()
762 }
763
764 pub fn list(&self) -> Vec<JobRecord> {
766 self.inner.lock().jobs.values().cloned().collect()
767 }
768
769 pub fn cancellation_token(&self, job_id: u64) -> Option<CancellationToken> {
771 if !self.inner.lock().jobs.contains_key(&job_id) {
772 return None;
773 }
774 Some(self.tokens.lock().entry(job_id).or_default().clone())
775 }
776
777 pub fn pause(&self, job_id: u64) -> Result<(), JobError> {
781 self.transition(job_id, JobState::Paused)
782 }
783
784 pub fn resume(&self, job_id: u64) -> Result<(), JobError> {
789 if self.active_drives.lock().contains(&job_id) {
790 return Err(JobError::DriveActive { job_id });
791 }
792 self.transition(job_id, JobState::Pending)
793 }
794
795 pub fn cancel(&self, job_id: u64) -> Result<(), JobError> {
805 let state = self.get(job_id).ok_or(JobError::NotFound { job_id })?.state;
806 match state {
807 JobState::Pending | JobState::Paused => {
808 let mut next = self.inner.lock().clone();
809 let record = next.jobs.get_mut(&job_id).expect("record checked above");
810 apply_transition(record, JobState::Cancelling)?;
811 apply_transition(record, JobState::Failed)?;
812 record.error = Some(match state {
813 JobState::Pending => "cancelled before the job started".to_string(),
814 _ => "cancelled while the job was paused".to_string(),
815 });
816 self.persist_and_swap(next)?;
817 self.tokens.lock().entry(job_id).or_default().cancel();
818 Ok(())
819 }
820 JobState::Running => {
821 self.transition(job_id, JobState::Cancelling)?;
822 self.tokens.lock().entry(job_id).or_default().cancel();
823 Ok(())
824 }
825 JobState::Cancelling | JobState::RollingBack => Ok(()),
826 JobState::Succeeded | JobState::Failed => Err(JobError::IllegalTransition {
827 from: state,
828 to: JobState::Cancelling,
829 }),
830 }
831 }
832
833 fn update_progress(&self, job_id: u64, progress: JobProgress) -> Result<(), JobError> {
835 let mut next = self.inner.lock().clone();
836 let record = next
837 .jobs
838 .get_mut(&job_id)
839 .ok_or(JobError::NotFound { job_id })?;
840 if record.state != JobState::Running {
841 return Err(JobError::UnexpectedState {
842 job_id,
843 expected: JobState::Running,
844 actual: record.state,
845 });
846 }
847 record.progress = progress;
848 record.updated_at_micros = unix_micros();
849 self.persist_and_swap(next)
850 }
851
852 fn transition(&self, job_id: u64, to: JobState) -> Result<(), JobError> {
854 let mut next = self.inner.lock().clone();
855 let record = next
856 .jobs
857 .get_mut(&job_id)
858 .ok_or(JobError::NotFound { job_id })?;
859 apply_transition(record, to)?;
860 self.persist_and_swap(next)
861 }
862
863 fn admit(&self, job_id: u64) -> Result<(), JobError> {
865 let mut next = self.inner.lock().clone();
866 let active = next
867 .jobs
868 .values()
869 .filter(|record| {
870 matches!(
871 record.state,
872 JobState::Running | JobState::Cancelling | JobState::RollingBack
873 )
874 })
875 .count();
876 let record = next
877 .jobs
878 .get(&job_id)
879 .ok_or(JobError::NotFound { job_id })?;
880 if record.state != JobState::Pending {
881 return Err(JobError::IllegalTransition {
882 from: record.state,
883 to: JobState::Running,
884 });
885 }
886 if active >= self.max_concurrent_jobs {
887 return Err(JobError::ConcurrencyLimit {
888 active,
889 limit: self.max_concurrent_jobs,
890 });
891 }
892 let record = next.jobs.get_mut(&job_id).expect("record checked above");
893 apply_transition(record, JobState::Running)?;
894 self.persist_and_swap(next)
895 }
896
897 fn save_checkpoint(
902 &self,
903 job_id: u64,
904 checkpoint: Vec<u8>,
905 progress: JobProgress,
906 ) -> Result<(), JobError> {
907 if checkpoint.len() > MAX_CHECKPOINT_BYTES {
908 return Err(JobError::CheckpointTooLarge {
909 bytes: checkpoint.len(),
910 limit: MAX_CHECKPOINT_BYTES,
911 });
912 }
913 let mut next = self.inner.lock().clone();
914 let record = next
915 .jobs
916 .get_mut(&job_id)
917 .ok_or(JobError::NotFound { job_id })?;
918 if !matches!(record.state, JobState::Running | JobState::Paused) {
919 return Err(JobError::UnexpectedState {
920 job_id,
921 expected: JobState::Running,
922 actual: record.state,
923 });
924 }
925 record.checkpoint = Some(checkpoint);
926 record.progress = progress;
927 record.updated_at_micros = unix_micros();
928 self.persist_and_swap(next)
929 }
930
931 fn complete(&self, job_id: u64) -> Result<(), JobError> {
934 let mut next = self.inner.lock().clone();
935 let record = next
936 .jobs
937 .get_mut(&job_id)
938 .ok_or(JobError::NotFound { job_id })?;
939 apply_transition(record, JobState::Succeeded)?;
940 record.checkpoint = None;
941 record.progress.fraction = 1.0;
942 self.persist_and_swap(next)
943 }
944
945 fn begin_rollback(&self, job_id: u64) -> Result<(), JobError> {
947 self.transition(job_id, JobState::RollingBack)
948 }
949
950 fn fail(&self, job_id: u64, error: String) -> Result<(), JobError> {
952 let mut next = self.inner.lock().clone();
953 let record = next
954 .jobs
955 .get_mut(&job_id)
956 .ok_or(JobError::NotFound { job_id })?;
957 apply_transition(record, JobState::Failed)?;
958 record.error = Some(error);
959 self.persist_and_swap(next)
960 }
961
962 fn persist_and_swap(&self, next: RegistryInner) -> Result<(), JobError> {
966 self.persist_locked(&next)?;
967 *self.inner.lock() = next;
968 Ok(())
969 }
970
971 fn persist_locked(&self, inner: &RegistryInner) -> Result<(), JobError> {
972 let snapshot = JobsSnapshot {
973 next_job_id: inner.next_job_id,
974 jobs: inner.jobs.values().cloned().collect(),
975 };
976 write_durable(&self.root, &snapshot, self.meta_dek.as_ref())
977 }
978}
979
980pub fn run_build_publish<J: BuildPublishJob + ?Sized>(
994 registry: &JobRegistry,
995 job_id: u64,
996 job: &mut J,
997) -> Result<(), JobError> {
998 let record = registry.get(job_id).ok_or(JobError::NotFound { job_id })?;
999 if record.state != JobState::Pending {
1000 return Err(JobError::IllegalTransition {
1001 from: record.state,
1002 to: JobState::Running,
1003 });
1004 }
1005 let mut completed_phases = 0_usize;
1008 if let Some(checkpoint) = &record.checkpoint {
1009 let decoded = decode_build_checkpoint(checkpoint)?;
1010 job.restore_checkpoint(&decoded.state)?;
1011 completed_phases = usize::from(decoded.completed_phases);
1012 }
1013 registry.admit(job_id)?;
1014 let _drive = DriveGuard { registry, job_id };
1017 let token = registry
1018 .cancellation_token(job_id)
1019 .ok_or(JobError::NotFound { job_id })?;
1020 let context = JobContext {
1021 registry,
1022 job_id,
1023 token,
1024 };
1025
1026 for (index, phase) in BuildPhase::ALL
1027 .iter()
1028 .copied()
1029 .enumerate()
1030 .skip(completed_phases)
1031 {
1032 let state = registry
1036 .get(job_id)
1037 .ok_or(JobError::NotFound { job_id })?
1038 .state;
1039 match state {
1040 JobState::Running => {}
1041 JobState::Paused => return Ok(()),
1042 JobState::Cancelling => {
1043 return rollback_and_fail(registry, job_id, job, JobError::Cancelled);
1044 }
1045 state => {
1046 return Err(JobError::IllegalTransition {
1047 from: state,
1048 to: JobState::Running,
1049 });
1050 }
1051 }
1052 if let Err(fault) = mongreldb_fault::inject(phase.before_hook()) {
1053 return park_on_fault(registry, job_id, job, fault);
1054 }
1055 let outcome = phase.invoke(job, &context);
1056 if let Err(fault) = mongreldb_fault::inject(phase.after_hook()) {
1057 return park_on_fault(registry, job_id, job, fault);
1058 }
1059 if let Err(error) = outcome {
1060 return rollback_and_fail(registry, job_id, job, error);
1061 }
1062 completed_phases = index + 1;
1063 let checkpoint = encode_build_checkpoint(completed_phases as u8, &job.checkpoint_state())?;
1064 let total = BuildPhase::ALL.len() as u64;
1065 let done = completed_phases as u64;
1066 let progress = JobProgress::new(done as f64 / total as f64, done, total)?;
1067 let state = registry
1072 .get(job_id)
1073 .ok_or(JobError::NotFound { job_id })?
1074 .state;
1075 match state {
1076 JobState::Running => registry.save_checkpoint(job_id, checkpoint, progress)?,
1077 JobState::Paused => {
1078 registry.save_checkpoint(job_id, checkpoint, progress)?;
1079 return Ok(());
1080 }
1081 JobState::Cancelling => {
1082 return rollback_and_fail(registry, job_id, job, JobError::Cancelled);
1083 }
1084 state => {
1085 return Err(JobError::IllegalTransition {
1086 from: state,
1087 to: JobState::Running,
1088 });
1089 }
1090 }
1091 }
1092 match registry.complete(job_id) {
1093 Ok(()) => Ok(()),
1094 Err(JobError::IllegalTransition { from, .. }) => match from {
1095 JobState::Paused => Ok(()),
1098 JobState::Cancelling => rollback_and_fail(registry, job_id, job, JobError::Cancelled),
1099 state => Err(JobError::IllegalTransition {
1100 from: state,
1101 to: JobState::Succeeded,
1102 }),
1103 },
1104 Err(error) => Err(error),
1105 }
1106}
1107
1108fn park_on_fault<J: BuildPublishJob + ?Sized>(
1112 registry: &JobRegistry,
1113 job_id: u64,
1114 job: &mut J,
1115 fault: mongreldb_fault::Fault,
1116) -> Result<(), JobError> {
1117 match registry.pause(job_id) {
1118 Ok(()) => Err(JobError::InjectedFault(fault.to_string())),
1119 Err(JobError::IllegalTransition {
1120 from: JobState::Cancelling,
1121 ..
1122 }) => rollback_and_fail(registry, job_id, job, JobError::Cancelled),
1123 Err(error) => Err(error),
1124 }
1125}
1126
1127fn rollback_and_fail<J: BuildPublishJob + ?Sized>(
1131 registry: &JobRegistry,
1132 job_id: u64,
1133 job: &mut J,
1134 error: JobError,
1135) -> Result<(), JobError> {
1136 match registry.begin_rollback(job_id) {
1137 Ok(()) => {}
1138 Err(JobError::IllegalTransition { .. }) => return Err(error),
1142 Err(storage) => return Err(storage),
1143 }
1144 let message = match job.rollback() {
1145 Ok(()) => error.to_string(),
1146 Err(rollback_error) => format!("{error}; rollback also failed: {rollback_error}"),
1147 };
1148 registry.fail(job_id, message)?;
1149 Err(error)
1150}
1151
1152struct DriveGuard<'a> {
1156 registry: &'a JobRegistry,
1157 job_id: u64,
1158}
1159
1160impl Drop for DriveGuard<'_> {
1161 fn drop(&mut self) {
1162 self.registry.active_drives.lock().remove(&self.job_id);
1163 }
1164}
1165
1166#[derive(Serialize, Deserialize)]
1169#[serde(deny_unknown_fields)]
1170struct BuildPublishCheckpoint {
1171 completed_phases: u8,
1172 state: Vec<u8>,
1173}
1174
1175fn encode_build_checkpoint(completed_phases: u8, state: &[u8]) -> Result<Vec<u8>, JobError> {
1176 serde_json::to_vec(&BuildPublishCheckpoint {
1177 completed_phases,
1178 state: state.to_vec(),
1179 })
1180 .map_err(|error| JobError::Storage(format!("job checkpoint serialize: {error}")))
1181}
1182
1183fn decode_build_checkpoint(bytes: &[u8]) -> Result<BuildPublishCheckpoint, JobError> {
1184 let checkpoint: BuildPublishCheckpoint = serde_json::from_slice(bytes)
1185 .map_err(|error| JobError::Storage(format!("job checkpoint deserialize: {error}")))?;
1186 if usize::from(checkpoint.completed_phases) > BuildPhase::ALL.len() {
1187 return Err(JobError::Storage(format!(
1188 "job checkpoint claims {} completed phases, only {} exist",
1189 checkpoint.completed_phases,
1190 BuildPhase::ALL.len()
1191 )));
1192 }
1193 Ok(checkpoint)
1194}
1195
1196fn apply_transition(record: &mut JobRecord, to: JobState) -> Result<(), JobError> {
1197 if !record.state.can_transition(to) {
1198 return Err(JobError::IllegalTransition {
1199 from: record.state,
1200 to,
1201 });
1202 }
1203 record.state = to;
1204 record.updated_at_micros = unix_micros();
1205 Ok(())
1206}
1207
1208fn recover_after_crash(inner: &mut RegistryInner) -> bool {
1210 let now = unix_micros();
1211 let mut changed = false;
1212 for record in inner.jobs.values_mut() {
1213 match record.state {
1214 JobState::Running => {
1215 record.state = JobState::Paused;
1218 record.updated_at_micros = now;
1219 changed = true;
1220 }
1221 JobState::Cancelling => {
1222 record.state = JobState::Failed;
1224 record.error =
1225 Some("cancelled (process restarted while the job was cancelling)".to_string());
1226 record.updated_at_micros = now;
1227 changed = true;
1228 }
1229 JobState::RollingBack => {
1230 record.state = JobState::Failed;
1231 const NOTE: &str = "rollback interrupted by process restart";
1232 record.error = Some(match record.error.take() {
1233 Some(error) => format!("{error}; {NOTE}"),
1234 None => NOTE.to_string(),
1235 });
1236 record.updated_at_micros = now;
1237 changed = true;
1238 }
1239 JobState::Pending | JobState::Paused | JobState::Succeeded | JobState::Failed => {}
1240 }
1241 }
1242 changed
1243}
1244
1245fn validate_snapshot(snapshot: JobsSnapshot) -> Result<RegistryInner, JobError> {
1248 let mut jobs = BTreeMap::new();
1249 for record in snapshot.jobs {
1250 if jobs.insert(record.job_id, record).is_some() {
1251 return Err(JobError::Storage(
1252 "duplicate job id in registry file".to_string(),
1253 ));
1254 }
1255 }
1256 let max_id = jobs.keys().next_back().copied().unwrap_or(0);
1257 if snapshot.next_job_id <= max_id {
1258 return Err(JobError::Storage(format!(
1259 "registry allocator at {} would reissue job id {max_id}",
1260 snapshot.next_job_id
1261 )));
1262 }
1263 Ok(RegistryInner {
1264 next_job_id: snapshot.next_job_id.max(1),
1265 jobs,
1266 })
1267}
1268
1269fn encode(snapshot: &JobsSnapshot) -> Result<Vec<u8>, JobError> {
1270 serde_json::to_vec(&JobsEnvelope {
1271 format_version: JOBS_FORMAT_VERSION,
1272 registry: snapshot.clone(),
1273 })
1274 .map_err(|error| JobError::Storage(format!("job registry serialize: {error}")))
1275}
1276
1277fn decode(body: &[u8]) -> Result<JobsSnapshot, JobError> {
1278 let envelope: JobsEnvelope = serde_json::from_slice(body)
1279 .map_err(|error| JobError::Storage(format!("job registry deserialize: {error}")))?;
1280 if envelope.format_version != JOBS_FORMAT_VERSION {
1281 return Err(JobError::Storage(format!(
1282 "unsupported job registry format version {}",
1283 envelope.format_version
1284 )));
1285 }
1286 Ok(envelope.registry)
1287}
1288
1289fn plaintext_frame(body: &[u8]) -> Vec<u8> {
1290 let hash = Sha256::digest(body);
1291 let mut out = Vec::with_capacity(body.len() + 8 + 32);
1292 out.extend_from_slice(MAGIC);
1293 out.extend_from_slice(&hash);
1294 out.extend_from_slice(body);
1295 out
1296}
1297
1298fn seal(body: &[u8], meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Vec<u8>, JobError> {
1299 match meta_dek {
1300 Some(dek) => crate::encryption::encrypt_blob(dek, body)
1301 .map_err(|error| JobError::Storage(format!("job registry seal: {error}"))),
1302 None => Ok(plaintext_frame(body)),
1303 }
1304}
1305
1306fn open_payload(
1307 bytes: &[u8],
1308 meta_dek: Option<&[u8; META_DEK_LEN]>,
1309) -> Result<JobsSnapshot, JobError> {
1310 match meta_dek {
1311 Some(dek) => {
1314 let body = crate::encryption::decrypt_blob(dek, bytes).map_err(|_| {
1315 JobError::Storage(
1316 "job registry authentication failed (wrong key or tampered)".to_string(),
1317 )
1318 })?;
1319 decode(&body)
1320 }
1321 None => parse_plaintext(bytes),
1322 }
1323}
1324
1325fn write_durable(
1328 root: &DurableRoot,
1329 snapshot: &JobsSnapshot,
1330 meta_dek: Option<&[u8; META_DEK_LEN]>,
1331) -> Result<(), JobError> {
1332 let body = encode(snapshot)?;
1333 let payload = seal(&body, meta_dek)?;
1334 root.write_atomic(JOBS_FILENAME, &payload)?;
1335 Ok(())
1336}
1337
1338fn read_durable(
1341 root: &DurableRoot,
1342 meta_dek: Option<&[u8; META_DEK_LEN]>,
1343) -> Result<Option<JobsSnapshot>, JobError> {
1344 let file = match root.open_regular(JOBS_FILENAME) {
1345 Ok(file) => file,
1346 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1347 Err(error) => return Err(error.into()),
1348 };
1349 let length = file.metadata()?.len();
1350 if length > MAX_JOBS_BYTES {
1351 return Err(JobError::Storage(format!(
1352 "job registry of {length} bytes exceeds the {MAX_JOBS_BYTES}-byte limit"
1353 )));
1354 }
1355 let mut bytes = Vec::with_capacity(length as usize);
1356 file.take(MAX_JOBS_BYTES + 1).read_to_end(&mut bytes)?;
1357 if bytes.len() as u64 != length {
1358 return Err(JobError::Storage(
1359 "job registry length changed while reading".to_string(),
1360 ));
1361 }
1362 open_payload(&bytes, meta_dek).map(Some)
1363}
1364
1365fn parse_plaintext(bytes: &[u8]) -> Result<JobsSnapshot, JobError> {
1366 if bytes.len() < 8 + 32 || &bytes[..8] != MAGIC {
1367 return Err(JobError::Storage(
1368 "job registry magic mismatch (corrupt or sealed with a key)".to_string(),
1369 ));
1370 }
1371 let (tag, body) = bytes[8..].split_at(32);
1372 let calc = Sha256::digest(body);
1373 if tag != calc.as_slice() {
1374 return Err(JobError::Storage(
1375 "job registry checksum mismatch (tampered or torn)".to_string(),
1376 ));
1377 }
1378 decode(body)
1379}
1380
1381fn unix_micros() -> u64 {
1384 let micros = std::time::SystemTime::now()
1385 .duration_since(std::time::UNIX_EPOCH)
1386 .map(|duration| duration.as_micros())
1387 .unwrap_or(0);
1388 u64::try_from(micros).unwrap_or(u64::MAX)
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393 use super::*;
1394
1395 fn open_temp() -> (tempfile::TempDir, JobRegistry) {
1396 let dir = tempfile::tempdir().unwrap();
1397 let registry = JobRegistry::open(dir.path(), None).unwrap();
1398 (dir, registry)
1399 }
1400
1401 fn target() -> JobTarget {
1402 JobTarget {
1403 table: "items".to_string(),
1404 index: Some("items_idx".to_string()),
1405 }
1406 }
1407
1408 #[test]
1409 fn transition_graph_matches_the_documented_edges() {
1410 let legal: [(JobState, JobState); 11] = [
1411 (JobState::Pending, JobState::Running),
1412 (JobState::Pending, JobState::Cancelling),
1413 (JobState::Running, JobState::Paused),
1414 (JobState::Running, JobState::Cancelling),
1415 (JobState::Running, JobState::RollingBack),
1416 (JobState::Running, JobState::Succeeded),
1417 (JobState::Paused, JobState::Pending),
1418 (JobState::Paused, JobState::Cancelling),
1419 (JobState::Cancelling, JobState::RollingBack),
1420 (JobState::Cancelling, JobState::Failed),
1421 (JobState::RollingBack, JobState::Failed),
1422 ];
1423 for from in JobState::ALL {
1424 for to in JobState::ALL {
1425 let expected = legal.contains(&(from, to));
1426 assert_eq!(from.can_transition(to), expected, "edge {from:?} -> {to:?}");
1427 }
1428 }
1429 for terminal in [JobState::Succeeded, JobState::Failed] {
1430 assert!(terminal.is_terminal());
1431 assert!(JobState::ALL
1432 .iter()
1433 .all(|&next| !terminal.can_transition(next)));
1434 }
1435 }
1436
1437 #[test]
1438 fn progress_validation_rejects_out_of_range_values() {
1439 assert!(JobProgress::new(0.5, 1, 2).is_ok());
1440 assert!(JobProgress::new(0.0, 0, 0).is_ok());
1441 assert!(JobProgress::new(1.0, 7, 7).is_ok());
1442 assert!(JobProgress::new(f64::NAN, 0, 0).is_err());
1443 assert!(JobProgress::new(-0.1, 0, 0).is_err());
1444 assert!(JobProgress::new(1.1, 0, 0).is_err());
1445 assert!(JobProgress::new(0.5, 3, 2).is_err());
1446 }
1447
1448 #[test]
1449 fn persistence_round_trip_preserves_records_and_allocator() {
1450 let dir = tempfile::tempdir().unwrap();
1451 let first = JobRegistry::open(dir.path(), None).unwrap();
1452 let a = first.submit(JobKind::IndexBuild, target()).unwrap();
1453 let b = first
1454 .submit(
1455 JobKind::LargeImport,
1456 JobTarget {
1457 table: "bulk".to_string(),
1458 index: None,
1459 },
1460 )
1461 .unwrap();
1462 assert_eq!((a, b), (1, 2));
1463 first.admit(a).unwrap();
1464 first
1465 .save_checkpoint(
1466 a,
1467 b"opaque-resume-state".to_vec(),
1468 JobProgress::new(0.5, 4, 8).unwrap(),
1469 )
1470 .unwrap();
1471 drop(first);
1472
1473 let reopened = JobRegistry::open(dir.path(), None).unwrap();
1474 let record = reopened.get(a).unwrap();
1477 assert_eq!(record.state, JobState::Paused);
1478 assert_eq!(record.kind, JobKind::IndexBuild);
1479 assert_eq!(record.target, target());
1480 assert_eq!(record.progress, JobProgress::new(0.5, 4, 8).unwrap());
1481 assert_eq!(
1482 record.checkpoint.as_deref(),
1483 Some(b"opaque-resume-state".as_slice())
1484 );
1485 assert!(record.error.is_none());
1486 assert!(record.updated_at_micros >= record.created_at_micros);
1487 assert_eq!(reopened.get(b).unwrap().state, JobState::Pending);
1488 let c = reopened
1490 .submit(
1491 JobKind::KeyRotation,
1492 JobTarget {
1493 table: "items".to_string(),
1494 index: None,
1495 },
1496 )
1497 .unwrap();
1498 assert_eq!(c, 3);
1499 assert_eq!(reopened.list().len(), 3);
1500 }
1501
1502 #[test]
1503 fn crash_recovery_maps_active_states_to_safe_ones() {
1504 let dir = tempfile::tempdir().unwrap();
1505 let registry = JobRegistry::open(dir.path(), None)
1506 .unwrap()
1507 .with_max_concurrent_jobs(8);
1508 let running = registry.submit(JobKind::IndexBuild, target()).unwrap();
1509 let cancelling = registry.submit(JobKind::IndexBuild, target()).unwrap();
1510 let rolling_back = registry.submit(JobKind::IndexBuild, target()).unwrap();
1511 let pending = registry.submit(JobKind::IndexBuild, target()).unwrap();
1512 let paused = registry.submit(JobKind::IndexBuild, target()).unwrap();
1513 let succeeded = registry.submit(JobKind::IndexBuild, target()).unwrap();
1514 let failed = registry.submit(JobKind::IndexBuild, target()).unwrap();
1515
1516 registry.admit(running).unwrap();
1517 registry
1518 .save_checkpoint(
1519 running,
1520 b"resume-bytes".to_vec(),
1521 JobProgress::new(0.25, 1, 4).unwrap(),
1522 )
1523 .unwrap();
1524 registry.admit(cancelling).unwrap();
1525 registry.cancel(cancelling).unwrap();
1526 registry.admit(rolling_back).unwrap();
1527 registry.begin_rollback(rolling_back).unwrap();
1528 registry.admit(paused).unwrap();
1529 registry.pause(paused).unwrap();
1530 registry.admit(succeeded).unwrap();
1531 registry.complete(succeeded).unwrap();
1532 registry.admit(failed).unwrap();
1533 registry.begin_rollback(failed).unwrap();
1534 registry.fail(failed, "boom".to_string()).unwrap();
1535 drop(registry);
1536
1537 let recovered = JobRegistry::open(dir.path(), None).unwrap();
1538 let running = recovered.get(running).unwrap();
1539 assert_eq!(running.state, JobState::Paused);
1540 assert_eq!(
1541 running.checkpoint.as_deref(),
1542 Some(b"resume-bytes".as_slice())
1543 );
1544 assert_eq!(running.progress, JobProgress::new(0.25, 1, 4).unwrap());
1545
1546 let cancelling = recovered.get(cancelling).unwrap();
1547 assert_eq!(cancelling.state, JobState::Failed);
1548 assert!(cancelling.error.unwrap().contains("cancelled"));
1549
1550 let rolling_back = recovered.get(rolling_back).unwrap();
1551 assert_eq!(rolling_back.state, JobState::Failed);
1552 assert!(rolling_back.error.unwrap().contains("rollback interrupted"));
1553
1554 assert_eq!(recovered.get(pending).unwrap().state, JobState::Pending);
1555 assert_eq!(recovered.get(paused).unwrap().state, JobState::Paused);
1556 assert_eq!(recovered.get(succeeded).unwrap().state, JobState::Succeeded);
1557 let failed = recovered.get(failed).unwrap();
1558 assert_eq!(failed.state, JobState::Failed);
1559 assert_eq!(failed.error.as_deref(), Some("boom"));
1560
1561 drop(recovered);
1563 let again = JobRegistry::open(dir.path(), None).unwrap();
1564 assert_eq!(again.list().len(), 7);
1565 assert!(again.list().iter().all(|record| !matches!(
1566 record.state,
1567 JobState::Running | JobState::Cancelling | JobState::RollingBack
1568 )));
1569 }
1570
1571 #[test]
1572 fn admission_enforces_the_concurrency_bound() {
1573 let (_dir, registry) = open_temp();
1574 let registry = registry.with_max_concurrent_jobs(1);
1575 let a = registry.submit(JobKind::IndexBuild, target()).unwrap();
1576 let b = registry.submit(JobKind::IndexBuild, target()).unwrap();
1577 registry.admit(a).unwrap();
1578 let error = registry.admit(b).unwrap_err();
1579 assert!(
1580 matches!(
1581 error,
1582 JobError::ConcurrencyLimit {
1583 active: 1,
1584 limit: 1
1585 }
1586 ),
1587 "expected ConcurrencyLimit, got {error:?}"
1588 );
1589 assert_eq!(registry.get(b).unwrap().state, JobState::Pending);
1591 registry.complete(a).unwrap();
1592 registry.admit(b).unwrap();
1593 }
1594
1595 #[test]
1596 fn pause_resume_cancel_follow_the_graph() {
1597 let (_dir, registry) = open_temp();
1598 let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1599 assert!(matches!(
1601 registry.pause(job),
1602 Err(JobError::IllegalTransition {
1603 from: JobState::Pending,
1604 to: JobState::Paused
1605 })
1606 ));
1607 registry.admit(job).unwrap();
1608 registry.pause(job).unwrap();
1609 assert_eq!(registry.get(job).unwrap().state, JobState::Paused);
1610 registry.resume(job).unwrap();
1611 assert_eq!(registry.get(job).unwrap().state, JobState::Pending);
1612 registry.admit(job).unwrap();
1613 registry.cancel(job).unwrap();
1614 assert_eq!(registry.get(job).unwrap().state, JobState::Cancelling);
1615 assert!(registry.cancellation_token(job).unwrap().is_cancelled());
1616 registry.cancel(job).unwrap();
1618 registry.begin_rollback(job).unwrap();
1619 registry.fail(job, "cancelled".to_string()).unwrap();
1620 assert!(matches!(
1622 registry.cancel(job),
1623 Err(JobError::IllegalTransition {
1624 from: JobState::Failed,
1625 to: JobState::Cancelling
1626 })
1627 ));
1628 assert!(matches!(
1629 registry.resume(job),
1630 Err(JobError::IllegalTransition {
1631 from: JobState::Failed,
1632 to: JobState::Pending
1633 })
1634 ));
1635 }
1636
1637 #[test]
1638 fn cancel_without_a_worker_completes_synchronously() {
1639 let (_dir, registry) = open_temp();
1640 let queued = registry.submit(JobKind::IndexBuild, target()).unwrap();
1641 registry.cancel(queued).unwrap();
1642 let record = registry.get(queued).unwrap();
1643 assert_eq!(record.state, JobState::Failed);
1644 assert_eq!(
1645 record.error.as_deref(),
1646 Some("cancelled before the job started")
1647 );
1648 assert!(registry.cancellation_token(queued).unwrap().is_cancelled());
1649
1650 let parked = registry.submit(JobKind::IndexBuild, target()).unwrap();
1651 registry.admit(parked).unwrap();
1652 registry.pause(parked).unwrap();
1653 registry.cancel(parked).unwrap();
1654 let record = registry.get(parked).unwrap();
1655 assert_eq!(record.state, JobState::Failed);
1656 assert_eq!(
1657 record.error.as_deref(),
1658 Some("cancelled while the job was paused")
1659 );
1660 }
1661
1662 #[test]
1663 fn missing_file_opens_empty_and_file_is_created_on_first_mutation() {
1664 let dir = tempfile::tempdir().unwrap();
1665 let registry = JobRegistry::open(dir.path(), None).unwrap();
1666 assert!(registry.list().is_empty());
1667 assert!(!dir.path().join(JOBS_FILENAME).exists());
1668 registry.submit(JobKind::IndexBuild, target()).unwrap();
1669 assert!(dir.path().join(JOBS_FILENAME).exists());
1670 }
1671
1672 #[test]
1673 fn tampered_file_fails_closed() {
1674 let dir = tempfile::tempdir().unwrap();
1676 let registry = JobRegistry::open(dir.path(), None).unwrap();
1677 registry.submit(JobKind::IndexBuild, target()).unwrap();
1678 drop(registry);
1679 let path = dir.path().join(JOBS_FILENAME);
1680 let mut bytes = std::fs::read(&path).unwrap();
1681 let last = bytes.len() - 1;
1682 bytes[last] ^= 0x01;
1683 std::fs::write(&path, &bytes).unwrap();
1684 let error = JobRegistry::open(dir.path(), None).unwrap_err();
1685 assert!(
1686 matches!(&error, JobError::Storage(message) if message.contains("checksum")),
1687 "expected checksum failure, got {error:?}"
1688 );
1689
1690 bytes[..8].copy_from_slice(b"NOTAJOB!");
1692 std::fs::write(&path, &bytes).unwrap();
1693 let error = JobRegistry::open(dir.path(), None).unwrap_err();
1694 assert!(
1695 matches!(&error, JobError::Storage(message) if message.contains("magic")),
1696 "expected magic failure, got {error:?}"
1697 );
1698
1699 std::fs::write(&path, b"MON").unwrap();
1701 assert!(matches!(
1702 JobRegistry::open(dir.path(), None),
1703 Err(JobError::Storage(_))
1704 ));
1705 }
1706
1707 #[test]
1708 fn unsupported_format_version_fails_closed() {
1709 let dir = tempfile::tempdir().unwrap();
1710 let body = serde_json::to_vec(&serde_json::json!({
1711 "format_version": 99,
1712 "registry": { "next_job_id": 1, "jobs": [] }
1713 }))
1714 .unwrap();
1715 let payload = plaintext_frame(&body);
1716 std::fs::write(dir.path().join(JOBS_FILENAME), payload).unwrap();
1717 let error = JobRegistry::open(dir.path(), None).unwrap_err();
1718 assert!(
1719 matches!(&error, JobError::Storage(message) if message.contains("version 99")),
1720 "expected version failure, got {error:?}"
1721 );
1722 }
1723
1724 #[test]
1725 fn allocator_inconsistency_fails_closed() {
1726 let dir = tempfile::tempdir().unwrap();
1727 let body = serde_json::to_vec(&serde_json::json!({
1728 "format_version": 1,
1729 "registry": {
1730 "next_job_id": 1,
1731 "jobs": [{
1732 "job_id": 1,
1733 "kind": "IndexBuild",
1734 "state": "Pending",
1735 "target": { "table": "items" },
1736 "progress": { "fraction": 0.0, "done": 0, "total": 0 },
1737 "created_at_micros": 1,
1738 "updated_at_micros": 1
1739 }]
1740 }
1741 }))
1742 .unwrap();
1743 std::fs::write(dir.path().join(JOBS_FILENAME), plaintext_frame(&body)).unwrap();
1744 assert!(matches!(
1745 JobRegistry::open(dir.path(), None),
1746 Err(JobError::Storage(_))
1747 ));
1748 }
1749
1750 #[test]
1751 fn resume_rejects_a_job_with_a_live_drive() {
1752 let (_dir, registry) = open_temp();
1753 let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1754 registry.admit(job).unwrap();
1755 registry.pause(job).unwrap();
1756 registry.active_drives.lock().insert(job);
1758 assert!(matches!(
1759 registry.resume(job),
1760 Err(JobError::DriveActive { job_id }) if job_id == job
1761 ));
1762 assert_eq!(registry.get(job).unwrap().state, JobState::Paused);
1763 registry.active_drives.lock().remove(&job);
1764 registry.resume(job).unwrap();
1765 assert_eq!(registry.get(job).unwrap().state, JobState::Pending);
1766 }
1767
1768 #[test]
1769 fn checkpoint_size_is_bounded() {
1770 let (_dir, registry) = open_temp();
1771 let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1772 registry.admit(job).unwrap();
1773 let oversized = vec![0_u8; MAX_CHECKPOINT_BYTES + 1];
1774 assert!(matches!(
1775 registry.save_checkpoint(job, oversized, JobProgress::default()),
1776 Err(JobError::CheckpointTooLarge { .. })
1777 ));
1778 }
1779
1780 #[test]
1781 fn job_error_maps_onto_the_engine_error() {
1782 assert!(matches!(
1783 MongrelError::from(JobError::Cancelled),
1784 MongrelError::Cancelled
1785 ));
1786 assert!(matches!(
1787 MongrelError::from(JobError::NotFound { job_id: 7 }),
1788 MongrelError::NotFound(_)
1789 ));
1790 assert!(matches!(
1791 MongrelError::from(JobError::ConcurrencyLimit {
1792 active: 3,
1793 limit: 2
1794 }),
1795 MongrelError::ResourceLimitExceeded { .. }
1796 ));
1797 assert!(matches!(
1798 MongrelError::from(JobError::InjectedFault("x".to_string())),
1799 MongrelError::Other(_)
1800 ));
1801 }
1802
1803 #[test]
1804 fn record_serde_uses_stable_text_encoding() {
1805 let (_dir, registry) = open_temp();
1806 let job = registry
1807 .submit(JobKind::MaterializedViewRebuild, target())
1808 .unwrap();
1809 registry.admit(job).unwrap();
1810 let record = registry.get(job).unwrap();
1811 let json = serde_json::to_string(&record).unwrap();
1812 assert!(json.contains("\"kind\":\"MaterializedViewRebuild\""));
1813 assert!(json.contains("\"state\":\"Running\""));
1814 let decoded: JobRecord = serde_json::from_str(&json).unwrap();
1815 assert_eq!(decoded, record);
1816 let unknown = json.replace("\"Running\"", "\"Napping\"");
1818 assert!(serde_json::from_str::<JobRecord>(&unknown).is_err());
1819 }
1820
1821 #[test]
1822 fn checkpoint_codec_round_trip_and_bounds() {
1823 let bytes = encode_build_checkpoint(3, b"impl-state").unwrap();
1824 let decoded = decode_build_checkpoint(&bytes).unwrap();
1825 assert_eq!(decoded.completed_phases, 3);
1826 assert_eq!(decoded.state, b"impl-state");
1827 let corrupt = encode_build_checkpoint(8, b"").unwrap();
1828 assert!(matches!(
1829 decode_build_checkpoint(&corrupt),
1830 Err(JobError::Storage(_))
1831 ));
1832 }
1833}