Skip to main content

mongreldb_core/
jobs.rs

1//! Persistent online jobs (spec section 10.6, S1F-002/S1F-003).
2//!
3//! This module delivers the Stage 1F job framework: the S1F-002 job state
4//! machine with a persisted registry, and the S1F-003 build-and-publish
5//! driver ([`run_build_publish`]) as a reusable protocol for every
6//! [`JobKind`] (index builds, backfills, validation, …).
7//!
8//! **Design (landed):** jobs are advanced **synchronously** by the caller
9//! (admin/ops entry points, tests, engine paths). There is no separate
10//! background executor thread pool — phase checkpoints make resume after
11//! crash or pause correct without one.
12//!
13//! # State machine (S1F-002)
14//!
15//! [`JobState`] is exactly the spec's seven states. Legal transitions are
16//! enforced by [`JobState::can_transition`]; every registry mutation goes
17//! through it and illegal transitions return [`JobError::IllegalTransition`]:
18//!
19//! ```text
20//! Pending     -> Running       (admission, concurrency-bounded)
21//! Pending     -> Cancelling    (cancel before start)
22//! Running     -> Paused        (operator pause; takes effect at phase boundary)
23//! Running     -> Cancelling    (operator cancel; cooperative)
24//! Running     -> RollingBack   (a build phase failed)
25//! Running     -> Succeeded     (all phases complete)
26//! Paused      -> Pending       (resume: requeue for the next drive)
27//! Paused      -> Cancelling    (cancel a parked job)
28//! Cancelling  -> RollingBack   (worker observed the cancel, cleaning up)
29//! Cancelling  -> Failed        (cancel completed without rollback work)
30//! RollingBack -> Failed        (rollback finished; terminal)
31//! ```
32//!
33//! `Succeeded` and `Failed` are terminal. There is no in-place retry edge:
34//! a failed job is resubmitted as a new job id.
35//!
36//! # Persistence
37//!
38//! The registry is mirrored to a sibling `JOBS` file next to `CATALOG`,
39//! written through [`crate::durable_file::DurableRoot::write_atomic`] — the
40//! same temp-write + fsync + atomic rename + parent-dir fsync path the
41//! catalog checkpoint uses (review fix #19), so a crash never leaves a
42//! half-linked registry. The frame mirrors `catalog.rs`: an 8-byte magic, a
43//! SHA-256 integrity tag over the body (or AES-256-GCM via the database
44//! `meta_dek` when the `encryption` feature is active), and a versioned
45//! serde_json envelope. `catalog.rs` is not modified; the jobs file is an
46//! independent sibling checkpoint.
47//!
48//! Two deliberate deviations from `catalog.rs`'s read path, both failing
49//! closed (the catalog predates the jobs file and keeps its lenient
50//! tamper-means-absent behavior for legacy opens; a jobs registry that
51//! silently vanishes could orphan schema state, so corruption is an error):
52//!
53//! - a checksum/authentication mismatch is [`JobError::Storage`], not `None`;
54//! - a missing file alone means "no jobs yet" and opens empty.
55//!
56//! Every state mutation rewrites the file before returning, so the durable
57//! record is never behind the in-memory one (mutations are applied to a
58//! clone, persisted, then swapped in).
59//!
60//! # Crash recovery
61//!
62//! Recovery runs at [`JobRegistry::open`] and is persisted immediately:
63//!
64//! - `Running -> Paused`: no worker survives a process crash. The job parks
65//!   with its last durable checkpoint; [`JobRegistry::resume`] requeues it
66//!   and the next [`run_build_publish`] drive resumes from that checkpoint.
67//! - `Cancelling -> Failed`: the crash itself completed the cancellation.
68//! - `RollingBack -> Failed`: the crash interrupted rollback; the recorded
69//!   error is annotated. Unpublished generations orphaned by an interrupted
70//!   rollback are reclaimed by the publish/GC paths of the driving surface.
71//!
72//! `Paused`, `Pending`, `Succeeded`, and `Failed` records reopen unchanged.
73//!
74//! # Cooperative cancellation and pause
75//!
76//! Every job owns a [`CancellationToken`]. `cancel()` on a `Running` job
77//! moves it to `Cancelling` and sets the token; the running job observes the
78//! token (via [`JobContext::check_cancelled`]) or the next phase-boundary
79//! state check, rolls back, and lands in `Failed`. Cancelling a `Pending` or
80//! `Paused` job has no live worker to notify, so the registry completes the
81//! cancellation synchronously (`Pending|Paused -> Cancelling -> Failed`).
82//! `pause()` moves `Running -> Paused`; the drive finishes any in-flight
83//! phase, persists that phase's checkpoint (progress is monotonic), and
84//! parks at the next phase boundary. [`JobRegistry::resume`] refuses to
85//! requeue a job whose previous drive is still draining in this process
86//! ([`JobError::DriveActive`]).
87//!
88//! # Build-and-publish driver (S1F-003)
89//!
90//! [`run_build_publish`] drives a [`BuildPublishJob`] through the spec's
91//! seven phases in order: record pending definition, pin snapshot, build
92//! hidden generation, catch up committed deltas, validate, publish
93//! atomically, release old generation after pins drop. After every completed
94//! phase the driver persists a checkpoint (completed-phase count plus the
95//! job's opaque [`BuildPublishJob::checkpoint_state`]); on resume, completed
96//! phases are skipped.
97//!
98//! Phase contract:
99//!
100//! - Phases must be idempotent. A fault or crash between phase completion
101//!   and checkpoint persistence re-runs the phase on resume.
102//! - Publish must be atomic: the hidden generation becomes visible in one
103//!   operation, so re-running any pre-publish phase is harmless.
104//! - A phase returning an error moves the job `Running -> RollingBack ->
105//!   Failed` and invokes [`BuildPublishJob::rollback`] between the two.
106//! - An injected fault (see below) parks the job as `Paused` instead:
107//!   transient environmental failures are resumable, while errors the phase
108//!   itself reports are job-logic failures. A durable-write error aborts the
109//!   drive with [`JobError::Storage`]/[`JobError::Io`]; crash recovery on the
110//!   next open then parks the job (`Running -> Paused`) for resume.
111//!
112//! # Fault-injection hooks (documented FND-006 extension)
113//!
114//! The driver fires `job.<phase>.before` / `job.<phase>.after` at every phase
115//! boundary, extending the section 9.6 catalog:
116//!
117//! - `job.record_pending.before` / `job.record_pending.after`
118//! - `job.pin_snapshot.before` / `job.pin_snapshot.after`
119//! - `job.build_hidden.before` / `job.build_hidden.after`
120//! - `job.catch_up.before` / `job.catch_up.after`
121//! - `job.validate.before` / `job.validate.after`
122//! - `job.publish.before` / `job.publish.after`
123//! - `job.release_old.before` / `job.release_old.after`
124//!
125//! `before` fires before the phase body runs (a failure skips the phase and
126//! parks the job); `after` fires after the phase body succeeded but before
127//! its checkpoint is durable (a failure re-runs the phase on resume).
128
129use parking_lot::Mutex;
130use serde::{Deserialize, Serialize};
131use sha2::{Digest, Sha256};
132use std::collections::{BTreeMap, HashMap, HashSet};
133use std::io::Read;
134use std::path::Path;
135use std::sync::atomic::{AtomicBool, Ordering};
136use std::sync::Arc;
137
138use crate::catalog::META_DEK_LEN;
139use crate::durable_file::DurableRoot;
140use crate::error::MongrelError;
141
142/// The sibling-of-`CATALOG` file mirroring the job registry.
143pub const JOBS_FILENAME: &str = "JOBS";
144const MAGIC: &[u8; 8] = b"MONGRJOB";
145const JOBS_FORMAT_VERSION: u16 = 1;
146/// Upper bound on one registry file, mirroring the catalog's 64 MiB cap
147/// (spec section 4.9: every resource is bounded).
148const MAX_JOBS_BYTES: u64 = 64 * 1024 * 1024;
149/// Upper bound on one job's opaque checkpoint payload.
150const MAX_CHECKPOINT_BYTES: usize = 1024 * 1024;
151/// Default bound on concurrently active (worker-holding) jobs.
152pub const DEFAULT_MAX_CONCURRENT_JOBS: usize = 2;
153
154/// Persistent job states (spec S1F-002, exact).
155///
156/// Serde encodes variants by name; names are part of the durable contract
157/// and must never change or be reused (spec section 4.10).
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159pub enum JobState {
160    /// Submitted, waiting for admission.
161    Pending,
162    /// Admitted; a worker is actively driving it.
163    Running,
164    /// Parked by an operator pause or by crash recovery; resumes from the
165    /// last durable checkpoint.
166    Paused,
167    /// Cancellation requested; the live worker has not finished rolling back.
168    Cancelling,
169    /// Terminal: every phase completed and published.
170    Succeeded,
171    /// Terminal: failed or cancelled; `error` on the record says why.
172    Failed,
173    /// A phase failed or a cancel was observed; rollback is in progress.
174    RollingBack,
175}
176
177impl JobState {
178    /// Every legal state, for exhaustive graph tests.
179    #[cfg(test)]
180    pub(crate) const ALL: [JobState; 7] = [
181        JobState::Pending,
182        JobState::Running,
183        JobState::Paused,
184        JobState::Cancelling,
185        JobState::Succeeded,
186        JobState::Failed,
187        JobState::RollingBack,
188    ];
189
190    /// Terminal states have no outgoing edges.
191    pub fn is_terminal(self) -> bool {
192        matches!(self, JobState::Succeeded | JobState::Failed)
193    }
194
195    /// Whether the `self -> next` edge exists in the documented graph
196    /// (module-level docs). This is the single enforcement point: every
197    /// registry mutation checks it before applying a transition.
198    pub fn can_transition(self, next: JobState) -> bool {
199        use JobState::{Cancelling, Failed, Paused, Pending, RollingBack, Running, Succeeded};
200        matches!(
201            (self, next),
202            (Pending, Running)
203                | (Pending, Cancelling)
204                | (Running, Paused)
205                | (Running, Cancelling)
206                | (Running, RollingBack)
207                | (Running, Succeeded)
208                | (Paused, Pending)
209                | (Paused, Cancelling)
210                | (Cancelling, RollingBack)
211                | (Cancelling, Failed)
212                | (RollingBack, Failed)
213        )
214    }
215}
216
217/// The S1F-002 job kinds (spec section 10.6). The framework is kind-agnostic
218/// beyond light target validation in [`JobRegistry::submit`]; each kind is
219/// driven through [`run_build_publish`] (or an equivalent phase driver) by
220/// the calling surface.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222pub enum JobKind {
223    /// Online secondary-index build (the S1F-003 reference case).
224    IndexBuild,
225    /// Backfill of a newly added or altered column.
226    ColumnBackfill,
227    /// Validation of existing rows against a new schema constraint.
228    SchemaValidation,
229    /// Rebuild of a materialized view's physical table.
230    MaterializedViewRebuild,
231    /// Rotation of encryption key hierarchy material.
232    KeyRotation,
233    /// Bulk import too large for the interactive write path.
234    LargeImport,
235}
236
237/// Table/index identifiers a job operates on.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct JobTarget {
241    /// Name of the table the job operates on.
242    pub table: String,
243    /// Index (or materialized-view) name for kinds that have one.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub index: Option<String>,
246}
247
248/// Job progress: a normalized fraction plus the raw units it derives from.
249#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct JobProgress {
252    /// Completion in `[0.0, 1.0]`.
253    pub fraction: f64,
254    /// Work units completed.
255    pub done: u64,
256    /// Total work units; `0` means "not yet measurable" (fraction leads).
257    pub total: u64,
258}
259
260impl Default for JobProgress {
261    fn default() -> Self {
262        Self {
263            fraction: 0.0,
264            done: 0,
265            total: 0,
266        }
267    }
268}
269
270impl JobProgress {
271    /// Validated constructor: `fraction` must lie in `[0.0, 1.0]` (NaN is
272    /// rejected), and a nonzero `total` must cover `done`.
273    pub fn new(fraction: f64, done: u64, total: u64) -> Result<Self, JobError> {
274        if !(0.0..=1.0).contains(&fraction) {
275            return Err(JobError::InvalidProgress(format!(
276                "fraction {fraction} is outside [0.0, 1.0]"
277            )));
278        }
279        if total > 0 && done > total {
280            return Err(JobError::InvalidProgress(format!(
281                "done {done} exceeds total {total}"
282            )));
283        }
284        Ok(Self {
285            fraction,
286            done,
287            total,
288        })
289    }
290}
291
292/// One persisted job record. `created_at_micros`/`updated_at_micros` are
293/// wall-clock Unix microseconds for operator diagnostics — job metadata, not
294/// MVCC visibility timestamps (those are the HLC authority's domain).
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct JobRecord {
298    /// Registry-allocated id; never reused within a database (spec section 7).
299    pub job_id: u64,
300    pub kind: JobKind,
301    pub state: JobState,
302    pub target: JobTarget,
303    pub progress: JobProgress,
304    /// Wall-clock creation time, microseconds since the Unix epoch.
305    pub created_at_micros: u64,
306    /// Wall-clock time of the last durable mutation, same units.
307    pub updated_at_micros: u64,
308    /// Terminal failure detail (also set for cancellations).
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub error: Option<String>,
311    /// Opaque resume-after-restart payload written by the active driver.
312    /// Cleared on success; retained after failure for diagnostics.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub checkpoint: Option<Vec<u8>>,
315}
316
317/// Typed errors of the jobs framework. `From<JobError> for MongrelError`
318/// maps them onto the engine error taxonomy for callers.
319#[derive(Debug, thiserror::Error)]
320pub enum JobError {
321    /// No record with this id exists.
322    #[error("job {job_id} not found")]
323    NotFound {
324        /// The unknown id.
325        job_id: u64,
326    },
327    /// A transition the documented graph (module docs) does not allow.
328    #[error("illegal job state transition from {from:?} to {to:?}")]
329    IllegalTransition {
330        /// Current state.
331        from: JobState,
332        /// Attempted target state.
333        to: JobState,
334    },
335    /// A mutation that requires the job to be in a specific state.
336    #[error("job {job_id} is {actual:?}, expected {expected:?}")]
337    UnexpectedState {
338        /// The job.
339        job_id: u64,
340        /// Required state.
341        expected: JobState,
342        /// Observed state.
343        actual: JobState,
344    },
345    /// Admission would exceed the configured active-job bound.
346    #[error("concurrent job limit reached: {active} active of {limit} allowed")]
347    ConcurrencyLimit {
348        /// Currently active jobs.
349        active: usize,
350        /// Configured bound.
351        limit: usize,
352    },
353    /// A resume was attempted while a drive is still draining in this
354    /// process; wait for it to park before requeuing the job.
355    #[error("job {job_id} still has a live drive in this process")]
356    DriveActive {
357        /// The busy job.
358        job_id: u64,
359    },
360    /// Progress values failed validation.
361    #[error("invalid job progress: {0}")]
362    InvalidProgress(String),
363    /// The job's cancellation token fired (cooperative cancellation).
364    #[error("job cancelled")]
365    Cancelled,
366    /// A build phase reported a job-logic failure.
367    #[error("job phase failed: {0}")]
368    Phase(String),
369    /// A named fault-injection hook fired at a phase boundary.
370    #[error("injected fault: {0}")]
371    InjectedFault(String),
372    /// The checkpoint payload exceeds [`MAX_CHECKPOINT_BYTES`].
373    #[error("job checkpoint of {bytes} bytes exceeds the {limit}-byte limit")]
374    CheckpointTooLarge {
375        /// Attempted size.
376        bytes: usize,
377        /// Configured bound.
378        limit: usize,
379    },
380    /// Durable registry file errors: corruption, tampering, unsupported
381    /// format version, or serialization failure. Always fail-closed.
382    #[error("job registry storage: {0}")]
383    Storage(String),
384    /// Filesystem failure on the registry file.
385    #[error("io error: {0}")]
386    Io(#[from] std::io::Error),
387}
388
389impl From<JobError> for MongrelError {
390    fn from(error: JobError) -> Self {
391        match error {
392            JobError::NotFound { job_id } => MongrelError::NotFound(format!("job {job_id}")),
393            JobError::Cancelled => MongrelError::Cancelled,
394            JobError::ConcurrencyLimit { active, limit } => MongrelError::ResourceLimitExceeded {
395                resource: "concurrent jobs",
396                requested: active,
397                limit,
398            },
399            JobError::InvalidProgress(message) => MongrelError::InvalidArgument(message),
400            JobError::Io(error) => MongrelError::Io(error),
401            other => MongrelError::Other(other.to_string()),
402        }
403    }
404}
405
406/// Cooperative cancellation handle for one job. Clones share the flag.
407///
408/// Tokens are process-local: they are not persisted. A crash parks the job
409/// (see module docs), so a cancel intent must be re-issued after restart.
410#[derive(Debug, Clone, Default)]
411pub struct CancellationToken {
412    flag: Arc<AtomicBool>,
413}
414
415impl CancellationToken {
416    /// Whether cancellation was requested.
417    pub fn is_cancelled(&self) -> bool {
418        self.flag.load(Ordering::Acquire)
419    }
420
421    /// Request cancellation. Idempotent.
422    pub fn cancel(&self) {
423        self.flag.store(true, Ordering::Release);
424    }
425
426    /// [`JobError::Cancelled`] when cancelled, `Ok(())` otherwise. Job phases
427    /// call this between work batches.
428    pub fn check(&self) -> Result<(), JobError> {
429        if self.is_cancelled() {
430            Err(JobError::Cancelled)
431        } else {
432            Ok(())
433        }
434    }
435}
436
437/// The S1F-003 build-and-publish phases, in protocol order.
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439pub enum BuildPhase {
440    /// 1. Record the pending definition (e.g. hidden index definition).
441    RecordPending,
442    /// 2. Pin a snapshot so the build sees a stable row set.
443    PinSnapshot,
444    /// 3. Build the hidden generation from the pinned snapshot.
445    BuildHidden,
446    /// 4. Catch up deltas committed since the snapshot was pinned.
447    CatchUp,
448    /// 5. Validate the built generation (counts, hashes, constraints).
449    Validate,
450    /// 6. Publish the new generation atomically.
451    Publish,
452    /// 7. Release the old generation once no snapshot pins it.
453    ReleaseOld,
454}
455
456impl BuildPhase {
457    /// Every phase in protocol order.
458    pub const ALL: [BuildPhase; 7] = [
459        BuildPhase::RecordPending,
460        BuildPhase::PinSnapshot,
461        BuildPhase::BuildHidden,
462        BuildPhase::CatchUp,
463        BuildPhase::Validate,
464        BuildPhase::Publish,
465        BuildPhase::ReleaseOld,
466    ];
467
468    /// Stable lowercase label (used in hook names and diagnostics).
469    pub fn label(self) -> &'static str {
470        match self {
471            BuildPhase::RecordPending => "record_pending",
472            BuildPhase::PinSnapshot => "pin_snapshot",
473            BuildPhase::BuildHidden => "build_hidden",
474            BuildPhase::CatchUp => "catch_up",
475            BuildPhase::Validate => "validate",
476            BuildPhase::Publish => "publish",
477            BuildPhase::ReleaseOld => "release_old",
478        }
479    }
480
481    /// The `job.<phase>.before` hook fired before the phase body runs.
482    pub fn before_hook(self) -> &'static str {
483        match self {
484            BuildPhase::RecordPending => "job.record_pending.before",
485            BuildPhase::PinSnapshot => "job.pin_snapshot.before",
486            BuildPhase::BuildHidden => "job.build_hidden.before",
487            BuildPhase::CatchUp => "job.catch_up.before",
488            BuildPhase::Validate => "job.validate.before",
489            BuildPhase::Publish => "job.publish.before",
490            BuildPhase::ReleaseOld => "job.release_old.before",
491        }
492    }
493
494    /// The `job.<phase>.after` hook fired after the phase body succeeded and
495    /// before its checkpoint is durable.
496    pub fn after_hook(self) -> &'static str {
497        match self {
498            BuildPhase::RecordPending => "job.record_pending.after",
499            BuildPhase::PinSnapshot => "job.pin_snapshot.after",
500            BuildPhase::BuildHidden => "job.build_hidden.after",
501            BuildPhase::CatchUp => "job.catch_up.after",
502            BuildPhase::Validate => "job.validate.after",
503            BuildPhase::Publish => "job.publish.after",
504            BuildPhase::ReleaseOld => "job.release_old.after",
505        }
506    }
507
508    fn invoke<J: BuildPublishJob + ?Sized>(
509        self,
510        job: &mut J,
511        context: &JobContext,
512    ) -> Result<(), JobError> {
513        match self {
514            BuildPhase::RecordPending => job.record_pending(context),
515            BuildPhase::PinSnapshot => job.pin_snapshot(context),
516            BuildPhase::BuildHidden => job.build_hidden(context),
517            BuildPhase::CatchUp => job.catch_up(context),
518            BuildPhase::Validate => job.validate(context),
519            BuildPhase::Publish => job.publish(context),
520            BuildPhase::ReleaseOld => job.release_old(context),
521        }
522    }
523}
524
525/// A build-and-publish job (spec S1F-003). Implementors perform the actual
526/// work per phase; [`run_build_publish`] sequences, checkpoints, and
527/// recovers. All methods default to no-ops so kinds that skip a phase (a
528/// large import has no old generation to release) implement only what they
529/// need. Phases must be idempotent — see the module-level phase contract.
530pub trait BuildPublishJob {
531    /// Opaque implementor state persisted inside the job checkpoint after
532    /// every completed phase (bounded by [`MAX_CHECKPOINT_BYTES`]).
533    fn checkpoint_state(&self) -> Vec<u8> {
534        Vec::new()
535    }
536
537    /// Restore state previously returned by [`Self::checkpoint_state`]
538    /// during a resume. Called once before the first uncompleted phase runs.
539    fn restore_checkpoint(&mut self, _state: &[u8]) -> Result<(), JobError> {
540        Ok(())
541    }
542
543    /// Phase 1: record the pending definition (S1F-003 step 1).
544    fn record_pending(&mut self, _context: &JobContext) -> Result<(), JobError> {
545        Ok(())
546    }
547
548    /// Phase 2: pin a snapshot for a stable build view (S1F-003 step 2).
549    fn pin_snapshot(&mut self, _context: &JobContext) -> Result<(), JobError> {
550        Ok(())
551    }
552
553    /// Phase 3: build the hidden generation from the pinned snapshot
554    /// (S1F-003 step 3). Nothing built here is visible to readers yet.
555    fn build_hidden(&mut self, _context: &JobContext) -> Result<(), JobError> {
556        Ok(())
557    }
558
559    /// Phase 4: fold in deltas committed after the snapshot pin (S1F-003
560    /// step 4), so the generation is current at publish time.
561    fn catch_up(&mut self, _context: &JobContext) -> Result<(), JobError> {
562        Ok(())
563    }
564
565    /// Phase 5: validate the generation before it becomes visible (S1F-003
566    /// step 5).
567    fn validate(&mut self, _context: &JobContext) -> Result<(), JobError> {
568        Ok(())
569    }
570
571    /// Phase 6: publish the generation atomically (S1F-003 step 6). After
572    /// this phase the new generation is authoritative for new readers.
573    fn publish(&mut self, _context: &JobContext) -> Result<(), JobError> {
574        Ok(())
575    }
576
577    /// Phase 7: release the old generation after all snapshot pins on it
578    /// drop (S1F-003 step 7).
579    fn release_old(&mut self, _context: &JobContext) -> Result<(), JobError> {
580        Ok(())
581    }
582
583    /// Best-effort cleanup of unpublished state, invoked between
584    /// `RollingBack` and `Failed` when a phase errors or a cancel lands.
585    /// A failure here is recorded on the job record, not propagated.
586    fn rollback(&mut self) -> Result<(), JobError> {
587        Ok(())
588    }
589}
590
591/// The handle a running [`BuildPublishJob`] phase receives: job identity,
592/// the cooperative cancellation token, and progress reporting back into the
593/// durable record.
594pub struct JobContext<'a> {
595    registry: &'a JobRegistry,
596    job_id: u64,
597    token: CancellationToken,
598}
599
600impl JobContext<'_> {
601    /// The id of the job being driven.
602    pub fn job_id(&self) -> u64 {
603        self.job_id
604    }
605
606    /// The job's cancellation token.
607    pub fn token(&self) -> &CancellationToken {
608        &self.token
609    }
610
611    /// `Err(JobError::Cancelled)` once the operator cancelled the job.
612    pub fn check_cancelled(&self) -> Result<(), JobError> {
613        self.token.check()
614    }
615
616    /// Persist unit-based progress (`fraction` is derived as `done/total`).
617    /// Requires the job to be `Running`; a pause landing mid-phase surfaces
618    /// here as [`JobError::UnexpectedState`] so the phase can stop early.
619    pub fn report_progress(&self, done: u64, total: u64) -> Result<(), JobError> {
620        if total == 0 {
621            return Err(JobError::InvalidProgress(
622                "unit progress requires a nonzero total".to_string(),
623            ));
624        }
625        let fraction = done as f64 / total as f64;
626        self.registry
627            .update_progress(self.job_id, JobProgress::new(fraction, done, total)?)
628    }
629}
630
631/// The durable registry file body.
632#[derive(Debug, Clone, Serialize, Deserialize)]
633#[serde(deny_unknown_fields)]
634struct JobsSnapshot {
635    /// Next job id to allocate; strictly greater than every live id.
636    next_job_id: u64,
637    jobs: Vec<JobRecord>,
638}
639
640#[derive(Serialize, Deserialize)]
641#[serde(deny_unknown_fields)]
642struct JobsEnvelope {
643    format_version: u16,
644    registry: JobsSnapshot,
645}
646
647#[derive(Debug, Clone, Default)]
648struct RegistryInner {
649    next_job_id: u64,
650    jobs: BTreeMap<u64, JobRecord>,
651}
652
653/// The persistent job registry: state machine, durability, admission, and
654/// cooperative-cancellation tokens for every job in one database.
655///
656/// The registry takes no lock of its own: the database directory's
657/// `_meta/.lock` (held by the owning `Database`) already rejects concurrent
658/// independent handles, and `Database::open` constructs exactly one registry
659/// per storage core.
660pub struct JobRegistry {
661    root: DurableRoot,
662    meta_dek: Option<[u8; META_DEK_LEN]>,
663    inner: Mutex<RegistryInner>,
664    tokens: Mutex<HashMap<u64, CancellationToken>>,
665    /// Process-local set of jobs with a live [`run_build_publish`] drive.
666    /// `resume()` consults it so a paused job cannot be requeued while its
667    /// previous drive is still draining toward the park point.
668    active_drives: Mutex<HashSet<u64>>,
669    max_concurrent_jobs: usize,
670}
671
672impl std::fmt::Debug for JobRegistry {
673    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
674        formatter
675            .debug_struct("JobRegistry")
676            .field("root", &self.root)
677            .field("max_concurrent_jobs", &self.max_concurrent_jobs)
678            .finish_non_exhaustive()
679    }
680}
681
682impl JobRegistry {
683    /// Open (or create) the registry stored in the database directory `dir`.
684    /// Applies crash recovery (module docs) and persists it before returning.
685    ///
686    /// `meta_dek` mirrors the catalog: `Some` seals the file with the
687    /// database metadata key, `None` writes the integrity-tagged plaintext
688    /// frame. Without the `encryption` feature, `Some` is rejected.
689    pub fn open(dir: &Path, meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Self, JobError> {
690        let root = DurableRoot::open(dir)?;
691        let mut inner = match read_durable(&root, meta_dek)? {
692            Some(snapshot) => validate_snapshot(snapshot)?,
693            None => RegistryInner {
694                next_job_id: 1,
695                jobs: BTreeMap::new(),
696            },
697        };
698        let recovered = recover_after_crash(&mut inner);
699        let registry = Self {
700            root,
701            meta_dek: meta_dek.copied(),
702            inner: Mutex::new(inner),
703            tokens: Mutex::new(HashMap::new()),
704            active_drives: Mutex::new(HashSet::new()),
705            max_concurrent_jobs: DEFAULT_MAX_CONCURRENT_JOBS,
706        };
707        if recovered {
708            registry.persist_locked(&registry.inner.lock())?;
709        }
710        Ok(registry)
711    }
712
713    /// Builder: override the active-job bound (minimum 1).
714    pub fn with_max_concurrent_jobs(mut self, limit: usize) -> Self {
715        self.max_concurrent_jobs = limit.max(1);
716        self
717    }
718
719    /// Submit a new job in `Pending` and persist it before returning its id.
720    pub fn submit(&self, kind: JobKind, target: JobTarget) -> Result<u64, JobError> {
721        if target.table.is_empty() {
722            return Err(JobError::InvalidProgress(
723                "job target table must not be empty".to_string(),
724            ));
725        }
726        if kind == JobKind::IndexBuild && target.index.is_none() {
727            return Err(JobError::InvalidProgress(
728                "an index-build job requires a target index".to_string(),
729            ));
730        }
731        let mut next = self.inner.lock().clone();
732        let now = unix_micros();
733        let job_id = next.next_job_id;
734        next.next_job_id = next
735            .next_job_id
736            .checked_add(1)
737            .ok_or_else(|| JobError::Storage("job id space exhausted".to_string()))?;
738        next.jobs.insert(
739            job_id,
740            JobRecord {
741                job_id,
742                kind,
743                state: JobState::Pending,
744                target,
745                progress: JobProgress::default(),
746                created_at_micros: now,
747                updated_at_micros: now,
748                error: None,
749                checkpoint: None,
750            },
751        );
752        self.persist_and_swap(next)?;
753        self.tokens
754            .lock()
755            .insert(job_id, CancellationToken::default());
756        Ok(job_id)
757    }
758
759    /// A snapshot of one record, if it exists.
760    pub fn get(&self, job_id: u64) -> Option<JobRecord> {
761        self.inner.lock().jobs.get(&job_id).cloned()
762    }
763
764    /// Every record, ordered by job id.
765    pub fn list(&self) -> Vec<JobRecord> {
766        self.inner.lock().jobs.values().cloned().collect()
767    }
768
769    /// The job's cooperative cancellation token, if the job exists.
770    pub fn cancellation_token(&self, job_id: u64) -> Option<CancellationToken> {
771        if !self.inner.lock().jobs.contains_key(&job_id) {
772            return None;
773        }
774        Some(self.tokens.lock().entry(job_id).or_default().clone())
775    }
776
777    /// Park a running job (`Running -> Paused`). The live drive finishes any
778    /// in-flight phase, persists its checkpoint, and stops at the next phase
779    /// boundary.
780    pub fn pause(&self, job_id: u64) -> Result<(), JobError> {
781        self.transition(job_id, JobState::Paused)
782    }
783
784    /// Requeue a parked job (`Paused -> Pending`). The next
785    /// [`run_build_publish`] drive admits it and resumes from its checkpoint.
786    /// Refused with [`JobError::DriveActive`] while the previous drive is
787    /// still draining in this process — join it first.
788    pub fn resume(&self, job_id: u64) -> Result<(), JobError> {
789        if self.active_drives.lock().contains(&job_id) {
790            return Err(JobError::DriveActive { job_id });
791        }
792        self.transition(job_id, JobState::Pending)
793    }
794
795    /// Cancel a job.
796    ///
797    /// - `Pending`/`Paused`: no worker is live, so the registry completes
798    ///   the cancellation synchronously (`-> Cancelling -> Failed`).
799    /// - `Running`: moves to `Cancelling` and sets the token; the live drive
800    ///   finishes the cancellation cooperatively (`-> RollingBack ->
801    ///   Failed`).
802    /// - `Cancelling`/`RollingBack`: already heading to `Failed`; a no-op.
803    /// - Terminal states: [`JobError::IllegalTransition`].
804    pub fn cancel(&self, job_id: u64) -> Result<(), JobError> {
805        let state = self.get(job_id).ok_or(JobError::NotFound { job_id })?.state;
806        match state {
807            JobState::Pending | JobState::Paused => {
808                let mut next = self.inner.lock().clone();
809                let record = next.jobs.get_mut(&job_id).expect("record checked above");
810                apply_transition(record, JobState::Cancelling)?;
811                apply_transition(record, JobState::Failed)?;
812                record.error = Some(match state {
813                    JobState::Pending => "cancelled before the job started".to_string(),
814                    _ => "cancelled while the job was paused".to_string(),
815                });
816                self.persist_and_swap(next)?;
817                self.tokens.lock().entry(job_id).or_default().cancel();
818                Ok(())
819            }
820            JobState::Running => {
821                self.transition(job_id, JobState::Cancelling)?;
822                self.tokens.lock().entry(job_id).or_default().cancel();
823                Ok(())
824            }
825            JobState::Cancelling | JobState::RollingBack => Ok(()),
826            JobState::Succeeded | JobState::Failed => Err(JobError::IllegalTransition {
827                from: state,
828                to: JobState::Cancelling,
829            }),
830        }
831    }
832
833    /// Persist a new progress value for a running job.
834    fn update_progress(&self, job_id: u64, progress: JobProgress) -> Result<(), JobError> {
835        let mut next = self.inner.lock().clone();
836        let record = next
837            .jobs
838            .get_mut(&job_id)
839            .ok_or(JobError::NotFound { job_id })?;
840        if record.state != JobState::Running {
841            return Err(JobError::UnexpectedState {
842                job_id,
843                expected: JobState::Running,
844                actual: record.state,
845            });
846        }
847        record.progress = progress;
848        record.updated_at_micros = unix_micros();
849        self.persist_and_swap(next)
850    }
851
852    /// Apply a legal transition and persist it.
853    fn transition(&self, job_id: u64, to: JobState) -> Result<(), JobError> {
854        let mut next = self.inner.lock().clone();
855        let record = next
856            .jobs
857            .get_mut(&job_id)
858            .ok_or(JobError::NotFound { job_id })?;
859        apply_transition(record, to)?;
860        self.persist_and_swap(next)
861    }
862
863    /// `Pending -> Running`, enforcing the active-job bound.
864    fn admit(&self, job_id: u64) -> Result<(), JobError> {
865        let mut next = self.inner.lock().clone();
866        let active = next
867            .jobs
868            .values()
869            .filter(|record| {
870                matches!(
871                    record.state,
872                    JobState::Running | JobState::Cancelling | JobState::RollingBack
873                )
874            })
875            .count();
876        let record = next
877            .jobs
878            .get(&job_id)
879            .ok_or(JobError::NotFound { job_id })?;
880        if record.state != JobState::Pending {
881            return Err(JobError::IllegalTransition {
882                from: record.state,
883                to: JobState::Running,
884            });
885        }
886        if active >= self.max_concurrent_jobs {
887            return Err(JobError::ConcurrencyLimit {
888                active,
889                limit: self.max_concurrent_jobs,
890            });
891        }
892        let record = next.jobs.get_mut(&job_id).expect("record checked above");
893        apply_transition(record, JobState::Running)?;
894        self.persist_and_swap(next)
895    }
896
897    /// Persist the driver checkpoint and phase-derived progress. Allowed in
898    /// `Running` and in `Paused`: an operator pause can land while the phase
899    /// whose checkpoint is being saved was in flight, and the completed
900    /// phase's checkpoint must still be durable before the drive parks.
901    fn save_checkpoint(
902        &self,
903        job_id: u64,
904        checkpoint: Vec<u8>,
905        progress: JobProgress,
906    ) -> Result<(), JobError> {
907        if checkpoint.len() > MAX_CHECKPOINT_BYTES {
908            return Err(JobError::CheckpointTooLarge {
909                bytes: checkpoint.len(),
910                limit: MAX_CHECKPOINT_BYTES,
911            });
912        }
913        let mut next = self.inner.lock().clone();
914        let record = next
915            .jobs
916            .get_mut(&job_id)
917            .ok_or(JobError::NotFound { job_id })?;
918        if !matches!(record.state, JobState::Running | JobState::Paused) {
919            return Err(JobError::UnexpectedState {
920                job_id,
921                expected: JobState::Running,
922                actual: record.state,
923            });
924        }
925        record.checkpoint = Some(checkpoint);
926        record.progress = progress;
927        record.updated_at_micros = unix_micros();
928        self.persist_and_swap(next)
929    }
930
931    /// `Running -> Succeeded`: clears the checkpoint (no resume remains) and
932    /// pins progress at 100%.
933    fn complete(&self, job_id: u64) -> Result<(), JobError> {
934        let mut next = self.inner.lock().clone();
935        let record = next
936            .jobs
937            .get_mut(&job_id)
938            .ok_or(JobError::NotFound { job_id })?;
939        apply_transition(record, JobState::Succeeded)?;
940        record.checkpoint = None;
941        record.progress.fraction = 1.0;
942        self.persist_and_swap(next)
943    }
944
945    /// `Running|Cancelling -> RollingBack`.
946    fn begin_rollback(&self, job_id: u64) -> Result<(), JobError> {
947        self.transition(job_id, JobState::RollingBack)
948    }
949
950    /// `RollingBack -> Failed`, recording the terminal error.
951    fn fail(&self, job_id: u64, error: String) -> Result<(), JobError> {
952        let mut next = self.inner.lock().clone();
953        let record = next
954            .jobs
955            .get_mut(&job_id)
956            .ok_or(JobError::NotFound { job_id })?;
957        apply_transition(record, JobState::Failed)?;
958        record.error = Some(error);
959        self.persist_and_swap(next)
960    }
961
962    /// Persist `next`, then swap it in as the live state. On a persistence
963    /// error the in-memory state is untouched, so memory never runs ahead of
964    /// the durable file.
965    fn persist_and_swap(&self, next: RegistryInner) -> Result<(), JobError> {
966        self.persist_locked(&next)?;
967        *self.inner.lock() = next;
968        Ok(())
969    }
970
971    fn persist_locked(&self, inner: &RegistryInner) -> Result<(), JobError> {
972        let snapshot = JobsSnapshot {
973            next_job_id: inner.next_job_id,
974            jobs: inner.jobs.values().cloned().collect(),
975        };
976        write_durable(&self.root, &snapshot, self.meta_dek.as_ref())
977    }
978}
979
980/// Drive one job through the full S1F-003 build-and-publish protocol.
981///
982/// The record must be `Pending`: [`JobRegistry::submit`] leaves it there,
983/// and a `Paused` job is requeued with [`JobRegistry::resume`] first. When
984/// the record carries a checkpoint (a resume), `job` is restored from it and
985/// completed phases are skipped; otherwise all seven phases run in order.
986///
987/// Returns `Ok(())` when every phase completed (the record is then
988/// `Succeeded`) or when an operator pause parked the job mid-run (the record
989/// is then `Paused` and a later drive resumes it). Phase errors and
990/// cancellation surface as `Err` after the record lands in `Failed`;
991/// injected boundary faults surface as [`JobError::InjectedFault`] with the
992/// record parked in `Paused`.
993pub fn run_build_publish<J: BuildPublishJob + ?Sized>(
994    registry: &JobRegistry,
995    job_id: u64,
996    job: &mut J,
997) -> Result<(), JobError> {
998    let record = registry.get(job_id).ok_or(JobError::NotFound { job_id })?;
999    if record.state != JobState::Pending {
1000        return Err(JobError::IllegalTransition {
1001            from: record.state,
1002            to: JobState::Running,
1003        });
1004    }
1005    // Restore before admission: a corrupt checkpoint leaves the job queued
1006    // rather than half-admitted.
1007    let mut completed_phases = 0_usize;
1008    if let Some(checkpoint) = &record.checkpoint {
1009        let decoded = decode_build_checkpoint(checkpoint)?;
1010        job.restore_checkpoint(&decoded.state)?;
1011        completed_phases = usize::from(decoded.completed_phases);
1012    }
1013    registry.admit(job_id)?;
1014    // From here until the guard drops the job has a live drive; `resume()`
1015    // refuses to requeue it.
1016    let _drive = DriveGuard { registry, job_id };
1017    let token = registry
1018        .cancellation_token(job_id)
1019        .ok_or(JobError::NotFound { job_id })?;
1020    let context = JobContext {
1021        registry,
1022        job_id,
1023        token,
1024    };
1025
1026    for (index, phase) in BuildPhase::ALL
1027        .iter()
1028        .copied()
1029        .enumerate()
1030        .skip(completed_phases)
1031    {
1032        // Cooperative stops, checked at every phase boundary: an operator
1033        // pause parks the drive (checkpoint durable), an operator cancel
1034        // rolls the job back.
1035        let state = registry
1036            .get(job_id)
1037            .ok_or(JobError::NotFound { job_id })?
1038            .state;
1039        match state {
1040            JobState::Running => {}
1041            JobState::Paused => return Ok(()),
1042            JobState::Cancelling => {
1043                return rollback_and_fail(registry, job_id, job, JobError::Cancelled);
1044            }
1045            state => {
1046                return Err(JobError::IllegalTransition {
1047                    from: state,
1048                    to: JobState::Running,
1049                });
1050            }
1051        }
1052        if let Err(fault) = mongreldb_fault::inject(phase.before_hook()) {
1053            return park_on_fault(registry, job_id, job, fault);
1054        }
1055        let outcome = phase.invoke(job, &context);
1056        if let Err(fault) = mongreldb_fault::inject(phase.after_hook()) {
1057            return park_on_fault(registry, job_id, job, fault);
1058        }
1059        if let Err(error) = outcome {
1060            return rollback_and_fail(registry, job_id, job, error);
1061        }
1062        completed_phases = index + 1;
1063        let checkpoint = encode_build_checkpoint(completed_phases as u8, &job.checkpoint_state())?;
1064        let total = BuildPhase::ALL.len() as u64;
1065        let done = completed_phases as u64;
1066        let progress = JobProgress::new(done as f64 / total as f64, done, total)?;
1067        // An operator pause/cancel may have landed while the phase ran. The
1068        // completed phase's checkpoint is saved either way (progress is
1069        // monotonic and phases are idempotent); the drive then parks or
1070        // rolls back instead of starting the next phase.
1071        let state = registry
1072            .get(job_id)
1073            .ok_or(JobError::NotFound { job_id })?
1074            .state;
1075        match state {
1076            JobState::Running => registry.save_checkpoint(job_id, checkpoint, progress)?,
1077            JobState::Paused => {
1078                registry.save_checkpoint(job_id, checkpoint, progress)?;
1079                return Ok(());
1080            }
1081            JobState::Cancelling => {
1082                return rollback_and_fail(registry, job_id, job, JobError::Cancelled);
1083            }
1084            state => {
1085                return Err(JobError::IllegalTransition {
1086                    from: state,
1087                    to: JobState::Running,
1088                });
1089            }
1090        }
1091    }
1092    match registry.complete(job_id) {
1093        Ok(()) => Ok(()),
1094        Err(JobError::IllegalTransition { from, .. }) => match from {
1095            // A last-instant operator pause wins; the next drive finishes the
1096            // already-checkpointed job. A last-instant cancel rolls back.
1097            JobState::Paused => Ok(()),
1098            JobState::Cancelling => rollback_and_fail(registry, job_id, job, JobError::Cancelled),
1099            state => Err(JobError::IllegalTransition {
1100                from: state,
1101                to: JobState::Succeeded,
1102            }),
1103        },
1104        Err(error) => Err(error),
1105    }
1106}
1107
1108/// An injected boundary fault is transient: park the job (its last
1109/// checkpoint is durable) and report the fault. If the operator cancelled
1110/// concurrently, cancellation wins and the job rolls back instead.
1111fn park_on_fault<J: BuildPublishJob + ?Sized>(
1112    registry: &JobRegistry,
1113    job_id: u64,
1114    job: &mut J,
1115    fault: mongreldb_fault::Fault,
1116) -> Result<(), JobError> {
1117    match registry.pause(job_id) {
1118        Ok(()) => Err(JobError::InjectedFault(fault.to_string())),
1119        Err(JobError::IllegalTransition {
1120            from: JobState::Cancelling,
1121            ..
1122        }) => rollback_and_fail(registry, job_id, job, JobError::Cancelled),
1123        Err(error) => Err(error),
1124    }
1125}
1126
1127/// Shared failure path: `Running|Cancelling -> RollingBack -> Failed` with
1128/// the job's `rollback()` in between. The original error is returned to the
1129/// caller; a rollback failure is recorded on the record, not propagated.
1130fn rollback_and_fail<J: BuildPublishJob + ?Sized>(
1131    registry: &JobRegistry,
1132    job_id: u64,
1133    job: &mut J,
1134    error: JobError,
1135) -> Result<(), JobError> {
1136    match registry.begin_rollback(job_id) {
1137        Ok(()) => {}
1138        // An operator pause/cancel landed concurrently and wins: the job is
1139        // no longer running, so there is nothing to roll back on this drive.
1140        // The failing phase re-runs (idempotency contract) on the next one.
1141        Err(JobError::IllegalTransition { .. }) => return Err(error),
1142        Err(storage) => return Err(storage),
1143    }
1144    let message = match job.rollback() {
1145        Ok(()) => error.to_string(),
1146        Err(rollback_error) => format!("{error}; rollback also failed: {rollback_error}"),
1147    };
1148    registry.fail(job_id, message)?;
1149    Err(error)
1150}
1151
1152/// RAII marker for a live [`run_build_publish`] drive: removes the job from
1153/// the registry's active-drive set on every exit path so [`JobRegistry::resume`]
1154/// can reject a requeue while the previous drive is still draining.
1155struct DriveGuard<'a> {
1156    registry: &'a JobRegistry,
1157    job_id: u64,
1158}
1159
1160impl Drop for DriveGuard<'_> {
1161    fn drop(&mut self) {
1162        self.registry.active_drives.lock().remove(&self.job_id);
1163    }
1164}
1165
1166/// The driver-owned payload inside [`JobRecord::checkpoint`]: how many
1167/// leading phases completed, plus the implementor's opaque state.
1168#[derive(Serialize, Deserialize)]
1169#[serde(deny_unknown_fields)]
1170struct BuildPublishCheckpoint {
1171    completed_phases: u8,
1172    state: Vec<u8>,
1173}
1174
1175fn encode_build_checkpoint(completed_phases: u8, state: &[u8]) -> Result<Vec<u8>, JobError> {
1176    serde_json::to_vec(&BuildPublishCheckpoint {
1177        completed_phases,
1178        state: state.to_vec(),
1179    })
1180    .map_err(|error| JobError::Storage(format!("job checkpoint serialize: {error}")))
1181}
1182
1183fn decode_build_checkpoint(bytes: &[u8]) -> Result<BuildPublishCheckpoint, JobError> {
1184    let checkpoint: BuildPublishCheckpoint = serde_json::from_slice(bytes)
1185        .map_err(|error| JobError::Storage(format!("job checkpoint deserialize: {error}")))?;
1186    if usize::from(checkpoint.completed_phases) > BuildPhase::ALL.len() {
1187        return Err(JobError::Storage(format!(
1188            "job checkpoint claims {} completed phases, only {} exist",
1189            checkpoint.completed_phases,
1190            BuildPhase::ALL.len()
1191        )));
1192    }
1193    Ok(checkpoint)
1194}
1195
1196fn apply_transition(record: &mut JobRecord, to: JobState) -> Result<(), JobError> {
1197    if !record.state.can_transition(to) {
1198        return Err(JobError::IllegalTransition {
1199            from: record.state,
1200            to,
1201        });
1202    }
1203    record.state = to;
1204    record.updated_at_micros = unix_micros();
1205    Ok(())
1206}
1207
1208/// Crash-recovery mapping (module docs). Returns whether anything changed.
1209fn recover_after_crash(inner: &mut RegistryInner) -> bool {
1210    let now = unix_micros();
1211    let mut changed = false;
1212    for record in inner.jobs.values_mut() {
1213        match record.state {
1214            JobState::Running => {
1215                // The worker died with the process; park the job on its last
1216                // durable checkpoint for an operator-driven resume.
1217                record.state = JobState::Paused;
1218                record.updated_at_micros = now;
1219                changed = true;
1220            }
1221            JobState::Cancelling => {
1222                // The crash itself completed the cancellation.
1223                record.state = JobState::Failed;
1224                record.error =
1225                    Some("cancelled (process restarted while the job was cancelling)".to_string());
1226                record.updated_at_micros = now;
1227                changed = true;
1228            }
1229            JobState::RollingBack => {
1230                record.state = JobState::Failed;
1231                const NOTE: &str = "rollback interrupted by process restart";
1232                record.error = Some(match record.error.take() {
1233                    Some(error) => format!("{error}; {NOTE}"),
1234                    None => NOTE.to_string(),
1235                });
1236                record.updated_at_micros = now;
1237                changed = true;
1238            }
1239            JobState::Pending | JobState::Paused | JobState::Succeeded | JobState::Failed => {}
1240        }
1241    }
1242    changed
1243}
1244
1245/// Fail-closed validation of the decoded file body: ids are unique and the
1246/// allocator can never reissue one (spec section 7).
1247fn validate_snapshot(snapshot: JobsSnapshot) -> Result<RegistryInner, JobError> {
1248    let mut jobs = BTreeMap::new();
1249    for record in snapshot.jobs {
1250        if jobs.insert(record.job_id, record).is_some() {
1251            return Err(JobError::Storage(
1252                "duplicate job id in registry file".to_string(),
1253            ));
1254        }
1255    }
1256    let max_id = jobs.keys().next_back().copied().unwrap_or(0);
1257    if snapshot.next_job_id <= max_id {
1258        return Err(JobError::Storage(format!(
1259            "registry allocator at {} would reissue job id {max_id}",
1260            snapshot.next_job_id
1261        )));
1262    }
1263    Ok(RegistryInner {
1264        next_job_id: snapshot.next_job_id.max(1),
1265        jobs,
1266    })
1267}
1268
1269fn encode(snapshot: &JobsSnapshot) -> Result<Vec<u8>, JobError> {
1270    serde_json::to_vec(&JobsEnvelope {
1271        format_version: JOBS_FORMAT_VERSION,
1272        registry: snapshot.clone(),
1273    })
1274    .map_err(|error| JobError::Storage(format!("job registry serialize: {error}")))
1275}
1276
1277fn decode(body: &[u8]) -> Result<JobsSnapshot, JobError> {
1278    let envelope: JobsEnvelope = serde_json::from_slice(body)
1279        .map_err(|error| JobError::Storage(format!("job registry deserialize: {error}")))?;
1280    if envelope.format_version != JOBS_FORMAT_VERSION {
1281        return Err(JobError::Storage(format!(
1282            "unsupported job registry format version {}",
1283            envelope.format_version
1284        )));
1285    }
1286    Ok(envelope.registry)
1287}
1288
1289fn plaintext_frame(body: &[u8]) -> Vec<u8> {
1290    let hash = Sha256::digest(body);
1291    let mut out = Vec::with_capacity(body.len() + 8 + 32);
1292    out.extend_from_slice(MAGIC);
1293    out.extend_from_slice(&hash);
1294    out.extend_from_slice(body);
1295    out
1296}
1297
1298fn seal(body: &[u8], meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Vec<u8>, JobError> {
1299    match meta_dek {
1300        Some(dek) => crate::encryption::encrypt_blob(dek, body)
1301            .map_err(|error| JobError::Storage(format!("job registry seal: {error}"))),
1302        None => Ok(plaintext_frame(body)),
1303    }
1304}
1305
1306fn open_payload(
1307    bytes: &[u8],
1308    meta_dek: Option<&[u8; META_DEK_LEN]>,
1309) -> Result<JobsSnapshot, JobError> {
1310    match meta_dek {
1311        // Fail closed: an unauthenticated registry is an error, never
1312        // "no jobs" (see module docs for the catalog deviation).
1313        Some(dek) => {
1314            let body = crate::encryption::decrypt_blob(dek, bytes).map_err(|_| {
1315                JobError::Storage(
1316                    "job registry authentication failed (wrong key or tampered)".to_string(),
1317                )
1318            })?;
1319            decode(&body)
1320        }
1321        None => parse_plaintext(bytes),
1322    }
1323}
1324
1325/// Write the registry file through the catalog's checksum + atomic-rename
1326/// path (temp write, fsync, rename, parent-dir fsync).
1327fn write_durable(
1328    root: &DurableRoot,
1329    snapshot: &JobsSnapshot,
1330    meta_dek: Option<&[u8; META_DEK_LEN]>,
1331) -> Result<(), JobError> {
1332    let body = encode(snapshot)?;
1333    let payload = seal(&body, meta_dek)?;
1334    root.write_atomic(JOBS_FILENAME, &payload)?;
1335    Ok(())
1336}
1337
1338/// Read the registry file. `Ok(None)` means no file exists yet; any present
1339/// but unverifiable content is an error (fail closed, module docs).
1340fn read_durable(
1341    root: &DurableRoot,
1342    meta_dek: Option<&[u8; META_DEK_LEN]>,
1343) -> Result<Option<JobsSnapshot>, JobError> {
1344    let file = match root.open_regular(JOBS_FILENAME) {
1345        Ok(file) => file,
1346        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1347        Err(error) => return Err(error.into()),
1348    };
1349    let length = file.metadata()?.len();
1350    if length > MAX_JOBS_BYTES {
1351        return Err(JobError::Storage(format!(
1352            "job registry of {length} bytes exceeds the {MAX_JOBS_BYTES}-byte limit"
1353        )));
1354    }
1355    let mut bytes = Vec::with_capacity(length as usize);
1356    file.take(MAX_JOBS_BYTES + 1).read_to_end(&mut bytes)?;
1357    if bytes.len() as u64 != length {
1358        return Err(JobError::Storage(
1359            "job registry length changed while reading".to_string(),
1360        ));
1361    }
1362    open_payload(&bytes, meta_dek).map(Some)
1363}
1364
1365fn parse_plaintext(bytes: &[u8]) -> Result<JobsSnapshot, JobError> {
1366    if bytes.len() < 8 + 32 || &bytes[..8] != MAGIC {
1367        return Err(JobError::Storage(
1368            "job registry magic mismatch (corrupt or sealed with a key)".to_string(),
1369        ));
1370    }
1371    let (tag, body) = bytes[8..].split_at(32);
1372    let calc = Sha256::digest(body);
1373    if tag != calc.as_slice() {
1374        return Err(JobError::Storage(
1375            "job registry checksum mismatch (tampered or torn)".to_string(),
1376        ));
1377    }
1378    decode(body)
1379}
1380
1381/// Wall-clock microseconds since the Unix epoch (saturating). Job metadata
1382/// only; never an MVCC/visibility timestamp.
1383fn unix_micros() -> u64 {
1384    let micros = std::time::SystemTime::now()
1385        .duration_since(std::time::UNIX_EPOCH)
1386        .map(|duration| duration.as_micros())
1387        .unwrap_or(0);
1388    u64::try_from(micros).unwrap_or(u64::MAX)
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393    use super::*;
1394
1395    fn open_temp() -> (tempfile::TempDir, JobRegistry) {
1396        let dir = tempfile::tempdir().unwrap();
1397        let registry = JobRegistry::open(dir.path(), None).unwrap();
1398        (dir, registry)
1399    }
1400
1401    fn target() -> JobTarget {
1402        JobTarget {
1403            table: "items".to_string(),
1404            index: Some("items_idx".to_string()),
1405        }
1406    }
1407
1408    #[test]
1409    fn transition_graph_matches_the_documented_edges() {
1410        let legal: [(JobState, JobState); 11] = [
1411            (JobState::Pending, JobState::Running),
1412            (JobState::Pending, JobState::Cancelling),
1413            (JobState::Running, JobState::Paused),
1414            (JobState::Running, JobState::Cancelling),
1415            (JobState::Running, JobState::RollingBack),
1416            (JobState::Running, JobState::Succeeded),
1417            (JobState::Paused, JobState::Pending),
1418            (JobState::Paused, JobState::Cancelling),
1419            (JobState::Cancelling, JobState::RollingBack),
1420            (JobState::Cancelling, JobState::Failed),
1421            (JobState::RollingBack, JobState::Failed),
1422        ];
1423        for from in JobState::ALL {
1424            for to in JobState::ALL {
1425                let expected = legal.contains(&(from, to));
1426                assert_eq!(from.can_transition(to), expected, "edge {from:?} -> {to:?}");
1427            }
1428        }
1429        for terminal in [JobState::Succeeded, JobState::Failed] {
1430            assert!(terminal.is_terminal());
1431            assert!(JobState::ALL
1432                .iter()
1433                .all(|&next| !terminal.can_transition(next)));
1434        }
1435    }
1436
1437    #[test]
1438    fn progress_validation_rejects_out_of_range_values() {
1439        assert!(JobProgress::new(0.5, 1, 2).is_ok());
1440        assert!(JobProgress::new(0.0, 0, 0).is_ok());
1441        assert!(JobProgress::new(1.0, 7, 7).is_ok());
1442        assert!(JobProgress::new(f64::NAN, 0, 0).is_err());
1443        assert!(JobProgress::new(-0.1, 0, 0).is_err());
1444        assert!(JobProgress::new(1.1, 0, 0).is_err());
1445        assert!(JobProgress::new(0.5, 3, 2).is_err());
1446    }
1447
1448    #[test]
1449    fn persistence_round_trip_preserves_records_and_allocator() {
1450        let dir = tempfile::tempdir().unwrap();
1451        let first = JobRegistry::open(dir.path(), None).unwrap();
1452        let a = first.submit(JobKind::IndexBuild, target()).unwrap();
1453        let b = first
1454            .submit(
1455                JobKind::LargeImport,
1456                JobTarget {
1457                    table: "bulk".to_string(),
1458                    index: None,
1459                },
1460            )
1461            .unwrap();
1462        assert_eq!((a, b), (1, 2));
1463        first.admit(a).unwrap();
1464        first
1465            .save_checkpoint(
1466                a,
1467                b"opaque-resume-state".to_vec(),
1468                JobProgress::new(0.5, 4, 8).unwrap(),
1469            )
1470            .unwrap();
1471        drop(first);
1472
1473        let reopened = JobRegistry::open(dir.path(), None).unwrap();
1474        // Recovery maps Running -> Paused (covered exhaustively below), so
1475        // compare the fields the mapping does not touch.
1476        let record = reopened.get(a).unwrap();
1477        assert_eq!(record.state, JobState::Paused);
1478        assert_eq!(record.kind, JobKind::IndexBuild);
1479        assert_eq!(record.target, target());
1480        assert_eq!(record.progress, JobProgress::new(0.5, 4, 8).unwrap());
1481        assert_eq!(
1482            record.checkpoint.as_deref(),
1483            Some(b"opaque-resume-state".as_slice())
1484        );
1485        assert!(record.error.is_none());
1486        assert!(record.updated_at_micros >= record.created_at_micros);
1487        assert_eq!(reopened.get(b).unwrap().state, JobState::Pending);
1488        // Ids are never reused across reopen.
1489        let c = reopened
1490            .submit(
1491                JobKind::KeyRotation,
1492                JobTarget {
1493                    table: "items".to_string(),
1494                    index: None,
1495                },
1496            )
1497            .unwrap();
1498        assert_eq!(c, 3);
1499        assert_eq!(reopened.list().len(), 3);
1500    }
1501
1502    #[test]
1503    fn crash_recovery_maps_active_states_to_safe_ones() {
1504        let dir = tempfile::tempdir().unwrap();
1505        let registry = JobRegistry::open(dir.path(), None)
1506            .unwrap()
1507            .with_max_concurrent_jobs(8);
1508        let running = registry.submit(JobKind::IndexBuild, target()).unwrap();
1509        let cancelling = registry.submit(JobKind::IndexBuild, target()).unwrap();
1510        let rolling_back = registry.submit(JobKind::IndexBuild, target()).unwrap();
1511        let pending = registry.submit(JobKind::IndexBuild, target()).unwrap();
1512        let paused = registry.submit(JobKind::IndexBuild, target()).unwrap();
1513        let succeeded = registry.submit(JobKind::IndexBuild, target()).unwrap();
1514        let failed = registry.submit(JobKind::IndexBuild, target()).unwrap();
1515
1516        registry.admit(running).unwrap();
1517        registry
1518            .save_checkpoint(
1519                running,
1520                b"resume-bytes".to_vec(),
1521                JobProgress::new(0.25, 1, 4).unwrap(),
1522            )
1523            .unwrap();
1524        registry.admit(cancelling).unwrap();
1525        registry.cancel(cancelling).unwrap();
1526        registry.admit(rolling_back).unwrap();
1527        registry.begin_rollback(rolling_back).unwrap();
1528        registry.admit(paused).unwrap();
1529        registry.pause(paused).unwrap();
1530        registry.admit(succeeded).unwrap();
1531        registry.complete(succeeded).unwrap();
1532        registry.admit(failed).unwrap();
1533        registry.begin_rollback(failed).unwrap();
1534        registry.fail(failed, "boom".to_string()).unwrap();
1535        drop(registry);
1536
1537        let recovered = JobRegistry::open(dir.path(), None).unwrap();
1538        let running = recovered.get(running).unwrap();
1539        assert_eq!(running.state, JobState::Paused);
1540        assert_eq!(
1541            running.checkpoint.as_deref(),
1542            Some(b"resume-bytes".as_slice())
1543        );
1544        assert_eq!(running.progress, JobProgress::new(0.25, 1, 4).unwrap());
1545
1546        let cancelling = recovered.get(cancelling).unwrap();
1547        assert_eq!(cancelling.state, JobState::Failed);
1548        assert!(cancelling.error.unwrap().contains("cancelled"));
1549
1550        let rolling_back = recovered.get(rolling_back).unwrap();
1551        assert_eq!(rolling_back.state, JobState::Failed);
1552        assert!(rolling_back.error.unwrap().contains("rollback interrupted"));
1553
1554        assert_eq!(recovered.get(pending).unwrap().state, JobState::Pending);
1555        assert_eq!(recovered.get(paused).unwrap().state, JobState::Paused);
1556        assert_eq!(recovered.get(succeeded).unwrap().state, JobState::Succeeded);
1557        let failed = recovered.get(failed).unwrap();
1558        assert_eq!(failed.state, JobState::Failed);
1559        assert_eq!(failed.error.as_deref(), Some("boom"));
1560
1561        // Recovery was persisted: a second reopen is a no-op.
1562        drop(recovered);
1563        let again = JobRegistry::open(dir.path(), None).unwrap();
1564        assert_eq!(again.list().len(), 7);
1565        assert!(again.list().iter().all(|record| !matches!(
1566            record.state,
1567            JobState::Running | JobState::Cancelling | JobState::RollingBack
1568        )));
1569    }
1570
1571    #[test]
1572    fn admission_enforces_the_concurrency_bound() {
1573        let (_dir, registry) = open_temp();
1574        let registry = registry.with_max_concurrent_jobs(1);
1575        let a = registry.submit(JobKind::IndexBuild, target()).unwrap();
1576        let b = registry.submit(JobKind::IndexBuild, target()).unwrap();
1577        registry.admit(a).unwrap();
1578        let error = registry.admit(b).unwrap_err();
1579        assert!(
1580            matches!(
1581                error,
1582                JobError::ConcurrencyLimit {
1583                    active: 1,
1584                    limit: 1
1585                }
1586            ),
1587            "expected ConcurrencyLimit, got {error:?}"
1588        );
1589        // A failed admission leaves the job queued.
1590        assert_eq!(registry.get(b).unwrap().state, JobState::Pending);
1591        registry.complete(a).unwrap();
1592        registry.admit(b).unwrap();
1593    }
1594
1595    #[test]
1596    fn pause_resume_cancel_follow_the_graph() {
1597        let (_dir, registry) = open_temp();
1598        let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1599        // Pause requires a live worker.
1600        assert!(matches!(
1601            registry.pause(job),
1602            Err(JobError::IllegalTransition {
1603                from: JobState::Pending,
1604                to: JobState::Paused
1605            })
1606        ));
1607        registry.admit(job).unwrap();
1608        registry.pause(job).unwrap();
1609        assert_eq!(registry.get(job).unwrap().state, JobState::Paused);
1610        registry.resume(job).unwrap();
1611        assert_eq!(registry.get(job).unwrap().state, JobState::Pending);
1612        registry.admit(job).unwrap();
1613        registry.cancel(job).unwrap();
1614        assert_eq!(registry.get(job).unwrap().state, JobState::Cancelling);
1615        assert!(registry.cancellation_token(job).unwrap().is_cancelled());
1616        // Cancelling is idempotent.
1617        registry.cancel(job).unwrap();
1618        registry.begin_rollback(job).unwrap();
1619        registry.fail(job, "cancelled".to_string()).unwrap();
1620        // Terminal states reject every operator verb.
1621        assert!(matches!(
1622            registry.cancel(job),
1623            Err(JobError::IllegalTransition {
1624                from: JobState::Failed,
1625                to: JobState::Cancelling
1626            })
1627        ));
1628        assert!(matches!(
1629            registry.resume(job),
1630            Err(JobError::IllegalTransition {
1631                from: JobState::Failed,
1632                to: JobState::Pending
1633            })
1634        ));
1635    }
1636
1637    #[test]
1638    fn cancel_without_a_worker_completes_synchronously() {
1639        let (_dir, registry) = open_temp();
1640        let queued = registry.submit(JobKind::IndexBuild, target()).unwrap();
1641        registry.cancel(queued).unwrap();
1642        let record = registry.get(queued).unwrap();
1643        assert_eq!(record.state, JobState::Failed);
1644        assert_eq!(
1645            record.error.as_deref(),
1646            Some("cancelled before the job started")
1647        );
1648        assert!(registry.cancellation_token(queued).unwrap().is_cancelled());
1649
1650        let parked = registry.submit(JobKind::IndexBuild, target()).unwrap();
1651        registry.admit(parked).unwrap();
1652        registry.pause(parked).unwrap();
1653        registry.cancel(parked).unwrap();
1654        let record = registry.get(parked).unwrap();
1655        assert_eq!(record.state, JobState::Failed);
1656        assert_eq!(
1657            record.error.as_deref(),
1658            Some("cancelled while the job was paused")
1659        );
1660    }
1661
1662    #[test]
1663    fn missing_file_opens_empty_and_file_is_created_on_first_mutation() {
1664        let dir = tempfile::tempdir().unwrap();
1665        let registry = JobRegistry::open(dir.path(), None).unwrap();
1666        assert!(registry.list().is_empty());
1667        assert!(!dir.path().join(JOBS_FILENAME).exists());
1668        registry.submit(JobKind::IndexBuild, target()).unwrap();
1669        assert!(dir.path().join(JOBS_FILENAME).exists());
1670    }
1671
1672    #[test]
1673    fn tampered_file_fails_closed() {
1674        // Corrupt body byte with intact magic+length: checksum fires.
1675        let dir = tempfile::tempdir().unwrap();
1676        let registry = JobRegistry::open(dir.path(), None).unwrap();
1677        registry.submit(JobKind::IndexBuild, target()).unwrap();
1678        drop(registry);
1679        let path = dir.path().join(JOBS_FILENAME);
1680        let mut bytes = std::fs::read(&path).unwrap();
1681        let last = bytes.len() - 1;
1682        bytes[last] ^= 0x01;
1683        std::fs::write(&path, &bytes).unwrap();
1684        let error = JobRegistry::open(dir.path(), None).unwrap_err();
1685        assert!(
1686            matches!(&error, JobError::Storage(message) if message.contains("checksum")),
1687            "expected checksum failure, got {error:?}"
1688        );
1689
1690        // Wrong magic (e.g. a sealed file opened without the key): error.
1691        bytes[..8].copy_from_slice(b"NOTAJOB!");
1692        std::fs::write(&path, &bytes).unwrap();
1693        let error = JobRegistry::open(dir.path(), None).unwrap_err();
1694        assert!(
1695            matches!(&error, JobError::Storage(message) if message.contains("magic")),
1696            "expected magic failure, got {error:?}"
1697        );
1698
1699        // Truncated below the frame header: error, never "empty registry".
1700        std::fs::write(&path, b"MON").unwrap();
1701        assert!(matches!(
1702            JobRegistry::open(dir.path(), None),
1703            Err(JobError::Storage(_))
1704        ));
1705    }
1706
1707    #[test]
1708    fn unsupported_format_version_fails_closed() {
1709        let dir = tempfile::tempdir().unwrap();
1710        let body = serde_json::to_vec(&serde_json::json!({
1711            "format_version": 99,
1712            "registry": { "next_job_id": 1, "jobs": [] }
1713        }))
1714        .unwrap();
1715        let payload = plaintext_frame(&body);
1716        std::fs::write(dir.path().join(JOBS_FILENAME), payload).unwrap();
1717        let error = JobRegistry::open(dir.path(), None).unwrap_err();
1718        assert!(
1719            matches!(&error, JobError::Storage(message) if message.contains("version 99")),
1720            "expected version failure, got {error:?}"
1721        );
1722    }
1723
1724    #[test]
1725    fn allocator_inconsistency_fails_closed() {
1726        let dir = tempfile::tempdir().unwrap();
1727        let body = serde_json::to_vec(&serde_json::json!({
1728            "format_version": 1,
1729            "registry": {
1730                "next_job_id": 1,
1731                "jobs": [{
1732                    "job_id": 1,
1733                    "kind": "IndexBuild",
1734                    "state": "Pending",
1735                    "target": { "table": "items" },
1736                    "progress": { "fraction": 0.0, "done": 0, "total": 0 },
1737                    "created_at_micros": 1,
1738                    "updated_at_micros": 1
1739                }]
1740            }
1741        }))
1742        .unwrap();
1743        std::fs::write(dir.path().join(JOBS_FILENAME), plaintext_frame(&body)).unwrap();
1744        assert!(matches!(
1745            JobRegistry::open(dir.path(), None),
1746            Err(JobError::Storage(_))
1747        ));
1748    }
1749
1750    #[test]
1751    fn resume_rejects_a_job_with_a_live_drive() {
1752        let (_dir, registry) = open_temp();
1753        let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1754        registry.admit(job).unwrap();
1755        registry.pause(job).unwrap();
1756        // Simulate a drive still draining toward the park point.
1757        registry.active_drives.lock().insert(job);
1758        assert!(matches!(
1759            registry.resume(job),
1760            Err(JobError::DriveActive { job_id }) if job_id == job
1761        ));
1762        assert_eq!(registry.get(job).unwrap().state, JobState::Paused);
1763        registry.active_drives.lock().remove(&job);
1764        registry.resume(job).unwrap();
1765        assert_eq!(registry.get(job).unwrap().state, JobState::Pending);
1766    }
1767
1768    #[test]
1769    fn checkpoint_size_is_bounded() {
1770        let (_dir, registry) = open_temp();
1771        let job = registry.submit(JobKind::IndexBuild, target()).unwrap();
1772        registry.admit(job).unwrap();
1773        let oversized = vec![0_u8; MAX_CHECKPOINT_BYTES + 1];
1774        assert!(matches!(
1775            registry.save_checkpoint(job, oversized, JobProgress::default()),
1776            Err(JobError::CheckpointTooLarge { .. })
1777        ));
1778    }
1779
1780    #[test]
1781    fn job_error_maps_onto_the_engine_error() {
1782        assert!(matches!(
1783            MongrelError::from(JobError::Cancelled),
1784            MongrelError::Cancelled
1785        ));
1786        assert!(matches!(
1787            MongrelError::from(JobError::NotFound { job_id: 7 }),
1788            MongrelError::NotFound(_)
1789        ));
1790        assert!(matches!(
1791            MongrelError::from(JobError::ConcurrencyLimit {
1792                active: 3,
1793                limit: 2
1794            }),
1795            MongrelError::ResourceLimitExceeded { .. }
1796        ));
1797        assert!(matches!(
1798            MongrelError::from(JobError::InjectedFault("x".to_string())),
1799            MongrelError::Other(_)
1800        ));
1801    }
1802
1803    #[test]
1804    fn record_serde_uses_stable_text_encoding() {
1805        let (_dir, registry) = open_temp();
1806        let job = registry
1807            .submit(JobKind::MaterializedViewRebuild, target())
1808            .unwrap();
1809        registry.admit(job).unwrap();
1810        let record = registry.get(job).unwrap();
1811        let json = serde_json::to_string(&record).unwrap();
1812        assert!(json.contains("\"kind\":\"MaterializedViewRebuild\""));
1813        assert!(json.contains("\"state\":\"Running\""));
1814        let decoded: JobRecord = serde_json::from_str(&json).unwrap();
1815        assert_eq!(decoded, record);
1816        // Enum serde guards the durable contract: unknown variants fail.
1817        let unknown = json.replace("\"Running\"", "\"Napping\"");
1818        assert!(serde_json::from_str::<JobRecord>(&unknown).is_err());
1819    }
1820
1821    #[test]
1822    fn checkpoint_codec_round_trip_and_bounds() {
1823        let bytes = encode_build_checkpoint(3, b"impl-state").unwrap();
1824        let decoded = decode_build_checkpoint(&bytes).unwrap();
1825        assert_eq!(decoded.completed_phases, 3);
1826        assert_eq!(decoded.state, b"impl-state");
1827        let corrupt = encode_build_checkpoint(8, b"").unwrap();
1828        assert!(matches!(
1829            decode_build_checkpoint(&corrupt),
1830            Err(JobError::Storage(_))
1831        ));
1832    }
1833}