rhiza-node 0.1.0

Network node runtime for rhiza
Documentation
use std::sync::{
    atomic::{AtomicUsize, Ordering},
    Arc,
};

use rhiza_core::{EntryType, LogHash, StoredCommand};
use rhiza_node::{recorder_router, HttpRecorderClient, PeerConfig};
use rhiza_quepaxa::{
    AcceptedValue, DecisionProof, Error, Membership, Proposal, ProposalPriority, RecordRequest,
    RecordSummary, RecorderRpc, RejectReason,
};

fn peers() -> Vec<PeerConfig> {
    (1..=3)
        .map(|index| {
            PeerConfig::new(
                format!("node-{index}"),
                format!("http://node-{index}:8081"),
                format!("peer-token-{index}"),
            )
            .unwrap()
        })
        .collect()
}

#[derive(Clone, Default)]
struct CountingRecorder {
    records: Arc<AtomicUsize>,
    proofs: Arc<AtomicUsize>,
}

impl RecorderRpc for CountingRecorder {
    fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
        Ok("node-1".into())
    }

    fn record(&self, request: RecordRequest) -> rhiza_quepaxa::Result<RecordSummary> {
        self.records.fetch_add(1, Ordering::Relaxed);
        Ok(RecordSummary {
            recorder_id: "node-1".into(),
            slot: request.slot,
            config_id: request.config_id,
            config_digest: request.config_digest,
            step: request.step,
            first_current: Some(request.proposal),
            aggregate_prior: None,
            decided: None,
        })
    }

    fn install_decision_proof(
        &self,
        _proof: DecisionProof,
        _membership: &Membership,
    ) -> rhiza_quepaxa::Result<()> {
        self.proofs.fetch_add(1, Ordering::Relaxed);
        Ok(())
    }
}

fn record_request(proposer_id: &str, slot: u64) -> RecordRequest {
    let membership = Membership::new(["node-1", "node-2", "node-3"]).unwrap();
    let command = StoredCommand::new(EntryType::Command, format!("command-{slot}").into_bytes());
    RecordRequest {
        cluster_id: "rhiza:sql:cluster-a".into(),
        epoch: 1,
        config_id: 1,
        config_digest: membership.digest(),
        slot,
        step: 4,
        proposal: Proposal::new(
            ProposalPriority::MAX,
            proposer_id,
            slot,
            AcceptedValue::from_command("rhiza:sql:cluster-a", slot, 1, 1, LogHash::ZERO, &command),
        ),
        command: Some(command),
    }
}

fn decision_proof(proposer_id: &str, slot: u64) -> DecisionProof {
    let request = record_request(proposer_id, slot);
    DecisionProof::FastPath {
        cluster_id: request.cluster_id,
        slot: request.slot,
        epoch: request.epoch,
        config_id: request.config_id,
        config_digest: request.config_digest,
        proposal: request.proposal,
        summaries: Vec::new(),
    }
}

#[tokio::test(flavor = "multi_thread")]
async fn http_recorder_accepts_member_relay_and_rejects_non_member_without_backend_call() {
    let recorder = CountingRecorder::default();
    let records = Arc::clone(&recorder.records);
    let proofs = Arc::clone(&recorder.proofs);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let address = listener.local_addr().unwrap();
    let server = tokio::spawn(async move {
        axum::serve(listener, recorder_router(recorder, peers()))
            .await
            .unwrap();
    });

    tokio::task::spawn_blocking(move || {
        let client =
            HttpRecorderClient::new(format!("http://{address}"), "node-2", "peer-token-2").unwrap();
        assert_eq!(client.record(record_request("node-1", 1)).unwrap().slot, 1);
        assert!(matches!(
            client.record(record_request("node-9", 2)),
            Err(Error::Rejected(RejectReason::InvalidRequest))
        ));
        let membership = Membership::new(["node-1", "node-2", "node-3"]).unwrap();
        client
            .install_decision_proof(decision_proof("node-1", 3), &membership)
            .unwrap();
        assert!(matches!(
            client.install_decision_proof(decision_proof("node-9", 4), &membership),
            Err(Error::Rejected(RejectReason::InvalidRequest))
        ));
        assert_eq!(client.recorder_id().unwrap(), "node-1");
    })
    .await
    .unwrap();

    assert_eq!(records.load(Ordering::Relaxed), 1);
    assert_eq!(proofs.load(Ordering::Relaxed), 1);
    server.abort();
}