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