use appcore_types::{CapabilityName, ClusterId, CoreId, ProtocolVersion, TenantId, TraceContext};
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Formatter};
mod base64_bytes;
pub const PEER_RPC_PROTOCOL_VERSION_V2: u16 = 2;
pub const PEER_QUERY_PATH_V2: &str = "/v2/peer/query";
pub const PEER_COMMAND_PATH_V2: &str = "/v2/peer/command";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerRpcStreamDirectionV2 {
Request,
Response,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerRpcChunkEncodingV2 {
Identity,
Gzip,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerRpcStreamCancelReasonV2 {
Caller,
Deadline,
Shutdown,
Transport,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerRpcStreamOpenV2 {
pub protocol_version: ProtocolVersion,
pub request_id: String,
pub stream_id: String,
pub trace_id: String,
pub direction: PeerRpcStreamDirectionV2,
pub call_kind: super::v1::PeerRpcCallKind,
pub source_core_id: CoreId,
pub target_core_id: CoreId,
pub tenant_id: TenantId,
pub cluster_id: ClusterId,
pub timestamp_ms: u64,
pub deadline_ms: u64,
pub nonce: String,
pub capability: CapabilityName,
pub payload_bytes: u64,
pub chunk_bytes: u32,
pub chunk_count: u32,
pub idempotency_key: Option<String>,
pub trace: Option<TraceContext>,
}
impl Debug for PeerRpcStreamOpenV2 {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PeerRpcStreamOpenV2")
.field("protocol_version", &self.protocol_version)
.field("request_id", &self.request_id)
.field("stream_id", &self.stream_id)
.field("trace_id", &self.trace_id)
.field("direction", &self.direction)
.field("call_kind", &self.call_kind)
.field("source_core_id", &self.source_core_id)
.field("target_core_id", &self.target_core_id)
.field("tenant_id", &self.tenant_id)
.field("cluster_id", &self.cluster_id)
.field("timestamp_ms", &self.timestamp_ms)
.field("deadline_ms", &self.deadline_ms)
.field("capability", &self.capability)
.field("payload_bytes", &self.payload_bytes)
.field("chunk_bytes", &self.chunk_bytes)
.field("chunk_count", &self.chunk_count)
.field("has_idempotency_key", &self.idempotency_key.is_some())
.field("trace", &self.trace)
.finish()
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerRpcStreamChunkV2 {
pub protocol_version: ProtocolVersion,
pub request_id: String,
pub stream_id: String,
pub sequence: u32,
pub encoding: PeerRpcChunkEncodingV2,
#[serde(with = "base64_bytes")]
pub payload: Vec<u8>,
pub decoded_bytes: u32,
pub chunk_hash: String,
}
impl Debug for PeerRpcStreamChunkV2 {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PeerRpcStreamChunkV2")
.field("protocol_version", &self.protocol_version)
.field("request_id", &self.request_id)
.field("stream_id", &self.stream_id)
.field("sequence", &self.sequence)
.field("encoding", &self.encoding)
.field("encoded_bytes", &self.payload.len())
.field("decoded_bytes", &self.decoded_bytes)
.field("chunk_hash", &self.chunk_hash)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerRpcStreamCommitV2 {
pub protocol_version: ProtocolVersion,
pub request_id: String,
pub stream_id: String,
pub chunk_count: u32,
pub payload_bytes: u64,
pub payload_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerRpcStreamCancelV2 {
pub protocol_version: ProtocolVersion,
pub request_id: String,
pub stream_id: String,
pub reason: PeerRpcStreamCancelReasonV2,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerRpcStreamPullV2 {
pub protocol_version: ProtocolVersion,
pub request_id: String,
pub stream_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "frame_type", content = "frame", rename_all = "snake_case")]
pub enum PeerRpcStreamFrameV2 {
Open(Box<PeerRpcStreamOpenV2>),
Chunk(PeerRpcStreamChunkV2),
Commit(PeerRpcStreamCommitV2),
Cancel(PeerRpcStreamCancelV2),
Pull(PeerRpcStreamPullV2),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerRpcStreamReplyV2 {
pub request_id: String,
pub stream_id: String,
pub next_sequence: u32,
pub received_bytes: u64,
pub response_frame: Option<Box<PeerRpcStreamFrameV2>>,
pub complete: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerRpcStreamHttpErrorCodeV2 {
Unauthorized,
Forbidden,
InvalidFrame,
ProtocolMismatch,
PayloadTooLarge,
ChunkTooLarge,
InvalidSequence,
InvalidChunkLength,
InvalidChunkHash,
InvalidPayloadHash,
IdentityMismatch,
TenantMismatch,
ClusterMismatch,
TargetMismatch,
NonceReplay,
DirectionMismatch,
CallKindMismatch,
Incomplete,
Expired,
Cancelled,
Io,
InvalidEncoding,
Closed,
CapacityExceeded,
EndpointUnavailable,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerRpcStreamHttpErrorV2 {
pub request_id: Option<String>,
pub stream_id: Option<String>,
pub code: PeerRpcStreamHttpErrorCodeV2,
}
impl PeerRpcStreamFrameV2 {
pub fn request_id(&self) -> &str {
match self {
Self::Open(frame) => &frame.request_id,
Self::Chunk(frame) => &frame.request_id,
Self::Commit(frame) => &frame.request_id,
Self::Cancel(frame) => &frame.request_id,
Self::Pull(frame) => &frame.request_id,
}
}
pub fn stream_id(&self) -> &str {
match self {
Self::Open(frame) => &frame.stream_id,
Self::Chunk(frame) => &frame.stream_id,
Self::Commit(frame) => &frame.stream_id,
Self::Cancel(frame) => &frame.stream_id,
Self::Pull(frame) => &frame.stream_id,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PeerRpcStreamErrorV2 {
#[error("invalid peer RPC V2 stream configuration")]
InvalidConfig,
#[error("peer RPC V2 protocol mismatch")]
ProtocolMismatch,
#[error("peer RPC V2 payload is too large")]
PayloadTooLarge,
#[error("peer RPC V2 chunk is too large")]
ChunkTooLarge,
#[error("peer RPC V2 chunk sequence is invalid")]
InvalidSequence,
#[error("peer RPC V2 chunk length is invalid")]
InvalidChunkLength,
#[error("peer RPC V2 chunk hash is invalid")]
InvalidChunkHash,
#[error("peer RPC V2 payload hash is invalid")]
InvalidPayloadHash,
#[error("peer RPC V2 stream identity mismatch")]
IdentityMismatch,
#[error("peer RPC V2 stream direction mismatch")]
DirectionMismatch,
#[error("peer RPC V2 call kind mismatch")]
CallKindMismatch,
#[error("peer RPC V2 stream is incomplete")]
Incomplete,
#[error("peer RPC V2 stream expired")]
Expired,
#[error("peer RPC V2 stream cancelled")]
Cancelled,
#[error("peer RPC V2 stream I/O failed")]
Io,
#[error("peer RPC V2 chunk encoding is invalid")]
InvalidEncoding,
#[error("peer RPC V2 stream is closed")]
Closed,
#[error("peer RPC V2 stream capacity is exhausted")]
CapacityExceeded,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_frame_has_stable_v2_json_shape() {
let frame = PeerRpcStreamFrameV2::Open(Box::new(PeerRpcStreamOpenV2 {
protocol_version: ProtocolVersion::new(2),
request_id: "req-1".to_string(),
stream_id: "stream-1".to_string(),
trace_id: "trace-1".to_string(),
direction: PeerRpcStreamDirectionV2::Request,
call_kind: super::super::v1::PeerRpcCallKind::Query,
source_core_id: CoreId::new("core-a").unwrap(),
target_core_id: CoreId::new("core-b").unwrap(),
tenant_id: TenantId::new("tenant-a").unwrap(),
cluster_id: ClusterId::new("cluster-a").unwrap(),
timestamp_ms: 10,
deadline_ms: 100,
nonce: "nonce-1".to_string(),
capability: CapabilityName::new("runtime.query").unwrap(),
payload_bytes: 5,
chunk_bytes: 3,
chunk_count: 2,
idempotency_key: None,
trace: None,
}));
let encoded = serde_json::to_value(frame).unwrap();
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../fixtures/peer-rpc-stream-open-v2.json")).unwrap();
assert_eq!(encoded, fixture);
}
#[test]
fn debug_omits_nonce_idempotency_and_chunk_payload() {
let open = PeerRpcStreamOpenV2 {
protocol_version: ProtocolVersion::new(2),
request_id: "req-1".to_string(),
stream_id: "stream-1".to_string(),
trace_id: "trace-1".to_string(),
direction: PeerRpcStreamDirectionV2::Request,
call_kind: super::super::v1::PeerRpcCallKind::Command,
source_core_id: CoreId::new("core-a").unwrap(),
target_core_id: CoreId::new("core-b").unwrap(),
tenant_id: TenantId::new("tenant-a").unwrap(),
cluster_id: ClusterId::new("cluster-a").unwrap(),
timestamp_ms: 10,
deadline_ms: 100,
nonce: "private-marker".to_string(),
capability: CapabilityName::new("runtime.command").unwrap(),
payload_bytes: 1,
chunk_bytes: 1,
chunk_count: 1,
idempotency_key: Some("private-marker".to_string()),
trace: None,
};
let chunk = PeerRpcStreamChunkV2 {
protocol_version: ProtocolVersion::new(2),
request_id: "req-1".to_string(),
stream_id: "stream-1".to_string(),
sequence: 0,
encoding: PeerRpcChunkEncodingV2::Identity,
payload: b"private-marker".to_vec(),
decoded_bytes: 14,
chunk_hash: "hash".to_string(),
};
assert!(!format!("{open:?}").contains("private-marker"));
assert!(!format!("{chunk:?}").contains("private-marker"));
}
#[test]
fn chunk_payload_uses_bounded_base64_json_string() {
let chunk = PeerRpcStreamFrameV2::Chunk(PeerRpcStreamChunkV2 {
protocol_version: ProtocolVersion::new(2),
request_id: "req-1".to_string(),
stream_id: "stream-1".to_string(),
sequence: 0,
encoding: PeerRpcChunkEncodingV2::Identity,
payload: vec![0, 127, 128, 255],
decoded_bytes: 4,
chunk_hash: "hash".to_string(),
});
let encoded = serde_json::to_value(&chunk).unwrap();
assert_eq!(encoded["frame"]["payload"], "AH+A/w==");
assert_eq!(
serde_json::from_value::<PeerRpcStreamFrameV2>(encoded).unwrap(),
chunk
);
}
}