Skip to main content

reddb_server/cluster/
ownership_transition.rs

1//! Ownership transition state machine for promote and fenced handoff
2//! (issue #995, PRD #987, ADR 0037).
3//!
4//! [`ShardOwnershipCatalog`] (issue #989) is the source of truth for *where* a
5//! range lives; this module is the only sanctioned way to *change* that. Per
6//! ADR 0037, "ownership changes are transitions, not arbitrary row edits" and
7//! "rebalancing, failover, and administrative recovery become catalog
8//! transitions" — so failover-promote and move-range handoff both funnel through
9//! the one fenced, versioned, audited machine here.
10//!
11//! ## Why a state machine rather than a `transfer_to` call
12//!
13//! [`RangeOwnership::transfer_to`] already bumps the epoch and version, but it is
14//! an *unconditional* edit: hand it any owner and it produces a new entry. That
15//! is unsafe as a control-plane operation, because a transition that races a
16//! concurrent one, names the wrong current owner, or activates a candidate that
17//! has not caught up to the range commit watermark must **fail closed**, not
18//! silently move authority. This machine adds the compare-and-swap and the safety
19//! gate around that edit:
20//!
21//! 1. **Prepare** ([`prepare`]) — a pure check against the catalog. The request
22//!    must name the *expected current owner*, *expected epoch*, and *expected
23//!    catalog version* (a three-part CAS, so a stale planner loses), a *valid
24//!    target candidate* (a current replica of the range — only a replica that
25//!    covers the range commit watermark may be promoted, per the glossary), and
26//!    *safety evidence* that the candidate's applied log covers the range commit
27//!    watermark. Any failure yields a [`TransitionRejection`] and the catalog is
28//!    never touched.
29//! 2. **Activate** ([`PreparedTransition::activate`]) — only reachable *after*
30//!    prepare succeeds, this applies the fenced transition to the catalog. The
31//!    epoch bump in the new entry is what fences the old owner: from this point
32//!    its writes carry a stale epoch and [`admit_public_write`] rejects them.
33//!
34//! Splitting prepare from activate is the literal encoding of the acceptance
35//! criterion "activate new owners only after safety checks": you cannot obtain a
36//! [`PreparedTransition`] without passing every check, and activation is a
37//! distinct, explicit second step.
38//!
39//! ## Promote vs. handoff
40//!
41//! Both kinds run the identical safety gate — the difference is *intent and
42//! audit*, recorded in [`TransitionKind`]:
43//!
44//! * [`Promote`](TransitionKind::Promote) — failover. The recorded owner is gone
45//!   (or being deposed); a caught-up replica takes authority. The old owner is
46//!   fenced by the epoch bump.
47//! * [`Handoff`](TransitionKind::Handoff) — move-range cutover. The current owner
48//!   keeps serving until the target has copied and caught up; only then does this
49//!   transition move the epoch, fencing the old owner at the cutover instant.
50//!
51//! Forced (disaster-recovery) transitions, which may proceed without ordinary
52//! safety checks, are out of scope here: ADR 0037 reserves them for a separate
53//! `FORCE` capability path, implemented in
54//! [`ownership_force`](super::ownership_force).
55//!
56//! Everything is a pure data model over the catalog, with no I/O, so the CAS,
57//! fencing, and audit story is exercised deterministically.
58
59use super::identity::NodeIdentity;
60use super::ownership::{
61    CatalogError, CatalogVersion, CollectionId, OwnershipEpoch, RangeId, RangeOwnership, RangeRole,
62    ShardOwnershipCatalog,
63};
64
65/// The highest `(term, lsn)` known durable for a range under its commit policy
66/// — the *range commit watermark*. Per the glossary, "failover and interrupted
67/// move-range recovery may promote only a candidate whose log covers this
68/// watermark", so it is the bar a transition's safety evidence must clear.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct CommitWatermark {
71    /// The owning term at the watermark. A candidate on an older term has not
72    /// observed the latest authority and cannot be promoted.
73    pub term: u64,
74    /// The highest durable WAL LSN for the range.
75    pub lsn: u64,
76}
77
78impl CommitWatermark {
79    pub fn new(term: u64, lsn: u64) -> Self {
80        Self { term, lsn }
81    }
82}
83
84/// Evidence that a candidate has caught up enough to take ownership safely: the
85/// `(term, lsn)` its log has durably applied **for the range**. The supervisor
86/// collects this from the candidate's per-range stream progress (issue #992)
87/// before requesting a transition; the machine admits the candidate only if this
88/// covers the [`CommitWatermark`].
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct CatchUpEvidence {
91    /// The node this evidence describes. Must match the transition target, so a
92    /// planner cannot present one node's progress to promote another.
93    pub candidate: NodeIdentity,
94    /// The highest term the candidate has applied for the range.
95    pub applied_term: u64,
96    /// The highest WAL LSN the candidate has applied for the range.
97    pub applied_lsn: u64,
98}
99
100impl CatchUpEvidence {
101    pub fn new(candidate: NodeIdentity, applied_term: u64, applied_lsn: u64) -> Self {
102        Self {
103            candidate,
104            applied_term,
105            applied_lsn,
106        }
107    }
108
109    /// Does this evidence cover the watermark? The candidate must be on at least
110    /// the watermark term **and**, on that term, have applied at least its LSN. A
111    /// candidate behind on either axis is fenced out of promotion.
112    pub fn covers(&self, watermark: CommitWatermark) -> bool {
113        self.applied_term > watermark.term
114            || (self.applied_term == watermark.term && self.applied_lsn >= watermark.lsn)
115    }
116}
117
118/// Validation receipts required before a compressed archive copy may be used as
119/// a restored recovery source. This is deliberately separate from
120/// [`CatchUpEvidence`]: hot mirror promotion needs live catch-up evidence, while
121/// archive recovery must first prove restore integrity.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct ArchiveRecoveryEvidence {
124    pub restore_validated: bool,
125    pub checksum_validated: bool,
126    pub watermark_validated: bool,
127}
128
129impl ArchiveRecoveryEvidence {
130    pub fn new(
131        restore_validated: bool,
132        checksum_validated: bool,
133        watermark_validated: bool,
134    ) -> Self {
135        Self {
136            restore_validated,
137            checksum_validated,
138            watermark_validated,
139        }
140    }
141}
142
143/// Why a compressed archive copy is not eligible to be used as a recovery
144/// source.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum ArchiveRecoveryRejection {
147    NotArchiveReplica {
148        collection: CollectionId,
149        range_id: RangeId,
150        candidate: NodeIdentity,
151        role: RangeRole,
152    },
153    MissingRestoreValidation {
154        collection: CollectionId,
155        range_id: RangeId,
156        candidate: NodeIdentity,
157    },
158    MissingChecksumValidation {
159        collection: CollectionId,
160        range_id: RangeId,
161        candidate: NodeIdentity,
162    },
163    MissingWatermarkValidation {
164        collection: CollectionId,
165        range_id: RangeId,
166        candidate: NodeIdentity,
167    },
168}
169
170/// Whether a transition is a failover promote or a move-range handoff. Both run
171/// the same safety gate; the kind is recorded for the audit trail and intent.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum TransitionKind {
174    /// Failover promote: a caught-up replica takes authority from a gone/deposed
175    /// owner.
176    Promote,
177    /// Move-range fenced handoff: authority cuts over from a live owner to a
178    /// caught-up target.
179    Handoff,
180}
181
182impl TransitionKind {
183    fn label(self) -> &'static str {
184        match self {
185            TransitionKind::Promote => "promote",
186            TransitionKind::Handoff => "handoff",
187        }
188    }
189}
190
191/// A request to move ownership of one range. Carries the three-part CAS
192/// (expected owner / epoch / catalog version), the target candidate, the range
193/// commit watermark the candidate must cover, and the candidate's catch-up
194/// evidence. Built with [`TransitionRequest::new`]; the replica set the new owner
195/// will carry defaults to empty and is set with
196/// [`with_replicas`](TransitionRequest::with_replicas).
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct TransitionRequest {
199    kind: TransitionKind,
200    collection: CollectionId,
201    range_id: RangeId,
202    expected_owner: NodeIdentity,
203    expected_epoch: OwnershipEpoch,
204    expected_version: CatalogVersion,
205    target: NodeIdentity,
206    watermark: CommitWatermark,
207    evidence: Option<CatchUpEvidence>,
208    new_replicas: Vec<NodeIdentity>,
209}
210
211impl TransitionRequest {
212    /// A transition request with no safety evidence yet and an empty post-cutover
213    /// replica set. Evidence must be attached with
214    /// [`with_evidence`](Self::with_evidence) before the transition can be
215    /// admitted — a request without it fails closed
216    /// ([`MissingSafetyEvidence`](TransitionRejection::MissingSafetyEvidence)).
217    #[allow(clippy::too_many_arguments)]
218    pub fn new(
219        kind: TransitionKind,
220        collection: CollectionId,
221        range_id: RangeId,
222        expected_owner: NodeIdentity,
223        expected_epoch: OwnershipEpoch,
224        expected_version: CatalogVersion,
225        target: NodeIdentity,
226        watermark: CommitWatermark,
227    ) -> Self {
228        Self {
229            kind,
230            collection,
231            range_id,
232            expected_owner,
233            expected_epoch,
234            expected_version,
235            target,
236            watermark,
237            evidence: None,
238            new_replicas: Vec::new(),
239        }
240    }
241
242    /// Attach the candidate's catch-up evidence for the safety gate.
243    pub fn with_evidence(mut self, evidence: CatchUpEvidence) -> Self {
244        self.evidence = Some(evidence);
245        self
246    }
247
248    /// Set the replica set the new owner will carry after cutover. Defaults to
249    /// empty. A handoff that demotes the old owner to a replica passes it here.
250    pub fn with_replicas(mut self, replicas: impl IntoIterator<Item = NodeIdentity>) -> Self {
251        self.new_replicas = replicas.into_iter().collect();
252        self
253    }
254
255    pub fn kind(&self) -> TransitionKind {
256        self.kind
257    }
258
259    pub fn collection(&self) -> &CollectionId {
260        &self.collection
261    }
262
263    pub fn range_id(&self) -> RangeId {
264        self.range_id
265    }
266
267    pub fn target(&self) -> &NodeIdentity {
268        &self.target
269    }
270}
271
272/// Why a target candidate is not eligible to take ownership.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum InvalidCandidateReason {
275    /// The target is not a current replica of the range. Only a replica that has
276    /// been receiving the range's stream can cover the commit watermark, so an
277    /// arbitrary node is never a valid promotion target.
278    NotAReplica,
279    /// The target is a compressed archive replica. Archive data may be a
280    /// recovery source only after restore, checksum validation, and watermark
281    /// validation prove the recovered range is safe.
282    ArchiveReplicaRequiresRestoreValidation,
283    /// The target is already the current owner — a transition to the incumbent is
284    /// a no-op and almost always a planner bug, so it is rejected.
285    AlreadyOwner,
286}
287
288impl InvalidCandidateReason {
289    fn label(self) -> &'static str {
290        match self {
291            InvalidCandidateReason::NotAReplica => "candidate is not a replica of the range",
292            InvalidCandidateReason::ArchiveReplicaRequiresRestoreValidation => {
293                "archive replica requires restore, checksum, and watermark validation"
294            }
295            InvalidCandidateReason::AlreadyOwner => "candidate is already the current owner",
296        }
297    }
298}
299
300/// Why an ownership transition was refused. Every variant leaves the catalog
301/// untouched — transitions fail closed.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub enum TransitionRejection {
304    /// No range with this `(collection, range_id)` exists in the catalog.
305    UnknownRange {
306        collection: CollectionId,
307        range_id: RangeId,
308    },
309    /// The request's expected current owner does not match the catalog. The
310    /// planner is working from a stale view of who owns the range.
311    OwnerMismatch {
312        collection: CollectionId,
313        range_id: RangeId,
314        expected: NodeIdentity,
315        current: NodeIdentity,
316    },
317    /// The request's expected ownership epoch does not match the catalog —
318    /// authority has already moved since the planner read it.
319    StaleEpoch {
320        collection: CollectionId,
321        range_id: RangeId,
322        expected: OwnershipEpoch,
323        current: OwnershipEpoch,
324    },
325    /// The request's expected catalog version does not match the catalog — the
326    /// entry has been edited since the planner read it (CAS failure).
327    StaleCatalogVersion {
328        collection: CollectionId,
329        range_id: RangeId,
330        expected: CatalogVersion,
331        current: CatalogVersion,
332    },
333    /// The target candidate is not eligible to take ownership.
334    InvalidCandidate {
335        collection: CollectionId,
336        range_id: RangeId,
337        candidate: NodeIdentity,
338        reason: InvalidCandidateReason,
339    },
340    /// No safety evidence was supplied for the candidate, so the safety gate
341    /// cannot be evaluated — fail closed.
342    MissingSafetyEvidence {
343        collection: CollectionId,
344        range_id: RangeId,
345        candidate: NodeIdentity,
346    },
347    /// Safety evidence was supplied but describes a different node than the
348    /// target — it cannot vouch for the candidate being promoted.
349    EvidenceForWrongCandidate {
350        collection: CollectionId,
351        range_id: RangeId,
352        target: NodeIdentity,
353        evidence_for: NodeIdentity,
354    },
355    /// The candidate's applied log does not cover the range commit watermark —
356    /// promoting it could lose committed writes, so the transition is refused.
357    SafetyCheckFailed {
358        collection: CollectionId,
359        range_id: RangeId,
360        candidate: NodeIdentity,
361        watermark: CommitWatermark,
362        applied_term: u64,
363        applied_lsn: u64,
364    },
365}
366
367impl std::fmt::Display for TransitionRejection {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        match self {
370            Self::UnknownRange {
371                collection,
372                range_id,
373            } => write!(f, "no range {collection}/{range_id} in the catalog"),
374            Self::OwnerMismatch {
375                collection,
376                range_id,
377                expected,
378                current,
379            } => write!(
380                f,
381                "ownership transition for {collection}/{range_id} expected current owner {expected}, but catalog owner is {current}"
382            ),
383            Self::StaleEpoch {
384                collection,
385                range_id,
386                expected,
387                current,
388            } => write!(
389                f,
390                "ownership transition for {collection}/{range_id} expected epoch {expected}, but catalog epoch is {current}"
391            ),
392            Self::StaleCatalogVersion {
393                collection,
394                range_id,
395                expected,
396                current,
397            } => write!(
398                f,
399                "ownership transition for {collection}/{range_id} expected catalog version {expected}, but catalog version is {current}"
400            ),
401            Self::InvalidCandidate {
402                collection,
403                range_id,
404                candidate,
405                reason,
406            } => write!(
407                f,
408                "invalid ownership transition candidate {candidate} for {collection}/{range_id}: {}",
409                reason.label()
410            ),
411            Self::MissingSafetyEvidence {
412                collection,
413                range_id,
414                candidate,
415            } => write!(
416                f,
417                "ownership transition for {collection}/{range_id} carries no safety evidence for candidate {candidate}"
418            ),
419            Self::EvidenceForWrongCandidate {
420                collection,
421                range_id,
422                target,
423                evidence_for,
424            } => write!(
425                f,
426                "ownership transition for {collection}/{range_id} targets {target} but its safety evidence describes {evidence_for}"
427            ),
428            Self::SafetyCheckFailed {
429                collection,
430                range_id,
431                candidate,
432                watermark,
433                applied_term,
434                applied_lsn,
435            } => write!(
436                f,
437                "candidate {candidate} for {collection}/{range_id} is behind the commit watermark (term {}, lsn {}): applied term {applied_term}, lsn {applied_lsn}",
438                watermark.term, watermark.lsn
439            ),
440        }
441    }
442}
443
444impl std::error::Error for TransitionRejection {}
445
446/// A validated, not-yet-applied ownership transition. Holding one is proof that
447/// every CAS and safety check passed; the only thing left is to
448/// [`activate`](Self::activate) it against the catalog. It carries the full
449/// before/after picture so it doubles as the audit record source.
450#[derive(Debug, Clone, PartialEq, Eq)]
451pub struct PreparedTransition {
452    kind: TransitionKind,
453    collection: CollectionId,
454    range_id: RangeId,
455    previous_owner: NodeIdentity,
456    new_owner: NodeIdentity,
457    previous_epoch: OwnershipEpoch,
458    previous_version: CatalogVersion,
459    watermark: CommitWatermark,
460    next: RangeOwnership,
461}
462
463impl PreparedTransition {
464    /// The new ownership entry that activation will install — epoch and version
465    /// already advanced past the current entry.
466    pub fn next_entry(&self) -> &RangeOwnership {
467        &self.next
468    }
469
470    /// The ownership epoch the new owner becomes authoritative under. Any write
471    /// the old owner attempts under the previous epoch is fenced once this is
472    /// installed.
473    pub fn new_epoch(&self) -> OwnershipEpoch {
474        self.next.epoch()
475    }
476
477    /// Apply the transition to the catalog, making the new owner authoritative
478    /// and fencing the old owner via the epoch bump. Returns the audit-ready
479    /// [`TransitionOutcome`]. Errors only on a catalog-level inconsistency
480    /// (e.g. the entry changed between prepare and activate so the version no
481    /// longer strictly advances) — the safety decision itself was already made.
482    pub fn activate(
483        self,
484        catalog: &mut ShardOwnershipCatalog,
485    ) -> Result<TransitionOutcome, CatalogError> {
486        let new_epoch = self.next.epoch();
487        let new_version = self.next.version();
488        catalog.apply_update(self.next)?;
489        Ok(TransitionOutcome {
490            kind: self.kind,
491            collection: self.collection,
492            range_id: self.range_id,
493            previous_owner: self.previous_owner,
494            new_owner: self.new_owner,
495            previous_epoch: self.previous_epoch,
496            new_epoch,
497            previous_version: self.previous_version,
498            new_version,
499            watermark: self.watermark,
500        })
501    }
502}
503
504/// The audit-ready record of an activated ownership transition. Every field a
505/// reviewer needs to reconstruct *what moved, from whom to whom, and across which
506/// epoch/version boundary* — the fenced before/after the ADR's audit requirement
507/// asks for.
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct TransitionOutcome {
510    pub kind: TransitionKind,
511    pub collection: CollectionId,
512    pub range_id: RangeId,
513    pub previous_owner: NodeIdentity,
514    pub new_owner: NodeIdentity,
515    pub previous_epoch: OwnershipEpoch,
516    pub new_epoch: OwnershipEpoch,
517    pub previous_version: CatalogVersion,
518    pub new_version: CatalogVersion,
519    pub watermark: CommitWatermark,
520}
521
522impl TransitionOutcome {
523    /// Whether the epoch advanced — true for every accepted transition, since
524    /// moving write authority always fences the old owner. A handy invariant for
525    /// audit assertions.
526    pub fn fenced_old_owner(&self) -> bool {
527        self.new_epoch > self.previous_epoch
528    }
529}
530
531impl std::fmt::Display for TransitionOutcome {
532    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
533        write!(
534            f,
535            "{} {}/{}: {} (epoch {}, version {}) -> {} (epoch {}, version {}) over watermark term {} lsn {}",
536            self.kind.label(),
537            self.collection,
538            self.range_id,
539            self.previous_owner,
540            self.previous_epoch,
541            self.previous_version,
542            self.new_owner,
543            self.new_epoch,
544            self.new_version,
545            self.watermark.term,
546            self.watermark.lsn,
547        )
548    }
549}
550
551/// Validate `request` against `catalog` without mutating anything. On success
552/// returns a [`PreparedTransition`] ready to [`activate`]; on any failed CAS or
553/// safety check returns a [`TransitionRejection`] and leaves the catalog
554/// untouched.
555///
556/// Checks run in fail-closed order: the range must exist; the expected owner,
557/// epoch, and catalog version must all match (CAS); the target must be a current
558/// replica that is not already the owner; and the candidate's safety evidence
559/// must be present, describe the target, and cover the commit watermark.
560///
561/// [`activate`]: PreparedTransition::activate
562pub fn prepare(
563    catalog: &ShardOwnershipCatalog,
564    request: &TransitionRequest,
565) -> Result<PreparedTransition, TransitionRejection> {
566    let current = catalog.range(&request.collection, request.range_id).ok_or(
567        TransitionRejection::UnknownRange {
568            collection: request.collection.clone(),
569            range_id: request.range_id,
570        },
571    )?;
572
573    // Three-part compare-and-swap: a planner working from a stale catalog view
574    // loses on whichever axis drifted first.
575    if *current.owner() != request.expected_owner {
576        return Err(TransitionRejection::OwnerMismatch {
577            collection: request.collection.clone(),
578            range_id: request.range_id,
579            expected: request.expected_owner.clone(),
580            current: current.owner().clone(),
581        });
582    }
583    if current.epoch() != request.expected_epoch {
584        return Err(TransitionRejection::StaleEpoch {
585            collection: request.collection.clone(),
586            range_id: request.range_id,
587            expected: request.expected_epoch,
588            current: current.epoch(),
589        });
590    }
591    if current.version() != request.expected_version {
592        return Err(TransitionRejection::StaleCatalogVersion {
593            collection: request.collection.clone(),
594            range_id: request.range_id,
595            expected: request.expected_version,
596            current: current.version(),
597        });
598    }
599
600    // Candidate eligibility: a valid target is a hot replica of the range and
601    // not the incumbent owner. Compressed archive replicas are recovery sources,
602    // not direct promotion candidates.
603    match current.role_of(&request.target) {
604        RangeRole::Owner => {
605            return Err(TransitionRejection::InvalidCandidate {
606                collection: request.collection.clone(),
607                range_id: request.range_id,
608                candidate: request.target.clone(),
609                reason: InvalidCandidateReason::AlreadyOwner,
610            });
611        }
612        RangeRole::Replica => {}
613        RangeRole::ArchiveReplica => {
614            return Err(TransitionRejection::InvalidCandidate {
615                collection: request.collection.clone(),
616                range_id: request.range_id,
617                candidate: request.target.clone(),
618                reason: InvalidCandidateReason::ArchiveReplicaRequiresRestoreValidation,
619            });
620        }
621        RangeRole::NoCopy => {
622            return Err(TransitionRejection::InvalidCandidate {
623                collection: request.collection.clone(),
624                range_id: request.range_id,
625                candidate: request.target.clone(),
626                reason: InvalidCandidateReason::NotAReplica,
627            });
628        }
629    }
630
631    // Safety gate: evidence must exist, vouch for the target, and cover the
632    // range commit watermark.
633    let evidence =
634        request
635            .evidence
636            .as_ref()
637            .ok_or_else(|| TransitionRejection::MissingSafetyEvidence {
638                collection: request.collection.clone(),
639                range_id: request.range_id,
640                candidate: request.target.clone(),
641            })?;
642    if evidence.candidate != request.target {
643        return Err(TransitionRejection::EvidenceForWrongCandidate {
644            collection: request.collection.clone(),
645            range_id: request.range_id,
646            target: request.target.clone(),
647            evidence_for: evidence.candidate.clone(),
648        });
649    }
650    if !evidence.covers(request.watermark) {
651        return Err(TransitionRejection::SafetyCheckFailed {
652            collection: request.collection.clone(),
653            range_id: request.range_id,
654            candidate: request.target.clone(),
655            watermark: request.watermark,
656            applied_term: evidence.applied_term,
657            applied_lsn: evidence.applied_lsn,
658        });
659    }
660
661    // All checks passed — build the fenced transition (epoch + version bumped).
662    let next = current.transfer_to(request.target.clone(), request.new_replicas.clone());
663    Ok(PreparedTransition {
664        kind: request.kind,
665        collection: request.collection.clone(),
666        range_id: request.range_id,
667        previous_owner: current.owner().clone(),
668        new_owner: request.target.clone(),
669        previous_epoch: current.epoch(),
670        previous_version: current.version(),
671        watermark: request.watermark,
672        next,
673    })
674}
675
676/// Prepare and activate a transition in one step — the common path when the
677/// caller does not need to inspect the [`PreparedTransition`] between the safety
678/// check and the catalog write. A [`TransitionRejection`] from prepare is mapped
679/// into the returned error.
680pub fn run_transition(
681    catalog: &mut ShardOwnershipCatalog,
682    request: &TransitionRequest,
683) -> Result<TransitionOutcome, TransitionError> {
684    let prepared = prepare(catalog, request)?;
685    prepared.activate(catalog).map_err(TransitionError::Catalog)
686}
687
688/// Validate that a compressed archive copy may be used as a recovery source.
689/// This never prepares a direct ownership transition; a recovered range must be
690/// restored and then enter the ordinary hot-replica path before promotion.
691pub fn validate_archive_recovery_source(
692    catalog: &ShardOwnershipCatalog,
693    collection: &CollectionId,
694    range_id: RangeId,
695    candidate: &NodeIdentity,
696    evidence: ArchiveRecoveryEvidence,
697) -> Result<(), ArchiveRecoveryRejection> {
698    let role = catalog
699        .role_at(candidate, collection, range_id)
700        .unwrap_or(RangeRole::NoCopy);
701    if role != RangeRole::ArchiveReplica {
702        return Err(ArchiveRecoveryRejection::NotArchiveReplica {
703            collection: collection.clone(),
704            range_id,
705            candidate: candidate.clone(),
706            role,
707        });
708    }
709    if !evidence.restore_validated {
710        return Err(ArchiveRecoveryRejection::MissingRestoreValidation {
711            collection: collection.clone(),
712            range_id,
713            candidate: candidate.clone(),
714        });
715    }
716    if !evidence.checksum_validated {
717        return Err(ArchiveRecoveryRejection::MissingChecksumValidation {
718            collection: collection.clone(),
719            range_id,
720            candidate: candidate.clone(),
721        });
722    }
723    if !evidence.watermark_validated {
724        return Err(ArchiveRecoveryRejection::MissingWatermarkValidation {
725            collection: collection.clone(),
726            range_id,
727            candidate: candidate.clone(),
728        });
729    }
730    Ok(())
731}
732
733/// The error of an end-to-end [`run_transition`]: either the safety gate refused
734/// the transition, or the catalog rejected the activation write.
735#[derive(Debug, Clone, PartialEq, Eq)]
736pub enum TransitionError {
737    /// A CAS or safety check failed during prepare.
738    Rejected(TransitionRejection),
739    /// The catalog refused the activation write.
740    Catalog(CatalogError),
741}
742
743impl From<TransitionRejection> for TransitionError {
744    fn from(value: TransitionRejection) -> Self {
745        TransitionError::Rejected(value)
746    }
747}
748
749impl std::fmt::Display for TransitionError {
750    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
751        match self {
752            Self::Rejected(err) => write!(f, "{err}"),
753            Self::Catalog(err) => write!(f, "{err}"),
754        }
755    }
756}
757
758impl std::error::Error for TransitionError {}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763    use crate::cluster::ownership::{
764        OwnershipEpoch, PlacementMetadata, RangeBounds, RangeRole, RangeWriteReject, ShardKeyMode,
765    };
766
767    fn collection(name: &str) -> CollectionId {
768        CollectionId::new(name).unwrap()
769    }
770
771    fn ident(cn: &str) -> NodeIdentity {
772        NodeIdentity::from_certificate_subject(cn).unwrap()
773    }
774
775    /// A catalog holding one full-keyspace range owned by `owner` with `replicas`.
776    fn catalog_with(owner: &str, replicas: &[&str]) -> (ShardOwnershipCatalog, CollectionId) {
777        let orders = collection("orders");
778        let mut catalog = ShardOwnershipCatalog::new();
779        catalog
780            .apply_update(RangeOwnership::establish(
781                orders.clone(),
782                RangeId::new(1),
783                ShardKeyMode::Hash,
784                RangeBounds::full(),
785                ident(owner),
786                replicas.iter().map(|r| ident(r)).collect::<Vec<_>>(),
787                PlacementMetadata::with_replication_factor(3),
788            ))
789            .unwrap();
790        (catalog, orders)
791    }
792
793    fn catalog_with_archive(
794        owner: &str,
795        replicas: &[&str],
796        archive_replicas: &[&str],
797    ) -> (ShardOwnershipCatalog, CollectionId) {
798        let (mut catalog, orders) = catalog_with(owner, replicas);
799        let range = catalog.range(&orders, RangeId::new(1)).unwrap();
800        let archived = range.update_replica_roles(
801            range.replicas().iter().cloned(),
802            archive_replicas.iter().map(|r| ident(r)),
803        );
804        catalog.apply_update(archived).unwrap();
805        (catalog, orders)
806    }
807
808    /// A request that, by default, names the catalog's current owner/epoch/version
809    /// and a watermark/evidence the candidate covers.
810    fn request(
811        kind: TransitionKind,
812        orders: &CollectionId,
813        expected_owner: &str,
814        target: &str,
815    ) -> TransitionRequest {
816        TransitionRequest::new(
817            kind,
818            orders.clone(),
819            RangeId::new(1),
820            ident(expected_owner),
821            OwnershipEpoch::initial(),
822            CatalogVersion::initial(),
823            ident(target),
824            CommitWatermark::new(1, 10),
825        )
826        .with_evidence(CatchUpEvidence::new(ident(target), 1, 10))
827    }
828
829    #[test]
830    fn successful_promote_moves_authority_and_bumps_epoch() {
831        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
832        let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b");
833
834        let outcome = run_transition(&mut catalog, &req).expect("promote should succeed");
835
836        assert_eq!(outcome.kind, TransitionKind::Promote);
837        assert_eq!(outcome.previous_owner, ident("CN=node-a"));
838        assert_eq!(outcome.new_owner, ident("CN=node-b"));
839        assert_eq!(outcome.previous_epoch, OwnershipEpoch::initial());
840        assert_eq!(outcome.new_epoch.value(), 2);
841        assert_eq!(outcome.new_version.value(), 2);
842        assert!(outcome.fenced_old_owner());
843
844        // The catalog now makes node-b authoritative for the range.
845        let range = catalog.range(&orders, RangeId::new(1)).unwrap();
846        assert_eq!(range.owner(), &ident("CN=node-b"));
847        assert_eq!(range.epoch().value(), 2);
848        // Audit string mentions both owners and the kind.
849        let audit = outcome.to_string();
850        assert!(audit.contains("promote"));
851        assert!(audit.contains("CN=node-a"));
852        assert!(audit.contains("CN=node-b"));
853    }
854
855    #[test]
856    fn successful_handoff_demotes_old_owner_to_replica() {
857        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
858        // Move-range cutover: hand off to node-b, keep node-a as a replica.
859        let req = request(TransitionKind::Handoff, &orders, "CN=node-a", "CN=node-b")
860            .with_replicas([ident("CN=node-a")]);
861
862        let outcome = run_transition(&mut catalog, &req).expect("handoff should succeed");
863        assert_eq!(outcome.kind, TransitionKind::Handoff);
864
865        let range = catalog.range(&orders, RangeId::new(1)).unwrap();
866        assert_eq!(range.owner(), &ident("CN=node-b"));
867        assert_eq!(range.role_of(&ident("CN=node-a")), RangeRole::Replica);
868        assert_eq!(range.role_of(&ident("CN=node-b")), RangeRole::Owner);
869    }
870
871    #[test]
872    fn old_owner_is_fenced_from_durable_writes_after_transition() {
873        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
874        // Before the transition node-a is admitted at the initial epoch.
875        assert!(catalog
876            .admit_public_write(
877                &ident("CN=node-a"),
878                &orders,
879                b"k",
880                OwnershipEpoch::initial()
881            )
882            .is_ok());
883
884        let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b")
885            .with_replicas([ident("CN=node-a")]);
886        run_transition(&mut catalog, &req).unwrap();
887
888        // node-a, now a replica at the *old* epoch, is fenced: it is no longer the
889        // owner, so a public durable write is rejected.
890        let err = catalog
891            .admit_public_write(
892                &ident("CN=node-a"),
893                &orders,
894                b"k",
895                OwnershipEpoch::initial(),
896            )
897            .unwrap_err();
898        assert!(matches!(err, RangeWriteReject::NotOwner { .. }));
899
900        // Even if node-a still believed it was owner, its old epoch is stale.
901        // node-b at the new epoch is the one admitted.
902        assert!(catalog
903            .admit_public_write(
904                &ident("CN=node-b"),
905                &orders,
906                b"k",
907                catalog.range(&orders, RangeId::new(1)).unwrap().epoch()
908            )
909            .is_ok());
910    }
911
912    #[test]
913    fn prepare_does_not_mutate_catalog() {
914        let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
915        let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b");
916        let _prepared = prepare(&catalog, &req).expect("prepare ok");
917        // Catalog still has node-a as owner at the initial epoch — activation is a
918        // separate step.
919        let range = catalog.range(&orders, RangeId::new(1)).unwrap();
920        assert_eq!(range.owner(), &ident("CN=node-a"));
921        assert_eq!(range.epoch(), OwnershipEpoch::initial());
922    }
923
924    /// The version/epoch a single unapplied `transfer_to` would advance the
925    /// current range to — used to obtain non-initial values without the private
926    /// `next()` constructor.
927    fn bumped(catalog: &ShardOwnershipCatalog, orders: &CollectionId) -> RangeOwnership {
928        catalog
929            .range(orders, RangeId::new(1))
930            .unwrap()
931            .transfer_to(ident("CN=tmp"), [])
932    }
933
934    #[test]
935    fn stale_catalog_version_is_rejected() {
936        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
937        // Advance the entry to version 2 without moving the owner or epoch
938        // (a replica-set change), so only the planner's version is stale.
939        let v2_entry = catalog
940            .range(&orders, RangeId::new(1))
941            .unwrap()
942            .update_replicas([ident("CN=node-b")]);
943        catalog.apply_update(v2_entry).unwrap();
944
945        let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b");
946        // The default request still carries the initial (stale) catalog version.
947        let err = prepare(&catalog, &req).unwrap_err();
948        match err {
949            TransitionRejection::StaleCatalogVersion {
950                expected, current, ..
951            } => {
952                assert_eq!(expected, CatalogVersion::initial());
953                assert_eq!(current.value(), 2);
954            }
955            other => panic!("expected StaleCatalogVersion, got {other:?}"),
956        }
957    }
958
959    #[test]
960    fn stale_expected_owner_is_rejected() {
961        let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
962        // Planner believes node-x owns the range.
963        let req = request(TransitionKind::Promote, &orders, "CN=node-x", "CN=node-b");
964        let err = prepare(&catalog, &req).unwrap_err();
965        match err {
966            TransitionRejection::OwnerMismatch {
967                expected, current, ..
968            } => {
969                assert_eq!(expected, ident("CN=node-x"));
970                assert_eq!(current, ident("CN=node-a"));
971            }
972            other => panic!("expected OwnerMismatch, got {other:?}"),
973        }
974    }
975
976    #[test]
977    fn stale_expected_epoch_is_rejected() {
978        let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
979        // A non-initial epoch value (2), obtained without the private `next()`.
980        let wrong_epoch = bumped(&catalog, &orders).epoch();
981        assert_eq!(wrong_epoch.value(), 2);
982        let req = TransitionRequest::new(
983            TransitionKind::Promote,
984            orders.clone(),
985            RangeId::new(1),
986            ident("CN=node-a"),
987            wrong_epoch,
988            CatalogVersion::initial(),
989            ident("CN=node-b"),
990            CommitWatermark::new(1, 10),
991        )
992        .with_evidence(CatchUpEvidence::new(ident("CN=node-b"), 1, 10));
993        let err = prepare(&catalog, &req).unwrap_err();
994        assert!(matches!(err, TransitionRejection::StaleEpoch { .. }));
995    }
996
997    #[test]
998    fn invalid_candidate_not_a_replica_is_rejected() {
999        let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1000        // node-z holds no copy of the range.
1001        let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-z");
1002        let err = prepare(&catalog, &req).unwrap_err();
1003        match err {
1004            TransitionRejection::InvalidCandidate { reason, .. } => {
1005                assert_eq!(reason, InvalidCandidateReason::NotAReplica);
1006            }
1007            other => panic!("expected InvalidCandidate(NotAReplica), got {other:?}"),
1008        }
1009    }
1010
1011    #[test]
1012    fn archive_replica_is_not_a_direct_promotion_candidate() {
1013        let (catalog, orders) =
1014            catalog_with_archive("CN=node-a", &["CN=node-b"], &["CN=archive-a"]);
1015        let version = catalog.range(&orders, RangeId::new(1)).unwrap().version();
1016        let req = TransitionRequest::new(
1017            TransitionKind::Promote,
1018            orders.clone(),
1019            RangeId::new(1),
1020            ident("CN=node-a"),
1021            OwnershipEpoch::initial(),
1022            version,
1023            ident("CN=archive-a"),
1024            CommitWatermark::new(1, 10),
1025        )
1026        .with_evidence(CatchUpEvidence::new(ident("CN=archive-a"), 1, 10));
1027
1028        let err = prepare(&catalog, &req).unwrap_err();
1029        match err {
1030            TransitionRejection::InvalidCandidate { reason, .. } => {
1031                assert_eq!(
1032                    reason,
1033                    InvalidCandidateReason::ArchiveReplicaRequiresRestoreValidation
1034                );
1035            }
1036            other => panic!("expected archive replica rejection, got {other:?}"),
1037        }
1038    }
1039
1040    #[test]
1041    fn archive_recovery_rejects_missing_restore_validation() {
1042        let (catalog, orders) = catalog_with_archive("CN=node-a", &[], &["CN=archive-a"]);
1043        let err = validate_archive_recovery_source(
1044            &catalog,
1045            &orders,
1046            RangeId::new(1),
1047            &ident("CN=archive-a"),
1048            ArchiveRecoveryEvidence::new(false, true, true),
1049        )
1050        .unwrap_err();
1051        assert!(matches!(
1052            err,
1053            ArchiveRecoveryRejection::MissingRestoreValidation { .. }
1054        ));
1055    }
1056
1057    #[test]
1058    fn archive_recovery_rejects_missing_checksum_validation() {
1059        let (catalog, orders) = catalog_with_archive("CN=node-a", &[], &["CN=archive-a"]);
1060        let err = validate_archive_recovery_source(
1061            &catalog,
1062            &orders,
1063            RangeId::new(1),
1064            &ident("CN=archive-a"),
1065            ArchiveRecoveryEvidence::new(true, false, true),
1066        )
1067        .unwrap_err();
1068        assert!(matches!(
1069            err,
1070            ArchiveRecoveryRejection::MissingChecksumValidation { .. }
1071        ));
1072    }
1073
1074    #[test]
1075    fn archive_recovery_rejects_missing_watermark_validation() {
1076        let (catalog, orders) = catalog_with_archive("CN=node-a", &[], &["CN=archive-a"]);
1077        let err = validate_archive_recovery_source(
1078            &catalog,
1079            &orders,
1080            RangeId::new(1),
1081            &ident("CN=archive-a"),
1082            ArchiveRecoveryEvidence::new(true, true, false),
1083        )
1084        .unwrap_err();
1085        assert!(matches!(
1086            err,
1087            ArchiveRecoveryRejection::MissingWatermarkValidation { .. }
1088        ));
1089    }
1090
1091    #[test]
1092    fn archive_recovery_and_hot_mirror_promotion_use_different_paths() {
1093        let (mut catalog, orders) =
1094            catalog_with_archive("CN=node-a", &["CN=node-b"], &["CN=archive-a"]);
1095        let version = catalog.range(&orders, RangeId::new(1)).unwrap().version();
1096
1097        let hot_mirror = TransitionRequest::new(
1098            TransitionKind::Promote,
1099            orders.clone(),
1100            RangeId::new(1),
1101            ident("CN=node-a"),
1102            OwnershipEpoch::initial(),
1103            version,
1104            ident("CN=node-b"),
1105            CommitWatermark::new(1, 10),
1106        )
1107        .with_evidence(CatchUpEvidence::new(ident("CN=node-b"), 1, 10));
1108        run_transition(&mut catalog, &hot_mirror).expect("hot mirror promotion succeeds");
1109
1110        let (catalog, orders) =
1111            catalog_with_archive("CN=node-a", &["CN=node-b"], &["CN=archive-a"]);
1112        validate_archive_recovery_source(
1113            &catalog,
1114            &orders,
1115            RangeId::new(1),
1116            &ident("CN=archive-a"),
1117            ArchiveRecoveryEvidence::new(true, true, true),
1118        )
1119        .expect("archive recovery accepts only after restore integrity validation");
1120    }
1121
1122    #[test]
1123    fn invalid_candidate_already_owner_is_rejected() {
1124        let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1125        // Targeting the incumbent owner is a no-op transition.
1126        let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-a")
1127            .with_evidence(CatchUpEvidence::new(ident("CN=node-a"), 1, 10));
1128        let err = prepare(&catalog, &req).unwrap_err();
1129        match err {
1130            TransitionRejection::InvalidCandidate { reason, .. } => {
1131                assert_eq!(reason, InvalidCandidateReason::AlreadyOwner);
1132            }
1133            other => panic!("expected InvalidCandidate(AlreadyOwner), got {other:?}"),
1134        }
1135    }
1136
1137    #[test]
1138    fn missing_safety_evidence_fails_closed() {
1139        let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1140        let req = TransitionRequest::new(
1141            TransitionKind::Promote,
1142            orders.clone(),
1143            RangeId::new(1),
1144            ident("CN=node-a"),
1145            OwnershipEpoch::initial(),
1146            CatalogVersion::initial(),
1147            ident("CN=node-b"),
1148            CommitWatermark::new(1, 10),
1149        ); // no evidence attached
1150        let err = prepare(&catalog, &req).unwrap_err();
1151        assert!(matches!(
1152            err,
1153            TransitionRejection::MissingSafetyEvidence { .. }
1154        ));
1155    }
1156
1157    #[test]
1158    fn evidence_for_a_different_candidate_is_rejected() {
1159        let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1160        // Target node-b but present node-c's progress as the evidence.
1161        let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b")
1162            .with_evidence(CatchUpEvidence::new(ident("CN=node-c"), 9, 99));
1163        let err = prepare(&catalog, &req).unwrap_err();
1164        assert!(matches!(
1165            err,
1166            TransitionRejection::EvidenceForWrongCandidate { .. }
1167        ));
1168    }
1169
1170    #[test]
1171    fn candidate_behind_commit_watermark_fails_safety_check() {
1172        let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1173        // Watermark is term 2 / lsn 50, but the candidate only applied term 2 lsn 49.
1174        let req = TransitionRequest::new(
1175            TransitionKind::Promote,
1176            orders.clone(),
1177            RangeId::new(1),
1178            ident("CN=node-a"),
1179            OwnershipEpoch::initial(),
1180            CatalogVersion::initial(),
1181            ident("CN=node-b"),
1182            CommitWatermark::new(2, 50),
1183        )
1184        .with_evidence(CatchUpEvidence::new(ident("CN=node-b"), 2, 49));
1185        let err = prepare(&catalog, &req).unwrap_err();
1186        match err {
1187            TransitionRejection::SafetyCheckFailed {
1188                applied_lsn,
1189                watermark,
1190                ..
1191            } => {
1192                assert_eq!(applied_lsn, 49);
1193                assert_eq!(watermark, CommitWatermark::new(2, 50));
1194            }
1195            other => panic!("expected SafetyCheckFailed, got {other:?}"),
1196        }
1197    }
1198
1199    #[test]
1200    fn candidate_on_older_term_fails_even_with_higher_lsn() {
1201        let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1202        // A higher LSN on a stale term does not cover the watermark.
1203        let req = TransitionRequest::new(
1204            TransitionKind::Promote,
1205            orders.clone(),
1206            RangeId::new(1),
1207            ident("CN=node-a"),
1208            OwnershipEpoch::initial(),
1209            CatalogVersion::initial(),
1210            ident("CN=node-b"),
1211            CommitWatermark::new(3, 10),
1212        )
1213        .with_evidence(CatchUpEvidence::new(ident("CN=node-b"), 2, 9999));
1214        let err = prepare(&catalog, &req).unwrap_err();
1215        assert!(matches!(err, TransitionRejection::SafetyCheckFailed { .. }));
1216    }
1217
1218    #[test]
1219    fn evidence_on_newer_term_covers_watermark() {
1220        // A candidate ahead on term covers the watermark regardless of LSN.
1221        let evidence = CatchUpEvidence::new(ident("CN=node-b"), 5, 0);
1222        assert!(evidence.covers(CommitWatermark::new(4, 9999)));
1223    }
1224
1225    #[test]
1226    fn rejected_transition_leaves_catalog_unchanged() {
1227        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1228        let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-z");
1229        assert!(run_transition(&mut catalog, &req).is_err());
1230        // Ownership and epoch are exactly as before.
1231        let range = catalog.range(&orders, RangeId::new(1)).unwrap();
1232        assert_eq!(range.owner(), &ident("CN=node-a"));
1233        assert_eq!(range.epoch(), OwnershipEpoch::initial());
1234        assert_eq!(range.version(), CatalogVersion::initial());
1235    }
1236
1237    #[test]
1238    fn second_transition_with_stale_cas_loses() {
1239        // Two planners race: the first promote wins and moves to v2/epoch2; the
1240        // second, still holding the v1 CAS, is rejected.
1241        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b", "CN=node-c"]);
1242        let first = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b");
1243        run_transition(&mut catalog, &first).unwrap();
1244
1245        // Second planner still thinks node-a owns it at the initial epoch/version.
1246        let second = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-c");
1247        let err = run_transition(&mut catalog, &second).unwrap_err();
1248        assert!(matches!(
1249            err,
1250            TransitionError::Rejected(TransitionRejection::OwnerMismatch { .. })
1251        ));
1252    }
1253}