Skip to main content

tsoracle_driver_openraft/
capabilities.rs

1//
2//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6//  tsoracle — Distributed Timestamp Oracle
7//  https://www.tsoracle.rs
8//
9//  Copyright (c) 2026 Prisma Risk
10//
11//  Licensed under the Apache License, Version 2.0 (the "License");
12//  you may not use this file except in compliance with the License.
13//  You may obtain a copy of the License at
14//
15//      https://www.apache.org/licenses/LICENSE-2.0
16//
17//  Unless required by applicable law or agreed to in writing, software
18//  distributed under the License is distributed on an "AS IS" BASIS,
19//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20//  See the License for the specific language governing permissions and
21//  limitations under the License.
22//
23
24//! Format-migration capability reporting and the leader-side all-members
25//! activation gate's pure logic.
26//!
27//! A [`NodeCapabilities`] is the answer to "what schema versions can this node
28//! read, and what version is it actively writing?" Every member reports its own
29//! via the `Capabilities` peer RPC (see `tsoracle-standalone`); the leader
30//! gathers all members' reports and runs [`all_members_can_read`] before any
31//! format bump is proposed (the proposal itself is a later phase). The struct
32//! crosses the wire as a postcard body inside the existing `RaftMessage`
33//! envelope, so it lives here in the driver crate where both the peer transport
34//! and the leader-side gate can reach it.
35
36use std::collections::BTreeSet;
37
38use async_trait::async_trait;
39use serde::{Deserialize, Serialize};
40
41type NodeId = u64;
42
43/// What schema versions a single node can read, and the version it is actively
44/// writing right now.
45///
46/// `min_readable_version` / `max_readable_version` are compile-time constants of
47/// the running binary (the oldest and newest formats it has a parser for);
48/// `active_write_version` is the durable, runtime value the node currently
49/// stamps on new records and wire bodies. The activation gate compares every
50/// member's `max_readable_version` against a proposed target so no committed
51/// record can reach a member that cannot decode it.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub struct NodeCapabilities {
54    pub min_readable_version: u8,
55    pub max_readable_version: u8,
56    pub active_write_version: u8,
57}
58
59impl NodeCapabilities {
60    /// Build the local node's capabilities: the readable range is fixed by this
61    /// binary's compile-time constants; the active write version is the durable
62    /// runtime value supplied by the caller (read through the state machine's
63    /// `active_write_version()` accessor).
64    pub fn local(active_write_version: u8) -> Self {
65        Self {
66            min_readable_version: tsoracle_openraft_toolkit::MIN_READABLE_VERSION,
67            max_readable_version: tsoracle_openraft_toolkit::MAX_READABLE_VERSION,
68            active_write_version,
69        }
70    }
71}
72
73/// The all-members activation gate: return the set of member node ids iff
74/// **every** gathered member can read `target` (its `max_readable_version >=
75/// target`), otherwise `None`.
76///
77/// The gate is all-members, not quorum, and the caller must have gathered every
78/// current member (voters AND learners) before calling this — a lagging or
79/// learner peer on an old-only binary would otherwise reject every record at
80/// the new version once it committed. The returned set is exactly the gathered
81/// members; a later phase embeds it in the bump entry so apply can re-validate
82/// the membership at the entry's own log position against this snapshot.
83pub fn all_members_can_read(
84    target: u8,
85    capabilities: &[(NodeId, NodeCapabilities)],
86) -> Option<BTreeSet<NodeId>> {
87    if capabilities
88        .iter()
89        .all(|(_, member)| member.max_readable_version >= target)
90    {
91        Some(capabilities.iter().map(|(node_id, _)| *node_id).collect())
92    } else {
93        None
94    }
95}
96
97/// Why an operator-initiated format activation could not proceed past the gate.
98///
99/// Returned by `StandaloneHost::initiate_format_activation`. None of these are
100/// retryable in place by the caller without remediation: `NotLeader` means
101/// re-issue against the leader; `MembersBelowTarget` means upgrade or remove
102/// the named members and re-issue; `MemberUnreachable` means the cluster could
103/// not confirm a member's capability and the gate fails closed rather than
104/// guess; `TargetOutOfRange` means re-issue with a target the local binary
105/// can actually read or upgrade the binary.
106#[derive(Debug, thiserror::Error)]
107pub enum FormatActivationError {
108    /// This node is not the raft leader, so it cannot drive an activation.
109    #[error("cannot initiate format activation: this node is not the leader")]
110    NotLeader,
111    /// `target` is outside the local binary's readable range
112    /// `[min, max]`. The gate short-circuits here before any peer RPC: an
113    /// out-of-range target is the same answer regardless of peer reports,
114    /// and the operator gets a fast, unambiguous rejection. The same
115    /// surface is used by the apply arm's defense-in-depth — see
116    /// `ApplyOutcome::FormatActivationTargetOutOfRange`.
117    #[error(
118        "format activation to target {target} blocked: target outside local readable range [{min}, {max}]"
119    )]
120    TargetOutOfRange { target: u8, min: u8, max: u8 },
121    /// At least one current member cannot read `target`. `incapable` lists the
122    /// offending `(node_id, max_readable_version)` pairs for the operator.
123    #[error("format activation to target {target} blocked: members below target: {incapable:?}")]
124    MembersBelowTarget {
125        target: u8,
126        incapable: Vec<(NodeId, u8)>,
127    },
128    /// A member could not be queried for its capabilities; the gate fails closed.
129    #[error("format activation gate failed: member {node_id} unreachable: {detail}")]
130    MemberUnreachable { node_id: NodeId, detail: String },
131    /// The membership committed as of the bump's own log position was no
132    /// longer a subset of the set the gate observed — an un-gated member was
133    /// added or promoted between the live gate query and the entry's
134    /// commit/apply, so the bump applied as a no-op. Re-gate and re-issue.
135    #[error(
136        "format activation to target {target} applied as a no-op: membership changed since the gate"
137    )]
138    MembershipChangedSinceGate { target: u8 },
139    /// The proposal (`raft.client_write`) failed before / during commit —
140    /// e.g. lost leadership, the cluster lost quorum, or a fatal raft error.
141    #[error("format activation proposal failed: {0}")]
142    ProposalFailed(String),
143}
144
145/// Reject `target` if it is outside the local binary's readable range
146/// `[MIN_READABLE_VERSION, MAX_READABLE_VERSION]`.
147///
148/// This is the lower-bound (and symmetric upper-bound) companion to the
149/// per-member [`all_members_can_read`] check. The per-member predicate
150/// guards against any member that cannot read the target; this local check
151/// guards against an unsupported target up front so the activation never
152/// even reaches a peer RPC or the apply arm. A target below MIN would flip
153/// the active write version to a value the encode/decode codec arms reject,
154/// wedging all subsequent log appends and snapshot builds.
155pub fn target_in_local_readable_range(target: u8) -> Result<(), FormatActivationError> {
156    let min = tsoracle_openraft_toolkit::MIN_READABLE_VERSION;
157    let max = tsoracle_openraft_toolkit::MAX_READABLE_VERSION;
158    if !(min..=max).contains(&target) {
159        return Err(FormatActivationError::TargetOutOfRange { target, min, max });
160    }
161    Ok(())
162}
163
164/// Abstracts querying one peer member for its [`NodeCapabilities`]. Implemented
165/// in the standalone transport crate as a thin adapter over the `Capabilities`
166/// peer RPC; defined here so [`StandaloneHost::gather_member_capabilities`]
167/// (in `standalone.rs`) can be generic over it (the driver crate does not
168/// depend on the transport crate) and so the gate is unit-testable with a fake
169/// source.
170///
171/// [`StandaloneHost::gather_member_capabilities`]: crate::standalone::StandaloneHost::gather_member_capabilities
172#[async_trait]
173pub trait CapabilitySource: Send + Sync {
174    /// The `Node` (membership endpoint) type this source dials.
175    type Node: Send + Sync;
176
177    /// Query `member`'s capabilities. `detail` on failure is a human-readable
178    /// reason folded into [`FormatActivationError::MemberUnreachable`].
179    async fn query(&self, node_id: NodeId, member: &Self::Node)
180    -> Result<NodeCapabilities, String>;
181}
182
183/// Gather every member's capabilities, answering the `local_node` from
184/// `local_capabilities` directly (no self-RPC) and every other member via
185/// `source`. Fails closed ([`FormatActivationError::MemberUnreachable`]) the
186/// moment any remote query fails — the all-members gate cannot pass on a
187/// member it could not confirm. `membership` is the full current voter+learner
188/// set.
189pub async fn gather_with<S: CapabilitySource>(
190    local_node: NodeId,
191    local_capabilities: NodeCapabilities,
192    membership: &[(NodeId, S::Node)],
193    source: &S,
194) -> Result<Vec<(NodeId, NodeCapabilities)>, FormatActivationError> {
195    let mut gathered = Vec::with_capacity(membership.len());
196    for (node_id, member) in membership {
197        let capabilities = if *node_id == local_node {
198            local_capabilities
199        } else {
200            source.query(*node_id, member).await.map_err(|detail| {
201                FormatActivationError::MemberUnreachable {
202                    node_id: *node_id,
203                    detail,
204                }
205            })?
206        };
207        gathered.push((*node_id, capabilities));
208    }
209    Ok(gathered)
210}
211
212/// Tolerant sibling of [`gather_with`]: query every member for its
213/// capabilities, but instead of failing closed on the first unreachable peer,
214/// capture each member's result. Returns one entry per member, in membership
215/// order, with the local node answered directly from `local_capabilities`.
216///
217/// Where [`gather_with`] backs the activation gate (which must refuse to
218/// proceed on a member it cannot confirm), this backs the read-only
219/// capabilities report (which must surface an unreachable member rather than
220/// hide the whole cluster behind one failure).
221pub async fn report_with<S: CapabilitySource>(
222    local_node: NodeId,
223    local_capabilities: NodeCapabilities,
224    membership: &[(NodeId, S::Node)],
225    source: &S,
226) -> Vec<(NodeId, Result<NodeCapabilities, String>)> {
227    let mut reports = Vec::with_capacity(membership.len());
228    for (node_id, member) in membership {
229        let result = if *node_id == local_node {
230            Ok(local_capabilities)
231        } else {
232            source.query(*node_id, member).await
233        };
234        reports.push((*node_id, result));
235    }
236    reports
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    use std::collections::HashMap;
244
245    fn caps(active: u8, max: u8) -> NodeCapabilities {
246        NodeCapabilities {
247            min_readable_version: tsoracle_openraft_toolkit::MIN_READABLE_VERSION,
248            max_readable_version: max,
249            active_write_version: active,
250        }
251    }
252
253    #[test]
254    fn local_capabilities_reports_compile_time_read_range() {
255        let capabilities = NodeCapabilities::local(7);
256        assert_eq!(
257            capabilities.min_readable_version,
258            tsoracle_openraft_toolkit::MIN_READABLE_VERSION
259        );
260        assert_eq!(
261            capabilities.max_readable_version,
262            tsoracle_openraft_toolkit::MAX_READABLE_VERSION
263        );
264        assert_eq!(capabilities.active_write_version, 7);
265    }
266
267    #[test]
268    fn node_capabilities_postcard_round_trips() {
269        let original = NodeCapabilities {
270            min_readable_version: 3,
271            max_readable_version: 5,
272            active_write_version: 4,
273        };
274        let bytes = postcard::to_stdvec(&original).expect("encode");
275        let decoded: NodeCapabilities = postcard::from_bytes(&bytes).expect("decode");
276        assert_eq!(original, decoded);
277    }
278
279    #[test]
280    fn gate_passes_when_all_members_can_read_target() {
281        let reports = vec![(1u64, caps(3, 4)), (2, caps(3, 5)), (3, caps(3, 4))];
282        let gated = all_members_can_read(4, &reports).expect("all members can read 4");
283        assert_eq!(gated, BTreeSet::from([1, 2, 3]));
284    }
285
286    #[test]
287    fn gate_passes_at_exact_equality() {
288        // max_readable_version == target is sufficient (>=, not >).
289        let reports = vec![(1u64, caps(3, 4)), (2, caps(3, 4))];
290        assert_eq!(
291            all_members_can_read(4, &reports),
292            Some(BTreeSet::from([1, 2]))
293        );
294    }
295
296    #[test]
297    fn gate_fails_when_one_member_is_below_target() {
298        // Node 2 can only read up to v3; target v4 must be refused.
299        let reports = vec![(1u64, caps(3, 4)), (2, caps(3, 3)), (3, caps(3, 4))];
300        assert_eq!(all_members_can_read(4, &reports), None);
301    }
302
303    #[test]
304    fn existing_incompatible_learner_blocks_activation_via_all_members_gate() {
305        // An existing incompatible learner blocks activation until
306        // remediated. There is NO separate "existing-learner block" code
307        // path: the all-members gate already covers learners (the leader
308        // gathers capabilities from voters AND learners read from
309        // `raft.metrics().borrow_watched().membership_config.nodes()`, then
310        // runs this predicate). The toolkit's `learner_meets_active_write_version`
311        // (the joiner gate) refuses NEW learners on admission; this
312        // predicate refuses an activation when an already-admitted member
313        // (voter or learner) cannot read the target. The two together
314        // cover spec §12's joiner-gate and existing-learner-block
315        // requirements without duplicate enforcement.
316        let target = 4u8;
317        let capable_voter = caps(3, 4);
318        let incompatible_learner = caps(3, 3); // max_readable_version < target
319        let reports = vec![(1u64, capable_voter), (2u64, incompatible_learner)];
320        assert_eq!(
321            all_members_can_read(target, &reports),
322            None,
323            "an incompatible already-admitted member (here a learner) must \
324             fail the all-members gate, blocking activation until remediated"
325        );
326    }
327
328    #[test]
329    fn gate_on_empty_membership_is_vacuously_satisfied_with_empty_set() {
330        // No members → no member can fail the predicate → Some(empty). A real
331        // cluster always has at least the local node, but the predicate itself
332        // is total; the empty case is documented and tested so callers above
333        // it can rely on Some meaning "every gathered member passed".
334        assert_eq!(all_members_can_read(4, &[]), Some(BTreeSet::new()));
335    }
336
337    #[test]
338    fn format_activation_error_below_target_names_members_and_target() {
339        let err = FormatActivationError::MembersBelowTarget {
340            target: 4,
341            incapable: vec![(2, 3), (5, 3)],
342        };
343        let rendered = err.to_string();
344        assert!(rendered.contains("target 4"), "got: {rendered}");
345        assert!(
346            rendered.contains('2') && rendered.contains('5'),
347            "got: {rendered}"
348        );
349    }
350
351    // ---- Local-binary readable-range gate (lower-bound check) ----
352    //
353    // The all-members gate at `all_members_can_read` checks every member's
354    // `max_readable_version >= target` — the upper-bound side. The
355    // lower-bound side is symmetric: `target` must also be at least
356    // `MIN_READABLE_VERSION` of the local binary, otherwise the activation
357    // would flip the cell to a version no member can encode/decode (an
358    // operator-facing footgun and durable-damage hazard). The gate also
359    // short-circuits at the local binary's range before any peer RPC: an
360    // out-of-range target is the same answer regardless of what peers
361    // report, and the operator gets a fast, clear rejection instead of an
362    // unreachable-peer noise tail.
363
364    #[test]
365    fn target_in_local_readable_range_accepts_min() {
366        assert!(
367            target_in_local_readable_range(tsoracle_openraft_toolkit::MIN_READABLE_VERSION).is_ok()
368        );
369    }
370
371    #[test]
372    fn target_in_local_readable_range_accepts_max() {
373        assert!(
374            target_in_local_readable_range(tsoracle_openraft_toolkit::MAX_READABLE_VERSION).is_ok()
375        );
376    }
377
378    #[test]
379    fn target_in_local_readable_range_rejects_zero() {
380        // Target 0 is the worst case: 0 is also the legacy-unframed wire
381        // sentinel, so a 0-stamped record collides with the legacy
382        // interpretation while also failing recovery decode.
383        let err = target_in_local_readable_range(0).expect_err("0 is below MIN");
384        match err {
385            FormatActivationError::TargetOutOfRange { target, min, max } => {
386                assert_eq!(target, 0);
387                assert_eq!(min, tsoracle_openraft_toolkit::MIN_READABLE_VERSION);
388                assert_eq!(max, tsoracle_openraft_toolkit::MAX_READABLE_VERSION);
389            }
390            other => panic!("expected TargetOutOfRange, got: {other:?}"),
391        }
392    }
393
394    #[test]
395    fn target_in_local_readable_range_rejects_below_min() {
396        // The exact below-min boundary. MIN is at least 1 in any sensible
397        // build (the codec's compile-time assert forbids an empty range),
398        // so MIN - 1 is always a representable u8 and always out of range.
399        let just_below = tsoracle_openraft_toolkit::MIN_READABLE_VERSION - 1;
400        let err = target_in_local_readable_range(just_below).expect_err("MIN-1 is out of range");
401        assert!(matches!(
402            err,
403            FormatActivationError::TargetOutOfRange { target, .. } if target == just_below
404        ));
405    }
406
407    #[test]
408    fn target_in_local_readable_range_rejects_above_max() {
409        // MAX + 1: this case is normally caught by the per-member
410        // `max_readable_version >= target` predicate at the gate, but the
411        // local-binary short-circuit must also reject it so a one-member
412        // cluster (or a misconfigured operator command) fails fast at the
413        // leader before any RPC.
414        let just_above = tsoracle_openraft_toolkit::MAX_READABLE_VERSION.saturating_add(1);
415        if just_above == tsoracle_openraft_toolkit::MAX_READABLE_VERSION {
416            // MAX is u8::MAX (impossible in practice but defended against);
417            // skip rather than spuriously fail.
418            return;
419        }
420        let err = target_in_local_readable_range(just_above).expect_err("MAX+1 is out of range");
421        assert!(matches!(
422            err,
423            FormatActivationError::TargetOutOfRange { target, .. } if target == just_above
424        ));
425    }
426
427    #[test]
428    fn target_out_of_range_error_names_target_and_local_range() {
429        // The operator-facing message must name the offending target AND
430        // the binary's own range, so the remediation is unambiguous: either
431        // re-issue with an in-range target, or upgrade the binary.
432        let err = FormatActivationError::TargetOutOfRange {
433            target: 1,
434            min: 4,
435            max: 4,
436        };
437        let rendered = err.to_string();
438        assert!(rendered.contains('1'), "target missing: {rendered}");
439        assert!(rendered.contains('4'), "range missing: {rendered}");
440    }
441
442    struct FakeSource {
443        responses: HashMap<NodeId, Result<NodeCapabilities, String>>,
444    }
445
446    #[async_trait]
447    impl CapabilitySource for FakeSource {
448        type Node = ();
449
450        async fn query(&self, node_id: NodeId, _member: &()) -> Result<NodeCapabilities, String> {
451            self.responses
452                .get(&node_id)
453                .cloned()
454                .unwrap_or_else(|| Err(format!("no fake response for {node_id}")))
455        }
456    }
457
458    #[tokio::test]
459    async fn gather_with_collects_remote_and_local() {
460        // Local node 1 reports active=3,max=4 directly; remote node 2 answers
461        // via the source. Membership = {1: (), 2: ()}.
462        let source = FakeSource {
463            responses: HashMap::from([(2u64, Ok(caps(3, 5)))]),
464        };
465        let membership: Vec<(NodeId, ())> = vec![(1, ()), (2, ())];
466        let gathered = gather_with(1, caps(3, 4), &membership, &source)
467            .await
468            .expect("gather succeeds");
469        let by_id: HashMap<NodeId, NodeCapabilities> = gathered.into_iter().collect();
470        assert_eq!(by_id[&1].active_write_version, 3);
471        assert_eq!(by_id[&2].max_readable_version, 5);
472    }
473
474    #[tokio::test]
475    async fn gather_with_surfaces_unreachable_member() {
476        let source = FakeSource {
477            responses: HashMap::from([(2u64, Err("connection refused".to_string()))]),
478        };
479        let membership: Vec<(NodeId, ())> = vec![(1, ()), (2, ())];
480        let err = gather_with(1, caps(3, 4), &membership, &source)
481            .await
482            .expect_err("an unreachable member fails the gather closed");
483        assert!(matches!(
484            err,
485            FormatActivationError::MemberUnreachable { node_id: 2, .. }
486        ));
487    }
488
489    #[tokio::test]
490    async fn report_with_answers_local_directly_and_queries_remotes() {
491        let source = FakeSource {
492            responses: HashMap::from([(2u64, Ok(caps(3, 5)))]),
493        };
494        let membership: Vec<(NodeId, ())> = vec![(1, ()), (2, ())];
495        let reports = report_with(1, caps(3, 4), &membership, &source).await;
496        assert_eq!(reports.len(), 2);
497        assert_eq!(reports[0].0, 1);
498        assert_eq!(reports[0].1.as_ref().unwrap().active_write_version, 3);
499        assert_eq!(reports[1].0, 2);
500        assert_eq!(reports[1].1.as_ref().unwrap().max_readable_version, 5);
501    }
502
503    #[tokio::test]
504    async fn report_with_captures_unreachable_without_short_circuiting() {
505        // Node 2 is unreachable; node 3 must still be reported (gather_with
506        // would have bailed at node 2). This is the tolerant contract.
507        let source = FakeSource {
508            responses: HashMap::from([
509                (2u64, Err("connection refused".to_string())),
510                (3u64, Ok(caps(3, 4))),
511            ]),
512        };
513        let membership: Vec<(NodeId, ())> = vec![(1, ()), (2, ()), (3, ())];
514        let reports = report_with(1, caps(3, 4), &membership, &source).await;
515        assert_eq!(reports.len(), 3);
516        assert!(reports[0].1.is_ok());
517        assert_eq!(reports[1].1.as_ref().unwrap_err(), "connection refused");
518        assert!(
519            reports[2].1.is_ok(),
520            "node 3 reported despite node 2 failing"
521        );
522    }
523
524    #[tokio::test]
525    async fn report_with_empty_membership_is_empty() {
526        let source = FakeSource {
527            responses: HashMap::new(),
528        };
529        let membership: Vec<(NodeId, ())> = vec![];
530        let reports = report_with(1, caps(3, 4), &membership, &source).await;
531        assert!(reports.is_empty());
532    }
533}