Skip to main content

mongreldb_core/
jobs.rs

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