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 #[cfg(not(feature = "encryption"))]
691 if meta_dek.is_some() {
692 return Err(JobError::Storage(
693 "a metadata key was supplied but the `encryption` feature is disabled".to_string(),
694 ));
695 }
696 let root = DurableRoot::open(dir)?;
697 let mut inner = match read_durable(&root, meta_dek)? {
698 Some(snapshot) => validate_snapshot(snapshot)?,
699 None => RegistryInner {
700 next_job_id: 1,
701 jobs: BTreeMap::new(),
702 },
703 };
704 let recovered = recover_after_crash(&mut inner);
705 let registry = Self {
706 root,
707 meta_dek: meta_dek.copied(),
708 inner: Mutex::new(inner),
709 tokens: Mutex::new(HashMap::new()),
710 active_drives: Mutex::new(HashSet::new()),
711 max_concurrent_jobs: DEFAULT_MAX_CONCURRENT_JOBS,
712 };
713 if recovered {
714 registry.persist_locked(®istry.inner.lock())?;
715 }
716 Ok(registry)
717 }
718
719 pub fn with_max_concurrent_jobs(mut self, limit: usize) -> Self {
721 self.max_concurrent_jobs = limit.max(1);
722 self
723 }
724
725 pub fn submit(&self, kind: JobKind, target: JobTarget) -> Result<u64, JobError> {
727 if target.table.is_empty() {
728 return Err(JobError::InvalidProgress(
729 "job target table must not be empty".to_string(),
730 ));
731 }
732 if kind == JobKind::IndexBuild && target.index.is_none() {
733 return Err(JobError::InvalidProgress(
734 "an index-build job requires a target index".to_string(),
735 ));
736 }
737 let mut next = self.inner.lock().clone();
738 let now = unix_micros();
739 let job_id = next.next_job_id;
740 next.next_job_id = next
741 .next_job_id
742 .checked_add(1)
743 .ok_or_else(|| JobError::Storage("job id space exhausted".to_string()))?;
744 next.jobs.insert(
745 job_id,
746 JobRecord {
747 job_id,
748 kind,
749 state: JobState::Pending,
750 target,
751 progress: JobProgress::default(),
752 created_at_micros: now,
753 updated_at_micros: now,
754 error: None,
755 checkpoint: None,
756 },
757 );
758 self.persist_and_swap(next)?;
759 self.tokens
760 .lock()
761 .insert(job_id, CancellationToken::default());
762 Ok(job_id)
763 }
764
765 pub fn get(&self, job_id: u64) -> Option<JobRecord> {
767 self.inner.lock().jobs.get(&job_id).cloned()
768 }
769
770 pub fn list(&self) -> Vec<JobRecord> {
772 self.inner.lock().jobs.values().cloned().collect()
773 }
774
775 pub fn cancellation_token(&self, job_id: u64) -> Option<CancellationToken> {
777 if !self.inner.lock().jobs.contains_key(&job_id) {
778 return None;
779 }
780 Some(self.tokens.lock().entry(job_id).or_default().clone())
781 }
782
783 pub fn pause(&self, job_id: u64) -> Result<(), JobError> {
787 self.transition(job_id, JobState::Paused)
788 }
789
790 pub fn resume(&self, job_id: u64) -> Result<(), JobError> {
795 if self.active_drives.lock().contains(&job_id) {
796 return Err(JobError::DriveActive { job_id });
797 }
798 self.transition(job_id, JobState::Pending)
799 }
800
801 pub fn cancel(&self, job_id: u64) -> Result<(), JobError> {
811 let state = self.get(job_id).ok_or(JobError::NotFound { job_id })?.state;
812 match state {
813 JobState::Pending | JobState::Paused => {
814 let mut next = self.inner.lock().clone();
815 let record = next.jobs.get_mut(&job_id).expect("record checked above");
816 apply_transition(record, JobState::Cancelling)?;
817 apply_transition(record, JobState::Failed)?;
818 record.error = Some(match state {
819 JobState::Pending => "cancelled before the job started".to_string(),
820 _ => "cancelled while the job was paused".to_string(),
821 });
822 self.persist_and_swap(next)?;
823 self.tokens.lock().entry(job_id).or_default().cancel();
824 Ok(())
825 }
826 JobState::Running => {
827 self.transition(job_id, JobState::Cancelling)?;
828 self.tokens.lock().entry(job_id).or_default().cancel();
829 Ok(())
830 }
831 JobState::Cancelling | JobState::RollingBack => Ok(()),
832 JobState::Succeeded | JobState::Failed => Err(JobError::IllegalTransition {
833 from: state,
834 to: JobState::Cancelling,
835 }),
836 }
837 }
838
839 fn update_progress(&self, job_id: u64, progress: JobProgress) -> Result<(), JobError> {
841 let mut next = self.inner.lock().clone();
842 let record = next
843 .jobs
844 .get_mut(&job_id)
845 .ok_or(JobError::NotFound { job_id })?;
846 if record.state != JobState::Running {
847 return Err(JobError::UnexpectedState {
848 job_id,
849 expected: JobState::Running,
850 actual: record.state,
851 });
852 }
853 record.progress = progress;
854 record.updated_at_micros = unix_micros();
855 self.persist_and_swap(next)
856 }
857
858 fn transition(&self, job_id: u64, to: JobState) -> Result<(), JobError> {
860 let mut next = self.inner.lock().clone();
861 let record = next
862 .jobs
863 .get_mut(&job_id)
864 .ok_or(JobError::NotFound { job_id })?;
865 apply_transition(record, to)?;
866 self.persist_and_swap(next)
867 }
868
869 fn admit(&self, job_id: u64) -> Result<(), JobError> {
871 let mut next = self.inner.lock().clone();
872 let active = next
873 .jobs
874 .values()
875 .filter(|record| {
876 matches!(
877 record.state,
878 JobState::Running | JobState::Cancelling | JobState::RollingBack
879 )
880 })
881 .count();
882 let record = next
883 .jobs
884 .get(&job_id)
885 .ok_or(JobError::NotFound { job_id })?;
886 if record.state != JobState::Pending {
887 return Err(JobError::IllegalTransition {
888 from: record.state,
889 to: JobState::Running,
890 });
891 }
892 if active >= self.max_concurrent_jobs {
893 return Err(JobError::ConcurrencyLimit {
894 active,
895 limit: self.max_concurrent_jobs,
896 });
897 }
898 let record = next.jobs.get_mut(&job_id).expect("record checked above");
899 apply_transition(record, JobState::Running)?;
900 self.persist_and_swap(next)
901 }
902
903 fn save_checkpoint(
908 &self,
909 job_id: u64,
910 checkpoint: Vec<u8>,
911 progress: JobProgress,
912 ) -> Result<(), JobError> {
913 if checkpoint.len() > MAX_CHECKPOINT_BYTES {
914 return Err(JobError::CheckpointTooLarge {
915 bytes: checkpoint.len(),
916 limit: MAX_CHECKPOINT_BYTES,
917 });
918 }
919 let mut next = self.inner.lock().clone();
920 let record = next
921 .jobs
922 .get_mut(&job_id)
923 .ok_or(JobError::NotFound { job_id })?;
924 if !matches!(record.state, JobState::Running | JobState::Paused) {
925 return Err(JobError::UnexpectedState {
926 job_id,
927 expected: JobState::Running,
928 actual: record.state,
929 });
930 }
931 record.checkpoint = Some(checkpoint);
932 record.progress = progress;
933 record.updated_at_micros = unix_micros();
934 self.persist_and_swap(next)
935 }
936
937 fn complete(&self, job_id: u64) -> Result<(), JobError> {
940 let mut next = self.inner.lock().clone();
941 let record = next
942 .jobs
943 .get_mut(&job_id)
944 .ok_or(JobError::NotFound { job_id })?;
945 apply_transition(record, JobState::Succeeded)?;
946 record.checkpoint = None;
947 record.progress.fraction = 1.0;
948 self.persist_and_swap(next)
949 }
950
951 fn begin_rollback(&self, job_id: u64) -> Result<(), JobError> {
953 self.transition(job_id, JobState::RollingBack)
954 }
955
956 fn fail(&self, job_id: u64, error: String) -> Result<(), JobError> {
958 let mut next = self.inner.lock().clone();
959 let record = next
960 .jobs
961 .get_mut(&job_id)
962 .ok_or(JobError::NotFound { job_id })?;
963 apply_transition(record, JobState::Failed)?;
964 record.error = Some(error);
965 self.persist_and_swap(next)
966 }
967
968 fn persist_and_swap(&self, next: RegistryInner) -> Result<(), JobError> {
972 self.persist_locked(&next)?;
973 *self.inner.lock() = next;
974 Ok(())
975 }
976
977 fn persist_locked(&self, inner: &RegistryInner) -> Result<(), JobError> {
978 let snapshot = JobsSnapshot {
979 next_job_id: inner.next_job_id,
980 jobs: inner.jobs.values().cloned().collect(),
981 };
982 write_durable(&self.root, &snapshot, self.meta_dek.as_ref())
983 }
984}
985
986pub fn run_build_publish<J: BuildPublishJob + ?Sized>(
1000 registry: &JobRegistry,
1001 job_id: u64,
1002 job: &mut J,
1003) -> Result<(), JobError> {
1004 let record = registry.get(job_id).ok_or(JobError::NotFound { job_id })?;
1005 if record.state != JobState::Pending {
1006 return Err(JobError::IllegalTransition {
1007 from: record.state,
1008 to: JobState::Running,
1009 });
1010 }
1011 let mut completed_phases = 0_usize;
1014 if let Some(checkpoint) = &record.checkpoint {
1015 let decoded = decode_build_checkpoint(checkpoint)?;
1016 job.restore_checkpoint(&decoded.state)?;
1017 completed_phases = usize::from(decoded.completed_phases);
1018 }
1019 registry.admit(job_id)?;
1020 let _drive = DriveGuard { registry, job_id };
1023 let token = registry
1024 .cancellation_token(job_id)
1025 .ok_or(JobError::NotFound { job_id })?;
1026 let context = JobContext {
1027 registry,
1028 job_id,
1029 token,
1030 };
1031
1032 for (index, phase) in BuildPhase::ALL
1033 .iter()
1034 .copied()
1035 .enumerate()
1036 .skip(completed_phases)
1037 {
1038 let state = registry
1042 .get(job_id)
1043 .ok_or(JobError::NotFound { job_id })?
1044 .state;
1045 match state {
1046 JobState::Running => {}
1047 JobState::Paused => return Ok(()),
1048 JobState::Cancelling => {
1049 return rollback_and_fail(registry, job_id, job, JobError::Cancelled);
1050 }
1051 state => {
1052 return Err(JobError::IllegalTransition {
1053 from: state,
1054 to: JobState::Running,
1055 });
1056 }
1057 }
1058 if let Err(fault) = mongreldb_fault::inject(phase.before_hook()) {
1059 return park_on_fault(registry, job_id, job, fault);
1060 }
1061 let outcome = phase.invoke(job, &context);
1062 if let Err(fault) = mongreldb_fault::inject(phase.after_hook()) {
1063 return park_on_fault(registry, job_id, job, fault);
1064 }
1065 if let Err(error) = outcome {
1066 return rollback_and_fail(registry, job_id, job, error);
1067 }
1068 completed_phases = index + 1;
1069 let checkpoint = encode_build_checkpoint(completed_phases as u8, &job.checkpoint_state())?;
1070 let total = BuildPhase::ALL.len() as u64;
1071 let done = completed_phases as u64;
1072 let progress = JobProgress::new(done as f64 / total as f64, done, total)?;
1073 let state = registry
1078 .get(job_id)
1079 .ok_or(JobError::NotFound { job_id })?
1080 .state;
1081 match state {
1082 JobState::Running => registry.save_checkpoint(job_id, checkpoint, progress)?,
1083 JobState::Paused => {
1084 registry.save_checkpoint(job_id, checkpoint, progress)?;
1085 return Ok(());
1086 }
1087 JobState::Cancelling => {
1088 return rollback_and_fail(registry, job_id, job, JobError::Cancelled);
1089 }
1090 state => {
1091 return Err(JobError::IllegalTransition {
1092 from: state,
1093 to: JobState::Running,
1094 });
1095 }
1096 }
1097 }
1098 match registry.complete(job_id) {
1099 Ok(()) => Ok(()),
1100 Err(JobError::IllegalTransition { from, .. }) => match from {
1101 JobState::Paused => Ok(()),
1104 JobState::Cancelling => rollback_and_fail(registry, job_id, job, JobError::Cancelled),
1105 state => Err(JobError::IllegalTransition {
1106 from: state,
1107 to: JobState::Succeeded,
1108 }),
1109 },
1110 Err(error) => Err(error),
1111 }
1112}
1113
1114fn park_on_fault<J: BuildPublishJob + ?Sized>(
1118 registry: &JobRegistry,
1119 job_id: u64,
1120 job: &mut J,
1121 fault: mongreldb_fault::Fault,
1122) -> Result<(), JobError> {
1123 match registry.pause(job_id) {
1124 Ok(()) => Err(JobError::InjectedFault(fault.to_string())),
1125 Err(JobError::IllegalTransition {
1126 from: JobState::Cancelling,
1127 ..
1128 }) => rollback_and_fail(registry, job_id, job, JobError::Cancelled),
1129 Err(error) => Err(error),
1130 }
1131}
1132
1133fn rollback_and_fail<J: BuildPublishJob + ?Sized>(
1137 registry: &JobRegistry,
1138 job_id: u64,
1139 job: &mut J,
1140 error: JobError,
1141) -> Result<(), JobError> {
1142 match registry.begin_rollback(job_id) {
1143 Ok(()) => {}
1144 Err(JobError::IllegalTransition { .. }) => return Err(error),
1148 Err(storage) => return Err(storage),
1149 }
1150 let message = match job.rollback() {
1151 Ok(()) => error.to_string(),
1152 Err(rollback_error) => format!("{error}; rollback also failed: {rollback_error}"),
1153 };
1154 registry.fail(job_id, message)?;
1155 Err(error)
1156}
1157
1158struct DriveGuard<'a> {
1162 registry: &'a JobRegistry,
1163 job_id: u64,
1164}
1165
1166impl Drop for DriveGuard<'_> {
1167 fn drop(&mut self) {
1168 self.registry.active_drives.lock().remove(&self.job_id);
1169 }
1170}
1171
1172#[derive(Serialize, Deserialize)]
1175#[serde(deny_unknown_fields)]
1176struct BuildPublishCheckpoint {
1177 completed_phases: u8,
1178 state: Vec<u8>,
1179}
1180
1181fn encode_build_checkpoint(completed_phases: u8, state: &[u8]) -> Result<Vec<u8>, JobError> {
1182 serde_json::to_vec(&BuildPublishCheckpoint {
1183 completed_phases,
1184 state: state.to_vec(),
1185 })
1186 .map_err(|error| JobError::Storage(format!("job checkpoint serialize: {error}")))
1187}
1188
1189fn decode_build_checkpoint(bytes: &[u8]) -> Result<BuildPublishCheckpoint, JobError> {
1190 let checkpoint: BuildPublishCheckpoint = serde_json::from_slice(bytes)
1191 .map_err(|error| JobError::Storage(format!("job checkpoint deserialize: {error}")))?;
1192 if usize::from(checkpoint.completed_phases) > BuildPhase::ALL.len() {
1193 return Err(JobError::Storage(format!(
1194 "job checkpoint claims {} completed phases, only {} exist",
1195 checkpoint.completed_phases,
1196 BuildPhase::ALL.len()
1197 )));
1198 }
1199 Ok(checkpoint)
1200}
1201
1202fn apply_transition(record: &mut JobRecord, to: JobState) -> Result<(), JobError> {
1203 if !record.state.can_transition(to) {
1204 return Err(JobError::IllegalTransition {
1205 from: record.state,
1206 to,
1207 });
1208 }
1209 record.state = to;
1210 record.updated_at_micros = unix_micros();
1211 Ok(())
1212}
1213
1214fn recover_after_crash(inner: &mut RegistryInner) -> bool {
1216 let now = unix_micros();
1217 let mut changed = false;
1218 for record in inner.jobs.values_mut() {
1219 match record.state {
1220 JobState::Running => {
1221 record.state = JobState::Paused;
1224 record.updated_at_micros = now;
1225 changed = true;
1226 }
1227 JobState::Cancelling => {
1228 record.state = JobState::Failed;
1230 record.error =
1231 Some("cancelled (process restarted while the job was cancelling)".to_string());
1232 record.updated_at_micros = now;
1233 changed = true;
1234 }
1235 JobState::RollingBack => {
1236 record.state = JobState::Failed;
1237 const NOTE: &str = "rollback interrupted by process restart";
1238 record.error = Some(match record.error.take() {
1239 Some(error) => format!("{error}; {NOTE}"),
1240 None => NOTE.to_string(),
1241 });
1242 record.updated_at_micros = now;
1243 changed = true;
1244 }
1245 JobState::Pending | JobState::Paused | JobState::Succeeded | JobState::Failed => {}
1246 }
1247 }
1248 changed
1249}
1250
1251fn validate_snapshot(snapshot: JobsSnapshot) -> Result<RegistryInner, JobError> {
1254 let mut jobs = BTreeMap::new();
1255 for record in snapshot.jobs {
1256 if jobs.insert(record.job_id, record).is_some() {
1257 return Err(JobError::Storage(
1258 "duplicate job id in registry file".to_string(),
1259 ));
1260 }
1261 }
1262 let max_id = jobs.keys().next_back().copied().unwrap_or(0);
1263 if snapshot.next_job_id <= max_id {
1264 return Err(JobError::Storage(format!(
1265 "registry allocator at {} would reissue job id {max_id}",
1266 snapshot.next_job_id
1267 )));
1268 }
1269 Ok(RegistryInner {
1270 next_job_id: snapshot.next_job_id.max(1),
1271 jobs,
1272 })
1273}
1274
1275fn encode(snapshot: &JobsSnapshot) -> Result<Vec<u8>, JobError> {
1276 serde_json::to_vec(&JobsEnvelope {
1277 format_version: JOBS_FORMAT_VERSION,
1278 registry: snapshot.clone(),
1279 })
1280 .map_err(|error| JobError::Storage(format!("job registry serialize: {error}")))
1281}
1282
1283fn decode(body: &[u8]) -> Result<JobsSnapshot, JobError> {
1284 let envelope: JobsEnvelope = serde_json::from_slice(body)
1285 .map_err(|error| JobError::Storage(format!("job registry deserialize: {error}")))?;
1286 if envelope.format_version != JOBS_FORMAT_VERSION {
1287 return Err(JobError::Storage(format!(
1288 "unsupported job registry format version {}",
1289 envelope.format_version
1290 )));
1291 }
1292 Ok(envelope.registry)
1293}
1294
1295fn plaintext_frame(body: &[u8]) -> Vec<u8> {
1296 let hash = Sha256::digest(body);
1297 let mut out = Vec::with_capacity(body.len() + 8 + 32);
1298 out.extend_from_slice(MAGIC);
1299 out.extend_from_slice(&hash);
1300 out.extend_from_slice(body);
1301 out
1302}
1303
1304#[cfg(feature = "encryption")]
1305fn seal(body: &[u8], meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Vec<u8>, JobError> {
1306 match meta_dek {
1307 Some(dek) => crate::encryption::encrypt_blob(dek, body)
1308 .map_err(|error| JobError::Storage(format!("job registry seal: {error}"))),
1309 None => Ok(plaintext_frame(body)),
1310 }
1311}
1312
1313#[cfg(not(feature = "encryption"))]
1314fn seal(body: &[u8], meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Vec<u8>, JobError> {
1315 if meta_dek.is_some() {
1316 return Err(JobError::Storage(
1317 "a metadata key was supplied but the `encryption` feature is disabled".to_string(),
1318 ));
1319 }
1320 Ok(plaintext_frame(body))
1321}
1322
1323#[cfg(feature = "encryption")]
1324fn open_payload(
1325 bytes: &[u8],
1326 meta_dek: Option<&[u8; META_DEK_LEN]>,
1327) -> Result<JobsSnapshot, JobError> {
1328 match meta_dek {
1329 Some(dek) => {
1332 let body = crate::encryption::decrypt_blob(dek, bytes).map_err(|_| {
1333 JobError::Storage(
1334 "job registry authentication failed (wrong key or tampered)".to_string(),
1335 )
1336 })?;
1337 decode(&body)
1338 }
1339 None => parse_plaintext(bytes),
1340 }
1341}
1342
1343#[cfg(not(feature = "encryption"))]
1344fn open_payload(
1345 bytes: &[u8],
1346 meta_dek: Option<&[u8; META_DEK_LEN]>,
1347) -> Result<JobsSnapshot, JobError> {
1348 if meta_dek.is_some() {
1349 return Err(JobError::Storage(
1350 "a metadata key was supplied but the `encryption` feature is disabled".to_string(),
1351 ));
1352 }
1353 parse_plaintext(bytes)
1354}
1355
1356fn write_durable(
1359 root: &DurableRoot,
1360 snapshot: &JobsSnapshot,
1361 meta_dek: Option<&[u8; META_DEK_LEN]>,
1362) -> Result<(), JobError> {
1363 let body = encode(snapshot)?;
1364 let payload = seal(&body, meta_dek)?;
1365 root.write_atomic(JOBS_FILENAME, &payload)?;
1366 Ok(())
1367}
1368
1369fn read_durable(
1372 root: &DurableRoot,
1373 meta_dek: Option<&[u8; META_DEK_LEN]>,
1374) -> Result<Option<JobsSnapshot>, JobError> {
1375 let file = match root.open_regular(JOBS_FILENAME) {
1376 Ok(file) => file,
1377 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1378 Err(error) => return Err(error.into()),
1379 };
1380 let length = file.metadata()?.len();
1381 if length > MAX_JOBS_BYTES {
1382 return Err(JobError::Storage(format!(
1383 "job registry of {length} bytes exceeds the {MAX_JOBS_BYTES}-byte limit"
1384 )));
1385 }
1386 let mut bytes = Vec::with_capacity(length as usize);
1387 file.take(MAX_JOBS_BYTES + 1).read_to_end(&mut bytes)?;
1388 if bytes.len() as u64 != length {
1389 return Err(JobError::Storage(
1390 "job registry length changed while reading".to_string(),
1391 ));
1392 }
1393 open_payload(&bytes, meta_dek).map(Some)
1394}
1395
1396fn parse_plaintext(bytes: &[u8]) -> Result<JobsSnapshot, JobError> {
1397 if bytes.len() < 8 + 32 || &bytes[..8] != MAGIC {
1398 return Err(JobError::Storage(
1399 "job registry magic mismatch (corrupt or sealed with a key)".to_string(),
1400 ));
1401 }
1402 let (tag, body) = bytes[8..].split_at(32);
1403 let calc = Sha256::digest(body);
1404 if tag != calc.as_slice() {
1405 return Err(JobError::Storage(
1406 "job registry checksum mismatch (tampered or torn)".to_string(),
1407 ));
1408 }
1409 decode(body)
1410}
1411
1412fn unix_micros() -> u64 {
1415 let micros = std::time::SystemTime::now()
1416 .duration_since(std::time::UNIX_EPOCH)
1417 .map(|duration| duration.as_micros())
1418 .unwrap_or(0);
1419 u64::try_from(micros).unwrap_or(u64::MAX)
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424 use super::*;
1425
1426 fn open_temp() -> (tempfile::TempDir, JobRegistry) {
1427 let dir = tempfile::tempdir().unwrap();
1428 let registry = JobRegistry::open(dir.path(), None).unwrap();
1429 (dir, registry)
1430 }
1431
1432 fn target() -> JobTarget {
1433 JobTarget {
1434 table: "items".to_string(),
1435 index: Some("items_idx".to_string()),
1436 }
1437 }
1438
1439 #[test]
1440 fn transition_graph_matches_the_documented_edges() {
1441 let legal: [(JobState, JobState); 11] = [
1442 (JobState::Pending, JobState::Running),
1443 (JobState::Pending, JobState::Cancelling),
1444 (JobState::Running, JobState::Paused),
1445 (JobState::Running, JobState::Cancelling),
1446 (JobState::Running, JobState::RollingBack),
1447 (JobState::Running, JobState::Succeeded),
1448 (JobState::Paused, JobState::Pending),
1449 (JobState::Paused, JobState::Cancelling),
1450 (JobState::Cancelling, JobState::RollingBack),
1451 (JobState::Cancelling, JobState::Failed),
1452 (JobState::RollingBack, JobState::Failed),
1453 ];
1454 for from in JobState::ALL {
1455 for to in JobState::ALL {
1456 let expected = legal.contains(&(from, to));
1457 assert_eq!(from.can_transition(to), expected, "edge {from:?} -> {to:?}");
1458 }
1459 }
1460 for terminal in [JobState::Succeeded, JobState::Failed] {
1461 assert!(terminal.is_terminal());
1462 assert!(JobState::ALL
1463 .iter()
1464 .all(|&next| !terminal.can_transition(next)));
1465 }
1466 }
1467
1468 #[test]
1469 fn progress_validation_rejects_out_of_range_values() {
1470 assert!(JobProgress::new(0.5, 1, 2).is_ok());
1471 assert!(JobProgress::new(0.0, 0, 0).is_ok());
1472 assert!(JobProgress::new(1.0, 7, 7).is_ok());
1473 assert!(JobProgress::new(f64::NAN, 0, 0).is_err());
1474 assert!(JobProgress::new(-0.1, 0, 0).is_err());
1475 assert!(JobProgress::new(1.1, 0, 0).is_err());
1476 assert!(JobProgress::new(0.5, 3, 2).is_err());
1477 }
1478
1479 #[test]
1480 fn persistence_round_trip_preserves_records_and_allocator() {
1481 let dir = tempfile::tempdir().unwrap();
1482 let first = JobRegistry::open(dir.path(), None).unwrap();
1483 let a = first.submit(JobKind::IndexBuild, target()).unwrap();
1484 let b = first
1485 .submit(
1486 JobKind::LargeImport,
1487 JobTarget {
1488 table: "bulk".to_string(),
1489 index: None,
1490 },
1491 )
1492 .unwrap();
1493 assert_eq!((a, b), (1, 2));
1494 first.admit(a).unwrap();
1495 first
1496 .save_checkpoint(
1497 a,
1498 b"opaque-resume-state".to_vec(),
1499 JobProgress::new(0.5, 4, 8).unwrap(),
1500 )
1501 .unwrap();
1502 drop(first);
1503
1504 let reopened = JobRegistry::open(dir.path(), None).unwrap();
1505 let record = reopened.get(a).unwrap();
1508 assert_eq!(record.state, JobState::Paused);
1509 assert_eq!(record.kind, JobKind::IndexBuild);
1510 assert_eq!(record.target, target());
1511 assert_eq!(record.progress, JobProgress::new(0.5, 4, 8).unwrap());
1512 assert_eq!(
1513 record.checkpoint.as_deref(),
1514 Some(b"opaque-resume-state".as_slice())
1515 );
1516 assert!(record.error.is_none());
1517 assert!(record.updated_at_micros >= record.created_at_micros);
1518 assert_eq!(reopened.get(b).unwrap().state, JobState::Pending);
1519 let c = reopened
1521 .submit(
1522 JobKind::KeyRotation,
1523 JobTarget {
1524 table: "items".to_string(),
1525 index: None,
1526 },
1527 )
1528 .unwrap();
1529 assert_eq!(c, 3);
1530 assert_eq!(reopened.list().len(), 3);
1531 }
1532
1533 #[test]
1534 fn crash_recovery_maps_active_states_to_safe_ones() {
1535 let dir = tempfile::tempdir().unwrap();
1536 let registry = JobRegistry::open(dir.path(), None)
1537 .unwrap()
1538 .with_max_concurrent_jobs(8);
1539 let running = registry.submit(JobKind::IndexBuild, target()).unwrap();
1540 let cancelling = registry.submit(JobKind::IndexBuild, target()).unwrap();
1541 let rolling_back = registry.submit(JobKind::IndexBuild, target()).unwrap();
1542 let pending = registry.submit(JobKind::IndexBuild, target()).unwrap();
1543 let paused = registry.submit(JobKind::IndexBuild, target()).unwrap();
1544 let succeeded = registry.submit(JobKind::IndexBuild, target()).unwrap();
1545 let failed = registry.submit(JobKind::IndexBuild, target()).unwrap();
1546
1547 registry.admit(running).unwrap();
1548 registry
1549 .save_checkpoint(
1550 running,
1551 b"resume-bytes".to_vec(),
1552 JobProgress::new(0.25, 1, 4).unwrap(),
1553 )
1554 .unwrap();
1555 registry.admit(cancelling).unwrap();
1556 registry.cancel(cancelling).unwrap();
1557 registry.admit(rolling_back).unwrap();
1558 registry.begin_rollback(rolling_back).unwrap();
1559 registry.admit(paused).unwrap();
1560 registry.pause(paused).unwrap();
1561 registry.admit(succeeded).unwrap();
1562 registry.complete(succeeded).unwrap();
1563 registry.admit(failed).unwrap();
1564 registry.begin_rollback(failed).unwrap();
1565 registry.fail(failed, "boom".to_string()).unwrap();
1566 drop(registry);
1567
1568 let recovered = JobRegistry::open(dir.path(), None).unwrap();
1569 let running = recovered.get(running).unwrap();
1570 assert_eq!(running.state, JobState::Paused);
1571 assert_eq!(
1572 running.checkpoint.as_deref(),
1573 Some(b"resume-bytes".as_slice())
1574 );
1575 assert_eq!(running.progress, JobProgress::new(0.25, 1, 4).unwrap());
1576
1577 let cancelling = recovered.get(cancelling).unwrap();
1578 assert_eq!(cancelling.state, JobState::Failed);
1579 assert!(cancelling.error.unwrap().contains("cancelled"));
1580
1581 let rolling_back = recovered.get(rolling_back).unwrap();
1582 assert_eq!(rolling_back.state, JobState::Failed);
1583 assert!(rolling_back.error.unwrap().contains("rollback interrupted"));
1584
1585 assert_eq!(recovered.get(pending).unwrap().state, JobState::Pending);
1586 assert_eq!(recovered.get(paused).unwrap().state, JobState::Paused);
1587 assert_eq!(recovered.get(succeeded).unwrap().state, JobState::Succeeded);
1588 let failed = recovered.get(failed).unwrap();
1589 assert_eq!(failed.state, JobState::Failed);
1590 assert_eq!(failed.error.as_deref(), Some("boom"));
1591
1592 drop(recovered);
1594 let again = JobRegistry::open(dir.path(), None).unwrap();
1595 assert_eq!(again.list().len(), 7);
1596 assert!(again.list().iter().all(|record| !matches!(
1597 record.state,
1598 JobState::Running | JobState::Cancelling | JobState::RollingBack
1599 )));
1600 }
1601
1602 #[test]
1603 fn admission_enforces_the_concurrency_bound() {
1604 let (_dir, registry) = open_temp();
1605 let registry = registry.with_max_concurrent_jobs(1);
1606 let a = registry.submit(JobKind::IndexBuild, target()).unwrap();
1607 let b = registry.submit(JobKind::IndexBuild, target()).unwrap();
1608 registry.admit(a).unwrap();
1609 let error = registry.admit(b).unwrap_err();
1610 assert!(
1611 matches!(
1612 error,
1613 JobError::ConcurrencyLimit {
1614 active: 1,
1615 limit: 1
1616 }
1617 ),
1618 "expected ConcurrencyLimit, got {error:?}"
1619 );
1620 assert_eq!(registry.get(b).unwrap().state, JobState::Pending);
1622 registry.complete(a).unwrap();
1623 registry.admit(b).unwrap();
1624 }
1625
1626 #[test]
1627 fn pause_resume_cancel_follow_the_graph() {
1628 let (_dir, registry) = open_temp();
1629 let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1630 assert!(matches!(
1632 registry.pause(job),
1633 Err(JobError::IllegalTransition {
1634 from: JobState::Pending,
1635 to: JobState::Paused
1636 })
1637 ));
1638 registry.admit(job).unwrap();
1639 registry.pause(job).unwrap();
1640 assert_eq!(registry.get(job).unwrap().state, JobState::Paused);
1641 registry.resume(job).unwrap();
1642 assert_eq!(registry.get(job).unwrap().state, JobState::Pending);
1643 registry.admit(job).unwrap();
1644 registry.cancel(job).unwrap();
1645 assert_eq!(registry.get(job).unwrap().state, JobState::Cancelling);
1646 assert!(registry.cancellation_token(job).unwrap().is_cancelled());
1647 registry.cancel(job).unwrap();
1649 registry.begin_rollback(job).unwrap();
1650 registry.fail(job, "cancelled".to_string()).unwrap();
1651 assert!(matches!(
1653 registry.cancel(job),
1654 Err(JobError::IllegalTransition {
1655 from: JobState::Failed,
1656 to: JobState::Cancelling
1657 })
1658 ));
1659 assert!(matches!(
1660 registry.resume(job),
1661 Err(JobError::IllegalTransition {
1662 from: JobState::Failed,
1663 to: JobState::Pending
1664 })
1665 ));
1666 }
1667
1668 #[test]
1669 fn cancel_without_a_worker_completes_synchronously() {
1670 let (_dir, registry) = open_temp();
1671 let queued = registry.submit(JobKind::IndexBuild, target()).unwrap();
1672 registry.cancel(queued).unwrap();
1673 let record = registry.get(queued).unwrap();
1674 assert_eq!(record.state, JobState::Failed);
1675 assert_eq!(
1676 record.error.as_deref(),
1677 Some("cancelled before the job started")
1678 );
1679 assert!(registry.cancellation_token(queued).unwrap().is_cancelled());
1680
1681 let parked = registry.submit(JobKind::IndexBuild, target()).unwrap();
1682 registry.admit(parked).unwrap();
1683 registry.pause(parked).unwrap();
1684 registry.cancel(parked).unwrap();
1685 let record = registry.get(parked).unwrap();
1686 assert_eq!(record.state, JobState::Failed);
1687 assert_eq!(
1688 record.error.as_deref(),
1689 Some("cancelled while the job was paused")
1690 );
1691 }
1692
1693 #[test]
1694 fn missing_file_opens_empty_and_file_is_created_on_first_mutation() {
1695 let dir = tempfile::tempdir().unwrap();
1696 let registry = JobRegistry::open(dir.path(), None).unwrap();
1697 assert!(registry.list().is_empty());
1698 assert!(!dir.path().join(JOBS_FILENAME).exists());
1699 registry.submit(JobKind::IndexBuild, target()).unwrap();
1700 assert!(dir.path().join(JOBS_FILENAME).exists());
1701 }
1702
1703 #[test]
1704 fn tampered_file_fails_closed() {
1705 let dir = tempfile::tempdir().unwrap();
1707 let registry = JobRegistry::open(dir.path(), None).unwrap();
1708 registry.submit(JobKind::IndexBuild, target()).unwrap();
1709 drop(registry);
1710 let path = dir.path().join(JOBS_FILENAME);
1711 let mut bytes = std::fs::read(&path).unwrap();
1712 let last = bytes.len() - 1;
1713 bytes[last] ^= 0x01;
1714 std::fs::write(&path, &bytes).unwrap();
1715 let error = JobRegistry::open(dir.path(), None).unwrap_err();
1716 assert!(
1717 matches!(&error, JobError::Storage(message) if message.contains("checksum")),
1718 "expected checksum failure, got {error:?}"
1719 );
1720
1721 bytes[..8].copy_from_slice(b"NOTAJOB!");
1723 std::fs::write(&path, &bytes).unwrap();
1724 let error = JobRegistry::open(dir.path(), None).unwrap_err();
1725 assert!(
1726 matches!(&error, JobError::Storage(message) if message.contains("magic")),
1727 "expected magic failure, got {error:?}"
1728 );
1729
1730 std::fs::write(&path, b"MON").unwrap();
1732 assert!(matches!(
1733 JobRegistry::open(dir.path(), None),
1734 Err(JobError::Storage(_))
1735 ));
1736 }
1737
1738 #[test]
1739 fn unsupported_format_version_fails_closed() {
1740 let dir = tempfile::tempdir().unwrap();
1741 let body = serde_json::to_vec(&serde_json::json!({
1742 "format_version": 99,
1743 "registry": { "next_job_id": 1, "jobs": [] }
1744 }))
1745 .unwrap();
1746 let payload = plaintext_frame(&body);
1747 std::fs::write(dir.path().join(JOBS_FILENAME), payload).unwrap();
1748 let error = JobRegistry::open(dir.path(), None).unwrap_err();
1749 assert!(
1750 matches!(&error, JobError::Storage(message) if message.contains("version 99")),
1751 "expected version failure, got {error:?}"
1752 );
1753 }
1754
1755 #[test]
1756 fn allocator_inconsistency_fails_closed() {
1757 let dir = tempfile::tempdir().unwrap();
1758 let body = serde_json::to_vec(&serde_json::json!({
1759 "format_version": 1,
1760 "registry": {
1761 "next_job_id": 1,
1762 "jobs": [{
1763 "job_id": 1,
1764 "kind": "IndexBuild",
1765 "state": "Pending",
1766 "target": { "table": "items" },
1767 "progress": { "fraction": 0.0, "done": 0, "total": 0 },
1768 "created_at_micros": 1,
1769 "updated_at_micros": 1
1770 }]
1771 }
1772 }))
1773 .unwrap();
1774 std::fs::write(dir.path().join(JOBS_FILENAME), plaintext_frame(&body)).unwrap();
1775 assert!(matches!(
1776 JobRegistry::open(dir.path(), None),
1777 Err(JobError::Storage(_))
1778 ));
1779 }
1780
1781 #[test]
1782 fn resume_rejects_a_job_with_a_live_drive() {
1783 let (_dir, registry) = open_temp();
1784 let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1785 registry.admit(job).unwrap();
1786 registry.pause(job).unwrap();
1787 registry.active_drives.lock().insert(job);
1789 assert!(matches!(
1790 registry.resume(job),
1791 Err(JobError::DriveActive { job_id }) if job_id == job
1792 ));
1793 assert_eq!(registry.get(job).unwrap().state, JobState::Paused);
1794 registry.active_drives.lock().remove(&job);
1795 registry.resume(job).unwrap();
1796 assert_eq!(registry.get(job).unwrap().state, JobState::Pending);
1797 }
1798
1799 #[test]
1800 fn checkpoint_size_is_bounded() {
1801 let (_dir, registry) = open_temp();
1802 let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1803 registry.admit(job).unwrap();
1804 let oversized = vec![0_u8; MAX_CHECKPOINT_BYTES + 1];
1805 assert!(matches!(
1806 registry.save_checkpoint(job, oversized, JobProgress::default()),
1807 Err(JobError::CheckpointTooLarge { .. })
1808 ));
1809 }
1810
1811 #[test]
1812 fn job_error_maps_onto_the_engine_error() {
1813 assert!(matches!(
1814 MongrelError::from(JobError::Cancelled),
1815 MongrelError::Cancelled
1816 ));
1817 assert!(matches!(
1818 MongrelError::from(JobError::NotFound { job_id: 7 }),
1819 MongrelError::NotFound(_)
1820 ));
1821 assert!(matches!(
1822 MongrelError::from(JobError::ConcurrencyLimit {
1823 active: 3,
1824 limit: 2
1825 }),
1826 MongrelError::ResourceLimitExceeded { .. }
1827 ));
1828 assert!(matches!(
1829 MongrelError::from(JobError::InjectedFault("x".to_string())),
1830 MongrelError::Other(_)
1831 ));
1832 }
1833
1834 #[test]
1835 fn record_serde_uses_stable_text_encoding() {
1836 let (_dir, registry) = open_temp();
1837 let job = registry
1838 .submit(JobKind::MaterializedViewRebuild, target())
1839 .unwrap();
1840 registry.admit(job).unwrap();
1841 let record = registry.get(job).unwrap();
1842 let json = serde_json::to_string(&record).unwrap();
1843 assert!(json.contains("\"kind\":\"MaterializedViewRebuild\""));
1844 assert!(json.contains("\"state\":\"Running\""));
1845 let decoded: JobRecord = serde_json::from_str(&json).unwrap();
1846 assert_eq!(decoded, record);
1847 let unknown = json.replace("\"Running\"", "\"Napping\"");
1849 assert!(serde_json::from_str::<JobRecord>(&unknown).is_err());
1850 }
1851
1852 #[test]
1853 fn checkpoint_codec_round_trip_and_bounds() {
1854 let bytes = encode_build_checkpoint(3, b"impl-state").unwrap();
1855 let decoded = decode_build_checkpoint(&bytes).unwrap();
1856 assert_eq!(decoded.completed_phases, 3);
1857 assert_eq!(decoded.state, b"impl-state");
1858 let corrupt = encode_build_checkpoint(8, b"").unwrap();
1859 assert!(matches!(
1860 decode_build_checkpoint(&corrupt),
1861 Err(JobError::Storage(_))
1862 ));
1863 }
1864}