Skip to main content

mongreldb_cluster/
merge.rs

1//! Safe tablet merge protocol (spec section 12.6, Stage 3F).
2//!
3//! Two adjacent, compatible tablets become one replacement tablet with zero
4//! data loss, zero duplication, and no routing window in which a key is
5//! unserved or served by two owners. Merge is the mirror of split (spec
6//! section 12.5) and reuses its seams and mechanics:
7//!
8//! ```text
9//! 1. Validate the pair (MergeRejection per violated requirement):
10//!    same table/schema, adjacent ranges, compatible placement,
11//!    no conflicting schema job, combined size under threshold
12//! 2. Meta marks both sources `Merging`            (TabletMetaPlane::set_tablet)
13//! 3. Create the replacement descriptor as learners (Creating; never routable)
14//! 4. Pin both source snapshots at `merge_ts`       (TabletKeyspace::pin_snapshot)
15//! 5. Build the replacement state (staged build, atomic install)
16//! 6. Stream/catch up both sources' deltas
17//! 7. Routing publication barrier (the phase machine)
18//! 8. Publish replacement Active + sources Retiring atomically
19//!    (MergePublishCommand, ONE meta command)
20//! 9. Redirect stale requests (check_generation + crate::split::retry_guidance)
21//! 10. Retain sources while old-generation pins remain (SourceRetentionGuard)
22//! 11. Remove source replicas (Retired + TabletLayout::teardown)
23//! ```
24//!
25//! The replacement is the spec's *hidden replacement*: the sources keep
26//! serving (`Merging` is routable) until the atomic publication flips
27//! routing to the `Active` replacement in one meta command.
28//!
29//! # Crash safety
30//!
31//! Identical to split: every step is idempotent and progress persists after
32//! each phase in a versioned, checksummed `merge.json` inside the *lower*
33//! source's tablet directory. [`MergeExecutor::resume`] reloads it and
34//! continues from the persisted phase; completion removes the record with
35//! the lower source's teardown. Fault hooks (registered in the
36//! `mongreldb-fault` catalog): `tablet.merge.before` / `tablet.merge.after`
37//! bracket the atomic publication, and `tablet.merge.phase.1` ..=
38//! `tablet.merge.phase.7` fire after each phase's durable record
39//! ([`MergePhase`] declaration order).
40//!
41//! # Generation rules
42//!
43//! With `g1`, `g2` the pre-merge source generations and
44//! `m = max(g1, g2)`: the sources are marked `Merging` at `g1 + 1` and
45//! `g2 + 1`; the replacement is created `Creating` at `m + 1`; the atomic
46//! publication assigns one command-wide generation `p = m + 2` to the
47//! replacement (`Active`) and both sources (`Retiring`) — a source whose
48//! generation lags jumps to `p` (a generation is a version, not a count of
49//! the tablet's own publications). Source removal publishes
50//! `Retiring -> Retired` at `p + 1` before deletion. Stale requests
51//! classify as on split: `TabletMoved` against a `Retiring` source,
52//! `StaleMetadata` against the `Active` replacement.
53
54use std::fmt;
55use std::path::PathBuf;
56
57use mongreldb_types::hlc::HlcTimestamp;
58use mongreldb_types::ids::{NodeId, SchemaVersion, TableId, TabletId};
59use serde::{Deserialize, Serialize};
60use sha2::{Digest, Sha256};
61
62use crate::meta::MetaRejectionReason;
63use crate::node::ClusterError;
64use crate::split::{
65    ChildAllocation, ChildPlan, ChildProgress, ChildStateSink, SnapshotPin, SourceRetentionGuard,
66    TabletDataError, TabletKeyspace, TabletMetaPlane, TabletMutation,
67};
68use crate::tablet::{Key, ReplicaRole, TabletDescriptor, TabletError, TabletLayout, TabletState};
69
70// ---------------------------------------------------------------------------
71// Errors
72// ---------------------------------------------------------------------------
73
74/// Why a merge pair was refused (spec section 12.6): one typed rejection per
75/// requirement.
76#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
77pub enum MergeRejection {
78    /// The sources partition different tables.
79    #[error("tablets belong to different tables ({first_table} vs {second_table})")]
80    DifferentTables {
81        /// The first source's table.
82        first_table: TableId,
83        /// The second source's table.
84        second_table: TableId,
85    },
86    /// The sources serve different schema versions.
87    #[error("schema versions differ on table {table}: {first} vs {second}")]
88    SchemaMismatch {
89        /// The shared table.
90        table: TableId,
91        /// The first source's schema version.
92        first: SchemaVersion,
93        /// The second source's schema version.
94        second: SchemaVersion,
95    },
96    /// The source ranges overlap or leave a gap.
97    #[error("tablets {first} and {second} are not adjacent")]
98    NotAdjacent {
99        /// One source.
100        first: TabletId,
101        /// The other source.
102        second: TabletId,
103    },
104    /// The sources place replicas on different node sets, so no single
105    /// existing placement can host the merged tablet (replica movement is
106    /// the placement wave's protocol, not merge's).
107    #[error("placement is incompatible: {first_nodes:?} vs {second_nodes:?}")]
108    IncompatiblePlacement {
109        /// The first source's replica nodes, sorted.
110        first_nodes: Vec<NodeId>,
111        /// The second source's replica nodes, sorted.
112        second_nodes: Vec<NodeId>,
113    },
114    /// A schema job is in flight on the table; merging under it would race
115    /// the job's tablet scan.
116    #[error("schema job {job_id} is active on table {table}")]
117    ConflictingSchemaJob {
118        /// The table with the active job.
119        table: TableId,
120        /// The conflicting job.
121        job_id: u64,
122    },
123    /// The merged tablet would exceed the configured size threshold.
124    #[error(
125        "combined size {combined_bytes} bytes exceeds the merge threshold {threshold_bytes} bytes"
126    )]
127    CombinedSizeExceedsThreshold {
128        /// `first_size_bytes + second_size_bytes`.
129        combined_bytes: u64,
130        /// The configured threshold.
131        threshold_bytes: u64,
132    },
133    /// A source is not serving (mid-split, mid-merge, or retired).
134    #[error("source tablet {tablet} is in state {state}, expected Active")]
135    InvalidSourceState {
136        /// The offending source.
137        tablet: TabletId,
138        /// Its current state.
139        state: TabletState,
140    },
141}
142
143/// The one error type of the merge surface: validation, planning, execution,
144/// and resume.
145#[derive(Debug, thiserror::Error)]
146pub enum MergeError {
147    /// Descriptor, layout, or persisted-progress failure. Always fail closed.
148    #[error(transparent)]
149    Tablet(#[from] TabletError),
150    /// An armed fault hook fired (crash-resume tests).
151    #[error(transparent)]
152    Fault(#[from] mongreldb_fault::Fault),
153    /// The meta plane refused a descriptor write or the publication.
154    #[error(transparent)]
155    MetaPlane(#[from] MetaRejectionReason),
156    /// A keyspace or replacement-sink seam operation failed.
157    #[error(transparent)]
158    TabletData(#[from] TabletDataError),
159    /// The pair violated a merge requirement.
160    #[error(transparent)]
161    Rejected(#[from] MergeRejection),
162    /// The plan is structurally inconsistent.
163    #[error("invalid merge plan: {0}")]
164    InvalidPlan(String),
165    /// A source keyspace holds a key outside its own partition (fail closed
166    /// instead of dropping or misplacing it).
167    #[error("applied key {0} lies outside its source partition")]
168    KeyOutsideSource(Key),
169    /// A source still has old-generation pins; retry when they drain.
170    #[error("source tablet {tablet} is retained by {pins} old-generation pin(s)")]
171    SourceRetained {
172        /// The retained source.
173        tablet: TabletId,
174        /// Old-generation pins still outstanding.
175        pins: usize,
176    },
177}
178
179// ---------------------------------------------------------------------------
180// Merge phases (persisted; declaration order frozen, spec section 4.10)
181// ---------------------------------------------------------------------------
182
183/// The durably persisted phases of one merge (spec section 12.6). The
184/// executor resumes from the last persisted phase after a crash.
185#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
186pub enum MergePhase {
187    /// The plan is formed and recorded; no meta-plane change yet.
188    Started,
189    /// Both sources are marked `Merging` (each at its own `g + 1`).
190    MarkedMerging,
191    /// The replacement descriptor and layout are created as `Creating`
192    /// learners.
193    ReplacementCreated,
194    /// Both source snapshots are pinned at `merge_ts`.
195    SnapshotsPinned,
196    /// The replacement state is built from the pinned snapshots.
197    ReplacementBuilt,
198    /// Both sources' deltas are streamed; the replacement is caught up, so
199    /// the routing publication barrier is satisfied.
200    CaughtUp,
201    /// Replacement `Active` + sources `Retiring` published atomically.
202    Published,
203    /// Both sources are `Retired` and their replicas torn down. Terminal.
204    SourcesRetired,
205}
206
207impl MergePhase {
208    /// The fault hook fired after this phase's progress record is durable
209    /// (`Started` has none). Hook names are registered in the
210    /// `mongreldb-fault` catalog.
211    pub fn hook_name(self) -> Option<&'static str> {
212        Some(match self {
213            Self::Started => return None,
214            Self::MarkedMerging => "tablet.merge.phase.1",
215            Self::ReplacementCreated => "tablet.merge.phase.2",
216            Self::SnapshotsPinned => "tablet.merge.phase.3",
217            Self::ReplacementBuilt => "tablet.merge.phase.4",
218            Self::CaughtUp => "tablet.merge.phase.5",
219            Self::Published => "tablet.merge.phase.6",
220            Self::SourcesRetired => "tablet.merge.phase.7",
221        })
222    }
223}
224
225impl fmt::Display for MergePhase {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        let name = match self {
228            Self::Started => "Started",
229            Self::MarkedMerging => "MarkedMerging",
230            Self::ReplacementCreated => "ReplacementCreated",
231            Self::SnapshotsPinned => "SnapshotsPinned",
232            Self::ReplacementBuilt => "ReplacementBuilt",
233            Self::CaughtUp => "CaughtUp",
234            Self::Published => "Published",
235            Self::SourcesRetired => "SourcesRetired",
236        };
237        f.write_str(name)
238    }
239}
240
241// ---------------------------------------------------------------------------
242// The merge plan
243// ---------------------------------------------------------------------------
244
245/// Everything merge validation needs about the candidate pair (spec section
246/// 12.6's requirement list).
247#[derive(Clone, Debug)]
248pub struct MergeInputs {
249    /// One source tablet (`Active`).
250    pub first: TabletDescriptor,
251    /// The other source tablet (`Active`).
252    pub second: TabletDescriptor,
253    /// The schema version the first source serves.
254    pub first_schema: SchemaVersion,
255    /// The schema version the second source serves.
256    pub second_schema: SchemaVersion,
257    /// The id of a schema job currently active on the table, if any (the
258    /// meta plane resolves its job registry down to this fact).
259    pub active_schema_job: Option<u64>,
260    /// The first source's applied size in bytes.
261    pub first_size_bytes: u64,
262    /// The second source's applied size in bytes.
263    pub second_size_bytes: u64,
264    /// The configured merge threshold: the merged tablet's combined size
265    /// must not exceed it.
266    pub max_merged_size_bytes: u64,
267}
268
269/// Validates a merge candidate against every requirement of spec section
270/// 12.6, returning the sources ordered lower-half-first.
271fn validate_merge_inputs(inputs: &MergeInputs) -> Result<[TabletDescriptor; 2], MergeRejection> {
272    let (first, second) = (&inputs.first, &inputs.second);
273    for source in [first, second] {
274        if source.state != TabletState::Active {
275            return Err(MergeRejection::InvalidSourceState {
276                tablet: source.tablet_id,
277                state: source.state,
278            });
279        }
280    }
281    if first.table_id != second.table_id {
282        return Err(MergeRejection::DifferentTables {
283            first_table: first.table_id,
284            second_table: second.table_id,
285        });
286    }
287    if inputs.first_schema != inputs.second_schema {
288        return Err(MergeRejection::SchemaMismatch {
289            table: first.table_id,
290            first: inputs.first_schema,
291            second: inputs.second_schema,
292        });
293    }
294    let ordered: [&TabletDescriptor; 2] = if first.partition.meets_start_of(&second.partition) {
295        [first, second]
296    } else if second.partition.meets_start_of(&first.partition) {
297        [second, first]
298    } else {
299        return Err(MergeRejection::NotAdjacent {
300            first: first.tablet_id,
301            second: second.tablet_id,
302        });
303    };
304    let nodes = |descriptor: &TabletDescriptor| -> Vec<NodeId> {
305        let mut nodes: Vec<NodeId> = descriptor
306            .replicas
307            .iter()
308            .map(|replica| replica.node_id)
309            .collect();
310        nodes.sort();
311        nodes
312    };
313    let (first_nodes, second_nodes) = (nodes(first), nodes(second));
314    if first_nodes != second_nodes {
315        return Err(MergeRejection::IncompatiblePlacement {
316            first_nodes,
317            second_nodes,
318        });
319    }
320    if let Some(job_id) = inputs.active_schema_job {
321        return Err(MergeRejection::ConflictingSchemaJob {
322            table: first.table_id,
323            job_id,
324        });
325    }
326    let combined = inputs
327        .first_size_bytes
328        .checked_add(inputs.second_size_bytes)
329        .ok_or(MergeRejection::CombinedSizeExceedsThreshold {
330            combined_bytes: u64::MAX,
331            threshold_bytes: inputs.max_merged_size_bytes,
332        })?;
333    if combined > inputs.max_merged_size_bytes {
334        return Err(MergeRejection::CombinedSizeExceedsThreshold {
335            combined_bytes: combined,
336            threshold_bytes: inputs.max_merged_size_bytes,
337        });
338    }
339    Ok([ordered[0].clone(), ordered[1].clone()])
340}
341
342/// One merge: the two sources (lower half first, as initiated) and the
343/// hidden replacement.
344#[derive(Clone, Debug)]
345pub struct MergePlan {
346    /// The sources as they were at initiation (`Active`, generations
347    /// `g1`, `g2`), lower half first.
348    pub sources: [TabletDescriptor; 2],
349    /// The replacement tablet: the union bounds, its layout, its initial
350    /// (learner) replica set.
351    pub replacement: ChildPlan,
352    /// The timestamp both source snapshots are pinned at.
353    pub merge_ts: HlcTimestamp,
354}
355
356impl MergePlan {
357    /// Structural validation: adjacent ordered sources, the replacement
358    /// covering exactly their union, fresh distinct ids, and a structurally
359    /// valid creation-time replacement descriptor.
360    pub fn validate(&self) -> Result<(), MergeError> {
361        for source in &self.sources {
362            source.validate()?;
363        }
364        if !self.sources[0]
365            .partition
366            .meets_start_of(&self.sources[1].partition)
367        {
368            return Err(MergeError::InvalidPlan(
369                "merge sources are not ordered adjacent halves".to_owned(),
370            ));
371        }
372        let union = self.sources[0]
373            .partition
374            .union_adjacent(&self.sources[1].partition)
375            .ok_or_else(|| MergeError::InvalidPlan("source bounds are not adjacent".to_owned()))?;
376        if self.replacement.bounds != union {
377            return Err(MergeError::InvalidPlan(
378                "replacement bounds are not the union of the source bounds".to_owned(),
379            ));
380        }
381        if self.sources[0].tablet_id == self.sources[1].tablet_id
382            || self
383                .sources
384                .iter()
385                .any(|source| source.tablet_id == self.replacement.layout.tablet_id())
386            || self.sources[0].raft_group_id == self.sources[1].raft_group_id
387            || self
388                .sources
389                .iter()
390                .any(|source| source.raft_group_id == self.replacement.layout.raft_group_id())
391        {
392            return Err(MergeError::InvalidPlan(
393                "source and replacement tablet/raft-group ids must be distinct".to_owned(),
394            ));
395        }
396        self.replacement_descriptor().validate()?;
397        Ok(())
398    }
399
400    /// The replacement descriptor as created: `Creating` at the inception
401    /// generation (`max(g1, g2) + 1`), every replica a learner. `Creating`
402    /// tablets are never routed to (spec section 12.5).
403    pub fn replacement_descriptor(&self) -> TabletDescriptor {
404        let generation = self
405            .sources
406            .iter()
407            .map(|source| source.generation)
408            .max()
409            .expect("two sources")
410            .checked_add(1)
411            .expect("descriptor generation overflows u64");
412        self.replacement.descriptor(
413            self.sources[0].table_id,
414            generation,
415            TabletState::Creating,
416            ReplicaRole::Learner,
417        )
418    }
419
420    /// The command-wide publication generation (`max(g1, g2) + 2`).
421    pub fn publish_generation(&self) -> u64 {
422        self.sources
423            .iter()
424            .map(|source| source.generation)
425            .max()
426            .expect("two sources")
427            .checked_add(2)
428            .expect("descriptor generation overflows u64")
429    }
430}
431
432/// Produces [`MergePlan`]s (spec section 12.6).
433#[derive(Clone, Debug)]
434pub struct MergePlanner {
435    node_data: PathBuf,
436}
437
438impl MergePlanner {
439    /// A planner rooted at the node's data directory.
440    pub fn new(node_data: impl Into<PathBuf>) -> Self {
441        Self {
442            node_data: node_data.into(),
443        }
444    }
445
446    /// Validates the candidate pair (typed [`MergeRejection`]s per violated
447    /// requirement) and plans the hidden replacement.
448    pub fn plan(
449        &self,
450        inputs: MergeInputs,
451        merge_ts: HlcTimestamp,
452        allocation: ChildAllocation,
453    ) -> Result<MergePlan, MergeError> {
454        inputs.first.validate()?;
455        inputs.second.validate()?;
456        let sources = validate_merge_inputs(&inputs)?;
457        let bounds = sources[0]
458            .partition
459            .union_adjacent(&sources[1].partition)
460            .ok_or_else(|| MergeError::InvalidPlan("source bounds are not adjacent".to_owned()))?;
461        let plan = MergePlan {
462            sources,
463            replacement: ChildPlan {
464                bounds,
465                layout: TabletLayout::new(
466                    self.node_data.clone(),
467                    allocation.tablet_id,
468                    allocation.raft_group_id,
469                ),
470                replicas: allocation.replicas,
471            },
472            merge_ts,
473        };
474        plan.validate()?;
475        Ok(plan)
476    }
477}
478
479// ---------------------------------------------------------------------------
480// The atomic routing publication
481// ---------------------------------------------------------------------------
482
483/// The routing publication of one merge, applied by the meta group as ONE
484/// command (spec section 12.6): the replacement becomes `Active` and both
485/// sources become `Retiring` atomically at one new generation. Never
486/// proposed before the replacement is caught up (the executor's phase
487/// machine is the barrier).
488///
489/// This is the shape the meta wave adopts as a meta command; the reference
490/// [`MergeMetaPlane::publish_merge`] applies its semantics.
491#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
492pub struct MergePublishCommand {
493    /// The sources, republished `Retiring` at the publication generation,
494    /// lower half first.
495    pub sources: [TabletDescriptor; 2],
496    /// The replacement, published `Active` at the publication generation.
497    /// Publication promotes the caught-up learners to voters (the Raft
498    /// membership change rides the same meta command in the runtime wave).
499    pub replacement: TabletDescriptor,
500    /// The timestamp both source snapshots were pinned at.
501    pub merge_ts: HlcTimestamp,
502}
503
504impl MergePublishCommand {
505    /// Builds the publication of `plan` at the command-wide publication
506    /// generation (see the module's generation rules).
507    pub fn from_plan(plan: &MergePlan) -> Result<Self, MergeError> {
508        let publish_generation = plan.publish_generation();
509        let mut sources = plan.sources.clone();
510        for source in &mut sources {
511            let marked = source.published_transition(TabletState::Merging)?;
512            let mut retiring = marked.published_transition(TabletState::Retiring)?;
513            // One command-wide generation for all three descriptors.
514            retiring.generation = publish_generation;
515            *source = retiring;
516        }
517        let mut replacement = plan
518            .replacement_descriptor()
519            .published_transition(TabletState::Active)?;
520        debug_assert_eq!(replacement.generation, publish_generation);
521        for replica in &mut replacement.replicas {
522            replica.role = ReplicaRole::Voter;
523        }
524        let command = Self {
525            sources,
526            replacement,
527            merge_ts: plan.merge_ts,
528        };
529        command.validate()?;
530        Ok(command)
531    }
532
533    /// The generation the publication assigns to all three descriptors.
534    pub fn publish_generation(&self) -> u64 {
535        self.replacement.generation
536    }
537
538    /// Structural validation of the atomic publication: states and the
539    /// shared generation, one table, and replacement bounds covering exactly
540    /// the union of the source bounds.
541    pub fn validate(&self) -> Result<(), MergeError> {
542        if self.replacement.state != TabletState::Active {
543            return Err(MergeError::InvalidPlan(format!(
544                "published replacement must be Active, is {}",
545                self.replacement.state
546            )));
547        }
548        let generation = self.replacement.generation;
549        for source in &self.sources {
550            if source.state != TabletState::Retiring {
551                return Err(MergeError::InvalidPlan(format!(
552                    "published source {} must be Retiring, is {}",
553                    source.tablet_id, source.state
554                )));
555            }
556            if source.generation != generation {
557                return Err(MergeError::InvalidPlan(
558                    "publication assigns one generation to all descriptors".to_owned(),
559                ));
560            }
561            if source.table_id != self.replacement.table_id {
562                return Err(MergeError::InvalidPlan(
563                    "sources and replacement name different tables".to_owned(),
564                ));
565            }
566            source.validate()?;
567        }
568        self.replacement.validate()?;
569        let union = self.sources[0]
570            .partition
571            .union_adjacent(&self.sources[1].partition)
572            .ok_or_else(|| MergeError::InvalidPlan("source bounds are not adjacent".to_owned()))?;
573        if self.replacement.partition != union {
574            return Err(MergeError::InvalidPlan(
575                "published replacement bounds are not the union of the source bounds".to_owned(),
576            ));
577        }
578        if self.sources[0].tablet_id == self.sources[1].tablet_id
579            || self
580                .sources
581                .iter()
582                .any(|source| source.tablet_id == self.replacement.tablet_id)
583        {
584            return Err(MergeError::InvalidPlan(
585                "publication tablet ids must be distinct".to_owned(),
586            ));
587        }
588        Ok(())
589    }
590}
591
592// ---------------------------------------------------------------------------
593// Persisted merge progress (crash resume)
594// ---------------------------------------------------------------------------
595
596/// Name of the progress record, inside the *lower* source's tablet directory.
597pub const MERGE_PROGRESS_FILENAME: &str = "merge.json";
598/// The progress-record format version this build writes.
599pub const MERGE_PROGRESS_FORMAT_VERSION: u32 = 1;
600/// The oldest progress-record format version this build accepts.
601pub const MIN_SUPPORTED_MERGE_PROGRESS_FORMAT_VERSION: u32 = 1;
602
603/// The durable progress of one merge: everything [`MergeExecutor::resume`]
604/// needs to continue idempotently after a crash.
605#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
606#[serde(deny_unknown_fields)]
607pub struct MergeProgress {
608    /// The source descriptors as they were at initiation (`Active`), lower
609    /// half first.
610    pub sources: [TabletDescriptor; 2],
611    /// The replacement plan.
612    pub replacement: ChildProgress,
613    /// The timestamp both source snapshots are pinned at.
614    pub merge_ts: HlcTimestamp,
615    /// The last durably completed phase.
616    pub phase: MergePhase,
617}
618
619impl MergeProgress {
620    /// Records `plan` at `phase`.
621    pub fn from_plan(plan: &MergePlan, phase: MergePhase) -> Self {
622        Self {
623            sources: plan.sources.clone(),
624            replacement: plan.replacement.progress(),
625            merge_ts: plan.merge_ts,
626            phase,
627        }
628    }
629
630    /// Rebuilds the runtime plan.
631    pub fn plan(&self) -> MergePlan {
632        MergePlan {
633            sources: self.sources.clone(),
634            replacement: self.replacement.plan(),
635            merge_ts: self.merge_ts,
636        }
637    }
638}
639
640/// The durable progress-record envelope: versioned and checksummed with the
641/// same idiom as `tablet.json` (fail closed on torn or foreign files).
642#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
643#[serde(deny_unknown_fields)]
644struct MergeProgressFile {
645    /// Durable format version; see [`MERGE_PROGRESS_FORMAT_VERSION`].
646    format_version: u32,
647    /// Lowercase-hex SHA-256 of the canonical JSON encoding of `progress`.
648    checksum: String,
649    /// The persisted progress.
650    progress: MergeProgress,
651}
652
653impl MergeProgressFile {
654    fn envelope(progress: &MergeProgress) -> Result<Self, MergeError> {
655        Ok(Self {
656            format_version: MERGE_PROGRESS_FORMAT_VERSION,
657            checksum: progress_checksum(progress).map_err(meta_io)?,
658            progress: progress.clone(),
659        })
660    }
661}
662
663/// SHA-256 of the canonical (compact JSON) encoding of the progress record.
664fn progress_checksum(progress: &MergeProgress) -> Result<String, ClusterError> {
665    let bytes = serde_json::to_vec(progress).map_err(|error| ClusterError::CorruptMetadata {
666        file: MERGE_PROGRESS_FILENAME,
667        detail: format!("encode: {error}"),
668    })?;
669    Ok(hex_encode(&Sha256::digest(&bytes)))
670}
671
672/// Maps a node-layer metadata failure onto the merge error surface.
673fn meta_io(error: ClusterError) -> MergeError {
674    MergeError::Tablet(TabletError::Metadata(error))
675}
676
677fn hex_encode(bytes: &[u8]) -> String {
678    const HEX: &[u8; 16] = b"0123456789abcdef";
679    let mut out = String::with_capacity(bytes.len() * 2);
680    for byte in bytes {
681        out.push(HEX[(byte >> 4) as usize] as char);
682        out.push(HEX[(byte & 0x0f) as usize] as char);
683    }
684    out
685}
686
687// ---------------------------------------------------------------------------
688// The meta-plane seam
689// ---------------------------------------------------------------------------
690
691/// The merge half of the meta-plane seam (see [`TabletMetaPlane`]).
692pub trait MergeMetaPlane: TabletMetaPlane {
693    /// The atomic routing publication of spec section 12.6: the replacement
694    /// becomes `Active` and the sources `Retiring` — ONE meta command, never
695    /// exposed before catch-up. The default applies the command's
696    /// descriptors within this one call; the meta-group binding overrides it
697    /// with a single raft proposal carrying the command.
698    fn publish_merge(&mut self, command: &MergePublishCommand) -> Result<(), MetaRejectionReason> {
699        command
700            .validate()
701            .map_err(|error| MetaRejectionReason::Invalid {
702                reason: error.to_string(),
703            })?;
704        self.set_tablet(&command.replacement)?;
705        for source in &command.sources {
706            self.set_tablet(source)?;
707        }
708        Ok(())
709    }
710}
711
712impl MergeMetaPlane for crate::split::InMemoryMetaPlane {}
713
714// ---------------------------------------------------------------------------
715// The merge executor
716// ---------------------------------------------------------------------------
717
718/// Drives one merge through the steps of spec section 12.6, persisting
719/// progress after every phase so a crash resumes where it stopped.
720/// Construct with [`Self::begin`] (a fresh merge) or [`Self::resume`] (after
721/// a crash; `None` when no merge is in progress), then [`Self::run`] to
722/// completion or [`Self::step`] phase by phase.
723///
724/// `M` is the meta plane, `K` the source keyspaces (ordered like the
725/// sources), `S` the replacement-state sink.
726pub struct MergeExecutor<M, K, S> {
727    progress: MergeProgress,
728    source_layouts: [TabletLayout; 2],
729    meta: M,
730    keyspaces: [K; 2],
731    sink: S,
732    snapshot_pins: [Option<Box<dyn SnapshotPin>>; 2],
733    retention: Option<[SourceRetentionGuard; 2]>,
734}
735
736impl<M: MergeMetaPlane, K: TabletKeyspace, S: ChildStateSink> MergeExecutor<M, K, S> {
737    /// Begins a fresh merge: validates the plan and records it durably
738    /// (`Started`). The source layouts must be this node's live replicas of
739    /// the two source tablets, ordered lower half first.
740    pub fn begin(
741        plan: MergePlan,
742        source_layouts: [TabletLayout; 2],
743        meta: M,
744        keyspaces: [K; 2],
745        sink: S,
746    ) -> Result<Self, MergeError> {
747        plan.validate()?;
748        for (source, layout) in plan.sources.iter().zip(source_layouts.iter()) {
749            if source.state != TabletState::Active {
750                return Err(MergeRejection::InvalidSourceState {
751                    tablet: source.tablet_id,
752                    state: source.state,
753                }
754                .into());
755            }
756            if layout.tablet_id() != source.tablet_id
757                || layout.raft_group_id() != source.raft_group_id
758            {
759                return Err(TabletError::TabletMismatch {
760                    path: layout.tablet_dir(),
761                    expected: layout.tablet_id(),
762                    found: source.tablet_id,
763                    expected_group: layout.raft_group_id(),
764                    found_group: source.raft_group_id,
765                }
766                .into());
767            }
768            layout.validate()?;
769        }
770        let executor = Self {
771            progress: MergeProgress::from_plan(&plan, MergePhase::Started),
772            source_layouts,
773            meta,
774            keyspaces,
775            sink,
776            snapshot_pins: [None, None],
777            retention: None,
778        };
779        executor.persist_progress()?;
780        Ok(executor)
781    }
782
783    /// Resumes a merge after a crash: reloads the persisted progress from the
784    /// lower source's tablet directory (`None` when no merge is in
785    /// progress — including one whose final teardown already removed the
786    /// record).
787    pub fn resume(
788        source_layouts: [TabletLayout; 2],
789        meta: M,
790        keyspaces: [K; 2],
791        sink: S,
792    ) -> Result<Option<Self>, MergeError> {
793        let Some(progress) = load_progress(&source_layouts[0])? else {
794            return Ok(None);
795        };
796        for (source, layout) in progress.sources.iter().zip(source_layouts.iter()) {
797            if source.tablet_id != layout.tablet_id()
798                || source.raft_group_id != layout.raft_group_id()
799            {
800                return Err(TabletError::TabletMismatch {
801                    path: layout.tablet_dir(),
802                    expected: layout.tablet_id(),
803                    found: source.tablet_id,
804                    expected_group: layout.raft_group_id(),
805                    found_group: source.raft_group_id,
806                }
807                .into());
808            }
809        }
810        progress.plan().validate()?;
811        Ok(Some(Self {
812            progress,
813            source_layouts,
814            meta,
815            keyspaces,
816            sink,
817            snapshot_pins: [None, None],
818            retention: None,
819        }))
820    }
821
822    /// The last durably completed phase.
823    pub fn phase(&self) -> MergePhase {
824        self.progress.phase
825    }
826
827    /// The persisted progress record.
828    pub fn progress(&self) -> &MergeProgress {
829        &self.progress
830    }
831
832    /// The plan the merge executes.
833    pub fn plan(&self) -> MergePlan {
834        self.progress.plan()
835    }
836
837    /// The source retention guards (installed at publication).
838    pub fn retention(&self) -> Option<&[SourceRetentionGuard; 2]> {
839        self.retention.as_ref()
840    }
841
842    /// The meta plane.
843    pub fn meta(&self) -> &M {
844        &self.meta
845    }
846
847    /// The source keyspaces, ordered like the sources.
848    pub fn keyspaces(&self) -> &[K; 2] {
849        &self.keyspaces
850    }
851
852    /// The replacement-state sink.
853    pub fn sink(&self) -> &S {
854        &self.sink
855    }
856
857    /// Executes the next phase, persists it, and fires its fault hook.
858    /// Returns the newly completed phase. Idempotent: re-entering after a
859    /// failure redoes only the failed phase's work.
860    pub fn step(&mut self) -> Result<MergePhase, MergeError> {
861        use MergePhase::{
862            CaughtUp, MarkedMerging, Published, ReplacementBuilt, ReplacementCreated,
863            SnapshotsPinned, SourcesRetired, Started,
864        };
865        let next = match self.progress.phase {
866            Started => {
867                self.mark_sources_merging()?;
868                MarkedMerging
869            }
870            MarkedMerging => {
871                self.create_replacement()?;
872                ReplacementCreated
873            }
874            ReplacementCreated => {
875                self.pin_source_snapshots()?;
876                SnapshotsPinned
877            }
878            SnapshotsPinned => {
879                self.build_replacement()?;
880                ReplacementBuilt
881            }
882            ReplacementBuilt => {
883                self.catch_up_replacement()?;
884                CaughtUp
885            }
886            CaughtUp => {
887                self.publish_replacement()?;
888                Published
889            }
890            Published => {
891                self.remove_sources()?;
892                SourcesRetired
893            }
894            SourcesRetired => SourcesRetired,
895        };
896        // The terminal phase needs no progress record: the lower source's
897        // teardown already removed it with the tablet directory.
898        self.progress.phase = next;
899        if next != SourcesRetired {
900            self.persist_progress()?;
901        }
902        if let Some(hook) = next.hook_name() {
903            mongreldb_fault::inject(hook)?;
904        }
905        Ok(next)
906    }
907
908    /// Runs every remaining phase to completion. A
909    /// [`MergeError::SourceRetained`] surfaces with the merge parked at
910    /// [`MergePhase::Published`] — drop the old-generation pins and call
911    /// [`Self::run`] (or [`Self::resume`]) again.
912    pub fn run(&mut self) -> Result<(), MergeError> {
913        while self.progress.phase != MergePhase::SourcesRetired {
914            self.step()?;
915        }
916        Ok(())
917    }
918
919    /// Runs until `phase` is complete (test/driver convenience).
920    pub fn run_until(&mut self, phase: MergePhase) -> Result<(), MergeError> {
921        while self.progress.phase != phase {
922            self.step()?;
923        }
924        Ok(())
925    }
926
927    /// Both sources are marked `Merging` (each at its own `g + 1`); local
928    /// replica metadata follows.
929    fn mark_sources_merging(&mut self) -> Result<(), MergeError> {
930        for (source, layout) in self.progress.sources.iter().zip(self.source_layouts.iter()) {
931            let marked = source.published_transition(TabletState::Merging)?;
932            self.meta.set_tablet(&marked)?;
933            layout.store_metadata(&marked)?;
934        }
935        // Borrow of `self.progress` ends; re-borrow mutably below is fine.
936        Ok(())
937    }
938
939    /// The replacement descriptor is created as `Creating` learners — never
940    /// routable — and its on-disk layout is created.
941    fn create_replacement(&mut self) -> Result<(), MergeError> {
942        let plan = self.plan();
943        let descriptor = plan.replacement_descriptor();
944        plan.replacement.layout.create(&descriptor)?;
945        self.meta.set_tablet(&descriptor)?;
946        Ok(())
947    }
948
949    /// Both source snapshots are pinned at `merge_ts` (re-pinned after a
950    /// crash resume).
951    fn pin_source_snapshots(&mut self) -> Result<(), MergeError> {
952        self.ensure_snapshot_pins()
953    }
954
955    /// Re-acquires any snapshot pin the executor does not hold.
956    fn ensure_snapshot_pins(&mut self) -> Result<(), MergeError> {
957        for index in 0..2 {
958            if self.snapshot_pins[index].is_none() {
959                self.snapshot_pins[index] =
960                    Some(self.keyspaces[index].pin_snapshot(self.progress.merge_ts)?);
961            }
962        }
963        Ok(())
964    }
965
966    /// The pinned snapshots are unioned into the replacement sink (staged
967    /// build, atomic install). Adjacent ranges are disjoint, so every key
968    /// appears exactly once; a key outside its source's partition fails
969    /// closed as corrupt.
970    fn build_replacement(&mut self) -> Result<(), MergeError> {
971        self.ensure_snapshot_pins()?;
972        self.sink.begin_build()?;
973        for index in 0..2 {
974            let snapshot = self.keyspaces[index].snapshot_at(self.progress.merge_ts)?;
975            for (key, value) in snapshot {
976                if !self.progress.sources[index].partition.contains(&key) {
977                    return Err(MergeError::KeyOutsideSource(key));
978                }
979                self.sink.stage(&key, &value)?;
980            }
981        }
982        self.sink.install_staged()?;
983        Ok(())
984    }
985
986    /// The post-`merge_ts` deltas of both sources stream into the
987    /// replacement.
988    fn catch_up_replacement(&mut self) -> Result<(), MergeError> {
989        self.ensure_snapshot_pins()?;
990        for index in 0..2 {
991            let deltas = self.keyspaces[index].deltas_after(self.progress.merge_ts)?;
992            for mutation in deltas {
993                let key = match &mutation {
994                    TabletMutation::Upsert(key, _) | TabletMutation::Delete(key) => key,
995                };
996                if !self.progress.sources[index].partition.contains(key) {
997                    return Err(MergeError::KeyOutsideSource(key.clone()));
998                }
999                self.sink.apply_delta(&mutation)?;
1000            }
1001        }
1002        Ok(())
1003    }
1004
1005    /// The atomic routing publication — replacement `Active`, sources
1006    /// `Retiring`, one generation — then the retention bookkeeping. The
1007    /// phase machine is the publication barrier.
1008    fn publish_replacement(&mut self) -> Result<(), MergeError> {
1009        let plan = self.plan();
1010        let command = MergePublishCommand::from_plan(&plan)?;
1011        mongreldb_fault::inject("tablet.merge.before")?;
1012        self.meta.publish_merge(&command)?;
1013        mongreldb_fault::inject("tablet.merge.after")?;
1014        // The local replica metadata follows the publication.
1015        plan.replacement
1016            .layout
1017            .store_metadata(&command.replacement)?;
1018        for (descriptor, layout) in command.sources.iter().zip(self.source_layouts.iter()) {
1019            layout.store_metadata(descriptor)?;
1020        }
1021        self.retention = Some(
1022            command
1023                .sources
1024                .clone()
1025                .map(|source| SourceRetentionGuard::new(source.tablet_id, source.generation)),
1026        );
1027        // The replacement is published; the snapshot pins have done their work.
1028        self.snapshot_pins = [None, None];
1029        Ok(())
1030    }
1031
1032    /// Once neither source has old-generation pins, both are published
1033    /// `Retired`, their descriptors removed, and their replicas torn down
1034    /// (the lower source last: its directory holds the progress record).
1035    fn remove_sources(&mut self) -> Result<(), MergeError> {
1036        if let Some(guards) = &self.retention {
1037            for guard in guards {
1038                if !guard.ready_for_removal() {
1039                    return Err(MergeError::SourceRetained {
1040                        tablet: guard.source(),
1041                        pins: guard.old_generation_pins(),
1042                    });
1043                }
1044            }
1045        }
1046        for index in 0..2 {
1047            let source_id = self.progress.sources[index].tablet_id;
1048            if let Some(current) = self.meta.tablet(source_id) {
1049                let retired = if current.state == TabletState::Retired {
1050                    current
1051                } else {
1052                    let retired = current.published_transition(TabletState::Retired)?;
1053                    self.meta.set_tablet(&retired)?;
1054                    retired
1055                };
1056                self.meta.remove_tablet(source_id, retired.generation)?;
1057            }
1058        }
1059        self.source_layouts[1].teardown()?;
1060        self.source_layouts[0].teardown()?;
1061        Ok(())
1062    }
1063
1064    /// Persists the progress record atomically into the lower source's
1065    /// tablet directory.
1066    fn persist_progress(&self) -> Result<(), MergeError> {
1067        let file = MergeProgressFile::envelope(&self.progress)?;
1068        let bytes = crate::node::encode_json(MERGE_PROGRESS_FILENAME, &file).map_err(meta_io)?;
1069        crate::node::write_meta_atomic(
1070            &self.source_layouts[0].tablet_dir(),
1071            MERGE_PROGRESS_FILENAME,
1072            &bytes,
1073        )
1074        .map_err(ClusterError::Io)
1075        .map_err(meta_io)?;
1076        Ok(())
1077    }
1078}
1079
1080/// Loads and verifies the persisted progress record (`None` when absent).
1081/// Corrupt, unknown-version, or foreign records fail closed.
1082fn load_progress(lower_layout: &TabletLayout) -> Result<Option<MergeProgress>, MergeError> {
1083    let path = lower_layout.tablet_dir().join(MERGE_PROGRESS_FILENAME);
1084    let Some(bytes) = crate::node::read_meta_file(&path).map_err(meta_io)? else {
1085        return Ok(None);
1086    };
1087    let file: MergeProgressFile =
1088        crate::node::decode_json(MERGE_PROGRESS_FILENAME, &bytes).map_err(meta_io)?;
1089    if file.format_version < MIN_SUPPORTED_MERGE_PROGRESS_FORMAT_VERSION
1090        || file.format_version > MERGE_PROGRESS_FORMAT_VERSION
1091    {
1092        return Err(meta_io(ClusterError::UnsupportedFormatVersion {
1093            file: MERGE_PROGRESS_FILENAME,
1094            found: file.format_version,
1095            min: MIN_SUPPORTED_MERGE_PROGRESS_FORMAT_VERSION,
1096            max: MERGE_PROGRESS_FORMAT_VERSION,
1097        }));
1098    }
1099    if file.checksum != progress_checksum(&file.progress).map_err(meta_io)? {
1100        return Err(meta_io(ClusterError::CorruptMetadata {
1101            file: MERGE_PROGRESS_FILENAME,
1102            detail: "checksum mismatch".to_owned(),
1103        }));
1104    }
1105    let progress = file.progress;
1106    if progress.sources[0].tablet_id != lower_layout.tablet_id()
1107        || progress.sources[0].raft_group_id != lower_layout.raft_group_id()
1108    {
1109        return Err(TabletError::TabletMismatch {
1110            path: lower_layout.tablet_dir(),
1111            expected: lower_layout.tablet_id(),
1112            found: progress.sources[0].tablet_id,
1113            expected_group: lower_layout.raft_group_id(),
1114            found_group: progress.sources[0].raft_group_id,
1115        }
1116        .into());
1117    }
1118    Ok(Some(progress))
1119}
1120
1121/// Reads the persisted merge progress of the *lower* source tablet, if any
1122/// (the node runtime's resume probe). Same fail-closed verification as
1123/// [`MergeExecutor::resume`].
1124pub fn merge_progress(lower_layout: &TabletLayout) -> Result<Option<MergeProgress>, MergeError> {
1125    load_progress(lower_layout)
1126}
1127
1128// ---------------------------------------------------------------------------
1129// Tests
1130// ---------------------------------------------------------------------------
1131
1132#[cfg(test)]
1133mod tests {
1134    use mongreldb_types::ids::RaftGroupId;
1135
1136    use super::*;
1137    use crate::split::{
1138        retry_guidance, ChildAllocation, InMemoryMetaPlane, MapChildSink, MapKeyspace,
1139        RetryGuidance, EXECUTOR_TEST_LOCK,
1140    };
1141    use crate::tablet::{
1142        check_generation, find_tablet_for_key, tablets_overlapping, Bound, KeyValue,
1143        PartitionBounds, ReplicaDescriptor, RoutingError, RowKeyEncoder,
1144    };
1145
1146    fn node(byte: u8) -> NodeId {
1147        NodeId::from_bytes([byte; 16])
1148    }
1149
1150    fn tablet_id(byte: u8) -> TabletId {
1151        TabletId::from_bytes([byte; 16])
1152    }
1153
1154    fn group_id(byte: u8) -> RaftGroupId {
1155        RaftGroupId::from_bytes([byte; 16])
1156    }
1157
1158    fn text_key(text: &str) -> Key {
1159        RowKeyEncoder::encode_key(&[KeyValue::Text(text.to_owned())])
1160    }
1161
1162    fn ts(micros: u64) -> HlcTimestamp {
1163        HlcTimestamp {
1164            physical_micros: micros,
1165            logical: 0,
1166            node_tiebreaker: 0,
1167        }
1168    }
1169
1170    fn voters() -> Vec<ReplicaDescriptor> {
1171        vec![
1172            ReplicaDescriptor {
1173                node_id: node(1),
1174                role: ReplicaRole::Voter,
1175                raft_node_id: 11,
1176            },
1177            ReplicaDescriptor {
1178                node_id: node(2),
1179                role: ReplicaRole::Voter,
1180                raft_node_id: 12,
1181            },
1182        ]
1183    }
1184
1185    fn source(
1186        tablet: u8,
1187        group: u8,
1188        low: Bound<Key>,
1189        high: Bound<Key>,
1190        generation: u64,
1191    ) -> TabletDescriptor {
1192        TabletDescriptor {
1193            tablet_id: tablet_id(tablet),
1194            table_id: TableId::new(3),
1195            database_id: mongreldb_types::ids::DatabaseId::ZERO,
1196            raft_group_id: group_id(group),
1197            partition: PartitionBounds::new(low, high).unwrap(),
1198            replicas: voters(),
1199            leader_hint: Some(node(1)),
1200            generation,
1201            state: TabletState::Active,
1202        }
1203    }
1204
1205    /// The lower half [a, m) at generation 4 and the upper half [m, z) at
1206    /// generation 6.
1207    fn source_pair() -> (TabletDescriptor, TabletDescriptor) {
1208        (
1209            source(
1210                11,
1211                11,
1212                Bound::Included(text_key("a")),
1213                Bound::Excluded(text_key("m")),
1214                4,
1215            ),
1216            source(
1217                12,
1218                12,
1219                Bound::Included(text_key("m")),
1220                Bound::Excluded(text_key("z")),
1221                6,
1222            ),
1223        )
1224    }
1225
1226    fn inputs(first: TabletDescriptor, second: TabletDescriptor) -> MergeInputs {
1227        MergeInputs {
1228            first,
1229            second,
1230            first_schema: SchemaVersion::new(9),
1231            second_schema: SchemaVersion::new(9),
1232            active_schema_job: None,
1233            first_size_bytes: 1_000,
1234            second_size_bytes: 2_000,
1235            max_merged_size_bytes: 1 << 20,
1236        }
1237    }
1238
1239    fn replacement_allocation() -> ChildAllocation {
1240        ChildAllocation {
1241            tablet_id: tablet_id(13),
1242            raft_group_id: group_id(13),
1243            replicas: vec![
1244                ReplicaDescriptor {
1245                    node_id: node(3),
1246                    role: ReplicaRole::Voter,
1247                    raft_node_id: 41,
1248                },
1249                ReplicaDescriptor {
1250                    node_id: node(4),
1251                    role: ReplicaRole::Voter,
1252                    raft_node_id: 42,
1253                },
1254            ],
1255        }
1256    }
1257
1258    // -- merge validation (spec 12.6 requirements) -----------------------------
1259
1260    #[test]
1261    fn merge_validation_rejects_each_violated_requirement() {
1262        let dir = tempfile::tempdir().unwrap();
1263        let planner = MergePlanner::new(dir.path());
1264        let (left, right) = source_pair();
1265        let plan = |inputs: MergeInputs| planner.plan(inputs, ts(150), replacement_allocation());
1266
1267        // The happy path validates and orders the sources lower-first.
1268        let merged = plan(inputs(left.clone(), right.clone())).unwrap();
1269        assert_eq!(merged.sources[0].tablet_id, left.tablet_id);
1270        assert_eq!(merged.sources[1].tablet_id, right.tablet_id);
1271        assert_eq!(
1272            merged.replacement.bounds,
1273            PartitionBounds::new(
1274                Bound::Included(text_key("a")),
1275                Bound::Excluded(text_key("z"))
1276            )
1277            .unwrap()
1278        );
1279        // Input order does not matter.
1280        let swapped = plan(inputs(right.clone(), left.clone())).unwrap();
1281        assert_eq!(swapped.sources[0].tablet_id, left.tablet_id);
1282
1283        // Different tables.
1284        let mut foreign = right.clone();
1285        foreign.table_id = TableId::new(4);
1286        assert!(matches!(
1287            plan(inputs(left.clone(), foreign)),
1288            Err(MergeError::Rejected(MergeRejection::DifferentTables {
1289                first_table,
1290                second_table,
1291            })) if first_table == TableId::new(3) && second_table == TableId::new(4)
1292        ));
1293
1294        // Schema mismatch.
1295        let mut mismatched = inputs(left.clone(), right.clone());
1296        mismatched.second_schema = SchemaVersion::new(10);
1297        assert!(matches!(
1298            plan(mismatched),
1299            Err(MergeError::Rejected(MergeRejection::SchemaMismatch {
1300                table,
1301                first,
1302                second,
1303            })) if table == TableId::new(3)
1304                && first == SchemaVersion::new(9)
1305                && second == SchemaVersion::new(10)
1306        ));
1307
1308        // Non-adjacent ranges (a gap), and overlapping ranges.
1309        let gapped = source(
1310            14,
1311            14,
1312            Bound::Included(text_key("n")),
1313            Bound::Excluded(text_key("z")),
1314            6,
1315        );
1316        assert!(matches!(
1317            plan(inputs(left.clone(), gapped)),
1318            Err(MergeError::Rejected(MergeRejection::NotAdjacent { .. }))
1319        ));
1320        let overlapping = source(
1321            14,
1322            14,
1323            Bound::Included(text_key("l")),
1324            Bound::Excluded(text_key("z")),
1325            6,
1326        );
1327        assert!(matches!(
1328            plan(inputs(left.clone(), overlapping)),
1329            Err(MergeError::Rejected(MergeRejection::NotAdjacent { .. }))
1330        ));
1331
1332        // Incompatible placement: different replica node sets.
1333        let mut elsewhere = right.clone();
1334        elsewhere.replicas[0].node_id = node(9);
1335        elsewhere.leader_hint = Some(node(9));
1336        assert!(matches!(
1337            plan(inputs(left.clone(), elsewhere)),
1338            Err(MergeError::Rejected(
1339                MergeRejection::IncompatiblePlacement { .. }
1340            ))
1341        ));
1342
1343        // A conflicting schema job.
1344        let mut with_job = inputs(left.clone(), right.clone());
1345        with_job.active_schema_job = Some(77);
1346        assert!(matches!(
1347            plan(with_job),
1348            Err(MergeError::Rejected(MergeRejection::ConflictingSchemaJob {
1349                table,
1350                job_id: 77,
1351            })) if table == TableId::new(3)
1352        ));
1353
1354        // Combined size over the threshold (and overflow-safe).
1355        let mut too_big = inputs(left.clone(), right.clone());
1356        too_big.max_merged_size_bytes = 2_999;
1357        assert!(matches!(
1358            plan(too_big),
1359            Err(MergeError::Rejected(
1360                MergeRejection::CombinedSizeExceedsThreshold {
1361                    combined_bytes: 3_000,
1362                    threshold_bytes: 2_999,
1363                }
1364            ))
1365        ));
1366        let mut overflowing = inputs(left.clone(), right.clone());
1367        overflowing.first_size_bytes = u64::MAX;
1368        assert!(matches!(
1369            plan(overflowing),
1370            Err(MergeError::Rejected(
1371                MergeRejection::CombinedSizeExceedsThreshold { .. }
1372            ))
1373        ));
1374
1375        // A source that is not Active.
1376        let mut splitting = left.clone();
1377        splitting.state = TabletState::Splitting;
1378        assert!(matches!(
1379            plan(inputs(splitting, right.clone())),
1380            Err(MergeError::Rejected(MergeRejection::InvalidSourceState {
1381                state: TabletState::Splitting,
1382                ..
1383            }))
1384        ));
1385
1386        // Replacement ids colliding with a source fail closed.
1387        let mut colliding = replacement_allocation();
1388        colliding.tablet_id = left.tablet_id;
1389        assert!(matches!(
1390            planner.plan(inputs(left.clone(), right.clone()), ts(150), colliding),
1391            Err(MergeError::InvalidPlan(_))
1392        ));
1393    }
1394
1395    #[test]
1396    fn merge_publish_command_flips_three_descriptors_at_one_generation() {
1397        let dir = tempfile::tempdir().unwrap();
1398        let planner = MergePlanner::new(dir.path());
1399        let (left, right) = source_pair();
1400        let plan = planner
1401            .plan(inputs(left, right), ts(150), replacement_allocation())
1402            .unwrap();
1403        // m = max(4, 6) = 6: replacement Creating at 7, publication at 8.
1404        assert_eq!(plan.replacement_descriptor().generation, 7);
1405        let command = MergePublishCommand::from_plan(&plan).unwrap();
1406        assert_eq!(command.publish_generation(), 8);
1407        assert_eq!(command.replacement.state, TabletState::Active);
1408        assert!(command
1409            .replacement
1410            .replicas
1411            .iter()
1412            .all(|replica| replica.role == ReplicaRole::Voter));
1413        for (source, original) in command.sources.iter().zip(plan.sources.iter()) {
1414            assert_eq!(source.state, TabletState::Retiring);
1415            assert_eq!(source.generation, 8, "source at g={}", original.generation);
1416        }
1417        // The shape survives serde (the meta wave journals it as one command).
1418        let bytes = serde_json::to_vec(&command).unwrap();
1419        let back: MergePublishCommand = serde_json::from_slice(&bytes).unwrap();
1420        assert_eq!(back, command);
1421
1422        // Tampered shapes fail validation.
1423        let mut wrong_state = command.clone();
1424        wrong_state.replacement.state = TabletState::Creating;
1425        assert!(wrong_state.validate().is_err());
1426        let mut skewed = command.clone();
1427        skewed.sources[0].generation = 9;
1428        assert!(skewed.validate().is_err());
1429        let mut wrong_bounds = command.clone();
1430        wrong_bounds.replacement.partition =
1431            PartitionBounds::new(Bound::Unbounded, Bound::Unbounded).unwrap();
1432        assert!(wrong_bounds.validate().is_err());
1433    }
1434
1435    // -- the full merge ---------------------------------------------------------
1436
1437    struct MergeFixture {
1438        _dir: tempfile::TempDir,
1439        sources: [TabletDescriptor; 2],
1440        source_layouts: [TabletLayout; 2],
1441        meta: InMemoryMetaPlane,
1442        keyspaces: [MapKeyspace; 2],
1443        sink: MapChildSink,
1444        plan: MergePlan,
1445    }
1446
1447    const LOWER_KEYS: [&str; 6] = ["b", "d", "f", "h", "j", "l"];
1448    const UPPER_KEYS: [&str; 7] = ["m", "o", "q", "s", "u", "w", "y"];
1449
1450    fn merge_fixture() -> MergeFixture {
1451        let dir = tempfile::tempdir().unwrap();
1452        let (left, right) = source_pair();
1453        let mut meta = InMemoryMetaPlane::new();
1454        meta.set_tablet(&left).unwrap();
1455        meta.set_tablet(&right).unwrap();
1456
1457        let left_layout = TabletLayout::new(dir.path(), left.tablet_id, left.raft_group_id);
1458        left_layout.create(&left).unwrap();
1459        let right_layout = TabletLayout::new(dir.path(), right.tablet_id, right.raft_group_id);
1460        right_layout.create(&right).unwrap();
1461
1462        let left_keyspace = MapKeyspace::new();
1463        for name in LOWER_KEYS {
1464            left_keyspace.insert(
1465                text_key(name),
1466                ts(100),
1467                format!("v-{name}@100").into_bytes(),
1468            );
1469        }
1470        left_keyspace.insert(text_key("d"), ts(200), b"v-d@200".to_vec());
1471        let right_keyspace = MapKeyspace::new();
1472        for name in UPPER_KEYS {
1473            right_keyspace.insert(
1474                text_key(name),
1475                ts(100),
1476                format!("v-{name}@100").into_bytes(),
1477            );
1478        }
1479        right_keyspace.insert(text_key("x"), ts(200), b"v-x@200".to_vec());
1480
1481        let planner = MergePlanner::new(dir.path());
1482        let plan = planner
1483            .plan(
1484                inputs(left.clone(), right.clone()),
1485                ts(150),
1486                replacement_allocation(),
1487            )
1488            .unwrap();
1489        MergeFixture {
1490            _dir: dir,
1491            sources: [left, right],
1492            source_layouts: [left_layout, right_layout],
1493            meta,
1494            keyspaces: [left_keyspace, right_keyspace],
1495            sink: MapChildSink::new(),
1496            plan,
1497        }
1498    }
1499
1500    type TestExecutor = MergeExecutor<InMemoryMetaPlane, MapKeyspace, MapChildSink>;
1501
1502    fn begin_executor(fixture: &MergeFixture) -> TestExecutor {
1503        MergeExecutor::begin(
1504            fixture.plan.clone(),
1505            fixture.source_layouts.clone(),
1506            fixture.meta.clone(),
1507            fixture.keyspaces.clone(),
1508            fixture.sink.clone(),
1509        )
1510        .unwrap()
1511    }
1512
1513    fn assert_merge_completed(fixture: &MergeFixture) {
1514        // Both source descriptors are removed; the replacement is Active at
1515        // the publication generation with promoted (voter) replicas.
1516        for source in &fixture.sources {
1517            assert!(fixture.meta.tablet(source.tablet_id).is_none());
1518        }
1519        let replacement = fixture.meta.tablet(tablet_id(13)).unwrap();
1520        assert_eq!(replacement.state, TabletState::Active);
1521        assert_eq!(replacement.generation, 8);
1522        assert!(replacement
1523            .replicas
1524            .iter()
1525            .all(|replica| replica.role == ReplicaRole::Voter));
1526        assert_eq!(replacement.partition, fixture.plan.replacement.bounds);
1527        assert_eq!(
1528            fixture.plan.replacement.layout.load_metadata().unwrap(),
1529            replacement
1530        );
1531        // Both source replicas are torn down and the progress record is gone.
1532        for layout in &fixture.source_layouts {
1533            assert!(!layout.tablet_dir().exists());
1534            assert!(!layout.group_dir().exists());
1535        }
1536        assert!(!fixture.source_layouts[0]
1537            .tablet_dir()
1538            .join(MERGE_PROGRESS_FILENAME)
1539            .exists());
1540        // Zero loss, zero duplication: the replacement holds both keyspaces.
1541        let rows = fixture.sink.rows();
1542        let mut expected = fixture.keyspaces[0].rows_at(ts(u64::MAX));
1543        expected.extend(fixture.keyspaces[1].rows_at(ts(u64::MAX)));
1544        assert_eq!(rows, expected);
1545    }
1546
1547    #[test]
1548    fn full_merge_replaces_adjacent_tablets_with_zero_loss() {
1549        let _lock = EXECUTOR_TEST_LOCK.lock().unwrap();
1550        let fixture = merge_fixture();
1551        let table = TableId::new(3);
1552        let mut executor = begin_executor(&fixture);
1553        assert_eq!(executor.phase(), MergePhase::Started);
1554
1555        // Both sources are marked Merging (each at its own g+1) and keep serving.
1556        assert_eq!(executor.step().unwrap(), MergePhase::MarkedMerging);
1557        let marked_left = fixture.meta.tablet(tablet_id(11)).unwrap();
1558        let marked_right = fixture.meta.tablet(tablet_id(12)).unwrap();
1559        assert_eq!(marked_left.state, TabletState::Merging);
1560        assert_eq!(marked_left.generation, 5);
1561        assert_eq!(marked_right.state, TabletState::Merging);
1562        assert_eq!(marked_right.generation, 7);
1563        // A stale generation against a Merging source is plain stale metadata:
1564        // the source still owns its range until publication.
1565        let error = check_generation(&marked_left, 4).unwrap_err();
1566        assert!(matches!(error, RoutingError::StaleMetadata { .. }));
1567        let tablets = fixture.meta.descriptors();
1568        assert_eq!(
1569            find_tablet_for_key(&tablets, table, &text_key("b"))
1570                .unwrap()
1571                .tablet_id,
1572            tablet_id(11)
1573        );
1574        assert_eq!(
1575            find_tablet_for_key(&tablets, table, &text_key("y"))
1576                .unwrap()
1577                .tablet_id,
1578            tablet_id(12)
1579        );
1580
1581        // The hidden replacement exists as Creating learners — never routable.
1582        assert_eq!(executor.step().unwrap(), MergePhase::ReplacementCreated);
1583        let creating = fixture.meta.tablet(tablet_id(13)).unwrap();
1584        assert_eq!(creating.state, TabletState::Creating);
1585        assert_eq!(creating.generation, 7);
1586        assert!(creating
1587            .replicas
1588            .iter()
1589            .all(|replica| replica.role == ReplicaRole::Learner));
1590        let tablets = fixture.meta.descriptors();
1591        assert_eq!(
1592            find_tablet_for_key(&tablets, table, &text_key("b"))
1593                .unwrap()
1594                .tablet_id,
1595            tablet_id(11),
1596            "hidden replacement exposed before catch-up"
1597        );
1598
1599        // Both snapshots pinned; the build unions them; deltas catch up.
1600        assert_eq!(executor.step().unwrap(), MergePhase::SnapshotsPinned);
1601        assert_eq!(fixture.keyspaces[0].pin_count(), 1);
1602        assert_eq!(fixture.keyspaces[1].pin_count(), 1);
1603        assert_eq!(executor.step().unwrap(), MergePhase::ReplacementBuilt);
1604        assert_eq!(
1605            fixture.sink.rows().len(),
1606            LOWER_KEYS.len() + UPPER_KEYS.len()
1607        );
1608        assert_eq!(
1609            fixture.sink.rows().get(&text_key("d")),
1610            Some(&b"v-d@100".to_vec()),
1611            "post-merge write leaked into the pinned snapshot"
1612        );
1613        assert_eq!(executor.step().unwrap(), MergePhase::CaughtUp);
1614        assert_eq!(
1615            fixture.sink.rows().get(&text_key("d")),
1616            Some(&b"v-d@200".to_vec())
1617        );
1618        assert_eq!(
1619            fixture.sink.rows().get(&text_key("x")),
1620            Some(&b"v-x@200".to_vec())
1621        );
1622
1623        // The atomic publication flips routing to the replacement.
1624        assert_eq!(executor.step().unwrap(), MergePhase::Published);
1625        assert_eq!(fixture.keyspaces[0].pin_count(), 0);
1626        assert_eq!(fixture.keyspaces[1].pin_count(), 0);
1627        for id in [tablet_id(11), tablet_id(12)] {
1628            let retiring = fixture.meta.tablet(id).unwrap();
1629            assert_eq!(retiring.state, TabletState::Retiring);
1630            assert_eq!(retiring.generation, 8);
1631        }
1632        let tablets = fixture.meta.descriptors();
1633        for name in ["b", "m", "y"] {
1634            assert_eq!(
1635                find_tablet_for_key(&tablets, table, &text_key(name))
1636                    .unwrap()
1637                    .tablet_id,
1638                tablet_id(13),
1639                "key {name} did not reroute to the replacement"
1640            );
1641        }
1642        let overlapping = tablets_overlapping(&tablets, table, &PartitionBounds::unbounded());
1643        assert_eq!(
1644            overlapping
1645                .iter()
1646                .map(|tablet| tablet.tablet_id)
1647                .collect::<Vec<_>>(),
1648            vec![tablet_id(13)]
1649        );
1650        // Stale requests against the retired sources reroute.
1651        let retiring = fixture.meta.tablet(tablet_id(11)).unwrap();
1652        let error = check_generation(&retiring, 4).unwrap_err();
1653        assert!(matches!(error, RoutingError::TabletMoved { .. }));
1654        assert!(matches!(
1655            retry_guidance(&error),
1656            RetryGuidance::RefreshAndReroute { .. }
1657        ));
1658        assert!(check_generation(&fixture.meta.tablet(tablet_id(13)).unwrap(), 8).is_ok());
1659
1660        // Retention gates removal of both sources until old pins drain.
1661        let pin_left = executor.retention().unwrap()[0].pin(4);
1662        assert!(matches!(
1663            executor.step(),
1664            Err(MergeError::SourceRetained { pins: 1, .. })
1665        ));
1666        assert_eq!(executor.phase(), MergePhase::Published);
1667        assert!(executor.retention().unwrap()[0].unpin(pin_left));
1668        let pin_right = executor.retention().unwrap()[1].pin(6);
1669        assert!(matches!(
1670            executor.step(),
1671            Err(MergeError::SourceRetained { pins: 1, .. })
1672        ));
1673        assert!(executor.retention().unwrap()[1].unpin(pin_right));
1674        assert_eq!(executor.step().unwrap(), MergePhase::SourcesRetired);
1675        assert_merge_completed(&fixture);
1676        executor.run().unwrap();
1677        assert!(MergeExecutor::resume(
1678            fixture.source_layouts.clone(),
1679            fixture.meta.clone(),
1680            fixture.keyspaces.clone(),
1681            fixture.sink.clone(),
1682        )
1683        .unwrap()
1684        .is_none());
1685    }
1686
1687    // -- crash-resume at every durable boundary --------------------------------
1688
1689    #[test]
1690    fn merge_resumes_after_a_crash_at_every_durable_boundary() {
1691        let _lock = EXECUTOR_TEST_LOCK.lock().unwrap();
1692        let hooks = [
1693            "tablet.merge.phase.1",
1694            "tablet.merge.phase.2",
1695            "tablet.merge.phase.3",
1696            "tablet.merge.phase.4",
1697            "tablet.merge.phase.5",
1698            "tablet.merge.phase.6",
1699            "tablet.merge.phase.7",
1700            "tablet.merge.before",
1701            "tablet.merge.after",
1702        ];
1703        for hook in hooks {
1704            let fixture = merge_fixture();
1705            let mut executor = begin_executor(&fixture);
1706            {
1707                let _guard =
1708                    mongreldb_fault::ScopedGuard::limited(hook, mongreldb_fault::Action::Fail, 1);
1709                assert!(
1710                    matches!(executor.run(), Err(MergeError::Fault(_))),
1711                    "hook {hook} did not fire"
1712                );
1713            }
1714            drop(executor);
1715            let resumed = MergeExecutor::resume(
1716                fixture.source_layouts.clone(),
1717                fixture.meta.clone(),
1718                fixture.keyspaces.clone(),
1719                fixture.sink.clone(),
1720            )
1721            .unwrap();
1722            if hook == "tablet.merge.phase.7" {
1723                assert!(resumed.is_none(), "hook {hook}");
1724            } else {
1725                resumed
1726                    .unwrap()
1727                    .run()
1728                    .unwrap_or_else(|error| panic!("resume after {hook} failed: {error}"));
1729            }
1730            assert_merge_completed(&fixture);
1731        }
1732    }
1733
1734    #[test]
1735    fn merge_build_fails_closed_on_out_of_range_keys() {
1736        let _lock = EXECUTOR_TEST_LOCK.lock().unwrap();
1737        // A key outside its source partition fails the build closed, in
1738        // either source (adjacent ranges are disjoint, so a foreign key can
1739        // never land silently in the union).
1740        for index in 0..2 {
1741            let fixture = merge_fixture();
1742            fixture.keyspaces[index].insert(text_key("zz-outside"), ts(120), b"rogue".to_vec());
1743            let mut executor = begin_executor(&fixture);
1744            assert!(
1745                matches!(
1746                    executor.run_until(MergePhase::ReplacementBuilt),
1747                    Err(MergeError::KeyOutsideSource(_))
1748                ),
1749                "source {index} accepted an out-of-range key"
1750            );
1751        }
1752    }
1753
1754    #[test]
1755    fn merge_progress_record_fails_closed_on_corruption() {
1756        let _lock = EXECUTOR_TEST_LOCK.lock().unwrap();
1757        let fixture = merge_fixture();
1758        let executor = begin_executor(&fixture);
1759        drop(executor);
1760        let path = fixture.source_layouts[0]
1761            .tablet_dir()
1762            .join(MERGE_PROGRESS_FILENAME);
1763        std::fs::write(&path, b"{ not json").unwrap();
1764        assert!(matches!(
1765            MergeExecutor::resume(
1766                fixture.source_layouts.clone(),
1767                fixture.meta.clone(),
1768                fixture.keyspaces.clone(),
1769                fixture.sink.clone(),
1770            ),
1771            Err(MergeError::Tablet(TabletError::Metadata(
1772                ClusterError::CorruptMetadata { .. }
1773            )))
1774        ));
1775        // The upper source's directory never holds the record.
1776        assert!(MergeExecutor::resume(
1777            [
1778                fixture.source_layouts[1].clone(),
1779                fixture.source_layouts[1].clone(),
1780            ],
1781            fixture.meta.clone(),
1782            fixture.keyspaces.clone(),
1783            fixture.sink.clone(),
1784        )
1785        .unwrap()
1786        .is_none());
1787    }
1788}