Skip to main content

mongreldb_cluster/
ddl.rs

1//! Distributed DDL and online index jobs (spec section 12.11, Stage 3K).
2//!
3//! One replicated [`DdlJobRecord`] drives one schema element through the
4//! spec's DDL phases — `Pending -> WriteOnly -> Backfilling -> Validating ->
5//! Public` for additions, `Public -> Dropping` for drops — with the
6//! transition graph enforced at apply time in the replicated [`DdlJobStore`].
7//! The new-index protocol maps the spec's steps onto explicit collaborators:
8//!
9//! ```text
10//!  1. Replicate definition as WriteOnly      (DdlCommand::SubmitJob + AdvancePhase)
11//!  2. New writes maintain the hidden index   (ApplySideIndexMaintainer seam)
12//!  3. Backfill every tablet at pinned snapshot (per-tablet backfill drivers,
13//!     resumable through DdlJobRecord::tablet_progress)
14//!  4. Catch up deltas                        (committed writes from the pinned
15//!     snapshot forward, swept until drained)
16//!  5. Validate counts/checksums              (TabletValidationReport per
17//!     tablet, JobValidationReport aggregate)
18//!  6. Publish Public atomically              (DdlCommand::PublishJob — ONE
19//!     store command; planner-visible from that metadata version)
20//!  7. Old generation reclaimed later         (DdlCommand::ReclaimIndex, gated
21//!     on no readers below the retirement metadata version)
22//! ```
23//!
24//! # Reconciliation notes
25//!
26//! - The job's administrative lifecycle reuses [`crate::meta::SchemaJobState`]
27//!   verbatim (submitted `Pending`, `Running/Paused/Cancelling/RollingBack`
28//!   driven, terminal `Succeeded/Failed`; the transition graph is the core job
29//!   registry's, mirrored by `crate::meta`). The schema-element protocol phase
30//!   ([`DdlPhase`]) is the second, orthogonal axis the spec's section 12.11
31//!   declares.
32//! - [`DdlJobRecord`] is the DDL-native superset of
33//!   [`crate::meta::SchemaJobRecord`]: it adds the phase axis, the replicated
34//!   [`DdlDefinition`], the pinned `source_schema_version`, and the per-tablet
35//!   progress map. [`DdlJobKind::AddIndex`] corresponds to
36//!   `SchemaJobKind::IndexBuild`, [`DdlJobKind::AlterSchema`] to
37//!   `ColumnBackfill`/`SchemaValidation`; `DropIndex` has no core mirror.
38//! - [`DdlJobStore`] is the deterministic, serde-versioned state-machine
39//!   section the meta group replicates for distributed DDL (same apply idiom
40//!   as [`crate::meta::MetaState`]: the metadata version ticks once per
41//!   applied command, refusals are journaled, never faulted). This wave keeps
42//!   it a sibling of `MetaState` — `crate::meta`'s format v2 command enum is
43//!   frozen by the parallel Stage 3 waves — and the integration wave binds
44//!   [`DdlCommand`] onto meta-group proposals. The table anchors
45//!   ([`TableAnchor`]) shadow `MetaState::tables`' last-writer-wins
46//!   `schema_version` semantics so stale-schema enforcement is deterministic
47//!   inside this store.
48//!
49//! # Write-path contract (spec step 2)
50//!
51//! While an index record of a table sits in `WriteOnly..=Public`, the tablet
52//! apply path dual-maintains it: every committed row mutation additionally
53//! lands in the hidden (or public) index generation through
54//! [`ApplySideIndexMaintainer`]. The handoff with the backfill driver is
55//! timestamp-based: writes committed at or below a tablet's catch-up
56//! watermark are covered by the delta sweep (they ride
57//! [`BackfillKeyspace::deltas_after`]); writes above it are covered by the
58//! maintainer. Both paths are idempotent set/remove operations, so a write
59//! covered twice converges. A write that arrives before the hidden generation
60//! is installed is skipped by the maintainer and covered by the sweep — the
61//! pinned snapshot plus the delta stream always bound it. The engine hook
62//! lands with the server wave; [`InMemoryTabletIndexes`] is the reference
63//! implementation.
64//!
65//! # Crash safety and resume
66//!
67//! The job record in the replicated store is the only progress authority:
68//! every per-tablet stage transition is an applied command before the driver
69//! moves on. A driver crash (or a pause) leaves the record behind; a new
70//! [`DdlDriver`] over the same store resumes, skips tablets already
71//! `CaughtUp`/`Validated`, and restarts an interrupted tablet's build from
72//! scratch — `HiddenIndexSink::begin_build` discards staged content, the same
73//! idiom as the split executor's child-state sink. Resume granularity is one
74//! tablet.
75//!
76//! # Schema versions and stale-schema retry
77//!
78//! Every job pins the `source_schema_version` it was submitted against
79//! (spec section 12.11: the schema version rides every transaction and
80//! plan). Submission and the atomic publish both check it against the table
81//! anchor; a mismatch refuses with
82//! [`DdlRejection::SchemaVersionMismatch`], which [`DdlError::category`]
83//! maps onto [`ErrorCategory::SchemaVersionMismatch`] (retry class
84//! `AfterMetadataRefresh`): the caller refreshes schema metadata and
85//! resubmits.
86//!
87//! # Scope of this wave
88//!
89//! The engine binding (real index-key extraction, tablet-core snapshots and
90//! committed-log delta streams) lands with the server wave, as does the
91//! meta-group raft binding of [`DdlCommand`]. Split/merge topology changes
92//! mid-job are reconciled by the driver re-enumerating the tablet set each
93//! step ([`DdlCommand::ForgetTabletProgress`] drops departed tablets); an
94//! index rebuild that retires a previous public generation is a follow-up —
95//! this wave refuses duplicate index names, so the reclamation pass is
96//! exercised through drops and cancellation.
97
98use std::collections::{BTreeMap, VecDeque};
99use std::fmt;
100use std::sync::{Arc, Mutex};
101use std::time::Duration;
102
103use mongreldb_types::errors::ErrorCategory;
104use mongreldb_types::hlc::{ClockSkewError, HlcClock, HlcTimestamp};
105use mongreldb_types::ids::{DatabaseId, MetadataVersion, SchemaVersion, TableId, TabletId};
106use serde::{Deserialize, Serialize};
107use sha2::{Digest, Sha256};
108
109use crate::meta::{MetaRejectionReason, SchemaJobState};
110use crate::split::{RecordStream, SnapshotPin, TabletDataError};
111use crate::tablet::Key;
112
113// ---------------------------------------------------------------------------
114// Constants
115// ---------------------------------------------------------------------------
116
117/// Snapshot format version of [`DdlJobStore`]; bumped on any layout change
118/// (spec section 4.10).
119pub const DDL_STORE_FORMAT_VERSION: u32 = 1;
120/// Oldest store format version this build accepts.
121pub const MIN_SUPPORTED_DDL_STORE_FORMAT_VERSION: u32 = 1;
122/// Bound on the journaled command refusals (mirrors
123/// `crate::meta::META_REJECTION_LIMIT`).
124pub const DDL_REJECTION_LIMIT: usize = 256;
125
126// ---------------------------------------------------------------------------
127// Errors
128// ---------------------------------------------------------------------------
129
130/// Why a [`DdlCommand`] was refused at apply. Refusals are deterministic
131/// (every replica reaches the same conclusion from the same state) and never
132/// fault the state machine: they are journaled in [`DdlJobStore::rejections`]
133/// and reported to the proposer.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
135pub enum DdlRejection {
136    /// A generic guard reused from the meta control plane's taxonomy
137    /// (`StaleWrite`, `Conflict`, `NotFound`, `Invalid`).
138    #[error(transparent)]
139    Meta(#[from] MetaRejectionReason),
140    /// The requested phase edge does not exist in the [`DdlPhase`] graph.
141    #[error("illegal DDL phase transition {from} -> {to} for job {job_id}")]
142    IllegalPhaseTransition {
143        /// The job whose phase was to move.
144        job_id: u64,
145        /// Current phase.
146        from: DdlPhase,
147        /// Refused target phase.
148        to: DdlPhase,
149    },
150    /// The job is not `Running`, so the driver must not advance it.
151    #[error("DDL job {job_id} is {state:?}, not Running")]
152    JobNotRunning {
153        /// The job.
154        job_id: u64,
155        /// Its current administrative state.
156        state: SchemaJobState,
157    },
158    /// The job's pinned schema version no longer matches the table's current
159    /// schema version (spec section 12.11: a stale schema returns structured
160    /// retry). Maps onto [`ErrorCategory::SchemaVersionMismatch`].
161    #[error(
162        "schema version mismatch on table {table_id}: job pinned {expected}, \
163         table is now {found}; refresh schema metadata and resubmit"
164    )]
165    SchemaVersionMismatch {
166        /// The table.
167        table_id: TableId,
168        /// The schema version the job pinned at submission.
169        expected: SchemaVersion,
170        /// The table's current schema version.
171        found: SchemaVersion,
172    },
173    /// A per-tablet progress update would move the durable cursor backwards.
174    #[error("tablet {tablet} progress regressed for job {job_id}: {reason}")]
175    TabletProgressRegression {
176        /// The job.
177        job_id: u64,
178        /// The tablet.
179        tablet: TabletId,
180        /// Why the update was refused.
181        reason: String,
182    },
183    /// Publish was attempted before every registered tablet validated.
184    #[error("DDL job {job_id} cannot publish: tablets pending validation: {pending:?}")]
185    ValidationIncomplete {
186        /// The job.
187        job_id: u64,
188        /// Tablets not yet `Validated`.
189        pending: Vec<TabletId>,
190    },
191    /// A reclamation was attempted while readers below the retirement version
192    /// may still hold plans over the retired generation.
193    #[error(
194        "index `{index_name}` on table {table_id} cannot be reclaimed: oldest reader \
195         pins metadata version {oldest_reader:?}, retirement requires {required}"
196    )]
197    ReclaimBlocked {
198        /// The table.
199        table_id: TableId,
200        /// The index.
201        index_name: String,
202        /// Oldest live reader pin, when any.
203        oldest_reader: Option<MetadataVersion>,
204        /// The retirement version every reader must have reached.
205        required: MetadataVersion,
206    },
207}
208
209/// The driver-side failure surface: store refusals, seam errors, and job
210/// outcomes the caller must observe (validation failure, cancellation).
211#[derive(Debug, thiserror::Error)]
212pub enum DdlError {
213    /// The replicated store refused a command.
214    #[error(transparent)]
215    Rejection(#[from] DdlRejection),
216    /// A tablet keyspace/sink seam operation failed.
217    #[error(transparent)]
218    TabletData(#[from] TabletDataError),
219    /// The HLC clock refused timestamp allocation.
220    #[error(transparent)]
221    Clock(#[from] ClockSkewError),
222    /// Counts/checksums of the built generation did not match a from-scratch
223    /// build; the job was rolled back and the hidden generation dropped.
224    #[error("DDL job {job_id} failed validation: {reason}")]
225    ValidationFailed {
226        /// The failed job.
227        job_id: u64,
228        /// Which tablet mismatched and how.
229        reason: String,
230    },
231    /// The job was cancelled; the unwind already ran.
232    #[error("DDL job {job_id} cancelled")]
233    Cancelled {
234        /// The cancelled job.
235        job_id: u64,
236    },
237}
238
239impl DdlError {
240    /// The stable error taxonomy mapping (spec section 9.7). A stale schema
241    /// maps onto [`ErrorCategory::SchemaVersionMismatch`] so the gateway
242    /// issues a structured metadata-refresh retry.
243    pub fn category(&self) -> ErrorCategory {
244        match self {
245            Self::Rejection(DdlRejection::SchemaVersionMismatch { .. }) => {
246                ErrorCategory::SchemaVersionMismatch
247            }
248            Self::Rejection(DdlRejection::Meta(MetaRejectionReason::StaleWrite { .. })) => {
249                ErrorCategory::StaleMetadata
250            }
251            Self::Cancelled { .. } => ErrorCategory::Cancelled,
252            Self::Rejection(_)
253            | Self::TabletData(_)
254            | Self::Clock(_)
255            | Self::ValidationFailed { .. } => ErrorCategory::ResourceExhausted,
256        }
257    }
258}
259
260// ---------------------------------------------------------------------------
261// DDL phases (spec section 12.11, exact)
262// ---------------------------------------------------------------------------
263
264/// The replicated protocol phase of one schema element (spec section 12.11).
265/// Serde encodes variants by name; names are part of the durable contract and
266/// must never change or be reused (spec section 4.10).
267#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
268pub enum DdlPhase {
269    /// Definition submitted; not yet replicated for the write path.
270    Pending,
271    /// New writes maintain the hidden definition; nothing planner-visible.
272    WriteOnly,
273    /// Per-tablet backfill at the pinned snapshot plus delta catch-up.
274    Backfilling,
275    /// Counts/checksums validated per tablet before publication.
276    Validating,
277    /// Planner-visible from the publication metadata version.
278    Public,
279    /// Being dropped: writes stopped maintaining it, the planner stopped
280    /// using it; the generation is reclaimed once readers drain.
281    Dropping,
282}
283
284impl DdlPhase {
285    /// Every phase in protocol order (spec section 12.11).
286    pub const ALL: [DdlPhase; 6] = [
287        DdlPhase::Pending,
288        DdlPhase::WriteOnly,
289        DdlPhase::Backfilling,
290        DdlPhase::Validating,
291        DdlPhase::Public,
292        DdlPhase::Dropping,
293    ];
294
295    /// Whether the `self -> next` edge exists in the spec's graph. This is
296    /// the single enforcement point: every store mutation checks it before
297    /// applying a phase move. Cancellation and validation failure do not move
298    /// phases — they unwind through the administrative state graph and remove
299    /// the unpublished record instead.
300    pub fn can_transition(self, next: Self) -> bool {
301        use DdlPhase::{Backfilling, Dropping, Pending, Public, Validating, WriteOnly};
302        matches!(
303            (self, next),
304            (Pending, WriteOnly)
305                | (WriteOnly, Backfilling)
306                | (Backfilling, Validating)
307                | (Validating, Public)
308                | (Public, Dropping)
309        )
310    }
311}
312
313impl fmt::Display for DdlPhase {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        let name = match self {
316            Self::Pending => "Pending",
317            Self::WriteOnly => "WriteOnly",
318            Self::Backfilling => "Backfilling",
319            Self::Validating => "Validating",
320            Self::Public => "Public",
321            Self::Dropping => "Dropping",
322        };
323        f.write_str(name)
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Job kinds and definitions
329// ---------------------------------------------------------------------------
330
331/// The Stage 3K DDL job kinds (spec section 12.11).
332#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
333pub enum DdlJobKind {
334    /// Online secondary-index build (the spec's worked example).
335    AddIndex,
336    /// Online index removal.
337    DropIndex,
338    /// Schema alteration (rides the same phase machine; publish advances the
339    /// table's schema version).
340    AlterSchema,
341}
342
343/// The replicated DDL definition of one job. The engine-opaque payloads ride
344/// JSON documents, mirroring `crate::meta::TableSchemaRecord`.
345#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
346pub enum DdlDefinition {
347    /// [`DdlJobKind::AddIndex`]: the new index's name and definition document.
348    AddIndex {
349        /// Unique index name within the table.
350        index_name: String,
351        /// Engine-opaque index definition (kind, columns, options).
352        spec: serde_json::Value,
353    },
354    /// [`DdlJobKind::DropIndex`]: the index to remove.
355    DropIndex {
356        /// The index name; must exist and be `Public`.
357        index_name: String,
358    },
359    /// [`DdlJobKind::AlterSchema`]: the replacement schema document.
360    AlterSchema {
361        /// Engine-opaque target schema; published at `source + 1`.
362        target: serde_json::Value,
363    },
364}
365
366impl DdlDefinition {
367    /// The index name this definition carries, for the index kinds.
368    pub fn index_name(&self) -> Option<&str> {
369        match self {
370            Self::AddIndex { index_name, .. } | Self::DropIndex { index_name } => Some(index_name),
371            Self::AlterSchema { .. } => None,
372        }
373    }
374}
375
376// ---------------------------------------------------------------------------
377// Per-tablet progress (resumable)
378// ---------------------------------------------------------------------------
379
380/// One tablet's position inside a job's `Backfilling`/`Validating` phases.
381/// Declaration order is the progress order; a later stage never moves back.
382#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
383pub enum TabletDdlStage {
384    /// Registered for the job; the build has not started (or was restarted).
385    Pending,
386    /// The build started; staged content may exist beside live state.
387    Backfilling,
388    /// Snapshot content installed and deltas swept through
389    /// [`TabletDdlProgress::caught_up_through`].
390    CaughtUp,
391    /// Counts/checksums validated for this tablet.
392    Validated,
393}
394
395impl fmt::Display for TabletDdlStage {
396    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397        let name = match self {
398            Self::Pending => "Pending",
399            Self::Backfilling => "Backfilling",
400            Self::CaughtUp => "CaughtUp",
401            Self::Validated => "Validated",
402        };
403        f.write_str(name)
404    }
405}
406
407/// The durable per-tablet progress cursor of one job. A driver restart
408/// resumes from this map alone.
409#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
410pub struct TabletDdlProgress {
411    /// The tablet.
412    pub tablet_id: TabletId,
413    /// Furthest stage reached.
414    pub stage: TabletDdlStage,
415    /// Rows scanned from the pinned snapshot (observability).
416    pub rows_scanned: u64,
417    /// Catch-up watermark: every committed write at or below this timestamp
418    /// is reflected in the tablet's built generation.
419    pub caught_up_through: Option<HlcTimestamp>,
420    /// The tablet's validation report, once produced.
421    pub validation: Option<TabletValidationReport>,
422}
423
424impl TabletDdlProgress {
425    /// A freshly registered tablet.
426    pub fn pending(tablet_id: TabletId) -> Self {
427        Self {
428            tablet_id,
429            stage: TabletDdlStage::Pending,
430            rows_scanned: 0,
431            caught_up_through: None,
432            validation: None,
433        }
434    }
435}
436
437// ---------------------------------------------------------------------------
438// Validation reports (spec step 5)
439// ---------------------------------------------------------------------------
440
441/// One tablet's typed validation report: the built generation's count and
442/// checksum against a from-scratch build at the same watermark.
443#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
444pub struct TabletValidationReport {
445    /// The tablet.
446    pub tablet_id: TabletId,
447    /// The watermark both sides were compared at.
448    pub watermark: HlcTimestamp,
449    /// From-scratch row/entry count.
450    pub expected_rows: u64,
451    /// Built-generation entry count.
452    pub actual_rows: u64,
453    /// SHA-256 of the from-scratch entry set (sorted key||entry stream).
454    pub expected_checksum: [u8; 32],
455    /// SHA-256 of the built generation.
456    pub actual_checksum: [u8; 32],
457}
458
459impl TabletValidationReport {
460    /// The tablet passes when counts and checksums both match.
461    pub fn passed(&self) -> bool {
462        self.expected_rows == self.actual_rows && self.expected_checksum == self.actual_checksum
463    }
464}
465
466/// The aggregate validation report of one job (spec step 5: per tablet plus
467/// aggregate).
468#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
469pub struct JobValidationReport {
470    /// The job.
471    pub job_id: u64,
472    /// Every tablet's report, in tablet-id order.
473    pub tablets: Vec<TabletValidationReport>,
474    /// Sum of expected rows.
475    pub total_expected: u64,
476    /// Sum of built rows.
477    pub total_actual: u64,
478    /// Every tablet passed.
479    pub passed: bool,
480}
481
482/// The deterministic checksum over one built (or from-scratch) entry set:
483/// SHA-256 over the sorted key||entry byte stream.
484pub fn generation_checksum(entries: &BTreeMap<Key, Vec<u8>>) -> [u8; 32] {
485    let mut hasher = Sha256::new();
486    for (key, entry) in entries {
487        hasher.update(key.as_bytes());
488        hasher.update(entry);
489    }
490    hasher.finalize().into()
491}
492
493// ---------------------------------------------------------------------------
494// Replicated records
495// ---------------------------------------------------------------------------
496
497/// One replicated DDL job record (spec section 12.11). Lives in
498/// [`DdlJobStore::jobs`]; the per-tablet progress map is the resume
499/// authority.
500#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
501pub struct DdlJobRecord {
502    /// Job identifier (allocated by the proposer; never reused).
503    pub job_id: u64,
504    /// Database owning the job's target.
505    pub database_id: DatabaseId,
506    /// Table the job operates on.
507    pub table_id: TableId,
508    /// Job kind.
509    pub kind: DdlJobKind,
510    /// Administrative lifecycle (`crate::meta::SchemaJobState`, reused).
511    pub state: SchemaJobState,
512    /// Schema-element protocol phase (spec section 12.11).
513    pub phase: DdlPhase,
514    /// The replicated definition.
515    pub definition: DdlDefinition,
516    /// The table schema version the job was submitted against; checked again
517    /// at publish (spec section 12.11 "schema version is included in every
518    /// transaction and plan").
519    pub source_schema_version: SchemaVersion,
520    /// Submission timestamp stamped by the proposer.
521    pub created_at: HlcTimestamp,
522    /// Timestamp of the last applied mutation.
523    pub updated_at: HlcTimestamp,
524    /// The job-wide backfill pin, stamped when entering `Backfilling`
525    /// (spec step 3: every tablet backfills at this pinned snapshot).
526    pub pinned_snapshot: Option<HlcTimestamp>,
527    /// Per-tablet resumable progress.
528    #[serde(with = "tablet_progress_map")]
529    pub tablet_progress: BTreeMap<TabletId, TabletDdlProgress>,
530    /// Failure/cancellation detail for terminal states.
531    pub error: Option<String>,
532    /// Store [`MetadataVersion`] of the last modification.
533    #[serde(default = "zero_metadata_version")]
534    pub metadata_version: MetadataVersion,
535}
536
537fn zero_metadata_version() -> MetadataVersion {
538    MetadataVersion::ZERO
539}
540
541/// One replicated index record: the schema element a job drives, outliving
542/// the job itself. Planner visibility is a pure function of this record and a
543/// metadata version (spec step 6).
544#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
545pub struct DdlIndexRecord {
546    /// Table owning the index.
547    pub table_id: TableId,
548    /// Index name (unique within the table).
549    pub index_name: String,
550    /// Engine-opaque index definition document.
551    pub definition: serde_json::Value,
552    /// Element protocol phase (mirrors the owning job's phase while the job
553    /// runs; `Public`/`Dropping` persist after it terminates).
554    pub phase: DdlPhase,
555    /// The job that last drove the record.
556    pub job_id: u64,
557    /// Creation timestamp.
558    pub created_at: HlcTimestamp,
559    /// The metadata version the index became planner-visible at
560    /// (`planner_visible_at` includes the record at or above this version).
561    pub publication_version: Option<MetadataVersion>,
562    /// Publication timestamp.
563    pub published_at: Option<HlcTimestamp>,
564    /// The metadata version the index entered `Dropping` at (planners at or
565    /// above it stop using the index; reclamation waits on reader pins below
566    /// it).
567    pub dropping_since: Option<MetadataVersion>,
568    /// Store [`MetadataVersion`] of the last modification.
569    #[serde(default = "zero_metadata_version")]
570    pub metadata_version: MetadataVersion,
571}
572
573/// The replicated table anchor this module's stale-schema checks run against.
574/// Shadows `crate::meta::MetaState::tables` (same last-writer-wins
575/// `schema_version` semantics); the integration wave feeds it from
576/// `MetaCommand::SetTableSchema` applies.
577#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
578pub struct TableAnchor {
579    /// The table.
580    pub table_id: TableId,
581    /// Database owning the table.
582    pub database_id: DatabaseId,
583    /// Current schema version (never reused, never lowered).
584    pub schema_version: SchemaVersion,
585    /// Current schema document (opaque to the meta group).
586    pub schema: serde_json::Value,
587    /// Store [`MetadataVersion`] of the last modification.
588    #[serde(default = "zero_metadata_version")]
589    pub metadata_version: MetadataVersion,
590}
591
592// ---------------------------------------------------------------------------
593// Commands
594// ---------------------------------------------------------------------------
595
596/// One replicated DDL control-plane command (spec section 12.11). Every
597/// variant is deterministic and idempotent at apply: records are versioned
598/// and guards reject stale or conflicting writes with a typed
599/// [`DdlRejection`] instead of faulting the state machine (the
600/// `crate::meta::MetaCommand` idiom).
601#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
602pub enum DdlCommand {
603    /// Registers (or advances) a table anchor. Last-writer-wins by
604    /// `schema_version`, mirroring `MetaCommand::SetTableSchema`.
605    RegisterTable {
606        /// The anchor to apply.
607        anchor: TableAnchor,
608    },
609    /// Submits a job (starts `SchemaJobState::Pending`; `DdlPhase::Pending`,
610    /// or `DdlPhase::Public` for a drop, whose element already is public).
611    SubmitJob {
612        /// The job record.
613        job: DdlJobRecord,
614    },
615    /// Advances one job along the [`DdlPhase`] graph. `Public` for an
616    /// addition is never reached here — publication rides [`Self::PublishJob`]
617    /// so validation completeness and the schema-version recheck gate the one
618    /// atomic command. `pinned_snapshot` is required for `Backfilling`.
619    AdvancePhase {
620        /// The job.
621        job_id: u64,
622        /// Target phase.
623        to: DdlPhase,
624        /// The job-wide backfill pin (required when `to` is `Backfilling`).
625        pinned_snapshot: Option<HlcTimestamp>,
626        /// Optimistic-concurrency token over the job record.
627        expected_version: Option<MetadataVersion>,
628    },
629    /// Registers or advances one tablet's durable progress cursor. Updates
630    /// are monotonic: the stage never moves backwards and `rows_scanned`
631    /// never shrinks within one stage.
632    UpdateTabletProgress {
633        /// The job.
634        job_id: u64,
635        /// The new cursor.
636        progress: TabletDdlProgress,
637    },
638    /// Drops the progress cursor of a tablet that left the table's topology
639    /// (split/merge reconciliation; only while the job still drives tablets).
640    ForgetTabletProgress {
641        /// The job.
642        job_id: u64,
643        /// The departed tablet.
644        tablet_id: TabletId,
645    },
646    /// Records one tablet's validation report; a passing report moves the
647    /// tablet to `Validated`.
648    ReportTabletValidation {
649        /// The job.
650        job_id: u64,
651        /// The report.
652        report: TabletValidationReport,
653    },
654    /// The atomic publication (spec step 6): validates completeness and the
655    /// pinned schema version, flips the element to `Public` stamped with this
656    /// command's metadata version, and succeeds the job — one command.
657    PublishJob {
658        /// The job.
659        job_id: u64,
660        /// Publication timestamp stamped by the proposer.
661        published_at: HlcTimestamp,
662    },
663    /// Moves the administrative lifecycle (`pause`/`resume`/`cancel`/
664    /// `admit`/`succeed`/`fail`), graph-enforced.
665    SetJobState {
666        /// The job.
667        job_id: u64,
668        /// Target state.
669        state: SchemaJobState,
670        /// Update timestamp stamped by the proposer.
671        updated_at: HlcTimestamp,
672        /// Failure/cancellation detail.
673        error: Option<String>,
674        /// Optimistic-concurrency token over the job record.
675        expected_version: Option<MetadataVersion>,
676    },
677    /// Unwinds an unpublished index record (cancel/validation-failure
678    /// rollback). Refuses to remove a `Public` record — fail closed.
679    RemoveIndexRecord {
680        /// The job owning the record.
681        job_id: u64,
682    },
683    /// The reclamation pass (spec step 7): removes a `Dropping` index record
684    /// once no live reader pins a metadata version below its retirement
685    /// version.
686    ReclaimIndex {
687        /// The table.
688        table_id: TableId,
689        /// The index.
690        index_name: String,
691    },
692    /// Pins a reader at a metadata version (its plans may reference every
693    /// element visible there). A reader's pin only moves forward.
694    PinReader {
695        /// Reader identifier.
696        reader_id: u64,
697        /// The pinned metadata version.
698        version: MetadataVersion,
699    },
700    /// Releases one reader pin.
701    ReleaseReader {
702        /// Reader identifier.
703        reader_id: u64,
704    },
705}
706
707// ---------------------------------------------------------------------------
708// The replicated store
709// ---------------------------------------------------------------------------
710
711/// One journaled refusal: the refused command's id and the typed reason.
712#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
713pub struct DdlStoreRejection {
714    /// Leader-assigned id of the refused command (`None` when it carried
715    /// none; always `Some` through the raft binding).
716    pub command_id: Option<[u8; 16]>,
717    /// Why it was refused.
718    pub reason: DdlRejection,
719}
720
721/// The replicated DDL control-plane state (spec section 12.11): the section
722/// of meta state the DDL jobs, index records, table anchors, and reader pins
723/// live in. Deterministic — every replica applies the same commands in the
724/// same order and reaches byte-identical state; versioned — `metadata_version`
725/// ticks once per applied command (accepted or refused — a refusal appends to
726/// the rejection journal, which is itself state), giving readers and the
727/// reclamation gate a monotonic watermark.
728#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
729#[serde(default)]
730pub struct DdlJobStore {
731    /// Snapshot format version; see [`DDL_STORE_FORMAT_VERSION`].
732    pub format_version: u32,
733    /// Monotonic per-applied-command version.
734    pub metadata_version: MetadataVersion,
735    /// Next job id to allocate (strictly greater than every live id).
736    pub next_job_id: u64,
737    /// Table anchors (stale-schema authority of this store).
738    pub tables: BTreeMap<TableId, TableAnchor>,
739    /// DDL jobs by id.
740    pub jobs: BTreeMap<u64, DdlJobRecord>,
741    /// Index records by (table, name).
742    #[serde(with = "index_record_map")]
743    pub indexes: BTreeMap<(TableId, String), DdlIndexRecord>,
744    /// Live reader pins (reader id -> pinned metadata version).
745    pub reader_pins: BTreeMap<u64, MetadataVersion>,
746    /// Bounded journal of refused commands, oldest first
747    /// ([`DDL_REJECTION_LIMIT`]).
748    pub rejections: VecDeque<DdlStoreRejection>,
749}
750
751impl Default for DdlJobStore {
752    fn default() -> Self {
753        Self {
754            format_version: DDL_STORE_FORMAT_VERSION,
755            metadata_version: MetadataVersion::ZERO,
756            next_job_id: 1,
757            tables: BTreeMap::new(),
758            jobs: BTreeMap::new(),
759            indexes: BTreeMap::new(),
760            reader_pins: BTreeMap::new(),
761            rejections: VecDeque::new(),
762        }
763    }
764}
765
766/// JSON-safe serde for the index-record map: `(TableId, String)` tuple keys
767/// are not JSON object keys, so the map is encoded as a sequence of
768/// `(table, name, record)` triples (and decoded back). Deterministic in
769/// `BTreeMap` order.
770mod index_record_map {
771    use super::{DdlIndexRecord, TableId};
772    use serde::{Deserialize, Deserializer, Serialize, Serializer};
773    use std::collections::BTreeMap;
774
775    pub fn serialize<S>(
776        map: &BTreeMap<(TableId, String), DdlIndexRecord>,
777        serializer: S,
778    ) -> Result<S::Ok, S::Error>
779    where
780        S: Serializer,
781    {
782        let triples: Vec<(TableId, String, DdlIndexRecord)> = map
783            .iter()
784            .map(|((table, name), record)| (*table, name.clone(), record.clone()))
785            .collect();
786        triples.serialize(serializer)
787    }
788
789    pub fn deserialize<'de, D>(
790        deserializer: D,
791    ) -> Result<BTreeMap<(TableId, String), DdlIndexRecord>, D::Error>
792    where
793        D: Deserializer<'de>,
794    {
795        let triples: Vec<(TableId, String, DdlIndexRecord)> = Vec::deserialize(deserializer)?;
796        Ok(triples
797            .into_iter()
798            .map(|(table, name, record)| ((table, name), record))
799            .collect())
800    }
801}
802
803/// JSON-safe serde for the per-tablet progress map: `TabletId` is a 16-byte
804/// newtype, not a JSON object key, so the map is encoded as a sequence of
805/// `(tablet, progress)` pairs (and decoded back). Deterministic in
806/// `BTreeMap` order.
807mod tablet_progress_map {
808    use super::{TabletDdlProgress, TabletId};
809    use serde::{Deserialize, Deserializer, Serialize, Serializer};
810    use std::collections::BTreeMap;
811
812    pub fn serialize<S>(
813        map: &BTreeMap<TabletId, TabletDdlProgress>,
814        serializer: S,
815    ) -> Result<S::Ok, S::Error>
816    where
817        S: Serializer,
818    {
819        let pairs: Vec<(TabletId, TabletDdlProgress)> = map
820            .iter()
821            .map(|(tablet, progress)| (*tablet, progress.clone()))
822            .collect();
823        pairs.serialize(serializer)
824    }
825
826    pub fn deserialize<'de, D>(
827        deserializer: D,
828    ) -> Result<BTreeMap<TabletId, TabletDdlProgress>, D::Error>
829    where
830        D: Deserializer<'de>,
831    {
832        let pairs: Vec<(TabletId, TabletDdlProgress)> = Vec::deserialize(deserializer)?;
833        Ok(pairs.into_iter().collect())
834    }
835}
836
837impl DdlJobStore {
838    /// Applies one committed command. `metadata_version` ticks first (every
839    /// applied command moves the watermark); a refusal journals the reason
840    /// and leaves the records untouched (the `crate::meta::MetaState::apply`
841    /// idiom).
842    pub fn apply(
843        &mut self,
844        command: &DdlCommand,
845        command_id: Option<[u8; 16]>,
846        commit_ts: HlcTimestamp,
847    ) -> Result<(), DdlRejection> {
848        self.metadata_version = MetadataVersion(self.metadata_version.get() + 1);
849        let version = self.metadata_version;
850        let result = self.dispatch(command, commit_ts, version);
851        if let Err(reason) = &result {
852            self.rejections.push_back(DdlStoreRejection {
853                command_id,
854                reason: reason.clone(),
855            });
856            while self.rejections.len() > DDL_REJECTION_LIMIT {
857                self.rejections.pop_front();
858            }
859        }
860        result
861    }
862
863    fn dispatch(
864        &mut self,
865        command: &DdlCommand,
866        commit_ts: HlcTimestamp,
867        version: MetadataVersion,
868    ) -> Result<(), DdlRejection> {
869        match command {
870            DdlCommand::RegisterTable { anchor } => self.apply_register_table(anchor, version),
871            DdlCommand::SubmitJob { job } => self.apply_submit_job(job, commit_ts, version),
872            DdlCommand::AdvancePhase {
873                job_id,
874                to,
875                pinned_snapshot,
876                expected_version,
877            } => self.apply_advance_phase(
878                *job_id,
879                *to,
880                *pinned_snapshot,
881                *expected_version,
882                commit_ts,
883                version,
884            ),
885            DdlCommand::UpdateTabletProgress { job_id, progress } => {
886                self.apply_update_tablet_progress(*job_id, progress, commit_ts, version)
887            }
888            DdlCommand::ForgetTabletProgress { job_id, tablet_id } => {
889                self.apply_forget_tablet_progress(*job_id, *tablet_id, commit_ts, version)
890            }
891            DdlCommand::ReportTabletValidation { job_id, report } => {
892                self.apply_report_tablet_validation(*job_id, report, commit_ts, version)
893            }
894            DdlCommand::PublishJob {
895                job_id,
896                published_at,
897            } => self.apply_publish_job(*job_id, *published_at, commit_ts, version),
898            DdlCommand::SetJobState {
899                job_id,
900                state,
901                updated_at,
902                error,
903                expected_version,
904            } => self.apply_set_job_state(
905                *job_id,
906                *state,
907                *updated_at,
908                error,
909                *expected_version,
910                version,
911            ),
912            DdlCommand::RemoveIndexRecord { job_id } => {
913                self.apply_remove_index_record(*job_id, version)
914            }
915            DdlCommand::ReclaimIndex {
916                table_id,
917                index_name,
918            } => self.apply_reclaim_index(*table_id, index_name, version),
919            DdlCommand::PinReader { reader_id, version } => {
920                self.apply_pin_reader(*reader_id, *version)
921            }
922            DdlCommand::ReleaseReader { reader_id } => {
923                self.reader_pins.remove(reader_id);
924                Ok(())
925            }
926        }
927    }
928
929    fn apply_register_table(
930        &mut self,
931        anchor: &TableAnchor,
932        version: MetadataVersion,
933    ) -> Result<(), DdlRejection> {
934        if anchor.table_id == TableId::ZERO {
935            return Err(MetaRejectionReason::Invalid {
936                reason: "reserved zero table id".to_owned(),
937            }
938            .into());
939        }
940        if anchor.schema_version == SchemaVersion::ZERO {
941            return Err(MetaRejectionReason::Invalid {
942                reason: "reserved zero schema version".to_owned(),
943            }
944            .into());
945        }
946        match self.tables.get(&anchor.table_id) {
947            Some(existing) => {
948                if anchor.schema_version > existing.schema_version {
949                    let mut anchor = anchor.clone();
950                    anchor.metadata_version = version;
951                    self.tables.insert(anchor.table_id, anchor);
952                    Ok(())
953                } else if anchor.schema_version == existing.schema_version {
954                    if existing.database_id == anchor.database_id
955                        && existing.schema == anchor.schema
956                    {
957                        Ok(())
958                    } else {
959                        Err(MetaRejectionReason::Conflict {
960                            resource: format!("table {}", anchor.table_id),
961                            reason: "schema version already used for different content".to_owned(),
962                        }
963                        .into())
964                    }
965                } else {
966                    Err(MetaRejectionReason::StaleWrite {
967                        resource: format!("table {}", anchor.table_id),
968                        current: MetadataVersion(existing.schema_version.get()),
969                        attempted: MetadataVersion(anchor.schema_version.get()),
970                    }
971                    .into())
972                }
973            }
974            None => {
975                let mut anchor = anchor.clone();
976                anchor.metadata_version = version;
977                self.tables.insert(anchor.table_id, anchor);
978                Ok(())
979            }
980        }
981    }
982
983    fn apply_submit_job(
984        &mut self,
985        job: &DdlJobRecord,
986        commit_ts: HlcTimestamp,
987        version: MetadataVersion,
988    ) -> Result<(), DdlRejection> {
989        if job.job_id == 0 {
990            return Err(MetaRejectionReason::Invalid {
991                reason: "reserved zero job id".to_owned(),
992            }
993            .into());
994        }
995        // Idempotent replay: an identical record under an existing id is a
996        // no-op; a different one conflicts (the `SubmitSchemaJob` idiom).
997        if let Some(existing) = self.jobs.get(&job.job_id) {
998            let mut comparable = existing.clone();
999            comparable.metadata_version = job.metadata_version;
1000            return if comparable == *job {
1001                Ok(())
1002            } else {
1003                Err(MetaRejectionReason::Conflict {
1004                    resource: format!("DDL job {}", job.job_id),
1005                    reason: "job id already exists with different content".to_owned(),
1006                }
1007                .into())
1008            };
1009        }
1010        let anchor = self
1011            .tables
1012            .get(&job.table_id)
1013            .ok_or(MetaRejectionReason::NotFound {
1014                resource: format!("table {}", job.table_id),
1015            })?;
1016        if anchor.database_id != job.database_id {
1017            return Err(MetaRejectionReason::Conflict {
1018                resource: format!("table {}", job.table_id),
1019                reason: "table belongs to a different database".to_owned(),
1020            }
1021            .into());
1022        }
1023        // Stale schema at submission: structured retry (spec section 12.11).
1024        if anchor.schema_version != job.source_schema_version {
1025            return Err(DdlRejection::SchemaVersionMismatch {
1026                table_id: job.table_id,
1027                expected: job.source_schema_version,
1028                found: anchor.schema_version,
1029            });
1030        }
1031        if job.state != SchemaJobState::Pending {
1032            return Err(MetaRejectionReason::Invalid {
1033                reason: "submitted jobs start Pending".to_owned(),
1034            }
1035            .into());
1036        }
1037        let expected_phase = match job.kind {
1038            DdlJobKind::AddIndex | DdlJobKind::AlterSchema => DdlPhase::Pending,
1039            DdlJobKind::DropIndex => DdlPhase::Public,
1040        };
1041        if job.phase != expected_phase {
1042            return Err(MetaRejectionReason::Invalid {
1043                reason: format!(
1044                    "{:?} jobs start in phase {expected_phase}, not {}",
1045                    job.kind, job.phase
1046                ),
1047            }
1048            .into());
1049        }
1050        // One non-terminal DDL job per table (serialization keeps the
1051        // per-tablet progress maps deterministic; relaxing this is a
1052        // documented follow-up).
1053        if let Some(active) = self
1054            .jobs
1055            .values()
1056            .find(|existing| existing.table_id == job.table_id && !existing.state.is_terminal())
1057        {
1058            return Err(MetaRejectionReason::Conflict {
1059                resource: format!("table {}", job.table_id),
1060                reason: format!("table already has active DDL job {}", active.job_id),
1061            }
1062            .into());
1063        }
1064        match &job.definition {
1065            DdlDefinition::AddIndex { index_name, spec } => {
1066                if index_name.trim().is_empty() {
1067                    return Err(MetaRejectionReason::Invalid {
1068                        reason: "index name is empty".to_owned(),
1069                    }
1070                    .into());
1071                }
1072                let key = (job.table_id, index_name.clone());
1073                if self.indexes.contains_key(&key) {
1074                    return Err(MetaRejectionReason::Conflict {
1075                        resource: format!("index `{index_name}` on table {}", job.table_id),
1076                        reason: "index name already exists".to_owned(),
1077                    }
1078                    .into());
1079                }
1080                self.indexes.insert(
1081                    key,
1082                    DdlIndexRecord {
1083                        table_id: job.table_id,
1084                        index_name: index_name.clone(),
1085                        definition: spec.clone(),
1086                        phase: DdlPhase::Pending,
1087                        job_id: job.job_id,
1088                        created_at: commit_ts,
1089                        publication_version: None,
1090                        published_at: None,
1091                        dropping_since: None,
1092                        metadata_version: version,
1093                    },
1094                );
1095            }
1096            DdlDefinition::DropIndex { index_name } => {
1097                let key = (job.table_id, index_name.clone());
1098                match self.indexes.get(&key) {
1099                    None => {
1100                        return Err(MetaRejectionReason::NotFound {
1101                            resource: format!("index `{index_name}` on table {}", job.table_id),
1102                        }
1103                        .into());
1104                    }
1105                    Some(record) if record.phase != DdlPhase::Public => {
1106                        return Err(MetaRejectionReason::Conflict {
1107                            resource: format!("index `{index_name}` on table {}", job.table_id),
1108                            reason: format!("index is {}, not Public", record.phase),
1109                        }
1110                        .into());
1111                    }
1112                    Some(_) => {}
1113                }
1114            }
1115            DdlDefinition::AlterSchema { .. } => {}
1116        }
1117        let mut job = job.clone();
1118        job.metadata_version = version;
1119        job.updated_at = commit_ts;
1120        self.next_job_id = self.next_job_id.max(job.job_id + 1);
1121        self.jobs.insert(job.job_id, job);
1122        Ok(())
1123    }
1124
1125    fn apply_advance_phase(
1126        &mut self,
1127        job_id: u64,
1128        to: DdlPhase,
1129        pinned_snapshot: Option<HlcTimestamp>,
1130        expected_version: Option<MetadataVersion>,
1131        commit_ts: HlcTimestamp,
1132        version: MetadataVersion,
1133    ) -> Result<(), DdlRejection> {
1134        let job = self
1135            .jobs
1136            .get(&job_id)
1137            .ok_or(MetaRejectionReason::NotFound {
1138                resource: format!("DDL job {job_id}"),
1139            })?;
1140        if let Some(expected) = expected_version {
1141            if expected != job.metadata_version {
1142                return Err(MetaRejectionReason::StaleWrite {
1143                    resource: format!("DDL job {job_id}"),
1144                    current: job.metadata_version,
1145                    attempted: expected,
1146                }
1147                .into());
1148            }
1149        }
1150        if job.state.is_terminal() {
1151            return Err(MetaRejectionReason::Conflict {
1152                resource: format!("DDL job {job_id}"),
1153                reason: format!("terminal state {:?}", job.state),
1154            }
1155            .into());
1156        }
1157        if job.state != SchemaJobState::Running {
1158            return Err(DdlRejection::JobNotRunning {
1159                job_id,
1160                state: job.state,
1161            });
1162        }
1163        // Idempotent replay of an already-applied advance.
1164        if job.phase == to {
1165            if to == DdlPhase::Backfilling && job.pinned_snapshot != pinned_snapshot {
1166                return Err(MetaRejectionReason::Conflict {
1167                    resource: format!("DDL job {job_id}"),
1168                    reason: "phase already reached with a different pinned snapshot".to_owned(),
1169                }
1170                .into());
1171            }
1172            return Ok(());
1173        }
1174        if !job.phase.can_transition(to) {
1175            return Err(DdlRejection::IllegalPhaseTransition {
1176                job_id,
1177                from: job.phase,
1178                to,
1179            });
1180        }
1181        // Addition kinds publish only through PublishJob, so the atomic
1182        // command carries the completeness and schema-version gates.
1183        if to == DdlPhase::Public && job.kind != DdlJobKind::DropIndex {
1184            return Err(MetaRejectionReason::Invalid {
1185                reason: "Public is reached through PublishJob, never AdvancePhase".to_owned(),
1186            }
1187            .into());
1188        }
1189        if to == DdlPhase::Backfilling && pinned_snapshot.is_none() {
1190            return Err(MetaRejectionReason::Invalid {
1191                reason: "Backfilling requires the job-wide pinned snapshot".to_owned(),
1192            }
1193            .into());
1194        }
1195        let job = self.jobs.get_mut(&job_id).expect("job existence checked");
1196        job.phase = to;
1197        if to == DdlPhase::Backfilling {
1198            job.pinned_snapshot = pinned_snapshot;
1199        }
1200        job.updated_at = commit_ts;
1201        job.metadata_version = version;
1202        // Index records mirror the element phase while a job drives them.
1203        if let Some(index_name) = job.definition.index_name() {
1204            let key = (job.table_id, index_name.to_owned());
1205            if let Some(record) = self.indexes.get_mut(&key) {
1206                record.phase = to;
1207                if to == DdlPhase::Dropping {
1208                    record.dropping_since = Some(version);
1209                }
1210                record.metadata_version = version;
1211            }
1212        }
1213        Ok(())
1214    }
1215
1216    fn apply_update_tablet_progress(
1217        &mut self,
1218        job_id: u64,
1219        progress: &TabletDdlProgress,
1220        commit_ts: HlcTimestamp,
1221        version: MetadataVersion,
1222    ) -> Result<(), DdlRejection> {
1223        let job = self
1224            .jobs
1225            .get_mut(&job_id)
1226            .ok_or(MetaRejectionReason::NotFound {
1227                resource: format!("DDL job {job_id}"),
1228            })?;
1229        if job.phase != DdlPhase::Backfilling {
1230            // Replay tolerance: once the job moved on, only dominated
1231            // (already-covered) updates remain legal.
1232            let dominated = job
1233                .tablet_progress
1234                .get(&progress.tablet_id)
1235                .is_some_and(|existing| {
1236                    existing.stage >= progress.stage
1237                        && existing.rows_scanned >= progress.rows_scanned
1238                });
1239            if dominated {
1240                return Ok(());
1241            }
1242            return Err(MetaRejectionReason::Conflict {
1243                resource: format!("DDL job {job_id}"),
1244                reason: format!("job is {}, not Backfilling", job.phase),
1245            }
1246            .into());
1247        }
1248        if let Some(existing) = job.tablet_progress.get(&progress.tablet_id) {
1249            if progress.stage < existing.stage {
1250                return Err(DdlRejection::TabletProgressRegression {
1251                    job_id,
1252                    tablet: progress.tablet_id,
1253                    reason: format!("stage {} -> {}", existing.stage, progress.stage),
1254                });
1255            }
1256            if progress.stage == existing.stage && progress.rows_scanned < existing.rows_scanned {
1257                return Err(DdlRejection::TabletProgressRegression {
1258                    job_id,
1259                    tablet: progress.tablet_id,
1260                    reason: format!(
1261                        "rows_scanned {} -> {}",
1262                        existing.rows_scanned, progress.rows_scanned
1263                    ),
1264                });
1265            }
1266        }
1267        job.tablet_progress
1268            .insert(progress.tablet_id, progress.clone());
1269        job.updated_at = commit_ts;
1270        job.metadata_version = version;
1271        Ok(())
1272    }
1273
1274    fn apply_forget_tablet_progress(
1275        &mut self,
1276        job_id: u64,
1277        tablet_id: TabletId,
1278        commit_ts: HlcTimestamp,
1279        version: MetadataVersion,
1280    ) -> Result<(), DdlRejection> {
1281        let job = self
1282            .jobs
1283            .get_mut(&job_id)
1284            .ok_or(MetaRejectionReason::NotFound {
1285                resource: format!("DDL job {job_id}"),
1286            })?;
1287        if !matches!(job.phase, DdlPhase::Backfilling | DdlPhase::Validating) {
1288            return Err(MetaRejectionReason::Conflict {
1289                resource: format!("DDL job {job_id}"),
1290                reason: format!(
1291                    "job is {}; tablet cursors are only mutable while driving",
1292                    job.phase
1293                ),
1294            }
1295            .into());
1296        }
1297        if job.tablet_progress.remove(&tablet_id).is_some() {
1298            job.updated_at = commit_ts;
1299            job.metadata_version = version;
1300        }
1301        Ok(())
1302    }
1303
1304    fn apply_report_tablet_validation(
1305        &mut self,
1306        job_id: u64,
1307        report: &TabletValidationReport,
1308        commit_ts: HlcTimestamp,
1309        version: MetadataVersion,
1310    ) -> Result<(), DdlRejection> {
1311        let job = self
1312            .jobs
1313            .get_mut(&job_id)
1314            .ok_or(MetaRejectionReason::NotFound {
1315                resource: format!("DDL job {job_id}"),
1316            })?;
1317        if job.phase != DdlPhase::Validating {
1318            return Err(MetaRejectionReason::Conflict {
1319                resource: format!("DDL job {job_id}"),
1320                reason: format!("job is {}, not Validating", job.phase),
1321            }
1322            .into());
1323        }
1324        let progress = job.tablet_progress.get_mut(&report.tablet_id).ok_or(
1325            MetaRejectionReason::NotFound {
1326                resource: format!("tablet {} progress of job {job_id}", report.tablet_id),
1327            },
1328        )?;
1329        if progress.stage < TabletDdlStage::CaughtUp {
1330            return Err(DdlRejection::TabletProgressRegression {
1331                job_id,
1332                tablet: report.tablet_id,
1333                reason: "validation reported before catch-up completed".to_owned(),
1334            });
1335        }
1336        if progress.stage == TabletDdlStage::Validated
1337            && progress.validation.as_ref() == Some(report)
1338        {
1339            return Ok(());
1340        }
1341        progress.validation = Some(report.clone());
1342        progress.caught_up_through = Some(report.watermark);
1343        if report.passed() {
1344            progress.stage = TabletDdlStage::Validated;
1345        }
1346        job.updated_at = commit_ts;
1347        job.metadata_version = version;
1348        Ok(())
1349    }
1350
1351    fn apply_publish_job(
1352        &mut self,
1353        job_id: u64,
1354        published_at: HlcTimestamp,
1355        commit_ts: HlcTimestamp,
1356        version: MetadataVersion,
1357    ) -> Result<(), DdlRejection> {
1358        let job = self
1359            .jobs
1360            .get(&job_id)
1361            .ok_or(MetaRejectionReason::NotFound {
1362                resource: format!("DDL job {job_id}"),
1363            })?;
1364        if job.state == SchemaJobState::Succeeded
1365            && job.phase == DdlPhase::Public
1366            && job.kind != DdlJobKind::DropIndex
1367        {
1368            return Ok(()); // idempotent replay of the publication
1369        }
1370        if job.kind == DdlJobKind::DropIndex {
1371            return Err(MetaRejectionReason::Invalid {
1372                reason: "drop jobs ride AdvancePhase(Public -> Dropping), never PublishJob"
1373                    .to_owned(),
1374            }
1375            .into());
1376        }
1377        if job.state != SchemaJobState::Running {
1378            return Err(DdlRejection::JobNotRunning {
1379                job_id,
1380                state: job.state,
1381            });
1382        }
1383        if job.phase != DdlPhase::Validating {
1384            return Err(DdlRejection::IllegalPhaseTransition {
1385                job_id,
1386                from: job.phase,
1387                to: DdlPhase::Public,
1388            });
1389        }
1390        // Stale-schema recheck (spec section 12.11: a stale schema returns
1391        // structured retry): the table must not have moved since submission.
1392        let anchor = self
1393            .tables
1394            .get(&job.table_id)
1395            .ok_or(MetaRejectionReason::NotFound {
1396                resource: format!("table {}", job.table_id),
1397            })?;
1398        if anchor.schema_version != job.source_schema_version {
1399            return Err(DdlRejection::SchemaVersionMismatch {
1400                table_id: job.table_id,
1401                expected: job.source_schema_version,
1402                found: anchor.schema_version,
1403            });
1404        }
1405        let pending: Vec<TabletId> = job
1406            .tablet_progress
1407            .values()
1408            .filter(|progress| {
1409                progress.stage != TabletDdlStage::Validated
1410                    || progress
1411                        .validation
1412                        .as_ref()
1413                        .is_none_or(|report| !report.passed())
1414            })
1415            .map(|progress| progress.tablet_id)
1416            .collect();
1417        if job.tablet_progress.is_empty() || !pending.is_empty() {
1418            return Err(DdlRejection::ValidationIncomplete { job_id, pending });
1419        }
1420        match &job.definition {
1421            DdlDefinition::AddIndex { index_name, .. } => {
1422                let key = (job.table_id, index_name.clone());
1423                let record = self
1424                    .indexes
1425                    .get_mut(&key)
1426                    .ok_or(MetaRejectionReason::NotFound {
1427                        resource: format!("index `{index_name}` on table {}", job.table_id),
1428                    })?;
1429                record.phase = DdlPhase::Public;
1430                record.publication_version = Some(version);
1431                record.published_at = Some(published_at);
1432                record.metadata_version = version;
1433            }
1434            DdlDefinition::AlterSchema { target } => {
1435                let anchor = self
1436                    .tables
1437                    .get_mut(&job.table_id)
1438                    .expect("anchor existence checked");
1439                anchor.schema_version = SchemaVersion(job.source_schema_version.get() + 1);
1440                anchor.schema = target.clone();
1441                anchor.metadata_version = version;
1442            }
1443            DdlDefinition::DropIndex { .. } => unreachable!("drop kind refused above"),
1444        }
1445        let job = self.jobs.get_mut(&job_id).expect("job existence checked");
1446        job.phase = DdlPhase::Public;
1447        job.state = SchemaJobState::Succeeded;
1448        job.updated_at = commit_ts;
1449        job.metadata_version = version;
1450        Ok(())
1451    }
1452
1453    fn apply_set_job_state(
1454        &mut self,
1455        job_id: u64,
1456        state: SchemaJobState,
1457        updated_at: HlcTimestamp,
1458        error: &Option<String>,
1459        expected_version: Option<MetadataVersion>,
1460        version: MetadataVersion,
1461    ) -> Result<(), DdlRejection> {
1462        let record = self
1463            .jobs
1464            .get_mut(&job_id)
1465            .ok_or(MetaRejectionReason::NotFound {
1466                resource: format!("DDL job {job_id}"),
1467            })?;
1468        if let Some(expected) = expected_version {
1469            if expected != record.metadata_version {
1470                return Err(MetaRejectionReason::StaleWrite {
1471                    resource: format!("DDL job {job_id}"),
1472                    current: record.metadata_version,
1473                    attempted: expected,
1474                }
1475                .into());
1476            }
1477        }
1478        if record.state.is_terminal() {
1479            return Err(MetaRejectionReason::Conflict {
1480                resource: format!("DDL job {job_id}"),
1481                reason: format!("terminal state {:?}", record.state),
1482            }
1483            .into());
1484        }
1485        if record.state != state && !record.state.can_transition(state) {
1486            return Err(MetaRejectionReason::Conflict {
1487                resource: format!("DDL job {job_id}"),
1488                reason: format!("illegal transition {:?} -> {:?}", record.state, state),
1489            }
1490            .into());
1491        }
1492        if record.state == state && record.error == *error {
1493            return Ok(());
1494        }
1495        record.state = state;
1496        record.updated_at = updated_at;
1497        record.error = error.clone();
1498        record.metadata_version = version;
1499        Ok(())
1500    }
1501
1502    fn apply_remove_index_record(
1503        &mut self,
1504        job_id: u64,
1505        version: MetadataVersion,
1506    ) -> Result<(), DdlRejection> {
1507        let job = self
1508            .jobs
1509            .get(&job_id)
1510            .ok_or(MetaRejectionReason::NotFound {
1511                resource: format!("DDL job {job_id}"),
1512            })?;
1513        if !matches!(
1514            job.state,
1515            SchemaJobState::RollingBack | SchemaJobState::Failed
1516        ) {
1517            return Err(MetaRejectionReason::Conflict {
1518                resource: format!("DDL job {job_id}"),
1519                reason: "index records are removed only during rollback".to_owned(),
1520            }
1521            .into());
1522        }
1523        let Some(index_name) = job.definition.index_name() else {
1524            return Ok(());
1525        };
1526        let key = (job.table_id, index_name.to_owned());
1527        match self.indexes.get(&key) {
1528            None => Ok(()),
1529            Some(record) if record.phase == DdlPhase::Public => {
1530                Err(MetaRejectionReason::Conflict {
1531                    resource: format!("index `{index_name}` on table {}", job.table_id),
1532                    reason: "refusing to remove a published index".to_owned(),
1533                }
1534                .into())
1535            }
1536            Some(_) => {
1537                self.indexes.remove(&key);
1538                let job = self.jobs.get_mut(&job_id).expect("job existence checked");
1539                job.metadata_version = version;
1540                Ok(())
1541            }
1542        }
1543    }
1544
1545    fn apply_reclaim_index(
1546        &mut self,
1547        table_id: TableId,
1548        index_name: &str,
1549        _version: MetadataVersion,
1550    ) -> Result<(), DdlRejection> {
1551        let key = (table_id, index_name.to_owned());
1552        let Some(record) = self.indexes.get(&key) else {
1553            return Ok(()); // already reclaimed (idempotent)
1554        };
1555        if record.phase != DdlPhase::Dropping {
1556            return Err(MetaRejectionReason::Conflict {
1557                resource: format!("index `{index_name}` on table {table_id}"),
1558                reason: format!("index is {}, not Dropping", record.phase),
1559            }
1560            .into());
1561        }
1562        let required = record
1563            .dropping_since
1564            .expect("Dropping records carry dropping_since");
1565        let oldest_reader = self.oldest_reader_version();
1566        if oldest_reader.is_some_and(|oldest| oldest < required) {
1567            return Err(DdlRejection::ReclaimBlocked {
1568                table_id,
1569                index_name: index_name.to_owned(),
1570                oldest_reader,
1571                required,
1572            });
1573        }
1574        self.indexes.remove(&key);
1575        Ok(())
1576    }
1577
1578    fn apply_pin_reader(
1579        &mut self,
1580        reader_id: u64,
1581        version: MetadataVersion,
1582    ) -> Result<(), DdlRejection> {
1583        if reader_id == 0 {
1584            return Err(MetaRejectionReason::Invalid {
1585                reason: "reserved zero reader id".to_owned(),
1586            }
1587            .into());
1588        }
1589        if let Some(existing) = self.reader_pins.get(&reader_id) {
1590            if version < *existing {
1591                return Err(MetaRejectionReason::Conflict {
1592                    resource: format!("reader {reader_id}"),
1593                    reason: format!("reader pins only move forward ({} -> {version})", *existing),
1594                }
1595                .into());
1596            }
1597        }
1598        self.reader_pins.insert(reader_id, version);
1599        Ok(())
1600    }
1601
1602    // -- Queries ------------------------------------------------------------
1603
1604    /// One job record.
1605    pub fn job(&self, job_id: u64) -> Option<&DdlJobRecord> {
1606        self.jobs.get(&job_id)
1607    }
1608
1609    /// One index record.
1610    pub fn index(&self, table_id: TableId, index_name: &str) -> Option<&DdlIndexRecord> {
1611        self.indexes.get(&(table_id, index_name.to_owned()))
1612    }
1613
1614    /// One table anchor.
1615    pub fn table_anchor(&self, table_id: TableId) -> Option<&TableAnchor> {
1616        self.tables.get(&table_id)
1617    }
1618
1619    /// The oldest live reader pin, the reclamation gate's watermark.
1620    pub fn oldest_reader_version(&self) -> Option<MetadataVersion> {
1621        self.reader_pins.values().copied().min()
1622    }
1623
1624    /// The indexes a planner at metadata version `version` may use (spec
1625    /// step 6): published at or below `version`, and not already `Dropping`
1626    /// at `version`. The atomic publication flips visibility at exactly one
1627    /// metadata version.
1628    pub fn planner_visible_at(
1629        &self,
1630        table_id: TableId,
1631        version: MetadataVersion,
1632    ) -> Vec<DdlIndexRecord> {
1633        self.indexes
1634            .values()
1635            .filter(|record| {
1636                record.table_id == table_id
1637                    && record
1638                        .publication_version
1639                        .is_some_and(|publication| publication <= version)
1640                    && record
1641                        .dropping_since
1642                        .is_none_or(|dropping| version < dropping)
1643            })
1644            .cloned()
1645            .collect()
1646    }
1647
1648    /// The indexes the planner of the current metadata version may use.
1649    pub fn planner_visible(&self, table_id: TableId) -> Vec<DdlIndexRecord> {
1650        self.planner_visible_at(table_id, self.metadata_version)
1651    }
1652
1653    /// The index definitions a tablet apply path dual-maintains for
1654    /// committed writes (spec step 2): every record in `WriteOnly`,
1655    /// `Backfilling`, `Validating`, or `Public` — hidden from planners but
1656    /// maintained from `WriteOnly` on. Deterministic name order.
1657    pub fn write_maintained(&self, table_id: TableId) -> Vec<DdlIndexRecord> {
1658        self.indexes
1659            .values()
1660            .filter(|record| {
1661                record.table_id == table_id
1662                    && matches!(
1663                        record.phase,
1664                        DdlPhase::WriteOnly
1665                            | DdlPhase::Backfilling
1666                            | DdlPhase::Validating
1667                            | DdlPhase::Public
1668                    )
1669            })
1670            .cloned()
1671            .collect()
1672    }
1673}
1674
1675// ---------------------------------------------------------------------------
1676// The tablet seams (spec steps 2-5)
1677// ---------------------------------------------------------------------------
1678
1679/// The boxed stream of timestamped committed mutations
1680/// ([`BackfillKeyspace::deltas_after`]). Unlike the split executor's delta
1681/// stream, the DDL catch-up needs commit timestamps to define its watermark:
1682/// `None` values are deletes (tombstones).
1683pub type DeltaStream<'a> = Box<dyn Iterator<Item = (HlcTimestamp, Key, Option<Vec<u8>>)> + 'a>;
1684
1685/// The applied keyspace of one tablet replica, as the DDL backfill driver
1686/// reads it (spec steps 3-5). The engine binding lands with the server wave:
1687/// the snapshot is the engine's MVCC snapshot mechanics and the deltas are
1688/// the committed-log stream from the pinned snapshot forward. All methods
1689/// take `&self`; implementors synchronize internally (the in-memory
1690/// reference does), so one provider hands out independent handles.
1691pub trait BackfillKeyspace {
1692    /// Pins the snapshot at `ts` (spec step 3). Idempotent: re-pinning the
1693    /// same timestamp returns an equivalent pin; dropping releases it.
1694    fn pin_snapshot(&self, ts: HlcTimestamp) -> Result<Box<dyn SnapshotPin>, TabletDataError>;
1695
1696    /// The keyspace contents visible at `ts`, in key order (tombstoned keys
1697    /// absent).
1698    fn snapshot_at(&self, ts: HlcTimestamp) -> Result<RecordStream<'_>, TabletDataError>;
1699
1700    /// Mutations committed after `ts`, oldest first (spec step 4's catch-up
1701    /// stream). Multiple versions of one key arrive in commit order; a `None`
1702    /// value is a delete.
1703    fn deltas_after(&self, ts: HlcTimestamp) -> Result<DeltaStream<'_>, TabletDataError>;
1704}
1705
1706/// The hidden-index build sink of one tablet (spec steps 3-4), staged like
1707/// the engine's snapshot-install idiom: a build is staged beside live state
1708/// (`begin_build`/`stage_entry`) and installed atomically
1709/// (`install_staged`), after which catch-up deltas and dual-maintained writes
1710/// apply (`apply_delta`). Restarting a build discards the staged content, so
1711/// a resumed tablet build is idempotent.
1712pub trait HiddenIndexSink {
1713    /// Starts (or restarts) a staged build of `index`, discarding prior
1714    /// staged content for it.
1715    fn begin_build(&mut self, index: &str) -> Result<(), TabletDataError>;
1716
1717    /// Adds one snapshot entry to the staged build.
1718    fn stage_entry(&mut self, index: &str, key: &Key, entry: &[u8]) -> Result<(), TabletDataError>;
1719
1720    /// Atomically installs the staged build as the tablet's hidden
1721    /// generation of `index`.
1722    fn install_staged(&mut self, index: &str) -> Result<(), TabletDataError>;
1723
1724    /// Applies one committed mutation to the installed generation (spec
1725    /// steps 2 and 4): `Some(entry)` sets, `None` removes. Idempotent —
1726    /// catch-up sweeps and the apply-path maintainer may both cover one
1727    /// write.
1728    fn apply_delta(
1729        &mut self,
1730        index: &str,
1731        key: &Key,
1732        entry: Option<&[u8]>,
1733    ) -> Result<(), TabletDataError>;
1734
1735    /// Drops the generation (and any staged build) of `index`. Idempotent:
1736    /// rollback and reclamation call this on every tablet of the table.
1737    fn drop_generation(&mut self, index: &str) -> Result<(), TabletDataError>;
1738
1739    /// The installed generation's entries (validation and tests).
1740    fn generation_entries(&self, index: &str) -> Result<BTreeMap<Key, Vec<u8>>, TabletDataError>;
1741
1742    /// The installed generation's entry count.
1743    fn entry_count(&self, index: &str) -> Result<u64, TabletDataError> {
1744        Ok(self.generation_entries(index)?.len() as u64)
1745    }
1746}
1747
1748/// The tablet apply path's dual-maintenance seam (spec step 2). While an
1749/// index record of the tablet's table sits in `WriteOnly..=Public`, every
1750/// committed row mutation additionally lands in the hidden (or public) index
1751/// generation. The engine binding hooks this into the tablet's apply loop;
1752/// it lands with the server wave.
1753pub trait ApplySideIndexMaintainer {
1754    /// Refreshes the maintainer's view: the names of every index the table
1755    /// currently maintains (`DdlJobStore::write_maintained`). The apply path
1756    /// calls this whenever its applied metadata view changes.
1757    fn sync_definitions(&mut self, maintained: Vec<String>) -> Result<(), TabletDataError>;
1758
1759    /// Dual-maintains one committed row mutation (`None` = delete) into
1760    /// every installed generation of the synced definitions. A mutation
1761    /// arriving before its generation is installed is skipped here and
1762    /// covered by the catch-up sweep instead (the documented handoff).
1763    fn apply_committed_write(
1764        &mut self,
1765        key: &Key,
1766        row: Option<&[u8]>,
1767    ) -> Result<(), TabletDataError>;
1768}
1769
1770/// The tablet topology and seam registry the [`DdlDriver`] drives. Handles
1771/// are independent (interior synchronization), so the driver can hold a
1772/// keyspace and a sink of one tablet at once.
1773pub trait DdlTabletProvider {
1774    /// The table's current tablets, in tablet-id order (deterministic).
1775    fn tablets_of(&self, table: TableId) -> Vec<TabletId>;
1776
1777    /// The backfill read seam of one tablet.
1778    fn keyspace(&self, tablet: TabletId) -> Result<Box<dyn BackfillKeyspace>, TabletDataError>;
1779
1780    /// The hidden-index build seam of one tablet.
1781    fn sink(&self, tablet: TabletId) -> Result<Box<dyn HiddenIndexSink>, TabletDataError>;
1782
1783    /// The apply-path dual-maintenance seam of one tablet.
1784    fn maintainer(
1785        &self,
1786        tablet: TabletId,
1787    ) -> Result<Box<dyn ApplySideIndexMaintainer>, TabletDataError>;
1788}
1789
1790/// Derives one index entry from one row (the engine binding supplies real
1791/// index-key extraction; tests ride [`identity_projection`]).
1792pub type IndexProjection = Arc<dyn Fn(&DdlIndexRecord, &Key, &[u8]) -> Vec<u8> + Send + Sync>;
1793
1794/// The default projection: the entry is the row's value bytes (the hidden
1795/// index is a full row copy). Both the backfill path and the from-scratch
1796/// comparison use it, so validation compares the two paths' convergence.
1797pub fn identity_projection() -> IndexProjection {
1798    Arc::new(|_index, _key, value| value.to_vec())
1799}
1800
1801// ---------------------------------------------------------------------------
1802// In-memory reference implementations
1803// ---------------------------------------------------------------------------
1804
1805/// The in-memory version chain behind [`InMemoryDdlKeyspace`]: per key, the
1806/// committed versions (timestamp, put-or-tombstone) in ascending order.
1807type VersionedRows = BTreeMap<Key, Vec<(HlcTimestamp, Option<Vec<u8>>)>>;
1808
1809/// The reference [`BackfillKeyspace`]: a shared in-memory multi-version map
1810/// with tombstones. Versions are kept per key so `snapshot_at` /
1811/// `deltas_after` split the timeline exactly at a timestamp; clones share
1812/// state, so a "crashed" driver's writes survive into the resumed one.
1813#[derive(Clone, Default)]
1814pub struct InMemoryDdlKeyspace {
1815    state: Arc<Mutex<VersionedRows>>,
1816}
1817
1818impl InMemoryDdlKeyspace {
1819    /// An empty keyspace.
1820    pub fn new() -> Self {
1821        Self::default()
1822    }
1823
1824    /// Commits one put of `key` at `ts`.
1825    pub fn insert(&self, key: Key, ts: HlcTimestamp, value: Vec<u8>) {
1826        let mut rows = self.state.lock().expect("keyspace lock poisoned");
1827        let chain = rows.entry(key).or_default();
1828        chain.push((ts, Some(value)));
1829        chain.sort_by_key(|(version, _)| *version);
1830    }
1831
1832    /// Commits one delete of `key` at `ts`.
1833    pub fn delete(&self, key: Key, ts: HlcTimestamp) {
1834        let mut rows = self.state.lock().expect("keyspace lock poisoned");
1835        let chain = rows.entry(key).or_default();
1836        chain.push((ts, None));
1837        chain.sort_by_key(|(version, _)| *version);
1838    }
1839
1840    /// Every key's newest live value at or below `ts` (assertion helper).
1841    pub fn rows_at(&self, ts: HlcTimestamp) -> BTreeMap<Key, Vec<u8>> {
1842        let rows = self.state.lock().expect("keyspace lock poisoned");
1843        rows.iter()
1844            .filter_map(|(key, chain)| {
1845                let (_, value) = chain.iter().rfind(|(version, _)| *version <= ts)?;
1846                value.clone().map(|value| (key.clone(), value))
1847            })
1848            .collect()
1849    }
1850}
1851
1852impl BackfillKeyspace for InMemoryDdlKeyspace {
1853    fn pin_snapshot(&self, ts: HlcTimestamp) -> Result<Box<dyn SnapshotPin>, TabletDataError> {
1854        Ok(Box::new(DdlSnapshotPin { ts }))
1855    }
1856
1857    fn snapshot_at(&self, ts: HlcTimestamp) -> Result<RecordStream<'_>, TabletDataError> {
1858        Ok(Box::new(self.rows_at(ts).into_iter()))
1859    }
1860
1861    fn deltas_after(&self, ts: HlcTimestamp) -> Result<DeltaStream<'_>, TabletDataError> {
1862        let rows = self.state.lock().expect("keyspace lock poisoned");
1863        let mut deltas: Vec<(HlcTimestamp, Key, Option<Vec<u8>>)> = Vec::new();
1864        for (key, chain) in rows.iter() {
1865            for (version, value) in chain {
1866                if *version > ts {
1867                    deltas.push((*version, key.clone(), value.clone()));
1868                }
1869            }
1870        }
1871        deltas.sort_by(|(left_ts, left_key, _), (right_ts, right_key, _)| {
1872            (left_ts, left_key).cmp(&(right_ts, right_key))
1873        });
1874        Ok(Box::new(deltas.into_iter()))
1875    }
1876}
1877
1878/// A [`InMemoryDdlKeyspace`] snapshot pin. The in-memory keyspace keeps its
1879/// whole version chain, so the pin is a pure timestamp record (a real engine
1880/// releases the pinned read generation on drop).
1881struct DdlSnapshotPin {
1882    ts: HlcTimestamp,
1883}
1884
1885impl SnapshotPin for DdlSnapshotPin {
1886    fn pinned_at(&self) -> HlcTimestamp {
1887        self.ts
1888    }
1889}
1890
1891/// The reference [`HiddenIndexSink`] + [`ApplySideIndexMaintainer`]: per
1892/// tablet, staged builds beside installed generations, with the apply-path
1893/// dual maintenance writing into installed generations only. Clones share
1894/// state (the same idiom as `MapChildSink`).
1895#[derive(Clone, Default)]
1896pub struct InMemoryTabletIndexes {
1897    state: Arc<Mutex<IndexesState>>,
1898}
1899
1900#[derive(Default)]
1901struct IndexesState {
1902    /// Synced maintained definition names (`sync_definitions`).
1903    maintained: Vec<String>,
1904    /// Staged builds by index name (beside live state).
1905    staged: BTreeMap<String, BTreeMap<Key, Vec<u8>>>,
1906    /// Installed generations by index name.
1907    generations: BTreeMap<String, BTreeMap<Key, Vec<u8>>>,
1908    /// Total `begin_build` calls (restart observability for tests).
1909    begin_builds: usize,
1910}
1911
1912impl InMemoryTabletIndexes {
1913    /// An empty tablet index set.
1914    pub fn new() -> Self {
1915        Self::default()
1916    }
1917
1918    /// The installed entries of one generation, empty when absent (assertion
1919    /// helper).
1920    pub fn entries(&self, index: &str) -> BTreeMap<Key, Vec<u8>> {
1921        self.state
1922            .lock()
1923            .expect("indexes lock poisoned")
1924            .generations
1925            .get(index)
1926            .cloned()
1927            .unwrap_or_default()
1928    }
1929
1930    /// How often a build was (re)started (assertion helper for resume
1931    /// tests: an already-finished tablet is never rebuilt).
1932    pub fn begin_build_count(&self) -> usize {
1933        self.state
1934            .lock()
1935            .expect("indexes lock poisoned")
1936            .begin_builds
1937    }
1938
1939    /// Whether `index` has an installed generation.
1940    pub fn is_installed(&self, index: &str) -> bool {
1941        self.state
1942            .lock()
1943            .expect("indexes lock poisoned")
1944            .generations
1945            .contains_key(index)
1946    }
1947
1948    /// The currently synced maintained definition names.
1949    pub fn maintained(&self) -> Vec<String> {
1950        self.state
1951            .lock()
1952            .expect("indexes lock poisoned")
1953            .maintained
1954            .clone()
1955    }
1956
1957    /// Seeds staged content without installing it (simulates a driver crash
1958    /// mid-build; the resumed driver's `begin_build` must discard it).
1959    #[cfg(test)]
1960    pub fn seed_staged(&self, index: &str, key: Key, entry: Vec<u8>) {
1961        self.state
1962            .lock()
1963            .expect("indexes lock poisoned")
1964            .staged
1965            .entry(index.to_owned())
1966            .or_default()
1967            .insert(key, entry);
1968    }
1969}
1970
1971impl HiddenIndexSink for InMemoryTabletIndexes {
1972    fn begin_build(&mut self, index: &str) -> Result<(), TabletDataError> {
1973        let mut state = self.state.lock().expect("indexes lock poisoned");
1974        state.staged.insert(index.to_owned(), BTreeMap::new());
1975        state.begin_builds += 1;
1976        Ok(())
1977    }
1978
1979    fn stage_entry(&mut self, index: &str, key: &Key, entry: &[u8]) -> Result<(), TabletDataError> {
1980        let mut state = self.state.lock().expect("indexes lock poisoned");
1981        let Some(staged) = state.staged.get_mut(index) else {
1982            return Err(TabletDataError::NoStagedBuild);
1983        };
1984        staged.insert(key.clone(), entry.to_vec());
1985        Ok(())
1986    }
1987
1988    fn install_staged(&mut self, index: &str) -> Result<(), TabletDataError> {
1989        let mut state = self.state.lock().expect("indexes lock poisoned");
1990        let Some(staged) = state.staged.remove(index) else {
1991            return Err(TabletDataError::NoStagedBuild);
1992        };
1993        state.generations.insert(index.to_owned(), staged);
1994        Ok(())
1995    }
1996
1997    fn apply_delta(
1998        &mut self,
1999        index: &str,
2000        key: &Key,
2001        entry: Option<&[u8]>,
2002    ) -> Result<(), TabletDataError> {
2003        let mut state = self.state.lock().expect("indexes lock poisoned");
2004        let Some(generation) = state.generations.get_mut(index) else {
2005            return Err(TabletDataError::Sink(format!(
2006                "generation `{index}` is not installed"
2007            )));
2008        };
2009        match entry {
2010            Some(entry) => {
2011                generation.insert(key.clone(), entry.to_vec());
2012            }
2013            None => {
2014                generation.remove(key);
2015            }
2016        }
2017        Ok(())
2018    }
2019
2020    fn drop_generation(&mut self, index: &str) -> Result<(), TabletDataError> {
2021        let mut state = self.state.lock().expect("indexes lock poisoned");
2022        state.generations.remove(index);
2023        state.staged.remove(index);
2024        Ok(())
2025    }
2026
2027    fn generation_entries(&self, index: &str) -> Result<BTreeMap<Key, Vec<u8>>, TabletDataError> {
2028        Ok(self.entries(index))
2029    }
2030}
2031
2032impl ApplySideIndexMaintainer for InMemoryTabletIndexes {
2033    fn sync_definitions(&mut self, maintained: Vec<String>) -> Result<(), TabletDataError> {
2034        self.state.lock().expect("indexes lock poisoned").maintained = maintained;
2035        Ok(())
2036    }
2037
2038    fn apply_committed_write(
2039        &mut self,
2040        key: &Key,
2041        row: Option<&[u8]>,
2042    ) -> Result<(), TabletDataError> {
2043        let mut state = self.state.lock().expect("indexes lock poisoned");
2044        for index in state.maintained.clone() {
2045            // Writes before the generation install are covered by the
2046            // catch-up sweep (the documented handoff), never lost.
2047            if let Some(generation) = state.generations.get_mut(&index) {
2048                match row {
2049                    Some(row) => {
2050                        generation.insert(key.clone(), row.to_vec());
2051                    }
2052                    None => {
2053                        generation.remove(key);
2054                    }
2055                }
2056            }
2057        }
2058        Ok(())
2059    }
2060}
2061
2062/// One in-memory tablet: its keyspace plus its hidden-index state.
2063struct InMemoryTablet {
2064    table_id: TableId,
2065    keyspace: InMemoryDdlKeyspace,
2066    indexes: InMemoryTabletIndexes,
2067}
2068
2069/// The reference [`DdlTabletProvider`]: a shared in-memory tablet set.
2070/// `commit_write` models the tablet apply path (spec step 2): the mutation
2071/// lands in the applied keyspace AND flows through the dual-maintenance seam
2072/// in one call.
2073#[derive(Clone, Default)]
2074pub struct InMemoryDdlTablets {
2075    tablets: Arc<Mutex<BTreeMap<TabletId, InMemoryTablet>>>,
2076}
2077
2078impl InMemoryDdlTablets {
2079    /// An empty tablet set.
2080    pub fn new() -> Self {
2081        Self::default()
2082    }
2083
2084    /// Adds one tablet of `table`.
2085    pub fn add_tablet(&self, tablet: TabletId, table: TableId) {
2086        self.tablets
2087            .lock()
2088            .expect("tablets lock poisoned")
2089            .entry(tablet)
2090            .or_insert_with(|| InMemoryTablet {
2091                table_id: table,
2092                keyspace: InMemoryDdlKeyspace::new(),
2093                indexes: InMemoryTabletIndexes::new(),
2094            });
2095    }
2096
2097    /// Removes one tablet (simulates a split/merge topology change; the
2098    /// driver forgets its progress cursor on the next step).
2099    #[cfg(test)]
2100    pub fn remove_tablet(&self, tablet: TabletId) {
2101        self.tablets
2102            .lock()
2103            .expect("tablets lock poisoned")
2104            .remove(&tablet);
2105    }
2106
2107    /// The keyspace handle of one tablet (test seeding).
2108    pub fn keyspace_handle(&self, tablet: TabletId) -> InMemoryDdlKeyspace {
2109        self.tablets
2110            .lock()
2111            .expect("tablets lock poisoned")
2112            .get(&tablet)
2113            .expect("unknown tablet")
2114            .keyspace
2115            .clone()
2116    }
2117
2118    /// The index-state handle of one tablet (test assertions).
2119    pub fn indexes_handle(&self, tablet: TabletId) -> InMemoryTabletIndexes {
2120        self.tablets
2121            .lock()
2122            .expect("tablets lock poisoned")
2123            .get(&tablet)
2124            .expect("unknown tablet")
2125            .indexes
2126            .clone()
2127    }
2128
2129    /// Models one committed write through the tablet apply path: the row
2130    /// mutation lands in the applied keyspace and is dual-maintained into
2131    /// every installed hidden/public generation (spec step 2). `None` deletes.
2132    pub fn commit_write(
2133        &self,
2134        tablet: TabletId,
2135        key: Key,
2136        ts: HlcTimestamp,
2137        row: Option<Vec<u8>>,
2138    ) -> Result<(), TabletDataError> {
2139        let (keyspace, mut indexes) = {
2140            let tablets = self.tablets.lock().expect("tablets lock poisoned");
2141            let tablet = tablets.get(&tablet).expect("unknown tablet");
2142            (tablet.keyspace.clone(), tablet.indexes.clone())
2143        };
2144        match row {
2145            Some(row) => {
2146                keyspace.insert(key.clone(), ts, row.clone());
2147                indexes.apply_committed_write(&key, Some(&row))
2148            }
2149            None => {
2150                keyspace.delete(key.clone(), ts);
2151                indexes.apply_committed_write(&key, None)
2152            }
2153        }
2154    }
2155}
2156
2157impl DdlTabletProvider for InMemoryDdlTablets {
2158    fn tablets_of(&self, table: TableId) -> Vec<TabletId> {
2159        self.tablets
2160            .lock()
2161            .expect("tablets lock poisoned")
2162            .iter()
2163            .filter(|(_, tablet)| tablet.table_id == table)
2164            .map(|(tablet_id, _)| *tablet_id)
2165            .collect()
2166    }
2167
2168    fn keyspace(&self, tablet: TabletId) -> Result<Box<dyn BackfillKeyspace>, TabletDataError> {
2169        let tablets = self.tablets.lock().expect("tablets lock poisoned");
2170        let Some(tablet) = tablets.get(&tablet) else {
2171            return Err(TabletDataError::Keyspace(format!(
2172                "unknown tablet {tablet}"
2173            )));
2174        };
2175        Ok(Box::new(tablet.keyspace.clone()))
2176    }
2177
2178    fn sink(&self, tablet: TabletId) -> Result<Box<dyn HiddenIndexSink>, TabletDataError> {
2179        let tablets = self.tablets.lock().expect("tablets lock poisoned");
2180        let Some(tablet) = tablets.get(&tablet) else {
2181            return Err(TabletDataError::Sink(format!("unknown tablet {tablet}")));
2182        };
2183        Ok(Box::new(tablet.indexes.clone()))
2184    }
2185
2186    fn maintainer(
2187        &self,
2188        tablet: TabletId,
2189    ) -> Result<Box<dyn ApplySideIndexMaintainer>, TabletDataError> {
2190        let tablets = self.tablets.lock().expect("tablets lock poisoned");
2191        let Some(tablet) = tablets.get(&tablet) else {
2192            return Err(TabletDataError::Sink(format!("unknown tablet {tablet}")));
2193        };
2194        Ok(Box::new(tablet.indexes.clone()))
2195    }
2196}
2197
2198// ---------------------------------------------------------------------------
2199// The driver
2200// ---------------------------------------------------------------------------
2201
2202/// The outcome of one [`DdlDriver::drive_job`] call.
2203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2204pub enum DriveOutcome {
2205    /// The job reached `Succeeded` (`Public` for additions, `Dropping` for
2206    /// drops).
2207    Completed,
2208    /// The job is paused; a later drive resumes from the durable cursors.
2209    Parked,
2210    /// The job was cancelled (or crashed mid-rollback) and the unwind ran:
2211    /// hidden generations dropped, unpublished records removed.
2212    RolledBack,
2213}
2214
2215/// One atomic step of [`DdlDriver::step_job`]; tests drive jobs stepwise to
2216/// interleave writes, pauses, and crashes between protocol actions.
2217#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2218enum DriveStep {
2219    /// `Pending -> Running`.
2220    Admitted,
2221    /// One [`DdlPhase`] edge applied.
2222    PhaseAdvanced {
2223        /// Previous phase.
2224        from: DdlPhase,
2225        /// New phase.
2226        to: DdlPhase,
2227    },
2228    /// One tablet's snapshot backfill + catch-up completed and was recorded.
2229    TabletBackfilled {
2230        /// The tablet.
2231        tablet: TabletId,
2232    },
2233    /// One tablet validated.
2234    TabletValidated {
2235        /// The tablet.
2236        tablet: TabletId,
2237    },
2238    /// The atomic publication landed at this metadata version.
2239    Published {
2240        /// The publication metadata version.
2241        version: MetadataVersion,
2242    },
2243    /// The job is paused.
2244    Parked,
2245    /// The job's rollback ran.
2246    RolledBack,
2247    /// The job is in a terminal state.
2248    Terminal,
2249}
2250
2251/// The outcome of one [`DdlDriver::reclaim_index`] call (spec step 7).
2252#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2253pub enum ReclaimOutcome {
2254    /// The record was removed; per-tablet generations were dropped.
2255    Reclaimed,
2256    /// Readers below the retirement version still pin the metadata; retry
2257    /// once they drain.
2258    Blocked {
2259        /// Oldest live reader pin, when any.
2260        oldest_reader: Option<MetadataVersion>,
2261        /// The retirement version every reader must have reached.
2262        required: MetadataVersion,
2263    },
2264}
2265
2266/// The operator-facing status of one job (spec section 12.11 admin surface).
2267#[derive(Clone, Debug, PartialEq)]
2268pub struct DdlJobStatus {
2269    /// The replicated record.
2270    pub record: DdlJobRecord,
2271    /// Tablets with a registered progress cursor.
2272    pub tablets_registered: usize,
2273    /// Tablets that completed snapshot backfill + catch-up.
2274    pub tablets_caught_up: usize,
2275    /// Tablets that validated.
2276    pub tablets_validated: usize,
2277    /// Total rows scanned from pinned snapshots.
2278    pub rows_backfilled: u64,
2279}
2280
2281/// The distributed DDL driver (spec section 12.11): submits jobs, drives
2282/// them through the phase machine against the replicated [`DdlJobStore`],
2283/// and runs the per-tablet backfill/catch-up/validation through the tablet
2284/// seams. One driver per cluster admin surface; the store's applied commands
2285/// are the only progress authority, so a replacement driver over the same
2286/// store resumes exactly.
2287pub struct DdlDriver<P: DdlTabletProvider> {
2288    store: Arc<Mutex<DdlJobStore>>,
2289    provider: P,
2290    clock: HlcClock,
2291    projection: IndexProjection,
2292}
2293
2294impl<P: DdlTabletProvider> DdlDriver<P> {
2295    /// A driver over `store` and `provider` with the [`identity_projection`].
2296    pub fn new(store: Arc<Mutex<DdlJobStore>>, provider: P) -> Self {
2297        Self::with_projection(store, provider, identity_projection())
2298    }
2299
2300    /// A driver with an explicit row-to-entry projection.
2301    pub fn with_projection(
2302        store: Arc<Mutex<DdlJobStore>>,
2303        provider: P,
2304        projection: IndexProjection,
2305    ) -> Self {
2306        Self {
2307            store,
2308            provider,
2309            clock: HlcClock::new(0, Duration::from_secs(30)),
2310            projection,
2311        }
2312    }
2313
2314    /// The shared store handle (tests and the raft binding).
2315    pub fn store_handle(&self) -> Arc<Mutex<DdlJobStore>> {
2316        self.store.clone()
2317    }
2318
2319    // -- Admin surface: submissions ----------------------------------------
2320
2321    /// Submits an online index build (spec steps 1-6). The job pins
2322    /// `source_schema_version`; a stale schema is refused with the structured
2323    /// [`DdlRejection::SchemaVersionMismatch`] retry.
2324    pub fn submit_add_index(
2325        &self,
2326        database_id: DatabaseId,
2327        table_id: TableId,
2328        index_name: impl Into<String>,
2329        spec: serde_json::Value,
2330        source_schema_version: SchemaVersion,
2331    ) -> Result<u64, DdlError> {
2332        let created_at = self.clock.now()?;
2333        let job = DdlJobRecord {
2334            job_id: self.peek_next_job_id(),
2335            database_id,
2336            table_id,
2337            kind: DdlJobKind::AddIndex,
2338            state: SchemaJobState::Pending,
2339            phase: DdlPhase::Pending,
2340            definition: DdlDefinition::AddIndex {
2341                index_name: index_name.into(),
2342                spec,
2343            },
2344            source_schema_version,
2345            created_at,
2346            updated_at: created_at,
2347            pinned_snapshot: None,
2348            tablet_progress: BTreeMap::new(),
2349            error: None,
2350            metadata_version: MetadataVersion::ZERO,
2351        };
2352        self.apply(DdlCommand::SubmitJob { job: job.clone() })?;
2353        Ok(job.job_id)
2354    }
2355
2356    /// Submits an online index drop (`Public -> Dropping -> reclaim`).
2357    pub fn submit_drop_index(
2358        &self,
2359        database_id: DatabaseId,
2360        table_id: TableId,
2361        index_name: impl Into<String>,
2362        source_schema_version: SchemaVersion,
2363    ) -> Result<u64, DdlError> {
2364        let created_at = self.clock.now()?;
2365        let job = DdlJobRecord {
2366            job_id: self.peek_next_job_id(),
2367            database_id,
2368            table_id,
2369            kind: DdlJobKind::DropIndex,
2370            state: SchemaJobState::Pending,
2371            phase: DdlPhase::Public,
2372            definition: DdlDefinition::DropIndex {
2373                index_name: index_name.into(),
2374            },
2375            source_schema_version,
2376            created_at,
2377            updated_at: created_at,
2378            pinned_snapshot: None,
2379            tablet_progress: BTreeMap::new(),
2380            error: None,
2381            metadata_version: MetadataVersion::ZERO,
2382        };
2383        self.apply(DdlCommand::SubmitJob { job: job.clone() })?;
2384        Ok(job.job_id)
2385    }
2386
2387    /// Submits a schema alteration. Publication advances the table anchor to
2388    /// `source_schema_version + 1` and swaps the schema document atomically.
2389    pub fn submit_alter_schema(
2390        &self,
2391        database_id: DatabaseId,
2392        table_id: TableId,
2393        target: serde_json::Value,
2394        source_schema_version: SchemaVersion,
2395    ) -> Result<u64, DdlError> {
2396        let created_at = self.clock.now()?;
2397        let job = DdlJobRecord {
2398            job_id: self.peek_next_job_id(),
2399            database_id,
2400            table_id,
2401            kind: DdlJobKind::AlterSchema,
2402            state: SchemaJobState::Pending,
2403            phase: DdlPhase::Pending,
2404            definition: DdlDefinition::AlterSchema { target },
2405            source_schema_version,
2406            created_at,
2407            updated_at: created_at,
2408            pinned_snapshot: None,
2409            tablet_progress: BTreeMap::new(),
2410            error: None,
2411            metadata_version: MetadataVersion::ZERO,
2412        };
2413        self.apply(DdlCommand::SubmitJob { job: job.clone() })?;
2414        Ok(job.job_id)
2415    }
2416
2417    // -- Admin surface: job control -----------------------------------------
2418
2419    /// Parks a `Running` job at the next tablet boundary (graph-enforced).
2420    /// Refuses from every other state (a double pause is an operator error,
2421    /// not an idempotent replay).
2422    pub fn pause_job(&self, job_id: u64) -> Result<(), DdlError> {
2423        let record = self.job_record(job_id)?;
2424        if record.state != SchemaJobState::Running {
2425            return Err(DdlError::Rejection(DdlRejection::JobNotRunning {
2426                job_id,
2427                state: record.state,
2428            }));
2429        }
2430        self.apply(DdlCommand::SetJobState {
2431            job_id,
2432            state: SchemaJobState::Paused,
2433            updated_at: self.clock.now()?,
2434            error: None,
2435            expected_version: Some(record.metadata_version),
2436        })
2437    }
2438
2439    /// Requeues a `Paused` job; the next drive admits and resumes it from the
2440    /// durable per-tablet cursors. Refuses from every other state.
2441    pub fn resume_job(&self, job_id: u64) -> Result<(), DdlError> {
2442        let record = self.job_record(job_id)?;
2443        if record.state != SchemaJobState::Paused {
2444            return Err(DdlError::Rejection(DdlRejection::Meta(
2445                MetaRejectionReason::Conflict {
2446                    resource: format!("DDL job {job_id}"),
2447                    reason: format!("cannot resume from {:?}", record.state),
2448                },
2449            )));
2450        }
2451        self.apply(DdlCommand::SetJobState {
2452            job_id,
2453            state: SchemaJobState::Pending,
2454            updated_at: self.clock.now()?,
2455            error: None,
2456            expected_version: Some(record.metadata_version),
2457        })
2458    }
2459
2460    /// Requests cancellation. The unwind (hidden generations dropped,
2461    /// unpublished records removed) runs on the next drive step. Terminal
2462    /// jobs refuse cancellation.
2463    pub fn cancel_job(&self, job_id: u64) -> Result<(), DdlError> {
2464        let record = self.job_record(job_id)?;
2465        if record.state.is_terminal() {
2466            return Err(DdlError::Rejection(DdlRejection::Meta(
2467                MetaRejectionReason::Conflict {
2468                    resource: format!("DDL job {job_id}"),
2469                    reason: format!("terminal state {:?}", record.state),
2470                },
2471            )));
2472        }
2473        self.apply(DdlCommand::SetJobState {
2474            job_id,
2475            state: SchemaJobState::Cancelling,
2476            updated_at: self.clock.now()?,
2477            error: None,
2478            expected_version: Some(record.metadata_version),
2479        })
2480    }
2481
2482    /// The operator-facing status of one job.
2483    pub fn job_status(&self, job_id: u64) -> Option<DdlJobStatus> {
2484        let record = self
2485            .store
2486            .lock()
2487            .expect("store lock poisoned")
2488            .job(job_id)?
2489            .clone();
2490        let tablets_registered = record.tablet_progress.len();
2491        let tablets_caught_up = record
2492            .tablet_progress
2493            .values()
2494            .filter(|progress| progress.stage >= TabletDdlStage::CaughtUp)
2495            .count();
2496        let tablets_validated = record
2497            .tablet_progress
2498            .values()
2499            .filter(|progress| progress.stage == TabletDdlStage::Validated)
2500            .count();
2501        let rows_backfilled = record
2502            .tablet_progress
2503            .values()
2504            .map(|progress| progress.rows_scanned)
2505            .sum();
2506        Some(DdlJobStatus {
2507            record,
2508            tablets_registered,
2509            tablets_caught_up,
2510            tablets_validated,
2511            rows_backfilled,
2512        })
2513    }
2514
2515    /// The aggregate validation report of one job (spec step 5), once every
2516    /// registered tablet reported.
2517    pub fn validation_report(&self, job_id: u64) -> Option<JobValidationReport> {
2518        let record = self
2519            .store
2520            .lock()
2521            .expect("store lock poisoned")
2522            .job(job_id)?
2523            .clone();
2524        let tablets: Vec<TabletValidationReport> = record
2525            .tablet_progress
2526            .values()
2527            .filter_map(|progress| progress.validation.clone())
2528            .collect();
2529        if tablets.is_empty() {
2530            return None;
2531        }
2532        let total_expected = tablets.iter().map(|report| report.expected_rows).sum();
2533        let total_actual = tablets.iter().map(|report| report.actual_rows).sum();
2534        let passed = tablets.iter().all(TabletValidationReport::passed);
2535        Some(JobValidationReport {
2536            job_id,
2537            tablets,
2538            total_expected,
2539            total_actual,
2540            passed,
2541        })
2542    }
2543
2544    // -- Admin surface: reclamation (spec step 7) ---------------------------
2545
2546    /// Pins a reader at a metadata version (its plans may reference every
2547    /// element visible there). The gateway wave owns real reader tracking.
2548    pub fn pin_reader(&self, reader_id: u64, version: MetadataVersion) -> Result<(), DdlError> {
2549        self.apply(DdlCommand::PinReader { reader_id, version })
2550    }
2551
2552    /// Releases one reader pin.
2553    pub fn release_reader(&self, reader_id: u64) -> Result<(), DdlError> {
2554        self.apply(DdlCommand::ReleaseReader { reader_id })
2555    }
2556
2557    /// Runs the reclamation pass for one dropped index: removes the record
2558    /// once no live reader pins a metadata version below its retirement
2559    /// version, and drops the per-tablet generations.
2560    pub fn reclaim_index(
2561        &self,
2562        table_id: TableId,
2563        index_name: &str,
2564    ) -> Result<ReclaimOutcome, DdlError> {
2565        match self.apply(DdlCommand::ReclaimIndex {
2566            table_id,
2567            index_name: index_name.to_owned(),
2568        }) {
2569            Ok(()) => {
2570                for tablet in self.provider.tablets_of(table_id) {
2571                    self.provider.sink(tablet)?.drop_generation(index_name)?;
2572                }
2573                Ok(ReclaimOutcome::Reclaimed)
2574            }
2575            Err(DdlError::Rejection(DdlRejection::ReclaimBlocked {
2576                oldest_reader,
2577                required,
2578                ..
2579            })) => Ok(ReclaimOutcome::Blocked {
2580                oldest_reader,
2581                required,
2582            }),
2583            Err(error) => Err(error),
2584        }
2585    }
2586
2587    // -- Driving ------------------------------------------------------------
2588
2589    /// Drives one job until it terminates, parks, or fails. Resumable: every
2590    /// step is an applied store command, so a fresh driver over the same
2591    /// store continues exactly where a crashed one stopped.
2592    pub fn drive_job(&self, job_id: u64) -> Result<DriveOutcome, DdlError> {
2593        loop {
2594            match self.step_job(job_id)? {
2595                DriveStep::Parked => return Ok(DriveOutcome::Parked),
2596                DriveStep::RolledBack => return Ok(DriveOutcome::RolledBack),
2597                DriveStep::Terminal => {
2598                    let state = self.job_record(job_id)?.state;
2599                    return Ok(match state {
2600                        SchemaJobState::Succeeded => DriveOutcome::Completed,
2601                        _ => DriveOutcome::RolledBack,
2602                    });
2603                }
2604                DriveStep::Admitted
2605                | DriveStep::PhaseAdvanced { .. }
2606                | DriveStep::TabletBackfilled { .. }
2607                | DriveStep::TabletValidated { .. }
2608                | DriveStep::Published { .. } => {}
2609            }
2610        }
2611    }
2612
2613    /// Performs one atomic protocol action of one job. Every mutating step
2614    /// is exactly one applied store command (plus the seam work it records).
2615    fn step_job(&self, job_id: u64) -> Result<DriveStep, DdlError> {
2616        let job = self.job_record(job_id)?;
2617        match job.state {
2618            SchemaJobState::Pending => {
2619                self.set_state(job_id, SchemaJobState::Running, None)?;
2620                return Ok(DriveStep::Admitted);
2621            }
2622            SchemaJobState::Paused => return Ok(DriveStep::Parked),
2623            SchemaJobState::Cancelling | SchemaJobState::RollingBack => {
2624                self.rollback(&job, "cancelled by operator")?;
2625                return Ok(DriveStep::RolledBack);
2626            }
2627            SchemaJobState::Failed | SchemaJobState::Succeeded => {
2628                return Ok(DriveStep::Terminal);
2629            }
2630            SchemaJobState::Running => {}
2631        }
2632        match job.phase {
2633            DdlPhase::Pending => {
2634                // Spec step 1: the definition is replicated WriteOnly; from
2635                // here the tablet apply path dual-maintains it (step 2).
2636                self.advance_phase(job_id, DdlPhase::WriteOnly, None)?;
2637                self.sync_maintainers(job.table_id)?;
2638                Ok(DriveStep::PhaseAdvanced {
2639                    from: DdlPhase::Pending,
2640                    to: DdlPhase::WriteOnly,
2641                })
2642            }
2643            DdlPhase::WriteOnly => {
2644                // Spec step 3: stamp the job-wide pinned snapshot.
2645                let pin = self.clock.now()?;
2646                self.advance_phase(job_id, DdlPhase::Backfilling, Some(pin))?;
2647                Ok(DriveStep::PhaseAdvanced {
2648                    from: DdlPhase::WriteOnly,
2649                    to: DdlPhase::Backfilling,
2650                })
2651            }
2652            DdlPhase::Backfilling => {
2653                let tablets = self.provider.tablets_of(job.table_id);
2654                for tablet in &tablets {
2655                    if !job.tablet_progress.contains_key(tablet) {
2656                        self.update_progress(job_id, TabletDdlProgress::pending(*tablet))?;
2657                    }
2658                }
2659                let mut job = self.job_record(job_id)?;
2660                let departed: Vec<TabletId> = job
2661                    .tablet_progress
2662                    .keys()
2663                    .filter(|tablet| !tablets.contains(tablet))
2664                    .copied()
2665                    .collect();
2666                for tablet in departed {
2667                    self.apply(DdlCommand::ForgetTabletProgress {
2668                        job_id,
2669                        tablet_id: tablet,
2670                    })?;
2671                }
2672                job = self.job_record(job_id)?;
2673                let next = tablets.iter().copied().find(|tablet| {
2674                    job.tablet_progress
2675                        .get(tablet)
2676                        .is_none_or(|progress| progress.stage < TabletDdlStage::CaughtUp)
2677                });
2678                match next {
2679                    Some(tablet) => {
2680                        let progress = job.tablet_progress.get(&tablet);
2681                        if progress.is_none_or(|p| p.stage < TabletDdlStage::Backfilling) {
2682                            let mut marker = TabletDdlProgress::pending(tablet);
2683                            marker.stage = TabletDdlStage::Backfilling;
2684                            self.update_progress(job_id, marker)?;
2685                            job = self.job_record(job_id)?;
2686                        }
2687                        let progress = self.backfill_tablet(&job, tablet)?;
2688                        self.update_progress(job_id, progress)?;
2689                        Ok(DriveStep::TabletBackfilled { tablet })
2690                    }
2691                    None => {
2692                        self.advance_phase(job_id, DdlPhase::Validating, None)?;
2693                        Ok(DriveStep::PhaseAdvanced {
2694                            from: DdlPhase::Backfilling,
2695                            to: DdlPhase::Validating,
2696                        })
2697                    }
2698                }
2699            }
2700            DdlPhase::Validating => {
2701                // Spec step 5: validate every backfilled tablet (the progress
2702                // map is the tablet set the job backfilled; tablets appearing
2703                // mid-Validating are the split/merge follow-up's concern).
2704                let next = job
2705                    .tablet_progress
2706                    .values()
2707                    .find(|progress| progress.stage < TabletDdlStage::Validated)
2708                    .map(|progress| progress.tablet_id);
2709                match next {
2710                    Some(tablet) => {
2711                        let report = self.validate_tablet(&job, tablet)?;
2712                        let passed = report.passed();
2713                        self.apply(DdlCommand::ReportTabletValidation {
2714                            job_id,
2715                            report: report.clone(),
2716                        })?;
2717                        if !passed {
2718                            let reason = format!(
2719                                "tablet {tablet}: expected {} rows, built {} rows",
2720                                report.expected_rows, report.actual_rows
2721                            );
2722                            self.rollback(&job, &reason)?;
2723                            return Err(DdlError::ValidationFailed { job_id, reason });
2724                        }
2725                        Ok(DriveStep::TabletValidated { tablet })
2726                    }
2727                    None => {
2728                        // Spec step 6: ONE atomic command flips the element
2729                        // Public at this metadata version.
2730                        let published_at = self.clock.now()?;
2731                        match self.apply(DdlCommand::PublishJob {
2732                            job_id,
2733                            published_at,
2734                        }) {
2735                            Ok(()) => Ok(DriveStep::Published {
2736                                version: self
2737                                    .store
2738                                    .lock()
2739                                    .expect("store lock poisoned")
2740                                    .metadata_version,
2741                            }),
2742                            Err(DdlError::Rejection(
2743                                reason @ DdlRejection::SchemaVersionMismatch { .. },
2744                            )) => {
2745                                self.rollback(&job, &reason.to_string())?;
2746                                Err(DdlError::Rejection(reason))
2747                            }
2748                            Err(error) => Err(error),
2749                        }
2750                    }
2751                }
2752            }
2753            DdlPhase::Public => match job.kind {
2754                // Spec drop path: one command flips the element Dropping —
2755                // writes stop maintaining it, planners stop using it.
2756                DdlJobKind::DropIndex => {
2757                    self.advance_phase(job_id, DdlPhase::Dropping, None)?;
2758                    self.sync_maintainers(job.table_id)?;
2759                    Ok(DriveStep::PhaseAdvanced {
2760                        from: DdlPhase::Public,
2761                        to: DdlPhase::Dropping,
2762                    })
2763                }
2764                DdlJobKind::AddIndex | DdlJobKind::AlterSchema => Ok(DriveStep::Terminal),
2765            },
2766            DdlPhase::Dropping => {
2767                self.set_state(job_id, SchemaJobState::Succeeded, None)?;
2768                Ok(DriveStep::Terminal)
2769            }
2770        }
2771    }
2772
2773    /// Spec steps 3-4 for one tablet: build the hidden generation from the
2774    /// pinned snapshot, install it, then sweep committed deltas forward until
2775    /// the stream drains. Returns the durable cursor.
2776    fn backfill_tablet(
2777        &self,
2778        job: &DdlJobRecord,
2779        tablet: TabletId,
2780    ) -> Result<TabletDdlProgress, DdlError> {
2781        let pin = job
2782            .pinned_snapshot
2783            .ok_or(DdlRejection::Meta(MetaRejectionReason::Invalid {
2784                reason: format!("job {} entered Backfilling without a pin", job.job_id),
2785            }))?;
2786        let keyspace = self.provider.keyspace(tablet)?;
2787        let _pin = keyspace.pin_snapshot(pin)?;
2788        let mut rows_scanned = 0_u64;
2789        match &job.definition {
2790            DdlDefinition::AddIndex { index_name, .. } => {
2791                let mut sink = self.provider.sink(tablet)?;
2792                let record = self.index_record(job, index_name)?;
2793                sink.begin_build(index_name)?;
2794                for (key, value) in keyspace.snapshot_at(pin)? {
2795                    let entry = (self.projection)(&record, &key, &value);
2796                    sink.stage_entry(index_name, &key, &entry)?;
2797                    rows_scanned += 1;
2798                }
2799                sink.install_staged(index_name)?;
2800            }
2801            DdlDefinition::AlterSchema { .. } => {
2802                // The engine's column backfill lands with the server wave;
2803                // this wave scans the pinned snapshot per tablet so the phase
2804                // machine and resume cursors carry real per-tablet progress.
2805                for (_key, _value) in keyspace.snapshot_at(pin)? {
2806                    rows_scanned += 1;
2807                }
2808            }
2809            DdlDefinition::DropIndex { .. } => {
2810                return Err(DdlRejection::Meta(MetaRejectionReason::Invalid {
2811                    reason: "drop jobs never backfill".to_owned(),
2812                })
2813                .into());
2814            }
2815        }
2816        let watermark = self.catch_up_tablet(job, tablet, pin)?;
2817        Ok(TabletDdlProgress {
2818            tablet_id: tablet,
2819            stage: TabletDdlStage::CaughtUp,
2820            rows_scanned,
2821            caught_up_through: Some(watermark),
2822            validation: None,
2823        })
2824    }
2825
2826    /// Spec step 4: sweeps committed deltas from `from` forward until a sweep
2827    /// applies nothing. Each non-empty sweep strictly advances the watermark,
2828    /// so the loop terminates; writes racing the sweep are picked up by the
2829    /// next one (or by the apply-path maintainer past the final watermark).
2830    fn catch_up_tablet(
2831        &self,
2832        job: &DdlJobRecord,
2833        tablet: TabletId,
2834        from: HlcTimestamp,
2835    ) -> Result<HlcTimestamp, DdlError> {
2836        let keyspace = self.provider.keyspace(tablet)?;
2837        let mut sink = match &job.definition {
2838            DdlDefinition::AddIndex { index_name, .. } => {
2839                Some((self.provider.sink(tablet)?, index_name.clone()))
2840            }
2841            _ => None,
2842        };
2843        let mut watermark = from;
2844        loop {
2845            let mut saw_any = false;
2846            let mut max_ts = watermark;
2847            for (ts, key, value) in keyspace.deltas_after(watermark)? {
2848                saw_any = true;
2849                max_ts = max_ts.max(ts);
2850                if let Some((sink, index_name)) = &mut sink {
2851                    let entry = match &value {
2852                        Some(value) => {
2853                            let record = self.index_record(job, index_name)?;
2854                            Some((self.projection)(&record, &key, value))
2855                        }
2856                        None => None,
2857                    };
2858                    sink.apply_delta(index_name, &key, entry.as_deref())?;
2859                }
2860            }
2861            if !saw_any {
2862                return Ok(watermark);
2863            }
2864            watermark = max_ts;
2865        }
2866    }
2867
2868    /// Spec step 5 for one tablet: a final catch-up sweep, then the built
2869    /// generation against a from-scratch build at the same watermark.
2870    fn validate_tablet(
2871        &self,
2872        job: &DdlJobRecord,
2873        tablet: TabletId,
2874    ) -> Result<TabletValidationReport, DdlError> {
2875        let pin = job.pinned_snapshot.expect("Validating implies a pin");
2876        let from = job
2877            .tablet_progress
2878            .get(&tablet)
2879            .and_then(|progress| progress.caught_up_through)
2880            .unwrap_or(pin);
2881        let watermark = self.catch_up_tablet(job, tablet, from)?;
2882        let keyspace = self.provider.keyspace(tablet)?;
2883        let expected: BTreeMap<Key, Vec<u8>> = match &job.definition {
2884            DdlDefinition::AddIndex { index_name, .. } => {
2885                let record = self.index_record(job, index_name)?;
2886                keyspace
2887                    .snapshot_at(watermark)?
2888                    .map(|(key, value)| {
2889                        let entry = (self.projection)(&record, &key, &value);
2890                        (key, entry)
2891                    })
2892                    .collect()
2893            }
2894            // AlterSchema validates scan completeness at the watermark until
2895            // the engine's column backfill lands (documented module scope).
2896            _ => keyspace.snapshot_at(watermark)?.collect(),
2897        };
2898        let actual = match &job.definition {
2899            DdlDefinition::AddIndex { index_name, .. } => {
2900                self.provider.sink(tablet)?.generation_entries(index_name)?
2901            }
2902            _ => expected.clone(),
2903        };
2904        Ok(TabletValidationReport {
2905            tablet_id: tablet,
2906            watermark,
2907            expected_rows: expected.len() as u64,
2908            actual_rows: actual.len() as u64,
2909            expected_checksum: generation_checksum(&expected),
2910            actual_checksum: generation_checksum(&actual),
2911        })
2912    }
2913
2914    /// The unwind for cancellation and validation failure: drop the hidden
2915    /// generations on every tablet, remove the unpublished index record, and
2916    /// fail the job. Idempotent; a `RollingBack` job (crash mid-rollback)
2917    /// re-enters here.
2918    fn rollback(&self, job: &DdlJobRecord, reason: &str) -> Result<(), DdlError> {
2919        let current = self.job_record(job.job_id)?;
2920        match current.state {
2921            SchemaJobState::Failed => return Ok(()),
2922            SchemaJobState::Succeeded => {
2923                return Err(DdlRejection::Meta(MetaRejectionReason::Conflict {
2924                    resource: format!("DDL job {}", job.job_id),
2925                    reason: "cannot roll back a succeeded job".to_owned(),
2926                })
2927                .into());
2928            }
2929            SchemaJobState::Running | SchemaJobState::Cancelling => {
2930                self.set_state(
2931                    job.job_id,
2932                    SchemaJobState::RollingBack,
2933                    Some(reason.to_owned()),
2934                )?;
2935            }
2936            SchemaJobState::Pending | SchemaJobState::Paused | SchemaJobState::RollingBack => {}
2937        }
2938        if let DdlDefinition::AddIndex { index_name, .. } = &job.definition {
2939            for tablet in self.provider.tablets_of(job.table_id) {
2940                self.provider.sink(tablet)?.drop_generation(index_name)?;
2941            }
2942            self.apply(DdlCommand::RemoveIndexRecord { job_id: job.job_id })?;
2943            self.sync_maintainers(job.table_id)?;
2944        }
2945        self.set_state(job.job_id, SchemaJobState::Failed, Some(reason.to_owned()))
2946    }
2947
2948    // -- Store helpers --------------------------------------------------------
2949
2950    fn apply(&self, command: DdlCommand) -> Result<(), DdlError> {
2951        let commit_ts = self.clock.now()?;
2952        self.store
2953            .lock()
2954            .expect("store lock poisoned")
2955            .apply(&command, None, commit_ts)
2956            .map_err(DdlError::from)
2957    }
2958
2959    fn job_record(&self, job_id: u64) -> Result<DdlJobRecord, DdlError> {
2960        self.store
2961            .lock()
2962            .expect("store lock poisoned")
2963            .job(job_id)
2964            .cloned()
2965            .ok_or_else(|| {
2966                DdlRejection::Meta(MetaRejectionReason::NotFound {
2967                    resource: format!("DDL job {job_id}"),
2968                })
2969                .into()
2970            })
2971    }
2972
2973    fn index_record(
2974        &self,
2975        job: &DdlJobRecord,
2976        index_name: &str,
2977    ) -> Result<DdlIndexRecord, DdlError> {
2978        self.store
2979            .lock()
2980            .expect("store lock poisoned")
2981            .index(job.table_id, index_name)
2982            .cloned()
2983            .ok_or_else(|| {
2984                DdlRejection::Meta(MetaRejectionReason::NotFound {
2985                    resource: format!("index `{index_name}` on table {}", job.table_id),
2986                })
2987                .into()
2988            })
2989    }
2990
2991    fn peek_next_job_id(&self) -> u64 {
2992        self.store.lock().expect("store lock poisoned").next_job_id
2993    }
2994
2995    fn set_state(
2996        &self,
2997        job_id: u64,
2998        state: SchemaJobState,
2999        error: Option<String>,
3000    ) -> Result<(), DdlError> {
3001        let updated_at = self.clock.now()?;
3002        self.apply(DdlCommand::SetJobState {
3003            job_id,
3004            state,
3005            updated_at,
3006            error,
3007            expected_version: None,
3008        })
3009    }
3010
3011    fn advance_phase(
3012        &self,
3013        job_id: u64,
3014        to: DdlPhase,
3015        pinned_snapshot: Option<HlcTimestamp>,
3016    ) -> Result<(), DdlError> {
3017        self.apply(DdlCommand::AdvancePhase {
3018            job_id,
3019            to,
3020            pinned_snapshot,
3021            expected_version: None,
3022        })
3023    }
3024
3025    fn update_progress(&self, job_id: u64, progress: TabletDdlProgress) -> Result<(), DdlError> {
3026        self.apply(DdlCommand::UpdateTabletProgress { job_id, progress })
3027    }
3028
3029    /// Pushes the table's maintained definition set to every tablet's
3030    /// apply-path maintainer (spec step 2's "tablet apply sees WriteOnly
3031    /// index defs for its table").
3032    fn sync_maintainers(&self, table_id: TableId) -> Result<(), DdlError> {
3033        let names: Vec<String> = self
3034            .store
3035            .lock()
3036            .expect("store lock poisoned")
3037            .write_maintained(table_id)
3038            .iter()
3039            .map(|record| record.index_name.clone())
3040            .collect();
3041        for tablet in self.provider.tablets_of(table_id) {
3042            self.provider
3043                .maintainer(tablet)?
3044                .sync_definitions(names.clone())?;
3045        }
3046        Ok(())
3047    }
3048}
3049
3050// ---------------------------------------------------------------------------
3051// Tests
3052// ---------------------------------------------------------------------------
3053
3054#[cfg(test)]
3055mod tests {
3056    use super::*;
3057
3058    const TABLE: u64 = 1;
3059
3060    fn ts(micros: u64) -> HlcTimestamp {
3061        HlcTimestamp {
3062            physical_micros: micros,
3063            logical: 0,
3064            node_tiebreaker: 0,
3065        }
3066    }
3067
3068    /// A commit timestamp `n` micros after the job's pinned snapshot.
3069    fn after(pin: HlcTimestamp, n: u64) -> HlcTimestamp {
3070        HlcTimestamp {
3071            physical_micros: pin.physical_micros + n,
3072            logical: 0,
3073            node_tiebreaker: 0,
3074        }
3075    }
3076
3077    fn tablet(n: u8) -> TabletId {
3078        TabletId::from_bytes([n; 16])
3079    }
3080
3081    fn database() -> DatabaseId {
3082        DatabaseId::from_bytes([7; 16])
3083    }
3084
3085    fn table() -> TableId {
3086        TableId(TABLE)
3087    }
3088
3089    fn key(n: u64) -> Key {
3090        Key::from_bytes(n.to_be_bytes().to_vec())
3091    }
3092
3093    fn value(n: u64) -> Vec<u8> {
3094        format!("value-{n}").into_bytes()
3095    }
3096
3097    fn spec() -> serde_json::Value {
3098        serde_json::json!({"kind": "Bitmap", "columns": [1]})
3099    }
3100
3101    struct Fixture {
3102        store: Arc<Mutex<DdlJobStore>>,
3103        tablets: InMemoryDdlTablets,
3104        driver: DdlDriver<InMemoryDdlTablets>,
3105    }
3106
3107    impl Fixture {
3108        /// A store with table 1 anchored at schema version 1, `tablet_count`
3109        /// tablets each seeded with `rows_per_tablet` rows at ts(1..=rows).
3110        fn new(tablet_count: u8, rows_per_tablet: u64) -> Self {
3111            let store = Arc::new(Mutex::new(DdlJobStore::default()));
3112            let tablets = InMemoryDdlTablets::new();
3113            for n in 1..=tablet_count {
3114                tablets.add_tablet(tablet(n), table());
3115                let keyspace = tablets.keyspace_handle(tablet(n));
3116                for row in 1..=rows_per_tablet {
3117                    keyspace.insert(key(row), ts(row), value(row));
3118                }
3119            }
3120            let driver = DdlDriver::new(store.clone(), tablets.clone());
3121            driver
3122                .apply(DdlCommand::RegisterTable {
3123                    anchor: TableAnchor {
3124                        table_id: table(),
3125                        database_id: database(),
3126                        schema_version: SchemaVersion(1),
3127                        schema: serde_json::json!({"columns": ["id", "v"]}),
3128                        metadata_version: MetadataVersion::ZERO,
3129                    },
3130                })
3131                .expect("register table");
3132            Self {
3133                store,
3134                tablets,
3135                driver,
3136            }
3137        }
3138
3139        fn store(&self) -> std::sync::MutexGuard<'_, DdlJobStore> {
3140            self.store.lock().expect("store lock poisoned")
3141        }
3142
3143        fn submit_add(&self, index_name: &str) -> Result<u64, DdlError> {
3144            self.driver
3145                .submit_add_index(database(), table(), index_name, spec(), SchemaVersion(1))
3146        }
3147
3148        fn pin_of(&self, job_id: u64) -> HlcTimestamp {
3149            self.store()
3150                .job(job_id)
3151                .and_then(|job| job.pinned_snapshot)
3152                .expect("job is pinned")
3153        }
3154
3155        /// Drives the job until `step` returns true, returning the steps it
3156        /// took (fails after a bound of steps to surface livelocks).
3157        fn drive_until(
3158            &self,
3159            job_id: u64,
3160            mut stop: impl FnMut(DriveStep) -> bool,
3161        ) -> Vec<DriveStep> {
3162            let mut steps = Vec::new();
3163            for _ in 0..64 {
3164                let step = self.driver.step_job(job_id).expect("step");
3165                steps.push(step);
3166                if stop(step) || matches!(step, DriveStep::Terminal | DriveStep::Parked) {
3167                    return steps;
3168                }
3169            }
3170            panic!("job {job_id} did not reach the expected step");
3171        }
3172
3173        fn drive_to_backfilled(&self, job_id: u64, count: usize) {
3174            let mut backfilled = 0_usize;
3175            self.drive_until(job_id, |step| {
3176                if matches!(step, DriveStep::TabletBackfilled { .. }) {
3177                    backfilled += 1;
3178                }
3179                backfilled == count
3180            });
3181        }
3182    }
3183
3184    #[test]
3185    fn add_index_full_lifecycle_over_three_tablets() {
3186        let fixture = Fixture::new(3, 5);
3187        let job_id = fixture.submit_add("idx_a").expect("submit");
3188
3189        // Spec step 1-2: the definition is replicated WriteOnly — maintained
3190        // by the write path, hidden from planners.
3191        let steps = fixture.drive_until(job_id, |step| {
3192            matches!(
3193                step,
3194                DriveStep::PhaseAdvanced {
3195                    from: DdlPhase::Pending,
3196                    to: DdlPhase::WriteOnly,
3197                }
3198            )
3199        });
3200        assert_eq!(steps.first(), Some(&DriveStep::Admitted));
3201        assert_eq!(fixture.store().write_maintained(table()).len(), 1);
3202        assert!(fixture.store().planner_visible(table()).is_empty());
3203        for n in 1..=3 {
3204            assert_eq!(
3205                fixture.tablets.indexes_handle(tablet(n)).maintained(),
3206                ["idx_a"]
3207            );
3208        }
3209
3210        // Spec step 3: the job-wide snapshot is pinned.
3211        fixture.drive_until(job_id, |step| {
3212            matches!(
3213                step,
3214                DriveStep::PhaseAdvanced {
3215                    from: DdlPhase::WriteOnly,
3216                    to: DdlPhase::Backfilling,
3217                }
3218            )
3219        });
3220        let pin = fixture.pin_of(job_id);
3221
3222        // Writes keep arriving. One lands before tablet 1's generation
3223        // install (the catch-up sweep must cover it); one lands after (the
3224        // apply-path maintainer must cover it); one delete exercises
3225        // tombstones on both paths.
3226        fixture
3227            .tablets
3228            .commit_write(tablet(1), key(100), after(pin, 1), Some(value(100)))
3229            .expect("write 100");
3230        fixture.drive_to_backfilled(job_id, 1);
3231        assert!(fixture
3232            .tablets
3233            .indexes_handle(tablet(1))
3234            .is_installed("idx_a"));
3235        fixture
3236            .tablets
3237            .commit_write(tablet(1), key(102), after(pin, 3), Some(value(102)))
3238            .expect("write 102");
3239        // Dual maintenance applied write 102 immediately.
3240        assert_eq!(
3241            fixture
3242                .tablets
3243                .indexes_handle(tablet(1))
3244                .entries("idx_a")
3245                .len(),
3246            7,
3247        );
3248        fixture
3249            .tablets
3250            .commit_write(tablet(2), key(101), after(pin, 2), Some(value(101)))
3251            .expect("write 101");
3252        fixture
3253            .tablets
3254            .commit_write(tablet(1), key(1), after(pin, 4), None)
3255            .expect("delete 1");
3256        fixture.drive_to_backfilled(job_id, 2);
3257
3258        // Spec step 5-6: validation, then the atomic publication.
3259        let steps = fixture.drive_until(job_id, |step| matches!(step, DriveStep::Published { .. }));
3260        let publication_version = steps.iter().find_map(|step| match step {
3261            DriveStep::Published { version } => Some(*version),
3262            _ => None,
3263        });
3264        let publication_version = publication_version.expect("published");
3265        let store = fixture.store();
3266        let job = store.job(job_id).expect("job");
3267        assert_eq!(job.state, SchemaJobState::Succeeded);
3268        assert_eq!(job.phase, DdlPhase::Public);
3269        let record = store.index(table(), "idx_a").expect("index record");
3270        assert_eq!(record.phase, DdlPhase::Public);
3271        assert_eq!(record.publication_version, Some(publication_version));
3272        // The planner flips at exactly one metadata version.
3273        assert!(store
3274            .planner_visible_at(table(), MetadataVersion(publication_version.get() - 1))
3275            .is_empty());
3276        assert_eq!(
3277            store.planner_visible_at(table(), publication_version).len(),
3278            1
3279        );
3280        drop(store);
3281
3282        // Spec step 5's aggregate report: per-tablet counts and checksums of
3283        // backfill+catch-up equal a from-scratch build.
3284        let report = fixture
3285            .driver
3286            .validation_report(job_id)
3287            .expect("validation report");
3288        assert!(report.passed);
3289        assert_eq!(report.tablets.len(), 3);
3290        assert_eq!(report.total_expected, report.total_actual);
3291        let per_tablet: BTreeMap<TabletId, &TabletValidationReport> = report
3292            .tablets
3293            .iter()
3294            .map(|tablet_report| (tablet_report.tablet_id, tablet_report))
3295            .collect();
3296        assert_eq!(per_tablet[&tablet(1)].expected_rows, 6); // 5 seeded - 1 deleted + 2 written
3297        assert_eq!(per_tablet[&tablet(2)].expected_rows, 6);
3298        assert_eq!(per_tablet[&tablet(3)].expected_rows, 5);
3299
3300        // Direct content equality on tablet 1: built generation == projected
3301        // from-scratch rows at the validation watermark.
3302        let watermark = per_tablet[&tablet(1)].watermark;
3303        let expected = fixture
3304            .tablets
3305            .keyspace_handle(tablet(1))
3306            .rows_at(watermark);
3307        let actual = fixture.tablets.indexes_handle(tablet(1)).entries("idx_a");
3308        assert_eq!(actual, expected);
3309
3310        // Status reporting aggregates the durable cursors.
3311        let status = fixture.driver.job_status(job_id).expect("status");
3312        assert_eq!(status.tablets_registered, 3);
3313        assert_eq!(status.tablets_caught_up, 3);
3314        assert_eq!(status.tablets_validated, 3);
3315        assert_eq!(status.rows_backfilled, 15);
3316    }
3317
3318    #[test]
3319    fn catch_up_covers_pre_install_writes_and_tombstones() {
3320        let fixture = Fixture::new(1, 3);
3321        let job_id = fixture.submit_add("idx_t").expect("submit");
3322        fixture.drive_until(job_id, |step| {
3323            matches!(
3324                step,
3325                DriveStep::PhaseAdvanced {
3326                    from: DdlPhase::WriteOnly,
3327                    to: DdlPhase::Backfilling,
3328                }
3329            )
3330        });
3331        let pin = fixture.pin_of(job_id);
3332        // Write-then-delete a fresh key and delete a seeded key, all before
3333        // the generation is installed: the maintainer skips them, so the
3334        // delta sweep must cover them exactly once in effect.
3335        fixture
3336            .tablets
3337            .commit_write(tablet(1), key(50), after(pin, 1), Some(value(50)))
3338            .expect("write 50");
3339        fixture
3340            .tablets
3341            .commit_write(tablet(1), key(50), after(pin, 2), None)
3342            .expect("delete 50");
3343        fixture
3344            .tablets
3345            .commit_write(tablet(1), key(2), after(pin, 3), None)
3346            .expect("delete 2");
3347        assert_eq!(
3348            fixture.driver.drive_job(job_id).expect("drive"),
3349            DriveOutcome::Completed
3350        );
3351
3352        let report = fixture.driver.validation_report(job_id).expect("report");
3353        assert!(report.passed);
3354        assert_eq!(report.total_expected, 2); // keys 1 and 3 survive
3355        let actual = fixture.tablets.indexes_handle(tablet(1)).entries("idx_t");
3356        assert_eq!(actual.len(), 2);
3357        assert!(!actual.contains_key(&key(2)));
3358        assert!(!actual.contains_key(&key(50)));
3359    }
3360
3361    #[test]
3362    fn atomic_publish_flips_planner_visibility_at_one_metadata_version() {
3363        let fixture = Fixture::new(1, 2);
3364        let job_id = fixture.submit_add("idx_v").expect("submit");
3365        let before = fixture.store().metadata_version;
3366        assert_eq!(
3367            fixture.driver.drive_job(job_id).expect("drive"),
3368            DriveOutcome::Completed
3369        );
3370        let store = fixture.store();
3371        let publication = store
3372            .index(table(), "idx_v")
3373            .and_then(|record| record.publication_version)
3374            .expect("publication version");
3375        assert!(publication > before);
3376        assert!(
3377            store
3378                .planner_visible_at(table(), MetadataVersion(publication.get() - 1))
3379                .is_empty(),
3380            "hidden before the publication version"
3381        );
3382        let visible = store.planner_visible_at(table(), publication);
3383        assert_eq!(visible.len(), 1);
3384        assert_eq!(visible[0].index_name, "idx_v");
3385    }
3386
3387    #[test]
3388    fn stale_schema_returns_structured_retry() {
3389        let fixture = Fixture::new(1, 2);
3390        let job_id = fixture.submit_add("idx_s").expect("submit");
3391        // Drive to the validation boundary, then move the table's schema
3392        // (models a concurrent alter landing mid-job).
3393        fixture.drive_until(job_id, |step| {
3394            matches!(
3395                step,
3396                DriveStep::PhaseAdvanced {
3397                    from: DdlPhase::Backfilling,
3398                    to: DdlPhase::Validating,
3399                }
3400            )
3401        });
3402        fixture
3403            .driver
3404            .apply(DdlCommand::RegisterTable {
3405                anchor: TableAnchor {
3406                    table_id: table(),
3407                    database_id: database(),
3408                    schema_version: SchemaVersion(2),
3409                    schema: serde_json::json!({"columns": ["id", "v", "w"]}),
3410                    metadata_version: MetadataVersion::ZERO,
3411                },
3412            })
3413            .expect("schema bump");
3414        let error = fixture
3415            .driver
3416            .drive_job(job_id)
3417            .expect_err("publish must refuse the stale schema");
3418        assert_eq!(error.category(), ErrorCategory::SchemaVersionMismatch);
3419        match &error {
3420            DdlError::Rejection(DdlRejection::SchemaVersionMismatch {
3421                table_id,
3422                expected,
3423                found,
3424            }) => {
3425                assert_eq!(*table_id, table());
3426                assert_eq!(*expected, SchemaVersion(1));
3427                assert_eq!(*found, SchemaVersion(2));
3428            }
3429            other => panic!("expected SchemaVersionMismatch, got {other:?}"),
3430        }
3431        // The failed job unwound: hidden generation dropped, record removed.
3432        let store = fixture.store();
3433        let job = store.job(job_id).expect("job");
3434        assert_eq!(job.state, SchemaJobState::Failed);
3435        assert!(store.index(table(), "idx_s").is_none());
3436        drop(store);
3437        assert!(!fixture
3438            .tablets
3439            .indexes_handle(tablet(1))
3440            .is_installed("idx_s"));
3441
3442        // Submission against the moved schema fails at submit time too; the
3443        // refreshed resubmission succeeds.
3444        let stale = fixture.driver.submit_add_index(
3445            database(),
3446            table(),
3447            "idx_s2",
3448            spec(),
3449            SchemaVersion(1),
3450        );
3451        assert!(matches!(
3452            stale,
3453            Err(DdlError::Rejection(
3454                DdlRejection::SchemaVersionMismatch { .. }
3455            ))
3456        ));
3457        let job_id = fixture
3458            .driver
3459            .submit_add_index(database(), table(), "idx_s2", spec(), SchemaVersion(2))
3460            .expect("resubmit at the current schema version");
3461        assert_eq!(
3462            fixture.driver.drive_job(job_id).expect("drive"),
3463            DriveOutcome::Completed
3464        );
3465    }
3466
3467    #[test]
3468    fn pause_resume_mid_backfill_resumes_exactly() {
3469        let fixture = Fixture::new(3, 4);
3470        let job_id = fixture.submit_add("idx_p").expect("submit");
3471        fixture.drive_to_backfilled(job_id, 1);
3472        fixture.driver.pause_job(job_id).expect("pause");
3473        assert!(
3474            fixture.driver.pause_job(job_id).is_err(),
3475            "double pause is refused"
3476        );
3477        assert_eq!(
3478            fixture.driver.drive_job(job_id).expect("drive"),
3479            DriveOutcome::Parked
3480        );
3481        assert_eq!(
3482            fixture
3483                .tablets
3484                .indexes_handle(tablet(1))
3485                .begin_build_count(),
3486            1
3487        );
3488        assert_eq!(
3489            fixture
3490                .tablets
3491                .indexes_handle(tablet(2))
3492                .begin_build_count(),
3493            0
3494        );
3495
3496        fixture.driver.resume_job(job_id).expect("resume");
3497        assert_eq!(
3498            fixture.driver.drive_job(job_id).expect("drive"),
3499            DriveOutcome::Completed
3500        );
3501        // Tablet 1 was never rebuilt; the others built exactly once.
3502        assert_eq!(
3503            fixture
3504                .tablets
3505                .indexes_handle(tablet(1))
3506                .begin_build_count(),
3507            1
3508        );
3509        assert_eq!(
3510            fixture
3511                .tablets
3512                .indexes_handle(tablet(2))
3513                .begin_build_count(),
3514            1
3515        );
3516        assert_eq!(
3517            fixture
3518                .tablets
3519                .indexes_handle(tablet(3))
3520                .begin_build_count(),
3521            1
3522        );
3523        assert!(
3524            fixture
3525                .driver
3526                .validation_report(job_id)
3527                .expect("report")
3528                .passed
3529        );
3530        assert!(
3531            fixture.driver.resume_job(job_id).is_err(),
3532            "resume of a terminal job is refused"
3533        );
3534    }
3535
3536    #[test]
3537    fn cancel_unwinds_the_hidden_generation_and_leaves_the_table_unaffected() {
3538        let fixture = Fixture::new(3, 4);
3539        let job_id = fixture.submit_add("idx_c").expect("submit");
3540        fixture.drive_to_backfilled(job_id, 1);
3541        assert!(fixture
3542            .tablets
3543            .indexes_handle(tablet(1))
3544            .is_installed("idx_c"));
3545
3546        fixture.driver.cancel_job(job_id).expect("cancel");
3547        assert_eq!(
3548            fixture.driver.drive_job(job_id).expect("drive"),
3549            DriveOutcome::RolledBack
3550        );
3551        let store = fixture.store();
3552        let job = store.job(job_id).expect("job");
3553        assert_eq!(job.state, SchemaJobState::Failed);
3554        assert_eq!(job.error.as_deref(), Some("cancelled by operator"));
3555        assert!(store.index(table(), "idx_c").is_none());
3556        assert!(store.planner_visible(table()).is_empty());
3557        assert!(store.write_maintained(table()).is_empty());
3558        // The table is unaffected: anchor and rows untouched.
3559        assert_eq!(
3560            store.table_anchor(table()).expect("anchor").schema_version,
3561            SchemaVersion(1)
3562        );
3563        drop(store);
3564        for n in 1..=3 {
3565            let indexes = fixture.tablets.indexes_handle(tablet(n));
3566            assert!(!indexes.is_installed("idx_c"));
3567            assert_eq!(indexes.entries("idx_c").len(), 0);
3568            assert_eq!(indexes.maintained(), Vec::<String>::new());
3569        }
3570        assert_eq!(
3571            fixture
3572                .tablets
3573                .keyspace_handle(tablet(1))
3574                .rows_at(ts(u64::MAX))
3575                .len(),
3576            4
3577        );
3578
3579        // Cancelling a terminal job is refused; a fresh job on the table is
3580        // admitted after the unwind.
3581        let second = fixture.submit_add("idx_c2").expect("submit after unwind");
3582        assert_eq!(
3583            fixture.driver.drive_job(second).expect("drive"),
3584            DriveOutcome::Completed
3585        );
3586        assert!(fixture.driver.cancel_job(second).is_err());
3587    }
3588
3589    #[test]
3590    fn drop_index_lifecycle_reclaims_after_readers_drain() {
3591        let fixture = Fixture::new(3, 3);
3592        let add = fixture.submit_add("idx_d").expect("submit add");
3593        assert_eq!(
3594            fixture.driver.drive_job(add).expect("drive"),
3595            DriveOutcome::Completed
3596        );
3597        let pin = fixture.pin_of(add);
3598        // Public indexes stay maintained.
3599        fixture
3600            .tablets
3601            .commit_write(tablet(1), key(40), after(pin, 1), Some(value(40)))
3602            .expect("maintained write");
3603        assert_eq!(
3604            fixture
3605                .tablets
3606                .indexes_handle(tablet(1))
3607                .entries("idx_d")
3608                .len(),
3609            4
3610        );
3611        let pre_drop_version = fixture.store().metadata_version;
3612
3613        let drop = fixture
3614            .driver
3615            .submit_drop_index(database(), table(), "idx_d", SchemaVersion(1))
3616            .expect("submit drop");
3617        assert_eq!(
3618            fixture.driver.drive_job(drop).expect("drive"),
3619            DriveOutcome::Completed
3620        );
3621        let dropping_since = {
3622            let store = fixture.store();
3623            let record = store.index(table(), "idx_d").expect("record");
3624            assert_eq!(record.phase, DdlPhase::Dropping);
3625            assert_eq!(
3626                store.job(drop).expect("job").state,
3627                SchemaJobState::Succeeded
3628            );
3629            // The planner stopped using it; an older plan still sees it.
3630            assert!(store.planner_visible(table()).is_empty());
3631            assert_eq!(store.planner_visible_at(table(), pre_drop_version).len(), 1);
3632            record.dropping_since.expect("dropping version")
3633        };
3634        assert!(dropping_since > pre_drop_version);
3635        // Writes stopped maintaining it.
3636        assert_eq!(
3637            fixture.tablets.indexes_handle(tablet(1)).maintained(),
3638            Vec::<String>::new()
3639        );
3640        fixture
3641            .tablets
3642            .commit_write(tablet(1), key(41), after(pin, 2), Some(value(41)))
3643            .expect("write after drop");
3644        assert_eq!(
3645            fixture
3646                .tablets
3647                .indexes_handle(tablet(1))
3648                .entries("idx_d")
3649                .len(),
3650            4
3651        );
3652
3653        // Reclamation is gated on readers below the retirement version.
3654        fixture
3655            .driver
3656            .pin_reader(1, pre_drop_version)
3657            .expect("pin reader");
3658        let blocked = fixture
3659            .driver
3660            .reclaim_index(table(), "idx_d")
3661            .expect("reclaim attempt");
3662        assert_eq!(
3663            blocked,
3664            ReclaimOutcome::Blocked {
3665                oldest_reader: Some(pre_drop_version),
3666                required: dropping_since,
3667            }
3668        );
3669        let forward_version = fixture.store().metadata_version;
3670        fixture
3671            .driver
3672            .pin_reader(1, forward_version)
3673            .expect("reader moves forward");
3674        assert_eq!(
3675            fixture
3676                .driver
3677                .reclaim_index(table(), "idx_d")
3678                .expect("reclaim"),
3679            ReclaimOutcome::Reclaimed
3680        );
3681        assert!(fixture.store().index(table(), "idx_d").is_none());
3682        for n in 1..=3 {
3683            assert!(!fixture
3684                .tablets
3685                .indexes_handle(tablet(n))
3686                .is_installed("idx_d"));
3687        }
3688        // A backward reader pin is refused.
3689        fixture
3690            .driver
3691            .pin_reader(2, dropping_since)
3692            .expect("pin reader 2");
3693        assert!(fixture.driver.pin_reader(2, pre_drop_version).is_err());
3694    }
3695
3696    #[test]
3697    fn per_tablet_progress_survives_a_driver_crash() {
3698        let fixture = Fixture::new(3, 4);
3699        let job_id = fixture.submit_add("idx_x").expect("submit");
3700        fixture.drive_to_backfilled(job_id, 1);
3701        let pin = fixture.pin_of(job_id);
3702        let crashed = fixture.driver;
3703        // The crashed driver also left an interrupted staged build behind on
3704        // tablet 2; the resumed driver must discard it via begin_build.
3705        fixture.tablets.indexes_handle(tablet(2)).seed_staged(
3706            "idx_x",
3707            key(999),
3708            b"garbage".to_vec(),
3709        );
3710        // A committed write lands while no driver runs.
3711        fixture
3712            .tablets
3713            .commit_write(tablet(2), key(60), after(pin, 1), Some(value(60)))
3714            .expect("write during outage");
3715        drop(crashed);
3716
3717        let resumed = DdlDriver::new(fixture.store.clone(), fixture.tablets.clone());
3718        assert_eq!(
3719            resumed.drive_job(job_id).expect("drive"),
3720            DriveOutcome::Completed
3721        );
3722        // Tablet 1's durable cursor skipped its rebuild; tablet 2's staged
3723        // garbage was discarded and rebuilt correctly.
3724        assert_eq!(
3725            fixture
3726                .tablets
3727                .indexes_handle(tablet(1))
3728                .begin_build_count(),
3729            1
3730        );
3731        assert_eq!(
3732            fixture
3733                .tablets
3734                .indexes_handle(tablet(2))
3735                .begin_build_count(),
3736            1
3737        );
3738        assert!(!fixture
3739            .tablets
3740            .indexes_handle(tablet(2))
3741            .entries("idx_x")
3742            .contains_key(&key(999)));
3743        assert_eq!(
3744            fixture
3745                .tablets
3746                .indexes_handle(tablet(2))
3747                .entries("idx_x")
3748                .len(),
3749            5
3750        );
3751        assert!(resumed.validation_report(job_id).expect("report").passed);
3752    }
3753
3754    #[test]
3755    fn phase_graph_is_enforced() {
3756        let edges: [(DdlPhase, DdlPhase); 5] = [
3757            (DdlPhase::Pending, DdlPhase::WriteOnly),
3758            (DdlPhase::WriteOnly, DdlPhase::Backfilling),
3759            (DdlPhase::Backfilling, DdlPhase::Validating),
3760            (DdlPhase::Validating, DdlPhase::Public),
3761            (DdlPhase::Public, DdlPhase::Dropping),
3762        ];
3763        for from in DdlPhase::ALL {
3764            for to in DdlPhase::ALL {
3765                assert_eq!(
3766                    from.can_transition(to),
3767                    edges.contains(&(from, to)),
3768                    "{from} -> {to}",
3769                );
3770            }
3771        }
3772
3773        // The store refuses off-graph and bypass-PublishJob moves.
3774        let fixture = Fixture::new(1, 1);
3775        let job_id = fixture.submit_add("idx_g").expect("submit");
3776        fixture.drive_until(job_id, |step| matches!(step, DriveStep::Admitted));
3777        let skipped = fixture
3778            .driver
3779            .advance_phase(job_id, DdlPhase::Validating, None);
3780        assert!(matches!(
3781            skipped,
3782            Err(DdlError::Rejection(DdlRejection::IllegalPhaseTransition {
3783                from: DdlPhase::Pending,
3784                to: DdlPhase::Validating,
3785                ..
3786            }))
3787        ));
3788        fixture.drive_to_backfilled(job_id, 1);
3789        fixture.drive_until(job_id, |step| {
3790            matches!(
3791                step,
3792                DriveStep::PhaseAdvanced {
3793                    from: DdlPhase::Backfilling,
3794                    to: DdlPhase::Validating,
3795                }
3796            )
3797        });
3798        let bypass = fixture.driver.advance_phase(job_id, DdlPhase::Public, None);
3799        assert!(
3800            matches!(
3801                bypass,
3802                Err(DdlError::Rejection(DdlRejection::Meta(
3803                    MetaRejectionReason::Invalid { .. }
3804                )))
3805            ),
3806            "Public rides PublishJob: {bypass:?}",
3807        );
3808    }
3809
3810    #[test]
3811    fn admin_state_graph_is_enforced() {
3812        let fixture = Fixture::new(1, 1);
3813        let job_id = fixture.submit_add("idx_a").expect("submit");
3814        assert!(
3815            fixture.driver.pause_job(job_id).is_err(),
3816            "Pending cannot pause"
3817        );
3818        assert!(
3819            fixture.driver.resume_job(job_id).is_err(),
3820            "Pending cannot resume"
3821        );
3822        fixture
3823            .driver
3824            .cancel_job(job_id)
3825            .expect("cancel from Pending");
3826        assert_eq!(
3827            fixture.driver.drive_job(job_id).expect("drive"),
3828            DriveOutcome::RolledBack
3829        );
3830        let job = fixture.store().job(job_id).expect("job").clone();
3831        assert_eq!(job.state, SchemaJobState::Failed);
3832        assert!(
3833            fixture.driver.cancel_job(job_id).is_err(),
3834            "terminal jobs refuse cancel"
3835        );
3836    }
3837
3838    #[test]
3839    fn alter_schema_publishes_the_next_schema_version_atomically() {
3840        let fixture = Fixture::new(2, 3);
3841        let target = serde_json::json!({"columns": ["id", "v", "w"]});
3842        let job_id = fixture
3843            .driver
3844            .submit_alter_schema(database(), table(), target.clone(), SchemaVersion(1))
3845            .expect("submit alter");
3846        assert_eq!(
3847            fixture.driver.drive_job(job_id).expect("drive"),
3848            DriveOutcome::Completed
3849        );
3850        let store = fixture.store();
3851        let anchor = store.table_anchor(table()).expect("anchor");
3852        assert_eq!(anchor.schema_version, SchemaVersion(2));
3853        assert_eq!(anchor.schema, target);
3854        let job = store.job(job_id).expect("job");
3855        assert_eq!(job.state, SchemaJobState::Succeeded);
3856        assert_eq!(job.phase, DdlPhase::Public);
3857        assert_eq!(job.tablet_progress.len(), 2);
3858        drop(store);
3859
3860        // The old schema version is now stale for new jobs; the new one works.
3861        let stale = fixture.submit_add("idx_old");
3862        assert!(matches!(
3863            stale,
3864            Err(DdlError::Rejection(
3865                DdlRejection::SchemaVersionMismatch { .. }
3866            ))
3867        ));
3868        let fresh = fixture
3869            .driver
3870            .submit_add_index(database(), table(), "idx_new", spec(), SchemaVersion(2))
3871            .expect("submit at v2");
3872        assert_eq!(
3873            fixture.driver.drive_job(fresh).expect("drive"),
3874            DriveOutcome::Completed
3875        );
3876    }
3877
3878    #[test]
3879    fn topology_change_mid_backfill_is_reconciled() {
3880        let fixture = Fixture::new(3, 3);
3881        let job_id = fixture.submit_add("idx_t").expect("submit");
3882        fixture.drive_to_backfilled(job_id, 1);
3883        // A split adds tablet 4; a merge retires tablet 3.
3884        fixture.tablets.add_tablet(tablet(4), table());
3885        let keyspace = fixture.tablets.keyspace_handle(tablet(4));
3886        for row in 1..=3 {
3887            keyspace.insert(key(row + 100), ts(row), value(row + 100));
3888        }
3889        fixture.tablets.remove_tablet(tablet(3));
3890        assert_eq!(
3891            fixture.driver.drive_job(job_id).expect("drive"),
3892            DriveOutcome::Completed
3893        );
3894        let status = fixture.driver.job_status(job_id).expect("status");
3895        assert_eq!(status.tablets_registered, 3);
3896        assert!(status.record.tablet_progress.contains_key(&tablet(4)));
3897        assert!(!status.record.tablet_progress.contains_key(&tablet(3)));
3898        assert!(
3899            fixture
3900                .driver
3901                .validation_report(job_id)
3902                .expect("report")
3903                .passed
3904        );
3905    }
3906
3907    #[test]
3908    fn conflicting_and_duplicate_submissions_are_refused() {
3909        let fixture = Fixture::new(1, 2);
3910        let job_id = fixture.submit_add("idx_1").expect("submit");
3911        // One active job per table.
3912        let concurrent = fixture.submit_add("idx_2");
3913        assert!(matches!(
3914            concurrent,
3915            Err(DdlError::Rejection(DdlRejection::Meta(
3916                MetaRejectionReason::Conflict { .. }
3917            )))
3918        ));
3919        assert_eq!(
3920            fixture.driver.drive_job(job_id).expect("drive"),
3921            DriveOutcome::Completed
3922        );
3923        // A completed index name cannot be re-added.
3924        let duplicate = fixture.submit_add("idx_1");
3925        assert!(matches!(
3926            duplicate,
3927            Err(DdlError::Rejection(DdlRejection::Meta(
3928                MetaRejectionReason::Conflict { .. }
3929            )))
3930        ));
3931        // Dropping a missing index fails closed.
3932        let missing =
3933            fixture
3934                .driver
3935                .submit_drop_index(database(), table(), "idx_missing", SchemaVersion(1));
3936        assert!(matches!(
3937            missing,
3938            Err(DdlError::Rejection(DdlRejection::Meta(
3939                MetaRejectionReason::NotFound { .. }
3940            )))
3941        ));
3942        // Identical command replay is idempotent (raft retry).
3943        let job = fixture.store().job(job_id).expect("job").clone();
3944        fixture
3945            .driver
3946            .apply(DdlCommand::SubmitJob { job })
3947            .expect("identical replay is a no-op");
3948        assert_eq!(fixture.store().jobs.len(), 1);
3949    }
3950
3951    #[test]
3952    fn store_roundtrips_through_json() {
3953        let fixture = Fixture::new(2, 3);
3954        let job_id = fixture.submit_add("idx_j").expect("submit");
3955        fixture.drive_to_backfilled(job_id, 1);
3956        let snapshot = {
3957            let store = fixture.store();
3958            serde_json::to_string(&*store).expect("encode")
3959        };
3960        let decoded: DdlJobStore = serde_json::from_str(&snapshot).expect("decode");
3961        assert_eq!(decoded, *fixture.store());
3962    }
3963
3964    #[test]
3965    fn command_roundtrips_through_json() {
3966        let job = DdlJobRecord {
3967            job_id: 9,
3968            database_id: database(),
3969            table_id: table(),
3970            kind: DdlJobKind::AddIndex,
3971            state: SchemaJobState::Pending,
3972            phase: DdlPhase::Pending,
3973            definition: DdlDefinition::AddIndex {
3974                index_name: "idx_serde".to_owned(),
3975                spec: spec(),
3976            },
3977            source_schema_version: SchemaVersion(1),
3978            created_at: ts(10),
3979            updated_at: ts(10),
3980            pinned_snapshot: None,
3981            tablet_progress: BTreeMap::new(),
3982            error: None,
3983            metadata_version: MetadataVersion::ZERO,
3984        };
3985        let command = DdlCommand::SubmitJob { job };
3986        let encoded = serde_json::to_string(&command).expect("encode");
3987        let decoded: DdlCommand = serde_json::from_str(&encoded).expect("decode");
3988        assert_eq!(decoded, command);
3989    }
3990}