1use parking_lot::{Condvar, Mutex};
131use serde::{Deserialize, Serialize};
132use sha2::{Digest, Sha256};
133use std::collections::{BTreeMap, HashMap, HashSet};
134use std::io::Read;
135use std::path::Path;
136use std::sync::atomic::{AtomicBool, Ordering};
137use std::sync::Arc;
138
139use crate::catalog::META_DEK_LEN;
140use crate::durable_file::DurableRoot;
141use crate::error::MongrelError;
142
143pub const JOBS_FILENAME: &str = "JOBS";
145const MAGIC: &[u8; 8] = b"MONGRJOB";
146const JOBS_FORMAT_VERSION: u16 = 1;
147const MAX_JOBS_BYTES: u64 = 64 * 1024 * 1024;
150const MAX_CHECKPOINT_BYTES: usize = 1024 * 1024;
152const MAX_DEFINITION_BYTES: usize = 1024 * 1024;
154pub const DEFAULT_MAX_CONCURRENT_JOBS: usize = 2;
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162pub enum JobState {
163 Pending,
165 Running,
167 Paused,
170 Cancelling,
172 Succeeded,
174 Failed,
176 RollingBack,
178}
179
180impl JobState {
181 #[cfg(test)]
183 pub(crate) const ALL: [JobState; 7] = [
184 JobState::Pending,
185 JobState::Running,
186 JobState::Paused,
187 JobState::Cancelling,
188 JobState::Succeeded,
189 JobState::Failed,
190 JobState::RollingBack,
191 ];
192
193 pub fn is_terminal(self) -> bool {
195 matches!(self, JobState::Succeeded | JobState::Failed)
196 }
197
198 pub fn can_transition(self, next: JobState) -> bool {
202 use JobState::{Cancelling, Failed, Paused, Pending, RollingBack, Running, Succeeded};
203 matches!(
204 (self, next),
205 (Pending, Running)
206 | (Pending, Cancelling)
207 | (Running, Paused)
208 | (Running, Cancelling)
209 | (Running, RollingBack)
210 | (Running, Succeeded)
211 | (Paused, Pending)
212 | (Paused, Cancelling)
213 | (Cancelling, RollingBack)
214 | (Cancelling, Failed)
215 | (RollingBack, Failed)
216 )
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225pub enum JobKind {
226 IndexBuild,
228 ColumnBackfill,
230 SchemaValidation,
232 MaterializedViewRebuild,
234 KeyRotation,
236 LargeImport,
238 EmbeddingGeneration,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(deny_unknown_fields)]
245pub struct JobTarget {
246 pub table: String,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub index: Option<String>,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
255#[serde(deny_unknown_fields)]
256pub struct JobProgress {
257 pub fraction: f64,
259 pub done: u64,
261 pub total: u64,
263}
264
265impl Default for JobProgress {
266 fn default() -> Self {
267 Self {
268 fraction: 0.0,
269 done: 0,
270 total: 0,
271 }
272 }
273}
274
275impl JobProgress {
276 pub fn new(fraction: f64, done: u64, total: u64) -> Result<Self, JobError> {
279 if !(0.0..=1.0).contains(&fraction) {
280 return Err(JobError::InvalidProgress(format!(
281 "fraction {fraction} is outside [0.0, 1.0]"
282 )));
283 }
284 if total > 0 && done > total {
285 return Err(JobError::InvalidProgress(format!(
286 "done {done} exceeds total {total}"
287 )));
288 }
289 Ok(Self {
290 fraction,
291 done,
292 total,
293 })
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct JobRecord {
303 pub job_id: u64,
305 pub kind: JobKind,
306 pub state: JobState,
307 pub target: JobTarget,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub definition: Option<Vec<u8>>,
313 pub progress: JobProgress,
314 pub created_at_micros: u64,
316 pub updated_at_micros: u64,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub error: Option<String>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub checkpoint: Option<Vec<u8>>,
325}
326
327#[derive(Debug, thiserror::Error)]
330pub enum JobError {
331 #[error("job {job_id} not found")]
333 NotFound {
334 job_id: u64,
336 },
337 #[error("illegal job state transition from {from:?} to {to:?}")]
339 IllegalTransition {
340 from: JobState,
342 to: JobState,
344 },
345 #[error("job {job_id} is {actual:?}, expected {expected:?}")]
347 UnexpectedState {
348 job_id: u64,
350 expected: JobState,
352 actual: JobState,
354 },
355 #[error("concurrent job limit reached: {active} active of {limit} allowed")]
357 ConcurrencyLimit {
358 active: usize,
360 limit: usize,
362 },
363 #[error("job {job_id} still has a live drive in this process")]
366 DriveActive {
367 job_id: u64,
369 },
370 #[error("invalid job progress: {0}")]
372 InvalidProgress(String),
373 #[error("job cancelled")]
375 Cancelled,
376 #[error("job phase failed: {0}")]
378 Phase(String),
379 #[error("job resource limit exceeded for {resource}: requested {requested}, limit {limit}")]
381 ResourceLimitExceeded {
382 resource: &'static str,
383 requested: usize,
384 limit: usize,
385 },
386 #[error("injected fault: {0}")]
388 InjectedFault(String),
389 #[error("job checkpoint of {bytes} bytes exceeds the {limit}-byte limit")]
391 CheckpointTooLarge {
392 bytes: usize,
394 limit: usize,
396 },
397 #[error("job registry storage: {0}")]
400 Storage(String),
401 #[error("io error: {0}")]
403 Io(#[from] std::io::Error),
404 #[error("timed out waiting for job {job_id} to become terminal")]
406 WaitTimeout { job_id: u64 },
407}
408
409impl From<JobError> for MongrelError {
410 fn from(error: JobError) -> Self {
411 match error {
412 JobError::NotFound { job_id } => MongrelError::NotFound(format!("job {job_id}")),
413 JobError::Cancelled => MongrelError::Cancelled,
414 JobError::ConcurrencyLimit { active, limit } => MongrelError::ResourceLimitExceeded {
415 resource: "concurrent jobs",
416 requested: active,
417 limit,
418 },
419 JobError::InvalidProgress(message) => MongrelError::InvalidArgument(message),
420 JobError::ResourceLimitExceeded {
421 resource,
422 requested,
423 limit,
424 } => MongrelError::ResourceLimitExceeded {
425 resource,
426 requested,
427 limit,
428 },
429 JobError::Io(error) => MongrelError::Io(error),
430 JobError::WaitTimeout { .. } => MongrelError::DeadlineExceeded,
431 other => MongrelError::Other(other.to_string()),
432 }
433 }
434}
435
436#[derive(Debug, Clone, Default)]
441pub struct CancellationToken {
442 flag: Arc<AtomicBool>,
443}
444
445impl CancellationToken {
446 pub fn is_cancelled(&self) -> bool {
448 self.flag.load(Ordering::Acquire)
449 }
450
451 pub fn cancel(&self) {
453 self.flag.store(true, Ordering::Release);
454 }
455
456 pub fn check(&self) -> Result<(), JobError> {
459 if self.is_cancelled() {
460 Err(JobError::Cancelled)
461 } else {
462 Ok(())
463 }
464 }
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum BuildPhase {
470 RecordPending,
472 PinSnapshot,
474 BuildHidden,
476 CatchUp,
478 Validate,
480 Publish,
482 ReleaseOld,
484}
485
486impl BuildPhase {
487 pub const ALL: [BuildPhase; 7] = [
489 BuildPhase::RecordPending,
490 BuildPhase::PinSnapshot,
491 BuildPhase::BuildHidden,
492 BuildPhase::CatchUp,
493 BuildPhase::Validate,
494 BuildPhase::Publish,
495 BuildPhase::ReleaseOld,
496 ];
497
498 pub fn label(self) -> &'static str {
500 match self {
501 BuildPhase::RecordPending => "record_pending",
502 BuildPhase::PinSnapshot => "pin_snapshot",
503 BuildPhase::BuildHidden => "build_hidden",
504 BuildPhase::CatchUp => "catch_up",
505 BuildPhase::Validate => "validate",
506 BuildPhase::Publish => "publish",
507 BuildPhase::ReleaseOld => "release_old",
508 }
509 }
510
511 pub fn before_hook(self) -> &'static str {
513 match self {
514 BuildPhase::RecordPending => "job.record_pending.before",
515 BuildPhase::PinSnapshot => "job.pin_snapshot.before",
516 BuildPhase::BuildHidden => "job.build_hidden.before",
517 BuildPhase::CatchUp => "job.catch_up.before",
518 BuildPhase::Validate => "job.validate.before",
519 BuildPhase::Publish => "job.publish.before",
520 BuildPhase::ReleaseOld => "job.release_old.before",
521 }
522 }
523
524 pub fn after_hook(self) -> &'static str {
527 match self {
528 BuildPhase::RecordPending => "job.record_pending.after",
529 BuildPhase::PinSnapshot => "job.pin_snapshot.after",
530 BuildPhase::BuildHidden => "job.build_hidden.after",
531 BuildPhase::CatchUp => "job.catch_up.after",
532 BuildPhase::Validate => "job.validate.after",
533 BuildPhase::Publish => "job.publish.after",
534 BuildPhase::ReleaseOld => "job.release_old.after",
535 }
536 }
537
538 fn invoke<J: BuildPublishJob + ?Sized>(
539 self,
540 job: &mut J,
541 context: &JobContext,
542 ) -> Result<(), JobError> {
543 match self {
544 BuildPhase::RecordPending => job.record_pending(context),
545 BuildPhase::PinSnapshot => job.pin_snapshot(context),
546 BuildPhase::BuildHidden => job.build_hidden(context),
547 BuildPhase::CatchUp => job.catch_up(context),
548 BuildPhase::Validate => job.validate(context),
549 BuildPhase::Publish => job.publish(context),
550 BuildPhase::ReleaseOld => job.release_old(context),
551 }
552 }
553}
554
555pub trait BuildPublishJob {
561 fn checkpoint_state(&self) -> Vec<u8> {
564 Vec::new()
565 }
566
567 fn restore_checkpoint(&mut self, _state: &[u8]) -> Result<(), JobError> {
570 Ok(())
571 }
572
573 fn record_pending(&mut self, _context: &JobContext) -> Result<(), JobError> {
575 Ok(())
576 }
577
578 fn pin_snapshot(&mut self, _context: &JobContext) -> Result<(), JobError> {
580 Ok(())
581 }
582
583 fn build_hidden(&mut self, _context: &JobContext) -> Result<(), JobError> {
586 Ok(())
587 }
588
589 fn catch_up(&mut self, _context: &JobContext) -> Result<(), JobError> {
592 Ok(())
593 }
594
595 fn validate(&mut self, _context: &JobContext) -> Result<(), JobError> {
598 Ok(())
599 }
600
601 fn publish(&mut self, _context: &JobContext) -> Result<(), JobError> {
604 Ok(())
605 }
606
607 fn release_old(&mut self, _context: &JobContext) -> Result<(), JobError> {
610 Ok(())
611 }
612
613 fn rollback(&mut self) -> Result<(), JobError> {
617 Ok(())
618 }
619}
620
621pub struct JobContext<'a> {
625 registry: &'a JobRegistry,
626 job_id: u64,
627 token: CancellationToken,
628}
629
630impl JobContext<'_> {
631 pub fn job_id(&self) -> u64 {
633 self.job_id
634 }
635
636 pub fn token(&self) -> &CancellationToken {
638 &self.token
639 }
640
641 pub fn check_cancelled(&self) -> Result<(), JobError> {
643 self.token.check()
644 }
645
646 pub fn report_progress(&self, done: u64, total: u64) -> Result<(), JobError> {
650 if total == 0 {
651 return Err(JobError::InvalidProgress(
652 "unit progress requires a nonzero total".to_string(),
653 ));
654 }
655 let fraction = done as f64 / total as f64;
656 self.registry
657 .update_progress(self.job_id, JobProgress::new(fraction, done, total)?)
658 }
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize)]
663#[serde(deny_unknown_fields)]
664struct JobsSnapshot {
665 next_job_id: u64,
667 jobs: Vec<JobRecord>,
668}
669
670#[derive(Serialize, Deserialize)]
671#[serde(deny_unknown_fields)]
672struct JobsEnvelope {
673 format_version: u16,
674 registry: JobsSnapshot,
675}
676
677#[derive(Debug, Clone, Default)]
678struct RegistryInner {
679 next_job_id: u64,
680 jobs: BTreeMap<u64, JobRecord>,
681}
682
683pub struct JobRegistry {
691 root: DurableRoot,
692 meta_dek: Option<[u8; META_DEK_LEN]>,
693 inner: Mutex<RegistryInner>,
694 tokens: Mutex<HashMap<u64, CancellationToken>>,
695 active_drives: Mutex<HashSet<u64>>,
699 state_changed: Condvar,
700 max_concurrent_jobs: usize,
701}
702
703impl std::fmt::Debug for JobRegistry {
704 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
705 formatter
706 .debug_struct("JobRegistry")
707 .field("root", &self.root)
708 .field("max_concurrent_jobs", &self.max_concurrent_jobs)
709 .finish_non_exhaustive()
710 }
711}
712
713impl JobRegistry {
714 pub fn open(dir: &Path, meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Self, JobError> {
721 let root = DurableRoot::open(dir)?;
722 let mut inner = match read_durable(&root, meta_dek)? {
723 Some(snapshot) => validate_snapshot(snapshot)?,
724 None => RegistryInner {
725 next_job_id: 1,
726 jobs: BTreeMap::new(),
727 },
728 };
729 let recovered = recover_after_crash(&mut inner);
730 let registry = Self {
731 root,
732 meta_dek: meta_dek.copied(),
733 inner: Mutex::new(inner),
734 tokens: Mutex::new(HashMap::new()),
735 active_drives: Mutex::new(HashSet::new()),
736 state_changed: Condvar::new(),
737 max_concurrent_jobs: DEFAULT_MAX_CONCURRENT_JOBS,
738 };
739 if recovered {
740 registry.persist_locked(®istry.inner.lock())?;
741 }
742 Ok(registry)
743 }
744
745 pub fn with_max_concurrent_jobs(mut self, limit: usize) -> Self {
747 self.max_concurrent_jobs = limit.max(1);
748 self
749 }
750
751 pub fn submit(&self, kind: JobKind, target: JobTarget) -> Result<u64, JobError> {
753 self.submit_with_definition(kind, target, None)
754 }
755
756 pub fn submit_with_definition(
761 &self,
762 kind: JobKind,
763 target: JobTarget,
764 definition: Option<Vec<u8>>,
765 ) -> Result<u64, JobError> {
766 if target.table.is_empty() {
767 return Err(JobError::InvalidProgress(
768 "job target table must not be empty".to_string(),
769 ));
770 }
771 if kind == JobKind::IndexBuild && target.index.is_none() {
772 return Err(JobError::InvalidProgress(
773 "an index-build job requires a target index".to_string(),
774 ));
775 }
776 if definition
777 .as_ref()
778 .is_some_and(|payload| payload.len() > MAX_DEFINITION_BYTES)
779 {
780 return Err(JobError::CheckpointTooLarge {
781 bytes: definition.as_ref().map_or(0, Vec::len),
782 limit: MAX_DEFINITION_BYTES,
783 });
784 }
785 let mut next = self.inner.lock().clone();
786 let now = unix_micros();
787 let job_id = next.next_job_id;
788 next.next_job_id = next
789 .next_job_id
790 .checked_add(1)
791 .ok_or_else(|| JobError::Storage("job id space exhausted".to_string()))?;
792 next.jobs.insert(
793 job_id,
794 JobRecord {
795 job_id,
796 kind,
797 state: JobState::Pending,
798 target,
799 definition,
800 progress: JobProgress::default(),
801 created_at_micros: now,
802 updated_at_micros: now,
803 error: None,
804 checkpoint: None,
805 },
806 );
807 self.persist_and_swap(next)?;
808 self.tokens
809 .lock()
810 .insert(job_id, CancellationToken::default());
811 Ok(job_id)
812 }
813
814 pub fn get(&self, job_id: u64) -> Option<JobRecord> {
816 self.inner.lock().jobs.get(&job_id).cloned()
817 }
818
819 pub fn list(&self) -> Vec<JobRecord> {
821 self.inner.lock().jobs.values().cloned().collect()
822 }
823
824 pub fn wait_terminal(
826 &self,
827 job_id: u64,
828 timeout: std::time::Duration,
829 ) -> Result<JobRecord, JobError> {
830 let deadline = std::time::Instant::now() + timeout;
831 let mut inner = self.inner.lock();
832 loop {
833 let record = inner
834 .jobs
835 .get(&job_id)
836 .ok_or(JobError::NotFound { job_id })?;
837 if record.state.is_terminal() {
838 return Ok(record.clone());
839 }
840 if self
841 .state_changed
842 .wait_until(&mut inner, deadline)
843 .timed_out()
844 {
845 return Err(JobError::WaitTimeout { job_id });
846 }
847 }
848 }
849
850 pub fn cancellation_token(&self, job_id: u64) -> Option<CancellationToken> {
852 if !self.inner.lock().jobs.contains_key(&job_id) {
853 return None;
854 }
855 Some(self.tokens.lock().entry(job_id).or_default().clone())
856 }
857
858 pub fn pause(&self, job_id: u64) -> Result<(), JobError> {
862 self.transition(job_id, JobState::Paused)
863 }
864
865 pub fn resume(&self, job_id: u64) -> Result<(), JobError> {
870 if self.active_drives.lock().contains(&job_id) {
871 return Err(JobError::DriveActive { job_id });
872 }
873 self.transition(job_id, JobState::Pending)
874 }
875
876 pub fn cancel(&self, job_id: u64) -> Result<(), JobError> {
886 let state = self.get(job_id).ok_or(JobError::NotFound { job_id })?.state;
887 match state {
888 JobState::Pending | JobState::Paused => {
889 let mut next = self.inner.lock().clone();
890 let record = next.jobs.get_mut(&job_id).expect("record checked above");
891 apply_transition(record, JobState::Cancelling)?;
892 apply_transition(record, JobState::Failed)?;
893 record.error = Some(match state {
894 JobState::Pending => "cancelled before the job started".to_string(),
895 _ => "cancelled while the job was paused".to_string(),
896 });
897 self.persist_and_swap(next)?;
898 self.tokens.lock().entry(job_id).or_default().cancel();
899 Ok(())
900 }
901 JobState::Running => {
902 self.transition(job_id, JobState::Cancelling)?;
903 self.tokens.lock().entry(job_id).or_default().cancel();
904 Ok(())
905 }
906 JobState::Cancelling | JobState::RollingBack => Ok(()),
907 JobState::Succeeded | JobState::Failed => Err(JobError::IllegalTransition {
908 from: state,
909 to: JobState::Cancelling,
910 }),
911 }
912 }
913
914 fn update_progress(&self, job_id: u64, progress: JobProgress) -> Result<(), JobError> {
916 let mut next = self.inner.lock().clone();
917 let record = next
918 .jobs
919 .get_mut(&job_id)
920 .ok_or(JobError::NotFound { job_id })?;
921 if record.state != JobState::Running {
922 return Err(JobError::UnexpectedState {
923 job_id,
924 expected: JobState::Running,
925 actual: record.state,
926 });
927 }
928 record.progress = progress;
929 record.updated_at_micros = unix_micros();
930 self.persist_and_swap(next)
931 }
932
933 fn transition(&self, job_id: u64, to: JobState) -> Result<(), JobError> {
935 let mut next = self.inner.lock().clone();
936 let record = next
937 .jobs
938 .get_mut(&job_id)
939 .ok_or(JobError::NotFound { job_id })?;
940 apply_transition(record, to)?;
941 self.persist_and_swap(next)
942 }
943
944 fn admit(&self, job_id: u64) -> Result<(), JobError> {
946 let mut next = self.inner.lock().clone();
947 let active = next
948 .jobs
949 .values()
950 .filter(|record| {
951 matches!(
952 record.state,
953 JobState::Running | JobState::Cancelling | JobState::RollingBack
954 )
955 })
956 .count();
957 let record = next
958 .jobs
959 .get(&job_id)
960 .ok_or(JobError::NotFound { job_id })?;
961 if record.state != JobState::Pending {
962 return Err(JobError::IllegalTransition {
963 from: record.state,
964 to: JobState::Running,
965 });
966 }
967 if active >= self.max_concurrent_jobs {
968 return Err(JobError::ConcurrencyLimit {
969 active,
970 limit: self.max_concurrent_jobs,
971 });
972 }
973 let record = next.jobs.get_mut(&job_id).expect("record checked above");
974 apply_transition(record, JobState::Running)?;
975 self.persist_and_swap(next)
976 }
977
978 fn save_checkpoint(
983 &self,
984 job_id: u64,
985 checkpoint: Vec<u8>,
986 progress: JobProgress,
987 ) -> Result<(), JobError> {
988 if checkpoint.len() > MAX_CHECKPOINT_BYTES {
989 return Err(JobError::CheckpointTooLarge {
990 bytes: checkpoint.len(),
991 limit: MAX_CHECKPOINT_BYTES,
992 });
993 }
994 let mut next = self.inner.lock().clone();
995 let record = next
996 .jobs
997 .get_mut(&job_id)
998 .ok_or(JobError::NotFound { job_id })?;
999 if !matches!(record.state, JobState::Running | JobState::Paused) {
1000 return Err(JobError::UnexpectedState {
1001 job_id,
1002 expected: JobState::Running,
1003 actual: record.state,
1004 });
1005 }
1006 record.checkpoint = Some(checkpoint);
1007 record.progress = progress;
1008 record.updated_at_micros = unix_micros();
1009 self.persist_and_swap(next)
1010 }
1011
1012 fn complete(&self, job_id: u64) -> Result<(), JobError> {
1015 let mut next = self.inner.lock().clone();
1016 let record = next
1017 .jobs
1018 .get_mut(&job_id)
1019 .ok_or(JobError::NotFound { job_id })?;
1020 apply_transition(record, JobState::Succeeded)?;
1021 record.checkpoint = None;
1022 record.progress.fraction = 1.0;
1023 self.persist_and_swap(next)
1024 }
1025
1026 fn begin_rollback(&self, job_id: u64) -> Result<(), JobError> {
1028 self.transition(job_id, JobState::RollingBack)
1029 }
1030
1031 fn fail(&self, job_id: u64, error: String) -> Result<(), JobError> {
1033 let mut next = self.inner.lock().clone();
1034 let record = next
1035 .jobs
1036 .get_mut(&job_id)
1037 .ok_or(JobError::NotFound { job_id })?;
1038 apply_transition(record, JobState::Failed)?;
1039 record.error = Some(error);
1040 self.persist_and_swap(next)
1041 }
1042
1043 fn persist_and_swap(&self, next: RegistryInner) -> Result<(), JobError> {
1047 self.persist_locked(&next)?;
1048 *self.inner.lock() = next;
1049 self.state_changed.notify_all();
1050 Ok(())
1051 }
1052
1053 fn persist_locked(&self, inner: &RegistryInner) -> Result<(), JobError> {
1054 let snapshot = JobsSnapshot {
1055 next_job_id: inner.next_job_id,
1056 jobs: inner.jobs.values().cloned().collect(),
1057 };
1058 write_durable(&self.root, &snapshot, self.meta_dek.as_ref())
1059 }
1060}
1061
1062pub fn run_build_publish<J: BuildPublishJob + ?Sized>(
1076 registry: &JobRegistry,
1077 job_id: u64,
1078 job: &mut J,
1079) -> Result<(), JobError> {
1080 let record = registry.get(job_id).ok_or(JobError::NotFound { job_id })?;
1081 if record.state != JobState::Pending {
1082 return Err(JobError::IllegalTransition {
1083 from: record.state,
1084 to: JobState::Running,
1085 });
1086 }
1087 let mut completed_phases = 0_usize;
1090 if let Some(checkpoint) = &record.checkpoint {
1091 let decoded = decode_build_checkpoint(checkpoint)?;
1092 job.restore_checkpoint(&decoded.state)?;
1093 completed_phases = usize::from(decoded.completed_phases);
1094 }
1095 registry.admit(job_id)?;
1096 let _drive = DriveGuard { registry, job_id };
1099 let token = registry
1100 .cancellation_token(job_id)
1101 .ok_or(JobError::NotFound { job_id })?;
1102 let context = JobContext {
1103 registry,
1104 job_id,
1105 token,
1106 };
1107
1108 for (index, phase) in BuildPhase::ALL
1109 .iter()
1110 .copied()
1111 .enumerate()
1112 .skip(completed_phases)
1113 {
1114 let state = registry
1118 .get(job_id)
1119 .ok_or(JobError::NotFound { job_id })?
1120 .state;
1121 match state {
1122 JobState::Running => {}
1123 JobState::Paused => return Ok(()),
1124 JobState::Cancelling => {
1125 return rollback_and_fail(registry, job_id, job, JobError::Cancelled);
1126 }
1127 state => {
1128 return Err(JobError::IllegalTransition {
1129 from: state,
1130 to: JobState::Running,
1131 });
1132 }
1133 }
1134 if let Err(fault) = mongreldb_fault::inject(phase.before_hook()) {
1135 return park_on_fault(registry, job_id, job, fault);
1136 }
1137 let outcome = phase.invoke(job, &context);
1138 if let Err(fault) = mongreldb_fault::inject(phase.after_hook()) {
1139 return park_on_fault(registry, job_id, job, fault);
1140 }
1141 if let Err(error) = outcome {
1142 return rollback_and_fail(registry, job_id, job, error);
1143 }
1144 completed_phases = index + 1;
1145 let checkpoint = encode_build_checkpoint(completed_phases as u8, &job.checkpoint_state())?;
1146 let total = BuildPhase::ALL.len() as u64;
1147 let done = completed_phases as u64;
1148 let progress = JobProgress::new(done as f64 / total as f64, done, total)?;
1149 let state = registry
1154 .get(job_id)
1155 .ok_or(JobError::NotFound { job_id })?
1156 .state;
1157 match state {
1158 JobState::Running => registry.save_checkpoint(job_id, checkpoint, progress)?,
1159 JobState::Paused => {
1160 registry.save_checkpoint(job_id, checkpoint, progress)?;
1161 return Ok(());
1162 }
1163 JobState::Cancelling => {
1164 return rollback_and_fail(registry, job_id, job, JobError::Cancelled);
1165 }
1166 state => {
1167 return Err(JobError::IllegalTransition {
1168 from: state,
1169 to: JobState::Running,
1170 });
1171 }
1172 }
1173 }
1174 match registry.complete(job_id) {
1175 Ok(()) => Ok(()),
1176 Err(JobError::IllegalTransition { from, .. }) => match from {
1177 JobState::Paused => Ok(()),
1180 JobState::Cancelling => rollback_and_fail(registry, job_id, job, JobError::Cancelled),
1181 state => Err(JobError::IllegalTransition {
1182 from: state,
1183 to: JobState::Succeeded,
1184 }),
1185 },
1186 Err(error) => Err(error),
1187 }
1188}
1189
1190fn park_on_fault<J: BuildPublishJob + ?Sized>(
1194 registry: &JobRegistry,
1195 job_id: u64,
1196 job: &mut J,
1197 fault: mongreldb_fault::Fault,
1198) -> Result<(), JobError> {
1199 match registry.pause(job_id) {
1200 Ok(()) => Err(JobError::InjectedFault(fault.to_string())),
1201 Err(JobError::IllegalTransition {
1202 from: JobState::Cancelling,
1203 ..
1204 }) => rollback_and_fail(registry, job_id, job, JobError::Cancelled),
1205 Err(error) => Err(error),
1206 }
1207}
1208
1209fn rollback_and_fail<J: BuildPublishJob + ?Sized>(
1213 registry: &JobRegistry,
1214 job_id: u64,
1215 job: &mut J,
1216 error: JobError,
1217) -> Result<(), JobError> {
1218 match registry.begin_rollback(job_id) {
1219 Ok(()) => {}
1220 Err(JobError::IllegalTransition { .. }) => return Err(error),
1224 Err(storage) => return Err(storage),
1225 }
1226 let message = match job.rollback() {
1227 Ok(()) => error.to_string(),
1228 Err(rollback_error) => format!("{error}; rollback also failed: {rollback_error}"),
1229 };
1230 registry.fail(job_id, message)?;
1231 Err(error)
1232}
1233
1234struct DriveGuard<'a> {
1238 registry: &'a JobRegistry,
1239 job_id: u64,
1240}
1241
1242impl Drop for DriveGuard<'_> {
1243 fn drop(&mut self) {
1244 self.registry.active_drives.lock().remove(&self.job_id);
1245 }
1246}
1247
1248#[derive(Serialize, Deserialize)]
1251#[serde(deny_unknown_fields)]
1252struct BuildPublishCheckpoint {
1253 completed_phases: u8,
1254 state: Vec<u8>,
1255}
1256
1257fn encode_build_checkpoint(completed_phases: u8, state: &[u8]) -> Result<Vec<u8>, JobError> {
1258 serde_json::to_vec(&BuildPublishCheckpoint {
1259 completed_phases,
1260 state: state.to_vec(),
1261 })
1262 .map_err(|error| JobError::Storage(format!("job checkpoint serialize: {error}")))
1263}
1264
1265fn decode_build_checkpoint(bytes: &[u8]) -> Result<BuildPublishCheckpoint, JobError> {
1266 let checkpoint: BuildPublishCheckpoint = serde_json::from_slice(bytes)
1267 .map_err(|error| JobError::Storage(format!("job checkpoint deserialize: {error}")))?;
1268 if usize::from(checkpoint.completed_phases) > BuildPhase::ALL.len() {
1269 return Err(JobError::Storage(format!(
1270 "job checkpoint claims {} completed phases, only {} exist",
1271 checkpoint.completed_phases,
1272 BuildPhase::ALL.len()
1273 )));
1274 }
1275 Ok(checkpoint)
1276}
1277
1278fn apply_transition(record: &mut JobRecord, to: JobState) -> Result<(), JobError> {
1279 if !record.state.can_transition(to) {
1280 return Err(JobError::IllegalTransition {
1281 from: record.state,
1282 to,
1283 });
1284 }
1285 record.state = to;
1286 record.updated_at_micros = unix_micros();
1287 Ok(())
1288}
1289
1290fn recover_after_crash(inner: &mut RegistryInner) -> bool {
1292 let now = unix_micros();
1293 let mut changed = false;
1294 for record in inner.jobs.values_mut() {
1295 match record.state {
1296 JobState::Running => {
1297 record.state = JobState::Paused;
1300 record.updated_at_micros = now;
1301 changed = true;
1302 }
1303 JobState::Cancelling => {
1304 record.state = JobState::Failed;
1306 record.error =
1307 Some("cancelled (process restarted while the job was cancelling)".to_string());
1308 record.updated_at_micros = now;
1309 changed = true;
1310 }
1311 JobState::RollingBack => {
1312 record.state = JobState::Failed;
1313 const NOTE: &str = "rollback interrupted by process restart";
1314 record.error = Some(match record.error.take() {
1315 Some(error) => format!("{error}; {NOTE}"),
1316 None => NOTE.to_string(),
1317 });
1318 record.updated_at_micros = now;
1319 changed = true;
1320 }
1321 JobState::Pending | JobState::Paused | JobState::Succeeded | JobState::Failed => {}
1322 }
1323 }
1324 changed
1325}
1326
1327fn validate_snapshot(snapshot: JobsSnapshot) -> Result<RegistryInner, JobError> {
1330 let mut jobs = BTreeMap::new();
1331 for record in snapshot.jobs {
1332 if record
1333 .definition
1334 .as_ref()
1335 .is_some_and(|payload| payload.len() > MAX_DEFINITION_BYTES)
1336 {
1337 return Err(JobError::Storage(format!(
1338 "job {} definition is {} bytes, limit is {}",
1339 record.job_id,
1340 record.definition.as_ref().map_or(0, Vec::len),
1341 MAX_DEFINITION_BYTES
1342 )));
1343 }
1344 if jobs.insert(record.job_id, record).is_some() {
1345 return Err(JobError::Storage(
1346 "duplicate job id in registry file".to_string(),
1347 ));
1348 }
1349 }
1350 let max_id = jobs.keys().next_back().copied().unwrap_or(0);
1351 if snapshot.next_job_id <= max_id {
1352 return Err(JobError::Storage(format!(
1353 "registry allocator at {} would reissue job id {max_id}",
1354 snapshot.next_job_id
1355 )));
1356 }
1357 Ok(RegistryInner {
1358 next_job_id: snapshot.next_job_id.max(1),
1359 jobs,
1360 })
1361}
1362
1363fn encode(snapshot: &JobsSnapshot) -> Result<Vec<u8>, JobError> {
1364 serde_json::to_vec(&JobsEnvelope {
1365 format_version: JOBS_FORMAT_VERSION,
1366 registry: snapshot.clone(),
1367 })
1368 .map_err(|error| JobError::Storage(format!("job registry serialize: {error}")))
1369}
1370
1371fn decode(body: &[u8]) -> Result<JobsSnapshot, JobError> {
1372 let envelope: JobsEnvelope = serde_json::from_slice(body)
1373 .map_err(|error| JobError::Storage(format!("job registry deserialize: {error}")))?;
1374 if envelope.format_version != JOBS_FORMAT_VERSION {
1375 return Err(JobError::Storage(format!(
1376 "unsupported job registry format version {}",
1377 envelope.format_version
1378 )));
1379 }
1380 Ok(envelope.registry)
1381}
1382
1383fn plaintext_frame(body: &[u8]) -> Vec<u8> {
1384 let hash = Sha256::digest(body);
1385 let mut out = Vec::with_capacity(body.len() + 8 + 32);
1386 out.extend_from_slice(MAGIC);
1387 out.extend_from_slice(&hash);
1388 out.extend_from_slice(body);
1389 out
1390}
1391
1392fn seal(body: &[u8], meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Vec<u8>, JobError> {
1393 match meta_dek {
1394 Some(dek) => crate::encryption::encrypt_blob(dek, body)
1395 .map_err(|error| JobError::Storage(format!("job registry seal: {error}"))),
1396 None => Ok(plaintext_frame(body)),
1397 }
1398}
1399
1400fn open_payload(
1401 bytes: &[u8],
1402 meta_dek: Option<&[u8; META_DEK_LEN]>,
1403) -> Result<JobsSnapshot, JobError> {
1404 match meta_dek {
1405 Some(dek) => {
1408 let body = crate::encryption::decrypt_blob(dek, bytes).map_err(|_| {
1409 JobError::Storage(
1410 "job registry authentication failed (wrong key or tampered)".to_string(),
1411 )
1412 })?;
1413 decode(&body)
1414 }
1415 None => parse_plaintext(bytes),
1416 }
1417}
1418
1419fn write_durable(
1422 root: &DurableRoot,
1423 snapshot: &JobsSnapshot,
1424 meta_dek: Option<&[u8; META_DEK_LEN]>,
1425) -> Result<(), JobError> {
1426 let body = encode(snapshot)?;
1427 let payload = seal(&body, meta_dek)?;
1428 root.write_atomic(JOBS_FILENAME, &payload)?;
1429 Ok(())
1430}
1431
1432fn read_durable(
1435 root: &DurableRoot,
1436 meta_dek: Option<&[u8; META_DEK_LEN]>,
1437) -> Result<Option<JobsSnapshot>, JobError> {
1438 let file = match root.open_regular(JOBS_FILENAME) {
1439 Ok(file) => file,
1440 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1441 Err(error) => return Err(error.into()),
1442 };
1443 let length = file.metadata()?.len();
1444 if length > MAX_JOBS_BYTES {
1445 return Err(JobError::Storage(format!(
1446 "job registry of {length} bytes exceeds the {MAX_JOBS_BYTES}-byte limit"
1447 )));
1448 }
1449 let mut bytes = Vec::with_capacity(length as usize);
1450 file.take(MAX_JOBS_BYTES + 1).read_to_end(&mut bytes)?;
1451 if bytes.len() as u64 != length {
1452 return Err(JobError::Storage(
1453 "job registry length changed while reading".to_string(),
1454 ));
1455 }
1456 open_payload(&bytes, meta_dek).map(Some)
1457}
1458
1459fn parse_plaintext(bytes: &[u8]) -> Result<JobsSnapshot, JobError> {
1460 if bytes.len() < 8 + 32 || &bytes[..8] != MAGIC {
1461 return Err(JobError::Storage(
1462 "job registry magic mismatch (corrupt or sealed with a key)".to_string(),
1463 ));
1464 }
1465 let (tag, body) = bytes[8..].split_at(32);
1466 let calc = Sha256::digest(body);
1467 if tag != calc.as_slice() {
1468 return Err(JobError::Storage(
1469 "job registry checksum mismatch (tampered or torn)".to_string(),
1470 ));
1471 }
1472 decode(body)
1473}
1474
1475fn unix_micros() -> u64 {
1478 let micros = std::time::SystemTime::now()
1479 .duration_since(std::time::UNIX_EPOCH)
1480 .map(|duration| duration.as_micros())
1481 .unwrap_or(0);
1482 u64::try_from(micros).unwrap_or(u64::MAX)
1483}
1484
1485#[cfg(test)]
1486mod tests {
1487 use super::*;
1488
1489 fn open_temp() -> (tempfile::TempDir, JobRegistry) {
1490 let dir = tempfile::tempdir().unwrap();
1491 let registry = JobRegistry::open(dir.path(), None).unwrap();
1492 (dir, registry)
1493 }
1494
1495 fn target() -> JobTarget {
1496 JobTarget {
1497 table: "items".to_string(),
1498 index: Some("items_idx".to_string()),
1499 }
1500 }
1501
1502 #[test]
1503 fn transition_graph_matches_the_documented_edges() {
1504 let legal: [(JobState, JobState); 11] = [
1505 (JobState::Pending, JobState::Running),
1506 (JobState::Pending, JobState::Cancelling),
1507 (JobState::Running, JobState::Paused),
1508 (JobState::Running, JobState::Cancelling),
1509 (JobState::Running, JobState::RollingBack),
1510 (JobState::Running, JobState::Succeeded),
1511 (JobState::Paused, JobState::Pending),
1512 (JobState::Paused, JobState::Cancelling),
1513 (JobState::Cancelling, JobState::RollingBack),
1514 (JobState::Cancelling, JobState::Failed),
1515 (JobState::RollingBack, JobState::Failed),
1516 ];
1517 for from in JobState::ALL {
1518 for to in JobState::ALL {
1519 let expected = legal.contains(&(from, to));
1520 assert_eq!(from.can_transition(to), expected, "edge {from:?} -> {to:?}");
1521 }
1522 }
1523 for terminal in [JobState::Succeeded, JobState::Failed] {
1524 assert!(terminal.is_terminal());
1525 assert!(JobState::ALL
1526 .iter()
1527 .all(|&next| !terminal.can_transition(next)));
1528 }
1529 }
1530
1531 #[test]
1532 fn progress_validation_rejects_out_of_range_values() {
1533 assert!(JobProgress::new(0.5, 1, 2).is_ok());
1534 assert!(JobProgress::new(0.0, 0, 0).is_ok());
1535 assert!(JobProgress::new(1.0, 7, 7).is_ok());
1536 assert!(JobProgress::new(f64::NAN, 0, 0).is_err());
1537 assert!(JobProgress::new(-0.1, 0, 0).is_err());
1538 assert!(JobProgress::new(1.1, 0, 0).is_err());
1539 assert!(JobProgress::new(0.5, 3, 2).is_err());
1540 }
1541
1542 #[test]
1543 fn persistence_round_trip_preserves_records_and_allocator() {
1544 let dir = tempfile::tempdir().unwrap();
1545 let first = JobRegistry::open(dir.path(), None).unwrap();
1546 let a = first.submit(JobKind::IndexBuild, target()).unwrap();
1547 let b = first
1548 .submit(
1549 JobKind::LargeImport,
1550 JobTarget {
1551 table: "bulk".to_string(),
1552 index: None,
1553 },
1554 )
1555 .unwrap();
1556 assert_eq!((a, b), (1, 2));
1557 first.admit(a).unwrap();
1558 first
1559 .save_checkpoint(
1560 a,
1561 b"opaque-resume-state".to_vec(),
1562 JobProgress::new(0.5, 4, 8).unwrap(),
1563 )
1564 .unwrap();
1565 drop(first);
1566
1567 let reopened = JobRegistry::open(dir.path(), None).unwrap();
1568 let record = reopened.get(a).unwrap();
1571 assert_eq!(record.state, JobState::Paused);
1572 assert_eq!(record.kind, JobKind::IndexBuild);
1573 assert_eq!(record.target, target());
1574 assert_eq!(record.progress, JobProgress::new(0.5, 4, 8).unwrap());
1575 assert_eq!(
1576 record.checkpoint.as_deref(),
1577 Some(b"opaque-resume-state".as_slice())
1578 );
1579 assert!(record.error.is_none());
1580 assert!(record.updated_at_micros >= record.created_at_micros);
1581 assert_eq!(reopened.get(b).unwrap().state, JobState::Pending);
1582 let c = reopened
1584 .submit(
1585 JobKind::KeyRotation,
1586 JobTarget {
1587 table: "items".to_string(),
1588 index: None,
1589 },
1590 )
1591 .unwrap();
1592 assert_eq!(c, 3);
1593 assert_eq!(reopened.list().len(), 3);
1594 }
1595
1596 #[test]
1597 fn crash_recovery_maps_active_states_to_safe_ones() {
1598 let dir = tempfile::tempdir().unwrap();
1599 let registry = JobRegistry::open(dir.path(), None)
1600 .unwrap()
1601 .with_max_concurrent_jobs(8);
1602 let running = registry.submit(JobKind::IndexBuild, target()).unwrap();
1603 let cancelling = registry.submit(JobKind::IndexBuild, target()).unwrap();
1604 let rolling_back = registry.submit(JobKind::IndexBuild, target()).unwrap();
1605 let pending = registry.submit(JobKind::IndexBuild, target()).unwrap();
1606 let paused = registry.submit(JobKind::IndexBuild, target()).unwrap();
1607 let succeeded = registry.submit(JobKind::IndexBuild, target()).unwrap();
1608 let failed = registry.submit(JobKind::IndexBuild, target()).unwrap();
1609
1610 registry.admit(running).unwrap();
1611 registry
1612 .save_checkpoint(
1613 running,
1614 b"resume-bytes".to_vec(),
1615 JobProgress::new(0.25, 1, 4).unwrap(),
1616 )
1617 .unwrap();
1618 registry.admit(cancelling).unwrap();
1619 registry.cancel(cancelling).unwrap();
1620 registry.admit(rolling_back).unwrap();
1621 registry.begin_rollback(rolling_back).unwrap();
1622 registry.admit(paused).unwrap();
1623 registry.pause(paused).unwrap();
1624 registry.admit(succeeded).unwrap();
1625 registry.complete(succeeded).unwrap();
1626 registry.admit(failed).unwrap();
1627 registry.begin_rollback(failed).unwrap();
1628 registry.fail(failed, "boom".to_string()).unwrap();
1629 drop(registry);
1630
1631 let recovered = JobRegistry::open(dir.path(), None).unwrap();
1632 let running = recovered.get(running).unwrap();
1633 assert_eq!(running.state, JobState::Paused);
1634 assert_eq!(
1635 running.checkpoint.as_deref(),
1636 Some(b"resume-bytes".as_slice())
1637 );
1638 assert_eq!(running.progress, JobProgress::new(0.25, 1, 4).unwrap());
1639
1640 let cancelling = recovered.get(cancelling).unwrap();
1641 assert_eq!(cancelling.state, JobState::Failed);
1642 assert!(cancelling.error.unwrap().contains("cancelled"));
1643
1644 let rolling_back = recovered.get(rolling_back).unwrap();
1645 assert_eq!(rolling_back.state, JobState::Failed);
1646 assert!(rolling_back.error.unwrap().contains("rollback interrupted"));
1647
1648 assert_eq!(recovered.get(pending).unwrap().state, JobState::Pending);
1649 assert_eq!(recovered.get(paused).unwrap().state, JobState::Paused);
1650 assert_eq!(recovered.get(succeeded).unwrap().state, JobState::Succeeded);
1651 let failed = recovered.get(failed).unwrap();
1652 assert_eq!(failed.state, JobState::Failed);
1653 assert_eq!(failed.error.as_deref(), Some("boom"));
1654
1655 drop(recovered);
1657 let again = JobRegistry::open(dir.path(), None).unwrap();
1658 assert_eq!(again.list().len(), 7);
1659 assert!(again.list().iter().all(|record| !matches!(
1660 record.state,
1661 JobState::Running | JobState::Cancelling | JobState::RollingBack
1662 )));
1663 }
1664
1665 #[test]
1666 fn admission_enforces_the_concurrency_bound() {
1667 let (_dir, registry) = open_temp();
1668 let registry = registry.with_max_concurrent_jobs(1);
1669 let a = registry.submit(JobKind::IndexBuild, target()).unwrap();
1670 let b = registry.submit(JobKind::IndexBuild, target()).unwrap();
1671 registry.admit(a).unwrap();
1672 let error = registry.admit(b).unwrap_err();
1673 assert!(
1674 matches!(
1675 error,
1676 JobError::ConcurrencyLimit {
1677 active: 1,
1678 limit: 1
1679 }
1680 ),
1681 "expected ConcurrencyLimit, got {error:?}"
1682 );
1683 assert_eq!(registry.get(b).unwrap().state, JobState::Pending);
1685 registry.complete(a).unwrap();
1686 registry.admit(b).unwrap();
1687 }
1688
1689 #[test]
1690 fn pause_resume_cancel_follow_the_graph() {
1691 let (_dir, registry) = open_temp();
1692 let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1693 assert!(matches!(
1695 registry.pause(job),
1696 Err(JobError::IllegalTransition {
1697 from: JobState::Pending,
1698 to: JobState::Paused
1699 })
1700 ));
1701 registry.admit(job).unwrap();
1702 registry.pause(job).unwrap();
1703 assert_eq!(registry.get(job).unwrap().state, JobState::Paused);
1704 registry.resume(job).unwrap();
1705 assert_eq!(registry.get(job).unwrap().state, JobState::Pending);
1706 registry.admit(job).unwrap();
1707 registry.cancel(job).unwrap();
1708 assert_eq!(registry.get(job).unwrap().state, JobState::Cancelling);
1709 assert!(registry.cancellation_token(job).unwrap().is_cancelled());
1710 registry.cancel(job).unwrap();
1712 registry.begin_rollback(job).unwrap();
1713 registry.fail(job, "cancelled".to_string()).unwrap();
1714 assert!(matches!(
1716 registry.cancel(job),
1717 Err(JobError::IllegalTransition {
1718 from: JobState::Failed,
1719 to: JobState::Cancelling
1720 })
1721 ));
1722 assert!(matches!(
1723 registry.resume(job),
1724 Err(JobError::IllegalTransition {
1725 from: JobState::Failed,
1726 to: JobState::Pending
1727 })
1728 ));
1729 }
1730
1731 #[test]
1732 fn cancel_without_a_worker_completes_synchronously() {
1733 let (_dir, registry) = open_temp();
1734 let queued = registry.submit(JobKind::IndexBuild, target()).unwrap();
1735 registry.cancel(queued).unwrap();
1736 let record = registry.get(queued).unwrap();
1737 assert_eq!(record.state, JobState::Failed);
1738 assert_eq!(
1739 record.error.as_deref(),
1740 Some("cancelled before the job started")
1741 );
1742 assert!(registry.cancellation_token(queued).unwrap().is_cancelled());
1743
1744 let parked = registry.submit(JobKind::IndexBuild, target()).unwrap();
1745 registry.admit(parked).unwrap();
1746 registry.pause(parked).unwrap();
1747 registry.cancel(parked).unwrap();
1748 let record = registry.get(parked).unwrap();
1749 assert_eq!(record.state, JobState::Failed);
1750 assert_eq!(
1751 record.error.as_deref(),
1752 Some("cancelled while the job was paused")
1753 );
1754 }
1755
1756 #[test]
1757 fn missing_file_opens_empty_and_file_is_created_on_first_mutation() {
1758 let dir = tempfile::tempdir().unwrap();
1759 let registry = JobRegistry::open(dir.path(), None).unwrap();
1760 assert!(registry.list().is_empty());
1761 assert!(!dir.path().join(JOBS_FILENAME).exists());
1762 registry.submit(JobKind::IndexBuild, target()).unwrap();
1763 assert!(dir.path().join(JOBS_FILENAME).exists());
1764 }
1765
1766 #[test]
1767 fn tampered_file_fails_closed() {
1768 let dir = tempfile::tempdir().unwrap();
1770 let registry = JobRegistry::open(dir.path(), None).unwrap();
1771 registry.submit(JobKind::IndexBuild, target()).unwrap();
1772 drop(registry);
1773 let path = dir.path().join(JOBS_FILENAME);
1774 let mut bytes = std::fs::read(&path).unwrap();
1775 let last = bytes.len() - 1;
1776 bytes[last] ^= 0x01;
1777 std::fs::write(&path, &bytes).unwrap();
1778 let error = JobRegistry::open(dir.path(), None).unwrap_err();
1779 assert!(
1780 matches!(&error, JobError::Storage(message) if message.contains("checksum")),
1781 "expected checksum failure, got {error:?}"
1782 );
1783
1784 bytes[..8].copy_from_slice(b"NOTAJOB!");
1786 std::fs::write(&path, &bytes).unwrap();
1787 let error = JobRegistry::open(dir.path(), None).unwrap_err();
1788 assert!(
1789 matches!(&error, JobError::Storage(message) if message.contains("magic")),
1790 "expected magic failure, got {error:?}"
1791 );
1792
1793 std::fs::write(&path, b"MON").unwrap();
1795 assert!(matches!(
1796 JobRegistry::open(dir.path(), None),
1797 Err(JobError::Storage(_))
1798 ));
1799 }
1800
1801 #[test]
1802 fn unsupported_format_version_fails_closed() {
1803 let dir = tempfile::tempdir().unwrap();
1804 let body = serde_json::to_vec(&serde_json::json!({
1805 "format_version": 99,
1806 "registry": { "next_job_id": 1, "jobs": [] }
1807 }))
1808 .unwrap();
1809 let payload = plaintext_frame(&body);
1810 std::fs::write(dir.path().join(JOBS_FILENAME), payload).unwrap();
1811 let error = JobRegistry::open(dir.path(), None).unwrap_err();
1812 assert!(
1813 matches!(&error, JobError::Storage(message) if message.contains("version 99")),
1814 "expected version failure, got {error:?}"
1815 );
1816 }
1817
1818 #[test]
1819 fn allocator_inconsistency_fails_closed() {
1820 let dir = tempfile::tempdir().unwrap();
1821 let body = serde_json::to_vec(&serde_json::json!({
1822 "format_version": 1,
1823 "registry": {
1824 "next_job_id": 1,
1825 "jobs": [{
1826 "job_id": 1,
1827 "kind": "IndexBuild",
1828 "state": "Pending",
1829 "target": { "table": "items" },
1830 "progress": { "fraction": 0.0, "done": 0, "total": 0 },
1831 "created_at_micros": 1,
1832 "updated_at_micros": 1
1833 }]
1834 }
1835 }))
1836 .unwrap();
1837 std::fs::write(dir.path().join(JOBS_FILENAME), plaintext_frame(&body)).unwrap();
1838 assert!(matches!(
1839 JobRegistry::open(dir.path(), None),
1840 Err(JobError::Storage(_))
1841 ));
1842 }
1843
1844 #[test]
1845 fn resume_rejects_a_job_with_a_live_drive() {
1846 let (_dir, registry) = open_temp();
1847 let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1848 registry.admit(job).unwrap();
1849 registry.pause(job).unwrap();
1850 registry.active_drives.lock().insert(job);
1852 assert!(matches!(
1853 registry.resume(job),
1854 Err(JobError::DriveActive { job_id }) if job_id == job
1855 ));
1856 assert_eq!(registry.get(job).unwrap().state, JobState::Paused);
1857 registry.active_drives.lock().remove(&job);
1858 registry.resume(job).unwrap();
1859 assert_eq!(registry.get(job).unwrap().state, JobState::Pending);
1860 }
1861
1862 #[test]
1863 fn checkpoint_size_is_bounded() {
1864 let (_dir, registry) = open_temp();
1865 let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1866 registry.admit(job).unwrap();
1867 let oversized = vec![0_u8; MAX_CHECKPOINT_BYTES + 1];
1868 assert!(matches!(
1869 registry.save_checkpoint(job, oversized, JobProgress::default()),
1870 Err(JobError::CheckpointTooLarge { .. })
1871 ));
1872 }
1873
1874 #[test]
1875 fn job_error_maps_onto_the_engine_error() {
1876 assert!(matches!(
1877 MongrelError::from(JobError::Cancelled),
1878 MongrelError::Cancelled
1879 ));
1880 assert!(matches!(
1881 MongrelError::from(JobError::NotFound { job_id: 7 }),
1882 MongrelError::NotFound(_)
1883 ));
1884 assert!(matches!(
1885 MongrelError::from(JobError::ConcurrencyLimit {
1886 active: 3,
1887 limit: 2
1888 }),
1889 MongrelError::ResourceLimitExceeded { .. }
1890 ));
1891 assert!(matches!(
1892 MongrelError::from(JobError::InjectedFault("x".to_string())),
1893 MongrelError::Other(_)
1894 ));
1895 }
1896
1897 #[test]
1898 fn record_serde_uses_stable_text_encoding() {
1899 let (_dir, registry) = open_temp();
1900 let job = registry
1901 .submit(JobKind::MaterializedViewRebuild, target())
1902 .unwrap();
1903 registry.admit(job).unwrap();
1904 let record = registry.get(job).unwrap();
1905 let json = serde_json::to_string(&record).unwrap();
1906 assert!(json.contains("\"kind\":\"MaterializedViewRebuild\""));
1907 assert!(json.contains("\"state\":\"Running\""));
1908 let decoded: JobRecord = serde_json::from_str(&json).unwrap();
1909 assert_eq!(decoded, record);
1910 let unknown = json.replace("\"Running\"", "\"Napping\"");
1912 assert!(serde_json::from_str::<JobRecord>(&unknown).is_err());
1913 }
1914
1915 #[test]
1916 fn checkpoint_codec_round_trip_and_bounds() {
1917 let bytes = encode_build_checkpoint(3, b"impl-state").unwrap();
1918 let decoded = decode_build_checkpoint(&bytes).unwrap();
1919 assert_eq!(decoded.completed_phases, 3);
1920 assert_eq!(decoded.state, b"impl-state");
1921 let corrupt = encode_build_checkpoint(8, b"").unwrap();
1922 assert!(matches!(
1923 decode_build_checkpoint(&corrupt),
1924 Err(JobError::Storage(_))
1925 ));
1926 }
1927}