tsoracle-standalone 1.2.0

Driver selection, configuration, and peer transport for running a standalone tsoracle node
Documentation
//
//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
//
//  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.
//

//! Driver-agnostic membership-admin surface. The openraft impl lives in
//! `admin::openraft`; paxos and file use [`UnsupportedAdmin`].

#[cfg(feature = "openraft")]
pub(crate) mod openraft;

#[cfg(feature = "openraft")]
pub(crate) mod service;

/// Test-only entry points into otherwise-`pub(crate)` admin internals.
///
/// Gated on both `cfg(test)`/`feature = "test-support"` AND
/// `feature = "openraft"` because the only seam currently exposed is the
/// openraft-shaped `AdminServiceImpl` constructor. Production builds (which
/// enable `openraft` but not `test-support`) never compile this module.
#[cfg(all(any(test, feature = "test-support"), feature = "openraft"))]
pub mod test_support {
    use std::sync::Arc;

    use super::MembershipAdmin;
    use crate::admin::service::AdminServiceImpl;
    use crate::admin_proto::membership_admin_server::MembershipAdmin as GrpcAdmin;

    /// Build the gRPC handler around an arbitrary `MembershipAdmin` so a
    /// test can call the generated trait methods directly (no socket).
    pub fn admin_service(admin: Arc<dyn MembershipAdmin>) -> impl GrpcAdmin {
        AdminServiceImpl::new(admin)
    }
}

use async_trait::async_trait;

/// A node's role in the current membership.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberRole {
    Voter,
    Learner,
}

/// One member as the queried node understands it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberEntry {
    pub id: u64,
    pub role: MemberRole,
    pub raft_addr: String,
    pub service_endpoint: String,
    pub admin_endpoint: String,
}

/// A snapshot of the cluster membership.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MembershipView {
    pub members: Vec<MemberEntry>,
    pub leader: Option<u64>,
}

/// A node to add to the cluster.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewMember {
    pub id: u64,
    pub raft_addr: String,
    pub service_endpoint: String,
    pub admin_endpoint: String,
}

/// A member's format-version capabilities, or the reason it could not be
/// reached. The read-only capabilities report carries one of these per member.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CapabilityState {
    /// The member answered: the oldest/newest formats its binary can read and
    /// the version it is actively writing right now.
    Reported {
        min_readable: u8,
        max_readable: u8,
        active_write: u8,
    },
    /// The member could not be queried; `detail` is the human-readable reason.
    Unreachable { detail: String },
}

/// One member's identity (reusing [`MemberEntry`]) plus its capability state.
/// Reusing `MemberEntry` lets the `capabilities` table and the `members
/// --capabilities` listing render from the same report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberCapability {
    pub member: MemberEntry,
    pub caps: CapabilityState,
}

/// A cluster-wide snapshot of every member's format capabilities, built from a
/// single membership read on the queried node so membership, roles, leader,
/// and the gather target stay consistent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityReport {
    pub members: Vec<MemberCapability>,
    pub leader: Option<u64>,
}

/// Membership-admin failure modes.
#[derive(Debug, thiserror::Error)]
pub enum AdminError {
    /// Mutating op reached a follower; `leader_admin_endpoint` is the leader's
    /// admin port when one is known.
    #[error("not the leader")]
    NotLeader {
        leader_admin_endpoint: Option<String>,
    },
    /// This driver does not support runtime membership changes.
    #[error("membership changes are not supported by this driver")]
    Unsupported,
    /// The referenced node is not a current member.
    #[error("node {0} is not a member")]
    NotMember(u64),
    /// A learner was asked to be promoted but had not caught up.
    #[error("node {0} has not caught up")]
    NotCaughtUp(u64),
    /// The change would drop the cluster below a viable quorum.
    #[error("change would lose quorum")]
    WouldLoseQuorum,
    /// The change did not commit within the deadline.
    #[error("membership change timed out")]
    Timeout,
    /// Wrapped driver error.
    #[error("driver error: {0}")]
    Driver(String),
    /// Format-activation gate rejection: at least one member can't read the
    /// target. `incapable` lists `(node_id, max_readable_version)`.
    #[error("format activation to target {target} blocked: members below target: {incapable:?}")]
    MembersBelowTarget {
        target: u8,
        incapable: Vec<(u64, u8)>,
    },
    /// Format-activation target outside local binary's readable range.
    #[error("format activation: target {target} outside readable range [{min}, {max}]")]
    TargetOutOfRange { target: u8, min: u8, max: u8 },
    /// Format-activation apply-keyed no-op: membership changed since gate.
    #[error("format activation to target {target} no-op: membership changed since gate")]
    MembershipChangedSinceGate { target: u8 },
}

/// Runtime membership administration. One impl per driver.
#[async_trait]
pub trait MembershipAdmin: Send + Sync {
    async fn list_members(&self) -> Result<MembershipView, AdminError>;
    async fn add_learner(&self, member: NewMember) -> Result<(), AdminError>;
    async fn promote(&self, id: u64) -> Result<(), AdminError>;
    async fn remove(&self, id: u64) -> Result<(), AdminError>;
    /// Initiate an all-members format-version activation to `target`. The
    /// implementation runs the all-members capability gate, proposes the
    /// bump via raft, and reports the apply-keyed outcome. Returns
    /// `Unsupported` on drivers without zero-downtime format migration
    /// (file, paxos).
    async fn activate_format(&self, target: u8) -> Result<(), AdminError>;

    /// Report every current member's format-version capabilities (read-only,
    /// answerable on any node). The openraft impl gathers tolerantly — an
    /// unreachable member is reported, not fatal. Drivers without the
    /// format-migration surface (file, paxos) return `Unsupported`.
    async fn report_capabilities(&self) -> Result<CapabilityReport, AdminError>;
}

/// Admin handle for drivers without runtime membership (file, and — in this
/// sub-project — paxos). `list_members` reports a fixed view supplied at
/// construction; every mutating op is `Unsupported`.
pub struct UnsupportedAdmin {
    view: MembershipView,
}

impl UnsupportedAdmin {
    pub fn new(view: MembershipView) -> Self {
        Self { view }
    }
}

#[async_trait]
impl MembershipAdmin for UnsupportedAdmin {
    async fn list_members(&self) -> Result<MembershipView, AdminError> {
        Ok(self.view.clone())
    }
    async fn add_learner(&self, _member: NewMember) -> Result<(), AdminError> {
        Err(AdminError::Unsupported)
    }
    async fn promote(&self, _id: u64) -> Result<(), AdminError> {
        Err(AdminError::Unsupported)
    }
    async fn remove(&self, _id: u64) -> Result<(), AdminError> {
        Err(AdminError::Unsupported)
    }
    async fn activate_format(&self, _target: u8) -> Result<(), AdminError> {
        Err(AdminError::Unsupported)
    }

    async fn report_capabilities(&self) -> Result<CapabilityReport, AdminError> {
        Err(AdminError::Unsupported)
    }
}

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

    fn empty_view() -> MembershipView {
        MembershipView {
            members: Vec::new(),
            leader: None,
        }
    }

    fn new_member() -> NewMember {
        NewMember {
            id: 2,
            raft_addr: "127.0.0.1:9".into(),
            service_endpoint: "127.0.0.1:8".into(),
            admin_endpoint: "127.0.0.1:7".into(),
        }
    }

    #[tokio::test]
    async fn unsupported_admin_rejects_every_mutation() {
        let admin = UnsupportedAdmin::new(empty_view());
        assert!(matches!(
            admin.add_learner(new_member()).await,
            Err(AdminError::Unsupported)
        ));
        assert!(matches!(
            admin.promote(2).await,
            Err(AdminError::Unsupported)
        ));
        assert!(matches!(
            admin.remove(2).await,
            Err(AdminError::Unsupported)
        ));
    }

    #[tokio::test]
    async fn unsupported_admin_rejects_activate_format() {
        let admin = UnsupportedAdmin::new(empty_view());
        assert!(matches!(
            admin.activate_format(5).await,
            Err(AdminError::Unsupported)
        ));
    }

    #[tokio::test]
    async fn unsupported_admin_rejects_report_capabilities() {
        let admin = UnsupportedAdmin::new(empty_view());
        assert!(matches!(
            admin.report_capabilities().await,
            Err(AdminError::Unsupported)
        ));
    }

    #[tokio::test]
    async fn unsupported_admin_returns_its_fixed_view() {
        let view = MembershipView {
            members: vec![MemberEntry {
                id: 1,
                role: MemberRole::Voter,
                raft_addr: "a:1".into(),
                service_endpoint: "a:2".into(),
                admin_endpoint: "a:3".into(),
            }],
            leader: Some(1),
        };
        let admin = UnsupportedAdmin::new(view.clone());
        assert_eq!(admin.list_members().await.unwrap(), view);
    }
}