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