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}