use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use tokio::sync::{Notify, mpsc};
use tokio::task::JoinHandle;
use zenoh::bytes::{Encoding, ZBytes};
use zenoh::key_expr::OwnedKeyExpr;
use crate::error::{BusError, Result};
use crate::identity::{ExecutionId, ProducerId};
use crate::metadata::{BusMetadata, MAX_SOURCE_PARTICIPANT_BYTES};
use crate::runtime_metrics::{RuntimeMetricHandle, RuntimeMetricSnapshot, RuntimeMetrics};
use crate::time::TimeWindow;
pub(crate) const OUTBOUND_CAPACITY: usize = 1024;
const OUTBOUND_MAX_BYTES: usize = 16 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct BusConfig {
pub namespace: String,
pub robot_id: String,
pub execution: ExecutionId,
pub participant: String,
pub producer: ProducerId,
pub connect_endpoints: Vec<String>,
}
impl BusConfig {
pub fn in_process(namespace: impl Into<String>, robot_id: impl Into<String>) -> Self {
BusConfig {
namespace: namespace.into(),
robot_id: robot_id.into(),
execution: ExecutionId::mint(),
participant: "local".to_string(),
producer: ProducerId::mint(),
connect_endpoints: Vec::new(),
}
}
#[must_use]
pub fn in_execution(mut self, execution: ExecutionId) -> Self {
self.execution = execution;
self
}
}
#[derive(Debug, Default)]
pub struct BusHealth {
pub outbound_drops: AtomicU64,
pub inbound_drops: AtomicU64,
pub decode_errors: AtomicU64,
}
struct Outbound {
key: String,
encoding: String,
attachment: Vec<u8>,
payload: Vec<u8>,
bytes: usize,
metric: RuntimeMetricHandle,
}
struct BusInner {
session: zenoh::Session,
root: String,
execution: ExecutionId,
participant: String,
producer: ProducerId,
seq: AtomicU64,
outbound: mpsc::Sender<Outbound>,
queued_bytes: AtomicUsize,
closing: AtomicBool,
shutdown: Notify,
drain: std::sync::Mutex<Option<JoinHandle<()>>>,
health: BusHealth,
runtime_metrics: RuntimeMetrics,
}
#[derive(Clone)]
pub struct Bus {
inner: Arc<BusInner>,
}
impl std::fmt::Debug for Bus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Bus")
.field("root", &self.inner.root)
.field("participant", &self.inner.participant)
.finish_non_exhaustive()
}
}
impl Bus {
pub async fn open(config: BusConfig) -> Result<Self> {
validate_segment(&config.namespace, "identity.namespace", true)?;
validate_segment(&config.robot_id, "identity.id", false)?;
if config.participant.is_empty() {
return Err(BusError::Namespace(
"participant id must not be empty".to_string(),
));
}
if config.participant.len() > MAX_SOURCE_PARTICIPANT_BYTES {
return Err(BusError::Namespace(format!(
"participant id exceeds the {MAX_SOURCE_PARTICIPANT_BYTES}-byte limit"
)));
}
let root = format!(
"{}/robots/{}/{}",
config.namespace,
config.robot_id,
config.execution.as_key_segment()
);
OwnedKeyExpr::new(root.clone())
.map_err(|e| BusError::Namespace(format!("invalid key root '{root}': {e}")))?;
let session = zenoh::open(zenoh_config(&config.connect_endpoints)?)
.await
.map_err(|e| BusError::Transport(e.to_string()))?;
let (tx, rx) = mpsc::channel::<Outbound>(OUTBOUND_CAPACITY);
let drain_session = session.clone();
let inner = Arc::new(BusInner {
session,
root,
execution: config.execution,
participant: config.participant,
producer: config.producer,
seq: AtomicU64::new(0),
outbound: tx,
queued_bytes: AtomicUsize::new(0),
closing: AtomicBool::new(false),
shutdown: Notify::new(),
drain: std::sync::Mutex::new(None),
health: BusHealth::default(),
runtime_metrics: RuntimeMetrics::default(),
});
let drain = tokio::spawn(drain_loop(drain_session, rx, Arc::clone(&inner)));
*inner.drain.lock().expect("drain mutex poisoned") = Some(drain);
Ok(Bus { inner })
}
pub fn root(&self) -> &str {
&self.inner.root
}
pub fn execution(&self) -> ExecutionId {
self.inner.execution
}
pub fn participant(&self) -> &str {
&self.inner.participant
}
pub fn producer(&self) -> ProducerId {
self.inner.producer
}
pub(crate) fn metadata(&self, produced_at: Option<TimeWindow>) -> BusMetadata {
BusMetadata {
codec: crate::abi::CodecId::MessagePack.as_u8(),
producer: self.inner.producer,
sequence: self.next_sequence(),
produced_at,
participant: self.inner.participant.clone(),
}
}
pub fn health(&self) -> &BusHealth {
&self.inner.health
}
#[doc(hidden)]
pub fn take_runtime_metrics(&self) -> Vec<RuntimeMetricSnapshot> {
self.inner.runtime_metrics.take()
}
pub(crate) fn runtime_metrics(&self) -> &RuntimeMetrics {
&self.inner.runtime_metrics
}
pub fn session(&self) -> &zenoh::Session {
&self.inner.session
}
pub fn full_key(&self, topic_key: &str) -> String {
format!("{}/{}", self.inner.root, topic_key)
}
pub fn next_sequence(&self) -> u64 {
self.inner.seq.fetch_add(1, Ordering::Relaxed)
}
pub(crate) fn enqueue(
&self,
key: String,
encoding: String,
attachment: Vec<u8>,
payload: Vec<u8>,
metric: RuntimeMetricHandle,
) -> Result<()> {
if self.inner.closing.load(Ordering::Acquire) {
return Err(BusError::Closed);
}
let bytes = key.len() + encoding.len() + attachment.len() + payload.len();
if !reserve_outbound_bytes(&self.inner.queued_bytes, bytes) {
metric.record_drop();
return Err(self.dropped(&key, "byte bound"));
}
metric.enqueue_started();
let outbound = Outbound {
key,
encoding,
attachment,
payload,
bytes,
metric: metric.clone(),
};
match self.inner.outbound.try_send(outbound) {
Ok(()) => {
metric.record_message();
Ok(())
}
Err(mpsc::error::TrySendError::Full(out)) => {
self.inner.queued_bytes.fetch_sub(bytes, Ordering::AcqRel);
out.metric.enqueue_finished();
out.metric.record_drop();
Err(self.dropped(&out.key, "sample bound"))
}
Err(mpsc::error::TrySendError::Closed(_)) => {
self.inner.queued_bytes.fetch_sub(bytes, Ordering::AcqRel);
metric.enqueue_finished();
Err(BusError::Closed)
}
}
}
fn dropped(&self, key: &str, detail: &str) -> BusError {
self.inner
.health
.outbound_drops
.fetch_add(1, Ordering::Relaxed);
tracing::warn!(
target: "phoxal.bus",
participant = %self.inner.participant,
key,
detail,
"outbound queue saturated; dropped sample (publish never blocks the step loop)"
);
BusError::Saturated {
topic: key.to_string(),
detail: detail.to_string(),
}
}
pub async fn close(&self) -> Result<()> {
self.inner.closing.store(true, Ordering::Release);
self.inner.shutdown.notify_one();
let handle = self
.inner
.drain
.lock()
.expect("drain mutex poisoned")
.take();
if let Some(handle) = handle {
let _ = handle.await;
}
self.inner
.session
.close()
.await
.map_err(|e| BusError::Transport(e.to_string()))?;
Ok(())
}
}
fn reserve_outbound_bytes(queued: &AtomicUsize, bytes: usize) -> bool {
queued
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
current
.checked_add(bytes)
.filter(|next| *next <= OUTBOUND_MAX_BYTES)
})
.is_ok()
}
async fn drain_loop(
session: zenoh::Session,
mut rx: mpsc::Receiver<Outbound>,
inner: Arc<BusInner>,
) {
loop {
tokio::select! {
biased;
_ = inner.shutdown.notified() => {
while let Ok(out) = rx.try_recv() {
inner.queued_bytes.fetch_sub(out.bytes, Ordering::AcqRel);
out.metric.enqueue_finished();
put(&session, out).await;
}
break;
}
msg = rx.recv() => match msg {
Some(out) => {
inner.queued_bytes.fetch_sub(out.bytes, Ordering::AcqRel);
out.metric.enqueue_finished();
put(&session, out).await;
}
None => break,
},
}
}
}
async fn put(session: &zenoh::Session, out: Outbound) {
let key = match OwnedKeyExpr::new(out.key.clone()) {
Ok(key) => key,
Err(e) => {
tracing::error!(target: "phoxal.bus", key = %out.key, error = %e, "invalid publish key");
return;
}
};
if let Err(e) = session
.put(key, ZBytes::from(out.payload))
.encoding(Encoding::from(out.encoding))
.attachment(out.attachment)
.await
{
tracing::warn!(target: "phoxal.bus", key = %out.key, error = %e, "publish failed");
}
}
fn validate_segment(value: &str, what: &str, multi: bool) -> Result<()> {
if value.is_empty() {
return Err(BusError::Namespace(format!("{what} must not be empty")));
}
if !multi && value.contains('/') {
return Err(BusError::Namespace(format!(
"{what} must be a single key segment, got '{value}'"
)));
}
for seg in value.split('/') {
if seg.is_empty() {
return Err(BusError::Namespace(format!(
"{what} '{value}' has an empty segment"
)));
}
if seg.contains('*') {
return Err(BusError::Namespace(format!(
"{what} '{value}' must be a concrete (non-wildcard) path"
)));
}
}
Ok(())
}
fn zenoh_config(connect_endpoints: &[String]) -> Result<zenoh::Config> {
let mut config = zenoh::Config::default();
config
.insert_json5("transport/link/tx/lease", "3000")
.map_err(|error| BusError::Transport(error.to_string()))?;
config
.insert_json5("transport/link/tx/keep_alive", "4")
.map_err(|error| BusError::Transport(error.to_string()))?;
config
.insert_json5("scouting/multicast/enabled", "false")
.map_err(|error| BusError::Transport(error.to_string()))?;
if connect_endpoints.is_empty() {
config
.insert_json5("listen/endpoints", "[]")
.map_err(|error| BusError::Transport(error.to_string()))?;
} else {
config
.insert_json5("mode", "\"client\"")
.map_err(|error| BusError::Transport(error.to_string()))?;
let json = serde_json::to_string(connect_endpoints)
.map_err(|error| BusError::Transport(error.to_string()))?;
config
.insert_json5("connect/endpoints", &json)
.map_err(|error| BusError::Transport(error.to_string()))?;
}
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Barrier;
use std::sync::atomic::AtomicUsize;
#[test]
fn concurrent_byte_reservations_never_exceed_the_global_limit() {
const CALLERS: usize = 8;
let queued = AtomicUsize::new(0);
let accepted = AtomicUsize::new(0);
let barrier = Barrier::new(CALLERS);
let bytes = OUTBOUND_MAX_BYTES / 2 + 1;
std::thread::scope(|scope| {
for _ in 0..CALLERS {
scope.spawn(|| {
barrier.wait();
if reserve_outbound_bytes(&queued, bytes) {
accepted.fetch_add(1, Ordering::Relaxed);
}
});
}
});
assert_eq!(accepted.load(Ordering::Relaxed), 1);
assert_eq!(queued.load(Ordering::Relaxed), bytes);
assert!(queued.load(Ordering::Relaxed) <= OUTBOUND_MAX_BYTES);
assert!(!reserve_outbound_bytes(&queued, usize::MAX));
}
#[test]
fn phoxal_transport_settings_are_accepted_by_pinned_zenoh_config() {
zenoh_config(&[]).expect("pinned Zenoh must accept Phoxal lease settings");
zenoh_config(&["tcp/127.0.0.1:7447".to_string()])
.expect("pinned Zenoh must accept Phoxal client settings");
}
}