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