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