Skip to main content

laminar_core/cluster/control/controller/
mod.rs

1//! Facade over `ClusterKv` + `BarrierCoordinator` + membership watch.
2//! `None` on `CheckpointCoordinator` means single-instance mode.
3
4use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
5use std::sync::Arc;
6use std::time::Duration;
7
8use futures::StreamExt as _;
9use sha2::{Digest as _, Sha256};
10use tokio::sync::watch;
11use uuid::Uuid;
12
13use super::barrier::{
14    BarrierAck, BarrierAnnouncement, BarrierCoordinator, ClusterKv, QuorumOutcome,
15};
16use super::leader::leader_of;
17use super::snapshot::AssignmentSnapshotStore;
18use crate::checkpoint::{
19    AssignmentDrainId, AssignmentDrainTransition, CheckpointAssignmentAdoption,
20    CheckpointAssignmentFence, CheckpointParticipant, LeaderProof, MAX_CHECKPOINT_PARTICIPANTS,
21};
22use crate::cluster::discovery::{assignable_node_ids, NodeId, NodeInfo, NodeState};
23use crate::state::Locality;
24
25const RECOVERY_INCARNATION_KEY: &str = "control:recovery-incarnation";
26const ADOPTED_ASSIGNMENT_KEY: &str = "control:adopted-assignment";
27const DRAIN_ACK_KEY: &str = "control:drain-ack";
28const DRAIN_ACK_PROTOCOL_VERSION: u16 = 1;
29const RELEASE_READY_ACK_KEY: &str = "control:recovery-release-ready";
30const RELEASE_READY_PROTOCOL_VERSION: u16 = 2;
31const RECOVERY_STOPPED_REPORT_KEY: &str = "control:recovery-stopped";
32/// Current wire version for a recovery stopped report.
33const RECOVERY_STOPPED_REPORT_PROTOCOL_VERSION: u16 = 4;
34/// Maximum encoded size of one recovery stopped report.
35const MAX_RECOVERY_STOPPED_REPORT_BYTES: usize = 1_024;
36/// Shared ceiling for mutable recovery intents and immutable release terminals.
37pub(super) const MAX_RECOVERY_ANNOUNCEMENT_BYTES: usize = 256 * 1_024;
38const RECOVERY_CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(5);
39const MAX_ADOPTED_ASSIGNMENT_BYTES: usize = 1_024;
40const MAX_DRAIN_ACK_BYTES: usize = 1_024;
41const MAX_RELEASE_READY_ACK_BYTES: usize = 1_024;
42const CONTROL_ROSTER_IO_CONCURRENCY: usize = 32;
43const PENDING_RELEASE_FAULT_AUDIT_INTERVAL: Duration = Duration::from_secs(1);
44#[cfg(feature = "cluster")]
45const CHECKPOINT_PREPARE_OBSERVATION_TIMEOUT: Duration = Duration::from_secs(30);
46
47#[cfg(feature = "cluster")]
48struct LeaderLeaseGate {
49    lease: watch::Receiver<Option<super::LeaderLease>>,
50    owner: super::LeaderLeaseOwner,
51    deadline: watch::Receiver<Arc<super::LeaseDeadline>>,
52}
53
54/// One authority-validated clustered `Prepare` and its local assignment disposition.
55#[cfg(feature = "cluster")]
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum CheckpointPrepareObservation {
58    /// The announced assignment exactly matches the locally installed certified assignment.
59    AssignmentReady(BarrierAnnouncement),
60    /// Authority is valid, but the assignment must be rejected before barrier injection.
61    AssignmentRejected {
62        /// Leader announcement to identify the negative acknowledgement.
63        announcement: BarrierAnnouncement,
64        /// Local certification failure sent back to the leader.
65        error: String,
66    },
67}
68
69/// Immutable identity of one coordinated recovery attempt.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
71pub struct RecoveryRoundId {
72    /// Monotonic recovery generation.
73    pub generation: u64,
74    /// Random attempt identity. This prevents a restarted driver from reusing a generation.
75    pub nonce: Uuid,
76    /// Node that owns and drives this round.
77    pub driver: NodeId,
78}
79
80/// Frozen recovery round, assignment certificate, and durable driver term.
81#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct RecoveryRound {
84    /// Unique round identity.
85    pub id: RecoveryRoundId,
86    /// Exact durable leader term that created and may advance this round.
87    pub leader_proof: LeaderProof,
88    /// Exact owner-complete assignment cut from which the quorum was frozen.
89    pub assignment_fence: CheckpointAssignmentFence,
90    /// Exact non-owner processes whose fault evidence must be inventoried before recovery starts.
91    ///
92    /// These participants stop and report Prepared checkpoint evidence, but do not join the
93    /// assignment-owner restore or release quorum.
94    pub evidence_participants: Vec<CheckpointParticipant>,
95    /// Exact shared-authority fault inventory revision frozen for this round.
96    fault_revision: u64,
97    /// Canonical nonzero fault reports covered by this round's terminal `Release`.
98    pub faults: Vec<RecoveryFault>,
99}
100
101/// One durable fault report covered by a coordinated recovery round.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct RecoveryFault {
105    /// Stable node slot that published the report.
106    pub reporter: NodeId,
107    /// Nonzero globally monotonic authority sequence observed by the recovery driver.
108    pub sequence: u64,
109    /// Whether automatic recovery may consume this fault.
110    #[serde(
111        default,
112        skip_serializing_if = "RecoveryFaultDisposition::is_recoverable"
113    )]
114    pub disposition: RecoveryFaultDisposition,
115}
116
117/// Durable recovery policy attached to one fault authority slot.
118#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum RecoveryFaultDisposition {
121    /// A coordinated rewind may consume the fault after a committed Release.
122    #[default]
123    Recoverable,
124    /// The failure is deterministic and must remain fenced until an operator replaces the
125    /// cluster authority namespace. Automatic recovery must never consume or downgrade it.
126    Terminal,
127}
128
129impl RecoveryFaultDisposition {
130    /// Whether the legacy automatic-recovery policy is in effect.
131    #[must_use]
132    pub const fn is_recoverable(&self) -> bool {
133        matches!(self, Self::Recoverable)
134    }
135}
136
137impl RecoveryFault {
138    /// Whether this fault permanently disables automatic recovery.
139    #[must_use]
140    pub const fn is_terminal(self) -> bool {
141        matches!(self.disposition, RecoveryFaultDisposition::Terminal)
142    }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
146#[serde(deny_unknown_fields)]
147pub(crate) struct RecoveryFaultPublisher {
148    pub(crate) participant: CheckpointParticipant,
149    pub(crate) process_term: u64,
150}
151
152/// Opaque process-local identity for one recoverable failure notification.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct RecoveryFaultRequest {
155    sequence: std::num::NonZeroU64,
156}
157
158/// Durable disposition of one process-local recovery-fault request.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum RecoveryFaultReportOutcome {
161    /// The exact request is the active fault for this stable node.
162    Active,
163    /// The exact request was already atomically covered by a committed recovery Release.
164    AlreadyCleared,
165    /// A newer request from the same process already superseded this request.
166    CoveredByNewerRequest,
167    /// A durable terminal fault already owns this stable-node slot. The attempted request was not
168    /// admitted and automatic recovery remains permanently fenced.
169    TerminalFenceActive,
170}
171
172impl RecoveryFaultRequest {
173    /// Process-local ordinal used to retain the request across bounded retries.
174    #[must_use]
175    pub const fn sequence(self) -> u64 {
176        self.sequence.get()
177    }
178}
179
180impl RecoveryFaultPublisher {
181    pub(crate) fn validate(&self) -> Result<(), String> {
182        if self.participant.node_id == 0
183            || self.participant.boot_incarnation.is_nil()
184            || self.process_term == 0
185        {
186            return Err("recovery fault publisher identity is not canonical".into());
187        }
188        Ok(())
189    }
190}
191
192/// One atomically observed shared-authority fault inventory.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct RecoveryFaultInventory {
195    pub(crate) revision: u64,
196    pub(crate) faults: Vec<RecoveryFault>,
197}
198
199impl RecoveryFaultInventory {
200    /// Authority sequence that admitted this exact active set.
201    #[must_use]
202    pub const fn revision(&self) -> u64 {
203        self.revision
204    }
205
206    /// Canonical active fault reports in stable-node order.
207    #[must_use]
208    pub fn faults(&self) -> &[RecoveryFault] {
209        &self.faults
210    }
211
212    /// Whether automatic recovery is durably disabled by any active report.
213    #[must_use]
214    pub fn has_terminal_fault(&self) -> bool {
215        self.faults.iter().copied().any(RecoveryFault::is_terminal)
216    }
217}
218
219/// One coherent recovery-admission view from the shared leader authority.
220/// This is evidence, not authority to open intake; callers must revalidate it with the audited
221/// leader proof immediately before activation.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct RecoveryAdmissionSnapshot {
224    pub(crate) committed_release: Option<RecoveryAnnouncement>,
225    pub(crate) fault_inventory: RecoveryFaultInventory,
226    pub(crate) authority_sequence: u64,
227    pub(crate) release_head: Option<super::leader_lease::RecoveryReleaseLink>,
228}
229
230impl RecoveryAdmissionSnapshot {
231    /// Latest committed recovery release in this authority view.
232    #[must_use]
233    pub fn committed_release(&self) -> Option<&RecoveryAnnouncement> {
234        self.committed_release.as_ref()
235    }
236
237    /// Active recovery faults from the same authority view.
238    #[must_use]
239    pub const fn fault_inventory(&self) -> &RecoveryFaultInventory {
240        &self.fault_inventory
241    }
242}
243
244impl RecoveryRound {
245    /// Construct a fresh uniquely identified recovery round.
246    ///
247    /// # Errors
248    /// Returns an error when the generation, leader proof, or assignment certificate is invalid.
249    pub fn new(
250        generation: u64,
251        leader_proof: LeaderProof,
252        assignment_fence: CheckpointAssignmentFence,
253        evidence_participants: Vec<CheckpointParticipant>,
254        fault_revision: u64,
255        faults: Vec<RecoveryFault>,
256    ) -> Result<Self, String> {
257        let driver = NodeId(leader_proof.owner.node_id);
258        let round = Self {
259            id: RecoveryRoundId {
260                generation,
261                nonce: Uuid::new_v4(),
262                driver,
263            },
264            leader_proof,
265            assignment_fence,
266            evidence_participants,
267            fault_revision,
268            faults,
269        };
270        round.validate()?;
271        Ok(round)
272    }
273
274    /// Whether `node` belongs to the immutable assignment-owner quorum.
275    #[must_use]
276    pub fn contains_owner(&self, node: NodeId) -> bool {
277        self.assignment_fence.contains(node.0)
278    }
279
280    /// Frozen assignment owners as runtime node identifiers.
281    #[must_use]
282    pub fn owners(&self) -> Vec<NodeId> {
283        self.assignment_fence
284            .participants
285            .iter()
286            .map(|participant| NodeId(participant.node_id))
287            .collect()
288    }
289
290    /// Whether this round carries a fault that automatic recovery must not consume.
291    #[must_use]
292    pub fn has_terminal_fault(&self) -> bool {
293        self.faults.iter().copied().any(RecoveryFault::is_terminal)
294    }
295
296    /// Frozen assignment-owner boot identity for `node`.
297    #[must_use]
298    pub(crate) fn owner_incarnation(&self, node: NodeId) -> Option<Uuid> {
299        self.assignment_fence.participant_incarnation(node.0)
300    }
301
302    /// Whether `node` must stop and report evidence for this round.
303    #[must_use]
304    pub fn contains_stopped_participant(&self, node: NodeId) -> bool {
305        self.contains_owner(node)
306            || self
307                .evidence_participants
308                .binary_search_by_key(&node.0, |participant| participant.node_id)
309                .is_ok()
310    }
311
312    /// Frozen stopped roster as sorted runtime node identifiers.
313    #[must_use]
314    pub fn stopped_participants(&self) -> Vec<NodeId> {
315        self.stopped_roster()
316            .into_iter()
317            .map(|participant| NodeId(participant.node_id))
318            .collect()
319    }
320
321    /// Frozen stopped-roster boot identity for `node`.
322    #[must_use]
323    pub(crate) fn stopped_participant_incarnation(&self, node: NodeId) -> Option<Uuid> {
324        self.assignment_fence
325            .participant_incarnation(node.0)
326            .or_else(|| {
327                self.evidence_participants
328                    .binary_search_by_key(&node.0, |participant| participant.node_id)
329                    .ok()
330                    .map(|index| self.evidence_participants[index].boot_incarnation)
331            })
332    }
333
334    fn stopped_roster(&self) -> Vec<CheckpointParticipant> {
335        let mut roster = Vec::with_capacity(
336            self.assignment_fence.participants.len() + self.evidence_participants.len(),
337        );
338        roster.extend(self.assignment_fence.participants.iter().copied());
339        roster.extend(self.evidence_participants.iter().copied());
340        roster.sort_unstable_by_key(|participant| participant.node_id);
341        roster
342    }
343
344    /// Exact fault sequence this round covers for `node`.
345    #[must_use]
346    pub fn fault_sequence(&self, node: NodeId) -> Option<u64> {
347        self.faults
348            .binary_search_by_key(&node, |fault| fault.reporter)
349            .ok()
350            .map(|index| self.faults[index].sequence)
351    }
352
353    /// Exact shared-authority fault inventory revision frozen into this round.
354    #[must_use]
355    pub const fn fault_revision(&self) -> u64 {
356        self.fault_revision
357    }
358
359    fn validate(&self) -> Result<(), String> {
360        if self.id.generation == 0 {
361            return Err("recovery generation must be nonzero".into());
362        }
363        if self.id.nonce.is_nil() {
364            return Err("recovery nonce must be non-nil".into());
365        }
366        if self.id.driver.is_unassigned() {
367            return Err("recovery driver must be assigned".into());
368        }
369        if !self.leader_proof.is_canonical() || self.id.driver.0 != self.leader_proof.owner.node_id
370        {
371            return Err("recovery driver must match a canonical leader proof".into());
372        }
373        if !self.assignment_fence.is_canonical() {
374            return Err("recovery assignment certificate is not canonical".into());
375        }
376        if !self.contains_owner(self.id.driver) {
377            return Err("recovery driver is absent from the frozen quorum".into());
378        }
379        if self.owner_incarnation(self.id.driver) != Some(self.leader_proof.owner.boot_id) {
380            return Err("recovery leader proof is not bound to the frozen driver process".into());
381        }
382        if self.fault_revision == 0 {
383            return Err("recovery fault revision must be nonzero".into());
384        }
385        if self.faults.is_empty()
386            || self.faults.iter().any(|fault| {
387                fault.reporter.is_unassigned()
388                    || fault.sequence == 0
389                    || fault.sequence > self.fault_revision
390            })
391            || self
392                .faults
393                .windows(2)
394                .any(|pair| pair[0].reporter >= pair[1].reporter)
395        {
396            return Err("recovery fault set is not canonical".into());
397        }
398        if self
399            .evidence_participants
400            .iter()
401            .any(|participant| participant.node_id == 0 || participant.boot_incarnation.is_nil())
402            || self
403                .evidence_participants
404                .windows(2)
405                .any(|pair| pair[0].node_id >= pair[1].node_id)
406        {
407            return Err("recovery evidence participant roster is not canonical".into());
408        }
409        if self.assignment_fence.participants.len() + self.evidence_participants.len()
410            > MAX_CHECKPOINT_PARTICIPANTS
411        {
412            return Err(format!(
413                "recovery stopped roster has {} participants; maximum is {MAX_CHECKPOINT_PARTICIPANTS}",
414                self.assignment_fence.participants.len() + self.evidence_participants.len()
415            ));
416        }
417        if self.evidence_participants.iter().any(|participant| {
418            self.assignment_fence.contains(participant.node_id)
419                || self
420                    .faults
421                    .binary_search_by_key(&NodeId(participant.node_id), |fault| fault.reporter)
422                    .is_err()
423        }) {
424            return Err("recovery evidence participants must be non-owner fault reporters".into());
425        }
426        let largest_terminal = RecoveryAnnouncement {
427            round: self.clone(),
428            phase: RecoverPhase::ReleaseCommitted { epoch: u64::MAX },
429        };
430        let encoded = serde_json::to_vec(&largest_terminal)
431            .map_err(|error| format!("could not encode recovery round: {error}"))?;
432        validate_recovery_announcement_size(encoded.len())?;
433        Ok(())
434    }
435}
436
437/// Phase of a coordinated recovery round, carried in the `control:recover` slot.
438#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
439pub enum RecoverPhase {
440    /// Stop the pipeline and ack; the rewind target is announced after the cluster quiesces.
441    Prepare,
442    /// Rewind to `epoch` and restart.
443    Start {
444        /// Rewind target; `0` means no committed cut exists — restart fresh.
445        epoch: u64,
446    },
447    /// Every restored owner must prepare its local transport authority and publish readiness while
448    /// source gates remain closed.
449    Release {
450        /// The identical rewind target carried by `Start`.
451        epoch: u64,
452    },
453    /// Leader-fenced terminal decision that every exact owner was ready for the release intent.
454    ReleaseCommitted {
455        /// The identical rewind target carried by `Start` and `Release`.
456        epoch: u64,
457    },
458}
459
460/// Durable announcement for one exact recovery round.
461#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
462#[serde(deny_unknown_fields)]
463pub struct RecoveryAnnouncement {
464    /// Frozen round identity and quorum.
465    pub round: RecoveryRound,
466    /// Current phase; `Start` is valid only after the identical `Prepare`.
467    pub phase: RecoverPhase,
468}
469
470impl RecoveryAnnouncement {
471    pub(crate) fn validate(&self) -> Result<(), String> {
472        self.round.validate()?;
473        if self.round.has_terminal_fault() && self.phase != RecoverPhase::Prepare {
474            return Err("a terminal recovery fault may only be retained in Prepare".into());
475        }
476        let encoded = serde_json::to_vec(self)
477            .map_err(|error| format!("could not encode recovery announcement: {error}"))?;
478        validate_recovery_announcement_size(encoded.len())
479    }
480}
481
482/// Failure to obtain a trustworthy local-process authority view.
483#[derive(Debug, thiserror::Error, PartialEq, Eq)]
484pub enum LocalProcessAuthorityEvidenceError {
485    /// Authority is temporarily unavailable because startup, lease, identity, or bounded storage
486    /// evidence is incomplete or changed during the read. Every checked storage read failure is
487    /// classified here, including backends that report a malformed outer envelope as an I/O
488    /// failure rather than returning its logical value.
489    #[error("local process authority evidence is unavailable: {0}")]
490    Unavailable(String),
491    /// Logical payload bytes successfully returned by checked storage are malformed,
492    /// non-canonical, or contradict their current-process slot or same-version audited fence.
493    #[error("local process authority evidence is invalid: {0}")]
494    Invalid(String),
495}
496
497/// One non-durable identity sample for the exact local process generation.
498///
499/// This value names process authority; it does not prove assignment adoption or grant future
500/// authority. Callers that expose it must sample a live identity around their in-memory read.
501#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
502#[serde(deny_unknown_fields)]
503pub struct LocalProcessAuthorityIdentity {
504    /// Stable node slot and boot incarnation.
505    pub participant: CheckpointParticipant,
506    /// Stable-node process term bound to `participant`.
507    pub process_term: u64,
508}
509
510impl LocalProcessAuthorityIdentity {
511    /// Whether every process-identity field has its canonical production shape.
512    #[must_use]
513    pub fn is_canonical(self) -> bool {
514        self.participant.node_id != 0
515            && !self.participant.boot_incarnation.is_nil()
516            && self.process_term != 0
517    }
518}
519
520/// One bounded, current-process view of locally retained cluster-control evidence.
521///
522/// A successful read samples the lease as live before and after the durable point read; it is not
523/// a future authority grant.
524#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
525#[serde(deny_unknown_fields)]
526pub struct LocalProcessAuthorityEvidence {
527    /// Stable node slot and boot incarnation sampled for this view.
528    pub participant: CheckpointParticipant,
529    /// Stable-node process term bound to `participant` by durable lease publication.
530    pub process_term: u64,
531    /// Exact report durably published by this boot and matched to the sampled locally audited
532    /// assignment fence.
533    pub adopted_assignment: CheckpointAssignmentAdoption,
534}
535
536/// Boot-bound acknowledgement that one process quiesced for a frozen recovery round.
537#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
538#[serde(deny_unknown_fields)]
539pub struct RecoveryStoppedReport {
540    /// Wire format version. Only [`RECOVERY_STOPPED_REPORT_PROTOCOL_VERSION`] is accepted.
541    protocol_version: u16,
542    /// Compact identity of the frozen recovery round for which the publisher stopped.
543    round_id: RecoveryRoundId,
544    /// Canonical SHA-256 digest of the exact full recovery round.
545    round_sha256: String,
546    /// Stable node slot and boot incarnation that published this report.
547    publisher: CheckpointParticipant,
548}
549
550impl RecoveryStoppedReport {
551    /// Construct a canonical stopped report.
552    ///
553    /// # Errors
554    /// Returns an error when the round, publisher, or encoded size is invalid.
555    pub fn new(round: &RecoveryRound, publisher: CheckpointParticipant) -> Result<Self, String> {
556        let report = Self {
557            protocol_version: RECOVERY_STOPPED_REPORT_PROTOCOL_VERSION,
558            round_id: round.id,
559            round_sha256: recovery_round_sha256(round)?,
560            publisher,
561        };
562        report.validate(round)?;
563        Ok(report)
564    }
565
566    /// Exact recovery-round identity bound into this report.
567    #[must_use]
568    pub const fn round_id(&self) -> RecoveryRoundId {
569        self.round_id
570    }
571
572    /// Exact process that published this report.
573    #[must_use]
574    pub const fn publisher(&self) -> CheckpointParticipant {
575        self.publisher
576    }
577
578    /// Validate all semantic and encoded-size invariants of this report.
579    ///
580    /// # Errors
581    /// Returns an error describing the first non-canonical invariant.
582    pub fn validate(&self, round: &RecoveryRound) -> Result<(), String> {
583        self.validate_semantics(round)?;
584        let encoded = serde_json::to_vec(self)
585            .map_err(|error| format!("could not encode recovery stopped report: {error}"))?;
586        validate_stopped_report_size(encoded.len())
587    }
588
589    fn validate_shape(&self) -> Result<(), String> {
590        if self.protocol_version != RECOVERY_STOPPED_REPORT_PROTOCOL_VERSION {
591            return Err(format!(
592                "unsupported recovery stopped report version {}; expected {RECOVERY_STOPPED_REPORT_PROTOCOL_VERSION}",
593                self.protocol_version
594            ));
595        }
596        if self.round_id.generation == 0
597            || self.round_id.nonce.is_nil()
598            || self.round_id.driver.is_unassigned()
599        {
600            return Err("recovery stopped report round identity is not canonical".into());
601        }
602        if !is_sha256_hex(&self.round_sha256) {
603            return Err("recovery stopped report round digest is not canonical".into());
604        }
605        if self.publisher.node_id == 0 || self.publisher.boot_incarnation.is_nil() {
606            return Err("recovery stopped report publisher is not canonical".into());
607        }
608        Ok(())
609    }
610
611    fn validate_semantics(&self, round: &RecoveryRound) -> Result<(), String> {
612        self.validate_shape()?;
613        round.validate()?;
614        if self.round_id != round.id || self.round_sha256 != recovery_round_sha256(round)? {
615            return Err("recovery stopped report does not match the exact frozen round".into());
616        }
617        if round.stopped_participant_incarnation(NodeId(self.publisher.node_id))
618            != Some(self.publisher.boot_incarnation)
619        {
620            return Err("recovery stopped report publisher does not match the frozen round".into());
621        }
622        Ok(())
623    }
624}
625
626#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
627#[serde(deny_unknown_fields)]
628struct RecoveryAnnouncementAck {
629    announcement: RecoveryAnnouncement,
630    incarnation: Uuid,
631}
632
633#[derive(serde::Serialize)]
634struct RecoveryStoppedRoundDigestInput<'a> {
635    protocol: &'static str,
636    round: &'a RecoveryRound,
637}
638
639#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
640#[serde(deny_unknown_fields)]
641pub(crate) struct RecoveryReleaseId {
642    protocol_version: u16,
643    generation: u64,
644    nonce: Uuid,
645    driver: NodeId,
646    epoch: u64,
647    round_sha256: String,
648}
649
650#[derive(serde::Serialize)]
651struct RecoveryReleaseDigestInput<'a> {
652    protocol: &'static str,
653    round: &'a RecoveryRound,
654    epoch: u64,
655}
656
657impl RecoveryReleaseId {
658    pub(crate) fn for_pending(release: &RecoveryAnnouncement) -> Result<Self, String> {
659        let RecoverPhase::Release { epoch } = release.phase else {
660            return Err("release readiness must bind a pending Release target".into());
661        };
662        release.validate()?;
663        let encoded = serde_json::to_vec(&RecoveryReleaseDigestInput {
664            protocol: "laminardb-recovery-release-v2",
665            round: &release.round,
666            epoch,
667        })
668        .map_err(|error| format!("could not encode recovery release identity: {error}"))?;
669        Ok(Self {
670            protocol_version: RELEASE_READY_PROTOCOL_VERSION,
671            generation: release.round.id.generation,
672            nonce: release.round.id.nonce,
673            driver: release.round.id.driver,
674            epoch,
675            round_sha256: sha256_hex(&encoded),
676        })
677    }
678
679    pub(crate) fn is_canonical(&self) -> bool {
680        self.protocol_version == RELEASE_READY_PROTOCOL_VERSION
681            && self.generation != 0
682            && !self.nonce.is_nil()
683            && !self.driver.is_unassigned()
684            && is_sha256_hex(&self.round_sha256)
685    }
686
687    pub(crate) fn generation(&self) -> u64 {
688        self.generation
689    }
690}
691
692#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
693#[serde(deny_unknown_fields)]
694struct RecoveryReleaseReadyAck {
695    release: RecoveryReleaseId,
696    participant: CheckpointParticipant,
697}
698
699impl RecoveryReleaseReadyAck {
700    fn is_canonical(&self) -> bool {
701        self.release.is_canonical()
702            && self.participant.node_id != 0
703            && !self.participant.boot_incarnation.is_nil()
704    }
705}
706
707/// Classified recovery-control failure. Only [`Self::Uncertain`] is retryable.
708#[derive(Debug, thiserror::Error, PartialEq, Eq)]
709pub enum RecoveryControlError {
710    /// A bounded durable operation has an unknown outcome.
711    #[error("recovery control outcome is uncertain: {0}")]
712    Uncertain(String),
713    /// Durable state is malformed or contradicts the exact protocol identity.
714    #[error("recovery control conflict: {0}")]
715    Conflict(String),
716    /// A newer process, round, fault set, or leader term replaced this operation.
717    #[error("recovery round was superseded: {0}")]
718    Superseded(String),
719}
720
721impl RecoveryControlError {
722    #[cfg(feature = "cluster")]
723    fn from_authority(error: super::ClusterCheckpointAuthorityError) -> Self {
724        match error {
725            super::ClusterCheckpointAuthorityError::Authority(super::LeaseError::Io(reason)) => {
726                Self::Uncertain(reason)
727            }
728            super::ClusterCheckpointAuthorityError::Fenced => {
729                Self::Superseded("durable leader authority changed".into())
730            }
731            error => Self::Conflict(error.to_string()),
732        }
733    }
734
735    #[cfg(feature = "cluster")]
736    fn from_process_authority(error: super::ProcessLeaseError) -> Self {
737        match error {
738            super::ProcessLeaseError::Io(reason) | super::ProcessLeaseError::Deadline(reason) => {
739                Self::Uncertain(reason)
740            }
741            super::ProcessLeaseError::Invalid(reason) => Self::Conflict(reason),
742            super::ProcessLeaseError::Json(error) => Self::Conflict(error.to_string()),
743        }
744    }
745}
746
747/// Exact leader-side observation of the compact release-readiness roster.
748#[derive(Debug, Clone, PartialEq, Eq)]
749enum ReleaseReadyStatus {
750    /// At least one frozen owner has not published readiness for this release intent.
751    Pending {
752        /// Exact owner slots still missing a matching record.
753        missing: Vec<NodeId>,
754    },
755    /// Every frozen owner published the exact release identity.
756    Complete,
757}
758
759/// One bounded leader attempt to commit a pending recovery Release.
760#[derive(Debug, Clone, PartialEq, Eq)]
761pub enum ReleaseCommitStatus {
762    /// At least one frozen owner has not prepared this exact release intent.
763    Pending {
764        /// Exact owner slots still missing a matching readiness record.
765        missing: Vec<NodeId>,
766    },
767    /// The leader durably admitted the immutable terminal into shared authority.
768    Committed {
769        /// Exact terminal record followers resolve from shared authority across takeover.
770        terminal: RecoveryAnnouncement,
771    },
772}
773
774/// Held after this process validates its atomically settled recovery fault and until its local
775/// source gate transition is complete. A concurrent fault report cannot cross that transition.
776#[must_use = "keep the release guard until local source intake is opened or definitively fenced"]
777pub struct RecoveryReleaseGuard<'a> {
778    _write_guard: tokio::sync::MutexGuard<'a, ()>,
779}
780
781impl std::fmt::Debug for RecoveryReleaseGuard<'_> {
782    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
783        formatter
784            .debug_struct("RecoveryReleaseGuard")
785            .finish_non_exhaustive()
786    }
787}
788
789/// Facade composing the cluster-control primitives.
790pub struct ClusterController {
791    instance_id: NodeId,
792    /// Fast, process-local/discovery KV used for barriers, routing, and assignment liveness.
793    kv: Arc<dyn ClusterKv>,
794    /// Durable authority for coordinated recovery. This is deliberately separate from gossip:
795    /// terminal recovery state and generations must survive every process in the cluster.
796    recovery_kv: Arc<dyn ClusterKv>,
797    barrier: BarrierCoordinator,
798    snapshot: Option<Arc<AssignmentSnapshotStore>>,
799    members_rx: watch::Receiver<Vec<NodeInfo>>,
800    /// Locally computed version-bound checkpoint fence; admission/recovery borrows it instead of
801    /// performing a gossip scan on the critical path.
802    checkpoint_assignment_fence: watch::Sender<Option<CheckpointAssignmentFence>>,
803    /// Locally audited drain transition. While present, the predecessor fence remains usable only
804    /// for a checkpoint carrying the transition's exact leader term.
805    checkpoint_drain_transition: watch::Sender<Option<AssignmentDrainTransition>>,
806    /// Serialises every local authority grant with terminal process fencing.
807    process_authority_transition: parking_lot::Mutex<()>,
808    /// Process-unique recovery identity, published before this node becomes Active.
809    recovery_incarnation: Uuid,
810    /// Stable-node process term bound to `recovery_incarnation`.
811    recovery_process_term: AtomicU64,
812    /// Per-process request identity; shared authority assigns the cluster-visible fault sequence.
813    recovery_fault_request_sequence: AtomicU64,
814    /// Recovery-safe cluster watermark installed after an immutable Commit outcome or from a
815    /// validated committed checkpoint. `i64::MIN` = uninitialised.
816    cluster_min_watermark: Arc<AtomicI64>,
817    /// Source-keyed decision frontiers installed from the same immutable committed channel cut.
818    /// A temporal operator consumes these instead of the pipeline-wide minimum so an unrelated
819    /// slow source cannot regress restored source-specific progress.
820    committed_source_watermarks: parking_lot::RwLock<Arc<rustc_hash::FxHashMap<String, i64>>>,
821    /// Serialises monotonic live publication with the exact replacement performed while recovery
822    /// owns the pipeline fence. Without this lock, a recovering process could replace a stale cut
823    /// while an in-flight normal publication concurrently reinstalls it.
824    committed_watermark_publication: parking_lot::Mutex<()>,
825    /// While draining, the node excludes itself from [`Self::assignable_instances`] so the
826    /// next rotation sheds its vnodes before it exits.
827    draining: Arc<AtomicBool>,
828    /// Held while a coordinated restart is in flight; the checkpoint gate consults it so
829    /// no checkpoint is injected mid-recovery.
830    recovering: Arc<AtomicBool>,
831    /// Whether this node has announced itself as Active.
832    active: Arc<AtomicBool>,
833    /// False permanently after the durable stable-node lease is lost.
834    process_lease_live: Arc<AtomicBool>,
835    /// Monotonic deadline consulted directly by hot compute/control paths.
836    process_lease_deadline: std::sync::OnceLock<Arc<super::LeaseDeadline>>,
837    /// Shared durable authority used to prove an unresponsive process term was revoked.
838    process_lease_authority: std::sync::OnceLock<Arc<super::process_lease::ProcessLeaseAuthority>>,
839    /// Whether this node may be elected leader. A certified leader may retain coordination while
840    /// draining so it can checkpoint its own predecessor cut.
841    leader_eligible: Arc<AtomicBool>,
842    /// Coalescing-safe local candidacy generation. Every observed loss advances the generation
843    /// synchronously, so a rapid loss/reacquire cannot reuse a prior leader token.
844    leader_candidacy: watch::Sender<super::LeaderCandidacy>,
845    /// Last certified assignment roster. Retained across transient certificate suspension so an
846    /// ownerless worker cannot displace an available data owner. If every certified owner is
847    /// unavailable, a durably lease-fenced idle worker may lead placement repair only.
848    leadership_participants: parking_lot::RwLock<Option<(u64, Vec<u64>)>>,
849    /// Serialises this node's recovery and fault-slot conditional writes.
850    recovery_writes: tokio::sync::Mutex<()>,
851    /// Coalesces non-authorizing fault audits while a Release still lacks readiness. The complete
852    /// path never consults this cache.
853    pending_release_fault_audit:
854        tokio::sync::Mutex<Option<(RecoveryReleaseId, tokio::time::Instant)>>,
855    /// Exact process incarnations that missed a capture quorum, keyed by stable node id. Entries
856    /// remain quarantined until that process acknowledges or a different lease-bound boot appears;
857    /// elapsed time is not evidence that a stalled owner became safe.
858    unresponsive: Arc<parking_lot::Mutex<rustc_hash::FxHashMap<u64, Option<Uuid>>>>,
859    /// This node's own failure-domain locality; peers carry theirs in `members_rx`.
860    self_locality: parking_lot::RwLock<Locality>,
861    /// When wired, leadership is lease-fenced: [`Self::is_leader`] also requires the durable
862    /// lease. Absent in embedded / static-discovery deployments (gossip-only leadership).
863    #[cfg(feature = "cluster")]
864    leader_lease: std::sync::OnceLock<LeaderLeaseGate>,
865}
866
867impl std::fmt::Debug for ClusterController {
868    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
869        f.debug_struct("ClusterController")
870            .field("instance_id", &self.instance_id)
871            .finish_non_exhaustive()
872    }
873}
874
875mod assignment;
876mod authority;
877mod barrier;
878mod construction;
879mod membership;
880mod recovery_faults;
881mod recovery_identity;
882mod recovery_protocol;
883mod wire;
884
885use wire::{
886    encode_drain_ack, encode_recovery_announcement, encode_recovery_stopped_report,
887    encode_release_ready_ack, is_sha256_hex, parse_drain_ack, parse_local_adopted_assignment,
888    parse_recovery_announcement, parse_recovery_announcement_ack,
889    parse_recovery_stopped_report_shape, parse_release_ready_ack, recovery_round_sha256,
890    sha256_hex, validate_recovery_announcement_size, validate_stopped_report_size, DrainAck,
891};
892
893#[cfg(test)]
894use wire::parse_recovery_stopped_report;
895
896#[cfg(test)]
897mod tests;