tsoracle-driver-openraft 0.3.2

openraft-backed ConsensusDriver for tsoracle
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//
//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
//
//  tsoracle — Distributed Timestamp Oracle
//  https://www.tsoracle.rs
//
//  Copyright (c) 2026 Prisma Risk
//
//  Licensed under the Apache License, Version 2.0 (the "License");
//  you may not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
//      https://www.apache.org/licenses/LICENSE-2.0
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.
//

//! Format-migration capability reporting and the leader-side all-members
//! activation gate's pure logic.
//!
//! A [`NodeCapabilities`] is the answer to "what schema versions can this node
//! read, and what version is it actively writing?" Every member reports its own
//! via the `Capabilities` peer RPC (see `tsoracle-standalone`); the leader
//! gathers all members' reports and runs [`all_members_can_read`] before any
//! format bump is proposed (the proposal itself is a later phase). The struct
//! crosses the wire as a postcard body inside the existing `RaftMessage`
//! envelope, so it lives here in the driver crate where both the peer transport
//! and the leader-side gate can reach it.

use std::collections::BTreeSet;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

type NodeId = u64;

/// What schema versions a single node can read, and the version it is actively
/// writing right now.
///
/// `min_readable_version` / `max_readable_version` are compile-time constants of
/// the running binary (the oldest and newest formats it has a parser for);
/// `active_write_version` is the durable, runtime value the node currently
/// stamps on new records and wire bodies. The activation gate compares every
/// member's `max_readable_version` against a proposed target so no committed
/// record can reach a member that cannot decode it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeCapabilities {
    pub min_readable_version: u8,
    pub max_readable_version: u8,
    pub active_write_version: u8,
}

impl NodeCapabilities {
    /// Build the local node's capabilities: the readable range is fixed by this
    /// binary's compile-time constants; the active write version is the durable
    /// runtime value supplied by the caller (read through the state machine's
    /// `active_write_version()` accessor).
    pub fn local(active_write_version: u8) -> Self {
        Self {
            min_readable_version: tsoracle_openraft_toolkit::MIN_READABLE_VERSION,
            max_readable_version: tsoracle_openraft_toolkit::MAX_READABLE_VERSION,
            active_write_version,
        }
    }
}

/// The all-members activation gate: return the set of member node ids iff
/// **every** gathered member can read `target` (its `max_readable_version >=
/// target`), otherwise `None`.
///
/// The gate is all-members, not quorum, and the caller must have gathered every
/// current member (voters AND learners) before calling this — a lagging or
/// learner peer on an old-only binary would otherwise reject every record at
/// the new version once it committed. The returned set is exactly the gathered
/// members; a later phase embeds it in the bump entry so apply can re-validate
/// the membership at the entry's own log position against this snapshot.
pub fn all_members_can_read(
    target: u8,
    capabilities: &[(NodeId, NodeCapabilities)],
) -> Option<BTreeSet<NodeId>> {
    if capabilities
        .iter()
        .all(|(_, member)| member.max_readable_version >= target)
    {
        Some(capabilities.iter().map(|(node_id, _)| *node_id).collect())
    } else {
        None
    }
}

/// Why an operator-initiated format activation could not proceed past the gate.
///
/// Returned by `StandaloneHost::initiate_format_activation`. None of these are
/// retryable in place by the caller without remediation: `NotLeader` means
/// re-issue against the leader; `MembersBelowTarget` means upgrade or remove
/// the named members and re-issue; `MemberUnreachable` means the cluster could
/// not confirm a member's capability and the gate fails closed rather than
/// guess; `TargetOutOfRange` means re-issue with a target the local binary
/// can actually read or upgrade the binary.
#[derive(Debug, thiserror::Error)]
pub enum FormatActivationError {
    /// This node is not the raft leader, so it cannot drive an activation.
    #[error("cannot initiate format activation: this node is not the leader")]
    NotLeader,
    /// `target` is outside the local binary's readable range
    /// `[min, max]`. The gate short-circuits here before any peer RPC: an
    /// out-of-range target is the same answer regardless of peer reports,
    /// and the operator gets a fast, unambiguous rejection. The same
    /// surface is used by the apply arm's defense-in-depth — see
    /// `ApplyOutcome::FormatActivationTargetOutOfRange`.
    #[error(
        "format activation to target {target} blocked: target outside local readable range [{min}, {max}]"
    )]
    TargetOutOfRange { target: u8, min: u8, max: u8 },
    /// At least one current member cannot read `target`. `incapable` lists the
    /// offending `(node_id, max_readable_version)` pairs for the operator.
    #[error("format activation to target {target} blocked: members below target: {incapable:?}")]
    MembersBelowTarget {
        target: u8,
        incapable: Vec<(NodeId, u8)>,
    },
    /// A member could not be queried for its capabilities; the gate fails closed.
    #[error("format activation gate failed: member {node_id} unreachable: {detail}")]
    MemberUnreachable { node_id: NodeId, detail: String },
    /// The membership committed as of the bump's own log position was no
    /// longer a subset of the set the gate observed — an un-gated member was
    /// added or promoted between the live gate query and the entry's
    /// commit/apply, so the bump applied as a no-op. Re-gate and re-issue.
    #[error(
        "format activation to target {target} applied as a no-op: membership changed since the gate"
    )]
    MembershipChangedSinceGate { target: u8 },
    /// The proposal (`raft.client_write`) failed before / during commit —
    /// e.g. lost leadership, the cluster lost quorum, or a fatal raft error.
    #[error("format activation proposal failed: {0}")]
    ProposalFailed(String),
}

/// Reject `target` if it is outside the local binary's readable range
/// `[MIN_READABLE_VERSION, MAX_READABLE_VERSION]`.
///
/// This is the lower-bound (and symmetric upper-bound) companion to the
/// per-member [`all_members_can_read`] check. The per-member predicate
/// guards against any member that cannot read the target; this local check
/// guards against an unsupported target up front so the activation never
/// even reaches a peer RPC or the apply arm. A target below MIN would flip
/// the active write version to a value the encode/decode codec arms reject,
/// wedging all subsequent log appends and snapshot builds.
pub fn target_in_local_readable_range(target: u8) -> Result<(), FormatActivationError> {
    let min = tsoracle_openraft_toolkit::MIN_READABLE_VERSION;
    let max = tsoracle_openraft_toolkit::MAX_READABLE_VERSION;
    if !(min..=max).contains(&target) {
        return Err(FormatActivationError::TargetOutOfRange { target, min, max });
    }
    Ok(())
}

/// Abstracts querying one peer member for its [`NodeCapabilities`]. Implemented
/// in the standalone transport crate as a thin adapter over the `Capabilities`
/// peer RPC; defined here so [`StandaloneHost::gather_member_capabilities`]
/// (in `standalone.rs`) can be generic over it (the driver crate does not
/// depend on the transport crate) and so the gate is unit-testable with a fake
/// source.
///
/// [`StandaloneHost::gather_member_capabilities`]: crate::standalone::StandaloneHost::gather_member_capabilities
#[async_trait]
pub trait CapabilitySource: Send + Sync {
    /// The `Node` (membership endpoint) type this source dials.
    type Node: Send + Sync;

    /// Query `member`'s capabilities. `detail` on failure is a human-readable
    /// reason folded into [`FormatActivationError::MemberUnreachable`].
    async fn query(&self, node_id: NodeId, member: &Self::Node)
    -> Result<NodeCapabilities, String>;
}

/// Gather every member's capabilities, answering the `local_node` from
/// `local_capabilities` directly (no self-RPC) and every other member via
/// `source`. Fails closed ([`FormatActivationError::MemberUnreachable`]) the
/// moment any remote query fails — the all-members gate cannot pass on a
/// member it could not confirm. `membership` is the full current voter+learner
/// set.
pub async fn gather_with<S: CapabilitySource>(
    local_node: NodeId,
    local_capabilities: NodeCapabilities,
    membership: &[(NodeId, S::Node)],
    source: &S,
) -> Result<Vec<(NodeId, NodeCapabilities)>, FormatActivationError> {
    let mut gathered = Vec::with_capacity(membership.len());
    for (node_id, member) in membership {
        let capabilities = if *node_id == local_node {
            local_capabilities
        } else {
            source.query(*node_id, member).await.map_err(|detail| {
                FormatActivationError::MemberUnreachable {
                    node_id: *node_id,
                    detail,
                }
            })?
        };
        gathered.push((*node_id, capabilities));
    }
    Ok(gathered)
}

#[cfg(test)]
mod tests {
    use super::*;

    use std::collections::HashMap;

    fn caps(active: u8, max: u8) -> NodeCapabilities {
        NodeCapabilities {
            min_readable_version: tsoracle_openraft_toolkit::MIN_READABLE_VERSION,
            max_readable_version: max,
            active_write_version: active,
        }
    }

    #[test]
    fn local_capabilities_reports_compile_time_read_range() {
        let capabilities = NodeCapabilities::local(7);
        assert_eq!(
            capabilities.min_readable_version,
            tsoracle_openraft_toolkit::MIN_READABLE_VERSION
        );
        assert_eq!(
            capabilities.max_readable_version,
            tsoracle_openraft_toolkit::MAX_READABLE_VERSION
        );
        assert_eq!(capabilities.active_write_version, 7);
    }

    #[test]
    fn node_capabilities_postcard_round_trips() {
        let original = NodeCapabilities {
            min_readable_version: 3,
            max_readable_version: 5,
            active_write_version: 4,
        };
        let bytes = postcard::to_stdvec(&original).expect("encode");
        let decoded: NodeCapabilities = postcard::from_bytes(&bytes).expect("decode");
        assert_eq!(original, decoded);
    }

    #[test]
    fn gate_passes_when_all_members_can_read_target() {
        let reports = vec![(1u64, caps(3, 4)), (2, caps(3, 5)), (3, caps(3, 4))];
        let gated = all_members_can_read(4, &reports).expect("all members can read 4");
        assert_eq!(gated, BTreeSet::from([1, 2, 3]));
    }

    #[test]
    fn gate_passes_at_exact_equality() {
        // max_readable_version == target is sufficient (>=, not >).
        let reports = vec![(1u64, caps(3, 4)), (2, caps(3, 4))];
        assert_eq!(
            all_members_can_read(4, &reports),
            Some(BTreeSet::from([1, 2]))
        );
    }

    #[test]
    fn gate_fails_when_one_member_is_below_target() {
        // Node 2 can only read up to v3; target v4 must be refused.
        let reports = vec![(1u64, caps(3, 4)), (2, caps(3, 3)), (3, caps(3, 4))];
        assert_eq!(all_members_can_read(4, &reports), None);
    }

    #[test]
    fn existing_incompatible_learner_blocks_activation_via_all_members_gate() {
        // An existing incompatible learner blocks activation until
        // remediated. There is NO separate "existing-learner block" code
        // path: the all-members gate already covers learners (the leader
        // gathers capabilities from voters AND learners read from
        // `raft.metrics().borrow_watched().membership_config.nodes()`, then
        // runs this predicate). The toolkit's `learner_meets_active_write_version`
        // (the joiner gate) refuses NEW learners on admission; this
        // predicate refuses an activation when an already-admitted member
        // (voter or learner) cannot read the target. The two together
        // cover spec §12's joiner-gate and existing-learner-block
        // requirements without duplicate enforcement.
        let target = 4u8;
        let capable_voter = caps(3, 4);
        let incompatible_learner = caps(3, 3); // max_readable_version < target
        let reports = vec![(1u64, capable_voter), (2u64, incompatible_learner)];
        assert_eq!(
            all_members_can_read(target, &reports),
            None,
            "an incompatible already-admitted member (here a learner) must \
             fail the all-members gate, blocking activation until remediated"
        );
    }

    #[test]
    fn gate_on_empty_membership_is_vacuously_satisfied_with_empty_set() {
        // No members → no member can fail the predicate → Some(empty). A real
        // cluster always has at least the local node, but the predicate itself
        // is total; the empty case is documented and tested so callers above
        // it can rely on Some meaning "every gathered member passed".
        assert_eq!(all_members_can_read(4, &[]), Some(BTreeSet::new()));
    }

    #[test]
    fn format_activation_error_below_target_names_members_and_target() {
        let err = FormatActivationError::MembersBelowTarget {
            target: 4,
            incapable: vec![(2, 3), (5, 3)],
        };
        let rendered = err.to_string();
        assert!(rendered.contains("target 4"), "got: {rendered}");
        assert!(
            rendered.contains('2') && rendered.contains('5'),
            "got: {rendered}"
        );
    }

    // ---- Local-binary readable-range gate (lower-bound check) ----
    //
    // The all-members gate at `all_members_can_read` checks every member's
    // `max_readable_version >= target` — the upper-bound side. The
    // lower-bound side is symmetric: `target` must also be at least
    // `MIN_READABLE_VERSION` of the local binary, otherwise the activation
    // would flip the cell to a version no member can encode/decode (an
    // operator-facing footgun and durable-damage hazard). The gate also
    // short-circuits at the local binary's range before any peer RPC: an
    // out-of-range target is the same answer regardless of what peers
    // report, and the operator gets a fast, clear rejection instead of an
    // unreachable-peer noise tail.

    #[test]
    fn target_in_local_readable_range_accepts_min() {
        assert!(
            target_in_local_readable_range(tsoracle_openraft_toolkit::MIN_READABLE_VERSION).is_ok()
        );
    }

    #[test]
    fn target_in_local_readable_range_accepts_max() {
        assert!(
            target_in_local_readable_range(tsoracle_openraft_toolkit::MAX_READABLE_VERSION).is_ok()
        );
    }

    #[test]
    fn target_in_local_readable_range_rejects_zero() {
        // Target 0 is the worst case: 0 is also the legacy-unframed wire
        // sentinel, so a 0-stamped record collides with the legacy
        // interpretation while also failing recovery decode.
        let err = target_in_local_readable_range(0).expect_err("0 is below MIN");
        match err {
            FormatActivationError::TargetOutOfRange { target, min, max } => {
                assert_eq!(target, 0);
                assert_eq!(min, tsoracle_openraft_toolkit::MIN_READABLE_VERSION);
                assert_eq!(max, tsoracle_openraft_toolkit::MAX_READABLE_VERSION);
            }
            other => panic!("expected TargetOutOfRange, got: {other:?}"),
        }
    }

    #[test]
    fn target_in_local_readable_range_rejects_below_min() {
        // The exact below-min boundary. MIN is at least 1 in any sensible
        // build (the codec's compile-time assert forbids an empty range),
        // so MIN - 1 is always a representable u8 and always out of range.
        let just_below = tsoracle_openraft_toolkit::MIN_READABLE_VERSION - 1;
        let err = target_in_local_readable_range(just_below).expect_err("MIN-1 is out of range");
        assert!(matches!(
            err,
            FormatActivationError::TargetOutOfRange { target, .. } if target == just_below
        ));
    }

    #[test]
    fn target_in_local_readable_range_rejects_above_max() {
        // MAX + 1: this case is normally caught by the per-member
        // `max_readable_version >= target` predicate at the gate, but the
        // local-binary short-circuit must also reject it so a one-member
        // cluster (or a misconfigured operator command) fails fast at the
        // leader before any RPC.
        let just_above = tsoracle_openraft_toolkit::MAX_READABLE_VERSION.saturating_add(1);
        if just_above == tsoracle_openraft_toolkit::MAX_READABLE_VERSION {
            // MAX is u8::MAX (impossible in practice but defended against);
            // skip rather than spuriously fail.
            return;
        }
        let err = target_in_local_readable_range(just_above).expect_err("MAX+1 is out of range");
        assert!(matches!(
            err,
            FormatActivationError::TargetOutOfRange { target, .. } if target == just_above
        ));
    }

    #[test]
    fn target_out_of_range_error_names_target_and_local_range() {
        // The operator-facing message must name the offending target AND
        // the binary's own range, so the remediation is unambiguous: either
        // re-issue with an in-range target, or upgrade the binary.
        let err = FormatActivationError::TargetOutOfRange {
            target: 1,
            min: 4,
            max: 4,
        };
        let rendered = err.to_string();
        assert!(rendered.contains('1'), "target missing: {rendered}");
        assert!(rendered.contains('4'), "range missing: {rendered}");
    }

    struct FakeSource {
        responses: HashMap<NodeId, Result<NodeCapabilities, String>>,
    }

    #[async_trait]
    impl CapabilitySource for FakeSource {
        type Node = ();

        async fn query(&self, node_id: NodeId, _member: &()) -> Result<NodeCapabilities, String> {
            self.responses
                .get(&node_id)
                .cloned()
                .unwrap_or_else(|| Err(format!("no fake response for {node_id}")))
        }
    }

    #[tokio::test]
    async fn gather_with_collects_remote_and_local() {
        // Local node 1 reports active=3,max=4 directly; remote node 2 answers
        // via the source. Membership = {1: (), 2: ()}.
        let source = FakeSource {
            responses: HashMap::from([(2u64, Ok(caps(3, 5)))]),
        };
        let membership: Vec<(NodeId, ())> = vec![(1, ()), (2, ())];
        let gathered = gather_with(1, caps(3, 4), &membership, &source)
            .await
            .expect("gather succeeds");
        let by_id: HashMap<NodeId, NodeCapabilities> = gathered.into_iter().collect();
        assert_eq!(by_id[&1].active_write_version, 3);
        assert_eq!(by_id[&2].max_readable_version, 5);
    }

    #[tokio::test]
    async fn gather_with_surfaces_unreachable_member() {
        let source = FakeSource {
            responses: HashMap::from([(2u64, Err("connection refused".to_string()))]),
        };
        let membership: Vec<(NodeId, ())> = vec![(1, ()), (2, ())];
        let err = gather_with(1, caps(3, 4), &membership, &source)
            .await
            .expect_err("an unreachable member fails the gather closed");
        assert!(matches!(
            err,
            FormatActivationError::MemberUnreachable { node_id: 2, .. }
        ));
    }
}