Skip to main content

laminar_core/cluster/control/barrier/
protocol.rs

1//! Barrier identities, canonical announcement history, acknowledgements, and outcomes.
2
3use super::{CheckpointAttempt, CheckpointWatermark, Deserialize, NodeId, Serialize};
4#[cfg(feature = "cluster")]
5use super::{Duration, BARRIER_ENDPOINT_VERSION, MAX_BARRIER_ENDPOINT_BYTES};
6
7/// Process identity attached to one control-plane endpoint. The durable process lease remains the
8/// authority; this value prevents a stable node id from resolving to a different process boot.
9#[cfg(feature = "cluster")]
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub(super) struct BarrierProcessIdentity {
13    pub(super) node_id: u64,
14    pub(super) boot_incarnation: uuid::Uuid,
15    pub(super) process_term: u64,
16}
17
18#[cfg(feature = "cluster")]
19impl BarrierProcessIdentity {
20    pub(super) fn from_process_lease(lease: &super::super::ProcessLease) -> Result<Self, String> {
21        lease
22            .validate(lease.node)
23            .map_err(|error| error.to_string())?;
24        Ok(Self {
25            node_id: lease.node.0,
26            boot_incarnation: lease.owner,
27            process_term: lease.term,
28        })
29    }
30
31    pub(super) const fn is_canonical(self) -> bool {
32        self.node_id != 0 && !self.boot_incarnation.is_nil() && self.process_term != 0
33    }
34
35    pub(super) fn matches_participant(
36        self,
37        participant: &crate::checkpoint::CheckpointParticipant,
38    ) -> bool {
39        self.node_id == participant.node_id && self.boot_incarnation == participant.boot_incarnation
40    }
41}
42
43#[cfg(feature = "cluster")]
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub(super) struct BarrierEndpointRecord {
47    version: u8,
48    address: String,
49    process: BarrierProcessIdentity,
50}
51
52#[cfg(feature = "cluster")]
53impl BarrierEndpointRecord {
54    pub(super) fn new(address: String, process: BarrierProcessIdentity) -> Result<Self, String> {
55        let record = Self {
56            version: BARRIER_ENDPOINT_VERSION,
57            address,
58            process,
59        };
60        record.validate()?;
61        Ok(record)
62    }
63
64    pub(super) fn validate(&self) -> Result<(), String> {
65        if self.version != BARRIER_ENDPOINT_VERSION {
66            return Err(format!(
67                "unsupported cluster control endpoint version {}",
68                self.version
69            ));
70        }
71        if self.address.is_empty() || self.address.len() > MAX_BARRIER_ENDPOINT_BYTES / 2 {
72            return Err("cluster control endpoint address is empty or oversized".into());
73        }
74        if !self.process.is_canonical() {
75            return Err("cluster control endpoint process identity is not canonical".into());
76        }
77        Ok(())
78    }
79
80    pub(super) fn encode(&self) -> Result<String, String> {
81        let encoded = serde_json::to_string(self).map_err(|error| error.to_string())?;
82        if encoded.len() > MAX_BARRIER_ENDPOINT_BYTES {
83            return Err("cluster control endpoint advertisement is oversized".into());
84        }
85        Ok(encoded)
86    }
87}
88
89#[cfg(feature = "cluster")]
90#[derive(Debug, Clone, Copy)]
91pub(super) struct ExpectedBarrierProcess {
92    node_id: u64,
93    boot_incarnation: uuid::Uuid,
94    process_term: Option<u64>,
95}
96
97#[cfg(feature = "cluster")]
98impl ExpectedBarrierProcess {
99    pub(super) const fn participant(node_id: u64, boot_incarnation: uuid::Uuid) -> Self {
100        Self {
101            node_id,
102            boot_incarnation,
103            process_term: None,
104        }
105    }
106
107    pub(super) const fn exact(process: &crate::checkpoint::LeaderProofOwner) -> Self {
108        Self {
109            node_id: process.node_id,
110            boot_incarnation: process.boot_id,
111            process_term: Some(process.process_term),
112        }
113    }
114
115    pub(super) fn matches(self, actual: BarrierProcessIdentity) -> bool {
116        self.node_id == actual.node_id
117            && self.boot_incarnation == actual.boot_incarnation
118            && self
119                .process_term
120                .is_none_or(|term| term == actual.process_term)
121    }
122}
123
124#[cfg(feature = "cluster")]
125pub(super) fn decode_barrier_endpoint(
126    raw: &str,
127) -> Result<(String, Option<BarrierProcessIdentity>), String> {
128    if raw.len() > MAX_BARRIER_ENDPOINT_BYTES {
129        return Err("cluster control endpoint advertisement is oversized".into());
130    }
131    if !raw.trim_start().starts_with('{') {
132        if raw.is_empty() {
133            return Err("cluster control endpoint address is empty".into());
134        }
135        return Ok((raw.to_string(), None));
136    }
137    let record: BarrierEndpointRecord = serde_json::from_str(raw)
138        .map_err(|error| format!("invalid cluster control endpoint advertisement: {error}"))?;
139    record.validate()?;
140    Ok((record.address, Some(record.process)))
141}
142
143#[cfg(feature = "cluster")]
144pub(super) fn encode_barrier_endpoint(
145    address: &str,
146    process: Option<BarrierProcessIdentity>,
147) -> Result<String, String> {
148    if address.is_empty() || address.len() > MAX_BARRIER_ENDPOINT_BYTES / 2 {
149        return Err("cluster control endpoint address is empty or oversized".into());
150    }
151    process.map_or_else(
152        || Ok(address.to_string()),
153        |process| BarrierEndpointRecord::new(address.to_string(), process)?.encode(),
154    )
155}
156
157/// Upper bound for a non-Prepare phase notification round. The durable KV
158/// announcement is authoritative; direct gRPC delivery is only the low-latency
159/// path and must never hold the checkpoint coordinator indefinitely.
160#[cfg(feature = "cluster")]
161pub(super) const PHASE_RPC_TIMEOUT: Duration = Duration::from_secs(3);
162
163#[cfg(feature = "cluster")]
164pub(super) const PREPARE_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(10);
165
166#[cfg(feature = "cluster")]
167pub(super) const PREPARE_RETRY_MAX_BACKOFF: Duration = Duration::from_millis(250);
168
169/// Barrier phase.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171pub enum Phase {
172    /// Align the shuffle, capture state locally, and transfer the immutable cut to a supervised
173    /// tail before acknowledging capture. Durable preparation continues in that tail.
174    Prepare,
175    /// Every node has aligned + captured this epoch (full-membership
176    /// capture quorum). Pipelines may resume the next epoch; the epoch
177    /// is NOT yet restorable.
178    Aligned,
179    /// Durability gate passed; commit sinks. The epoch is restorable.
180    Commit,
181    /// Prepare failed; roll back.
182    Abort,
183}
184
185pub(super) const fn is_terminal_phase(phase: Phase) -> bool {
186    matches!(phase, Phase::Commit | Phase::Abort)
187}
188
189/// Leader-written barrier announcement.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct BarrierAnnouncement {
192    /// Monotonic checkpoint ID retained in the wire field named `epoch`.
193    pub epoch: u64,
194    /// The same nonzero coordinator-assigned checkpoint ID.
195    pub checkpoint_id: u64,
196    /// Exact clustered assignment cut captured when this attempt was admitted. Required on every
197    /// clustered `Prepare` and retained on terminal phases for exact follower validation.
198    pub assignment_fence: Option<super::super::CheckpointAssignmentFence>,
199    /// Exact durable leader term that issued this announcement. Clustered reversible phases
200    /// (`Prepare` and `Aligned`) are rejected unless this proof is present and still live.
201    /// Terminal notifications carry it only for diagnostics; their authority comes from the
202    /// immutable durable checkpoint outcome.
203    pub leader_proof: Option<super::super::LeaderProof>,
204    /// Phase this announcement signals.
205    pub phase: Phase,
206    /// Checkpoint behavior flags from [`crate::checkpoint::flags`].
207    pub flags: u64,
208}
209
210pub(super) fn announcement_attempt(ann: &BarrierAnnouncement) -> CheckpointAttempt {
211    CheckpointAttempt::new(ann.epoch, ann.checkpoint_id)
212}
213
214pub(super) fn validate_announcement_attempt(ann: &BarrierAnnouncement) -> Result<(), String> {
215    if announcement_attempt(ann).is_canonical() {
216        Ok(())
217    } else {
218        Err(format!(
219            "barrier announcement must use one nonzero canonical checkpoint ID; received epoch {} and checkpoint ID {}",
220            ann.epoch, ann.checkpoint_id
221        ))
222    }
223}
224
225pub(super) fn validate_ack_attempt(ack: &BarrierAck) -> Result<(), String> {
226    if CheckpointAttempt::new(ack.epoch, ack.checkpoint_id).is_canonical() {
227        Ok(())
228    } else {
229        Err(format!(
230            "barrier acknowledgement must use one nonzero canonical checkpoint ID; received epoch {} and checkpoint ID {}",
231            ack.epoch, ack.checkpoint_id
232        ))
233    }
234}
235
236#[cfg(feature = "cluster")]
237pub(super) fn validate_wire_checkpoint_attempt(
238    epoch: u64,
239    checkpoint_id: u64,
240) -> Result<(), tonic::Status> {
241    if CheckpointAttempt::new(epoch, checkpoint_id).is_canonical() {
242        Ok(())
243    } else {
244        Err(tonic::Status::invalid_argument(
245            "Barrier request must use one nonzero canonical checkpoint ID",
246        ))
247    }
248}
249
250pub(super) fn same_announcement_identity(
251    left: &BarrierAnnouncement,
252    right: &BarrierAnnouncement,
253) -> bool {
254    announcement_attempt(left) == announcement_attempt(right)
255        && left.assignment_fence == right.assignment_fence
256        && left.leader_proof == right.leader_proof
257        && left.flags == right.flags
258}
259
260/// Merge one exact attempt from a single history. A successor may settle a reversible phase under
261/// a new assignment and leader proof, but reversible equivocation and opposing decisions fail
262/// closed.
263pub(super) fn merge_history_exact(
264    current: BarrierAnnouncement,
265    incoming: BarrierAnnouncement,
266    history: &str,
267) -> Result<BarrierAnnouncement, String> {
268    if current.flags != incoming.flags {
269        return Err(format!(
270            "conflicting {history} barrier flags for exact attempt ({}, {})",
271            current.epoch, current.checkpoint_id
272        ));
273    }
274    if current.phase == incoming.phase && current != incoming {
275        return Err(format!(
276            "conflicting {history} {:?} payloads for exact attempt ({}, {})",
277            current.phase, current.epoch, current.checkpoint_id
278        ));
279    }
280    if is_terminal_phase(current.phase) && is_terminal_phase(incoming.phase) {
281        if current.phase != incoming.phase {
282            return Err(format!(
283                "conflicting {history} terminal phases for exact attempt ({}, {})",
284                current.epoch, current.checkpoint_id
285            ));
286        }
287        return Ok(current);
288    }
289    if is_terminal_phase(current.phase) {
290        return Ok(current);
291    }
292    if is_terminal_phase(incoming.phase) {
293        return Ok(incoming);
294    }
295    if !same_announcement_identity(&current, &incoming) {
296        return Err(format!(
297            "conflicting {history} barrier certificates for exact attempt ({}, {})",
298            current.epoch, current.checkpoint_id
299        ));
300    }
301    if current.phase == Phase::Aligned && incoming.phase == Phase::Prepare {
302        Ok(current)
303    } else {
304        Ok(incoming)
305    }
306}
307
308/// Merge direct deliveries without allowing a delayed phase to regress the same exact attempt.
309#[cfg(feature = "cluster")]
310pub(super) fn merge_direct_announcement(
311    current: BarrierAnnouncement,
312    incoming: BarrierAnnouncement,
313) -> Result<BarrierAnnouncement, String> {
314    validate_announcement_attempt(&current)?;
315    validate_announcement_attempt(&incoming)?;
316    match incoming.checkpoint_id.cmp(&current.checkpoint_id) {
317        std::cmp::Ordering::Greater => Ok(incoming),
318        std::cmp::Ordering::Less => Ok(current),
319        std::cmp::Ordering::Equal => merge_history_exact(current, incoming, "direct"),
320    }
321}
322
323/// Merge the low-latency direct value with the durable leader announcement.
324/// At the same exact attempt a terminal KV value is the decision authority;
325/// otherwise phase progress is monotonic while gossip catches up.
326pub(super) fn merge_observed_announcement(
327    grpc: BarrierAnnouncement,
328    durable: BarrierAnnouncement,
329) -> Result<BarrierAnnouncement, String> {
330    validate_announcement_attempt(&grpc)?;
331    validate_announcement_attempt(&durable)?;
332    match durable.checkpoint_id.cmp(&grpc.checkpoint_id) {
333        std::cmp::Ordering::Greater => Ok(durable),
334        std::cmp::Ordering::Less => Ok(grpc),
335        std::cmp::Ordering::Equal => {
336            if grpc.phase == durable.phase && !is_terminal_phase(grpc.phase) && grpc != durable {
337                Err(format!(
338                    "conflicting direct and durable {:?} payloads for exact attempt ({}, {})",
339                    grpc.phase, grpc.epoch, grpc.checkpoint_id
340                ))
341            } else if is_terminal_phase(durable.phase) {
342                Ok(durable)
343            } else if is_terminal_phase(grpc.phase) {
344                Ok(grpc)
345            } else if !same_announcement_identity(&grpc, &durable) {
346                Err(format!(
347                    "conflicting direct and durable certificates for exact attempt ({}, {})",
348                    grpc.epoch, grpc.checkpoint_id
349                ))
350            } else if grpc.phase == Phase::Aligned && durable.phase == Phase::Prepare {
351                Ok(grpc)
352            } else {
353                Ok(durable)
354            }
355        }
356    }
357}
358
359/// Merge per-node durable histories for leader reclamation. A successor terminal may carry a new
360/// certificate; reversible phases must retain one certificate and terminal outcomes must agree.
361pub(super) fn merge_scanned_announcement(
362    current: BarrierAnnouncement,
363    incoming: BarrierAnnouncement,
364) -> Result<BarrierAnnouncement, String> {
365    validate_announcement_attempt(&current)?;
366    validate_announcement_attempt(&incoming)?;
367    match incoming.checkpoint_id.cmp(&current.checkpoint_id) {
368        std::cmp::Ordering::Greater => Ok(incoming),
369        std::cmp::Ordering::Less => Ok(current),
370        std::cmp::Ordering::Equal => merge_history_exact(current, incoming, "durable"),
371    }
372}
373
374pub(super) fn validate_publication_order(
375    current: &BarrierAnnouncement,
376    incoming: &BarrierAnnouncement,
377) -> Result<(), String> {
378    validate_announcement_attempt(current)?;
379    validate_announcement_attempt(incoming)?;
380    match incoming.checkpoint_id.cmp(&current.checkpoint_id) {
381        std::cmp::Ordering::Greater => Ok(()),
382        std::cmp::Ordering::Less => Err(format!(
383            "stale barrier publication ({}, {}) cannot replace newer admitted attempt ({}, {})",
384            incoming.epoch, incoming.checkpoint_id, current.epoch, current.checkpoint_id
385        )),
386        std::cmp::Ordering::Equal if current == incoming => Ok(()),
387        std::cmp::Ordering::Equal => {
388            let merged =
389                merge_history_exact(current.clone(), incoming.clone(), "local publication")?;
390            if merged == *incoming {
391                Ok(())
392            } else {
393                Err(format!(
394                    "barrier publication cannot regress exact attempt ({}, {}) from {:?} to {:?}",
395                    current.epoch, current.checkpoint_id, current.phase, incoming.phase
396                ))
397            }
398        }
399    }
400}
401
402pub(super) fn validate_scanned_announcements(
403    mut announcements: Vec<BarrierAnnouncement>,
404) -> Result<Option<BarrierAnnouncement>, String> {
405    for announcement in &announcements {
406        validate_announcement_attempt(announcement)?;
407    }
408    // Group exact attempts and audit every reversible certificate before a terminal can absorb it.
409    // Canonical attempt IDs are the only ordering authority.
410    announcements.sort_unstable_by_key(|announcement| {
411        (
412            announcement.checkpoint_id,
413            is_terminal_phase(announcement.phase),
414        )
415    });
416    announcements
417        .into_iter()
418        .try_fold(None, |highest, announcement| {
419            Ok(Some(match highest {
420                None => announcement,
421                Some(current) => merge_scanned_announcement(current, announcement)?,
422            }))
423        })
424}
425
426/// Follower checkpoint-prepare disposition.
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
428pub enum BarrierAckDisposition {
429    /// Legacy durable-prepare acknowledgement. It is deliberately not accepted as a capture
430    /// acknowledgement without an explicit mixed-version capability negotiation.
431    Prepared,
432    /// Legacy durable-prepare acknowledgement retaining handoff replay work.
433    PreparedWithReplay,
434    /// Local checkpoint preparation failed and requires normal failure handling.
435    Failed,
436    /// Local alignment and capture completed, the sink epoch is sealed, and a supervised tail
437    /// owns the exact immutable cut. Durable preparation may still be running.
438    Captured,
439    /// Capture ownership completed and the captured cut retains handoff replay work.
440    CapturedWithReplay,
441}
442
443impl BarrierAckDisposition {
444    pub(super) const fn precedence(self) -> u8 {
445        match self {
446            Self::Prepared => 0,
447            Self::PreparedWithReplay => 1,
448            Self::Captured => 2,
449            Self::CapturedWithReplay => 3,
450            Self::Failed => 4,
451        }
452    }
453}
454
455/// Follower acknowledgement for one exact announcement identity.
456#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
457pub struct BarrierAck {
458    /// Canonical checkpoint ID retained in the wire field named `epoch`.
459    pub epoch: u64,
460    /// The same nonzero coordinator-assigned checkpoint ID being acknowledged.
461    pub checkpoint_id: u64,
462    /// SHA-256 binding of the announcement's assignment certificate.
463    pub assignment_digest: Option<[u8; 32]>,
464    /// Exact behavior flags echoed from the announcement.
465    pub flags: u64,
466    /// Typed local capture/prepare result.
467    pub disposition: BarrierAckDisposition,
468    /// Free-text reason; populated when preparation fails.
469    pub error: Option<String>,
470    /// Follower event-time state at ack time. Uninitialized inputs block advancement; only
471    /// explicitly idle inputs are excluded from the active cluster minimum.
472    pub watermark: CheckpointWatermark,
473}
474
475/// Outcome of `wait_for_quorum`.
476#[derive(Debug, Clone, PartialEq, Eq)]
477pub enum QuorumOutcome {
478    /// All expected peers prepared successfully.
479    Reached {
480        /// Peers that acked successfully.
481        acks: Vec<NodeId>,
482        /// Safe aggregate across all required followers.
483        follower_watermark: CheckpointWatermark,
484        /// At least one follower retained replay in the prepared handoff cut.
485        handoff_replay_pending: bool,
486    },
487    /// Deadline expired with at least one peer silent.
488    TimedOut {
489        /// Peers that did ack.
490        got: Vec<NodeId>,
491        /// Peers that didn't.
492        missing: Vec<NodeId>,
493    },
494    /// At least one peer reported a fatal prepare failure.
495    Failed {
496        /// `(peer, error_message)` for every failed ack.
497        failures: Vec<(NodeId, String)>,
498    },
499}