Replaces the stub that returned Err(Unimplemented) with a working iceoryx2
publish-subscribe sink. Key design:
- One iceoryx2 pub/sub service per configured stream at {prefix}/{stream}
- Fixed-size [u8] samples with in-band binary header (seq, src_ts_us, ts_us,
NUL-padded key(48), len(32), value(N)) — no unsafe repr(packed) needed
- Heartbeat stream at {prefix}/hzbridge_heartbeat with same layout
- Uses BackpressureStrategy::DiscardData to avoid blocking the feeder
- Uses ipc_threadsafe::Service so the Publisher is Send (ipc::Service uses
SingleThreaded sync which is !Send)
- Daemon builds Iceoryx2Sink by default; --allow-null-sink for dev use only
Constraint compliance:
C1 — no unsafe (crate-root forbid(unsafe_code) enforced by compiler)
C2 — no async (all iceoryx2 calls synchronous)
C5 — iceoryx2 0.9.1 already pinned per ADR-001
C8 — fixed-size samples, pre-allocated publishers, bounded loan slots
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
337 lines
13 KiB
Rust
337 lines
13 KiB
Rust
//! M5: Real iceoryx2 production sink.
|
||
//!
|
||
//! One publish-subscribe service per configured stream plus a dedicated
|
||
//! heartbeat stream. All samples are fixed-size `[u8]` slices with an
|
||
//! in-band header so consumers can read key/value directly from shm.
|
||
//!
|
||
//! Binary layout per sample (all values little-endian):
|
||
//! [0..8) seq u64 — monotonically increasing per-stream
|
||
//! [8..16) src_ts_us u64 — upstream Hazelcast event timestamp (µs epoch)
|
||
//! [16..24) ts_us u64 — local publish timestamp (µs epoch)
|
||
//! [24..72) key [u8; 48] — NUL-padded map key (UTF-8)
|
||
//! [72..76) len u32 — actual value length (≤ max_value_kib × 1024)
|
||
//! [76..) value [u8; MAX_VALUE] — opaque value bytes
|
||
//!
|
||
//! Heartbeat uses the same layout with seq=0 and key="heartbeat".
|
||
//!
|
||
//! Constraint compliance:
|
||
//! C1 — `#![forbid(unsafe_code)]` at crate root: no unsafe here.
|
||
//! C2 — no async: iceoryx2 API is synchronous.
|
||
//! C5 — iceoryx2 0.9.1 already pinned in Cargo.toml.
|
||
//! C8 — fixed-size samples, pre-allocated publishers.
|
||
|
||
use crate::config::Config;
|
||
use crate::metrics::MetricsSnapshot;
|
||
use crate::sink::{PlaneSink, SinkError};
|
||
use iceoryx2::prelude::*;
|
||
|
||
/// Alias for thread-safe IPC service type.
|
||
use log::{info, warn};
|
||
use std::collections::HashMap;
|
||
|
||
// ── Binary layout constants ────────────────────────────────────────────────
|
||
|
||
/// Byte offset at which the value payload begins.
|
||
const HEADER_BYTES: usize = 76; // seq(8) + src_ts_us(8) + ts_us(8) + key(48) + len(4)
|
||
const KEY_LEN: usize = 48;
|
||
|
||
// iceoryx2 Publisher<Service=ipc_threadsafe::Service, Payload=[u8], UserHeader=()>
|
||
type PublisherIpc = iceoryx2::port::publisher::Publisher<ipc_threadsafe::Service, [u8], ()>;
|
||
|
||
// ── Per-stream publisher state ──────────────────────────────────────────────
|
||
|
||
struct StreamPublisher {
|
||
/// The iceoryx2 publisher port.
|
||
publisher: PublisherIpc,
|
||
/// Total sample size = HEADER_BYTES + max_value_bytes.
|
||
sample_size: usize,
|
||
/// Monotonically increasing per-stream sequence number.
|
||
seq: u64,
|
||
}
|
||
|
||
// ── Iceoryx2Sink ────────────────────────────────────────────────────────────
|
||
|
||
/// The production `PlaneSink` that publishes into iceoryx2 services.
|
||
///
|
||
/// Keyed by the **short stream name** from config (e.g. `"eigen_scan"`).
|
||
/// The full iceoryx2 service name is `{prefix}/{short_name}`.
|
||
pub struct Iceoryx2Sink {
|
||
/// Map from short stream name → per-stream publisher.
|
||
streams: HashMap<String, StreamPublisher>,
|
||
/// Heartbeat publisher (best-effort; `None` if creation failed).
|
||
heartbeat: Option<StreamPublisher>,
|
||
/// The iceoryx2 node — held for the sink's lifetime.
|
||
_node: iceoryx2::node::Node<ipc_threadsafe::Service>,
|
||
/// Cached from config for use in `publish_and_notify`.
|
||
max_value_bytes: usize,
|
||
}
|
||
|
||
type NodeIpc = iceoryx2::node::Node<ipc_threadsafe::Service>;
|
||
|
||
impl Iceoryx2Sink {
|
||
/// Create the sink, opening or creating iceoryx2 services for every
|
||
/// configured stream plus the heartbeat stream.
|
||
///
|
||
/// Returns `SinkError` if any iceoryx2 service cannot be created or
|
||
/// opened. Per §5.2 "local shm failure is a broken box" — the caller
|
||
/// (the runtime) treats this as Fatal.
|
||
pub fn new(config: &Config) -> Result<Self, SinkError> {
|
||
let max_value_bytes = config.plane.max_value_kib * 1024;
|
||
let sample_size = HEADER_BYTES + max_value_bytes;
|
||
let prefix = &config.plane.service_prefix;
|
||
|
||
let node_name = NodeName::new(&format!("hzbridge_{}", config.hz.cluster))
|
||
.map_err(|e| SinkError::new(format!("node name: {e}")))?;
|
||
let node = NodeBuilder::new()
|
||
.name(&node_name)
|
||
.create::<ipc_threadsafe::Service>()
|
||
.map_err(|e| SinkError::new(format!("node create: {e:?}")))?;
|
||
|
||
let mut streams = HashMap::new();
|
||
for sc in &config.streams {
|
||
let full_name = format!("{}/{}", prefix, sc.stream);
|
||
let pub_ = Self::open_service(&node, &full_name, sample_size)?;
|
||
info!(
|
||
"sink=Iceoryx2Sink msg=stream_ready stream={} sample_size={}",
|
||
full_name, sample_size,
|
||
);
|
||
streams.insert(sc.stream.clone(), pub_);
|
||
}
|
||
|
||
// Heartbeat — best-effort; a failure here does not abort the bridge.
|
||
let hb_name = format!("{}/hzbridge_heartbeat", prefix);
|
||
let heartbeat = match Self::open_service(&node, &hb_name, sample_size) {
|
||
Ok(p) => {
|
||
info!("sink=Iceoryx2Sink msg=heartbeat_ready stream={}", hb_name);
|
||
Some(p)
|
||
}
|
||
Err(e) => {
|
||
warn!("sink=Iceoryx2Sink msg=heartbeat_failed error={e}");
|
||
None
|
||
}
|
||
};
|
||
|
||
Ok(Self {
|
||
streams,
|
||
heartbeat,
|
||
_node: node,
|
||
max_value_bytes,
|
||
})
|
||
}
|
||
|
||
/// Create or open one publish-subscribe service and return a publisher.
|
||
fn open_service(
|
||
node: &NodeIpc,
|
||
service_name: &str,
|
||
sample_size: usize,
|
||
) -> Result<StreamPublisher, SinkError> {
|
||
let name = ServiceName::new(service_name)
|
||
.map_err(|e| SinkError::new(format!("service name '{service_name}': {e}")))?;
|
||
let service = node
|
||
.service_builder(&name)
|
||
.publish_subscribe::<[u8]>()
|
||
.open_or_create()
|
||
.map_err(|e| SinkError::new(format!("open_or_create '{service_name}': {e:?}")))?;
|
||
let publisher = service
|
||
.publisher_builder()
|
||
.initial_max_slice_len(sample_size)
|
||
.max_loaned_samples(2)
|
||
.backpressure_strategy(BackpressureStrategy::DiscardData)
|
||
.create()
|
||
.map_err(|e| SinkError::new(format!("publisher '{service_name}': {e:?}")))?;
|
||
Ok(StreamPublisher {
|
||
publisher,
|
||
sample_size,
|
||
seq: 0,
|
||
})
|
||
}
|
||
|
||
/// Write a value into a loaned iceoryx2 sample and send it.
|
||
fn write_sample(
|
||
pub_: &mut StreamPublisher,
|
||
key: &str,
|
||
value_utf8: &[u8],
|
||
src_ts_us: u64,
|
||
ts_us: u64,
|
||
max_value_bytes: usize,
|
||
) -> Result<(), SinkError> {
|
||
pub_.seq = pub_.seq.wrapping_add(1);
|
||
let mut sample = pub_
|
||
.publisher
|
||
.loan_slice(pub_.sample_size)
|
||
.map_err(|e| SinkError::new(format!("loan_slice: {e:?}")))?;
|
||
|
||
let buf = sample.payload_mut();
|
||
let seq = pub_.seq;
|
||
|
||
// seq
|
||
buf[0..8].copy_from_slice(&seq.to_le_bytes());
|
||
// src_ts_us
|
||
buf[8..16].copy_from_slice(&src_ts_us.to_le_bytes());
|
||
// ts_us
|
||
buf[16..24].copy_from_slice(&ts_us.to_le_bytes());
|
||
// key (NUL-padded, truncated to KEY_LEN)
|
||
let key_bytes = key.as_bytes();
|
||
let copy_len = key_bytes.len().min(KEY_LEN);
|
||
buf[24..24 + copy_len].copy_from_slice(&key_bytes[..copy_len]);
|
||
if copy_len < KEY_LEN {
|
||
buf[24 + copy_len..72].fill(0);
|
||
}
|
||
// len
|
||
let value_len = value_utf8.len().min(max_value_bytes);
|
||
buf[72..76].copy_from_slice(&(value_len as u32).to_le_bytes());
|
||
// value
|
||
let vs = HEADER_BYTES;
|
||
buf[vs..vs + value_len].copy_from_slice(&value_utf8[..value_len]);
|
||
// Zero remaining value buffer
|
||
if value_len < max_value_bytes {
|
||
buf[vs + value_len..vs + max_value_bytes].fill(0);
|
||
}
|
||
|
||
sample.send().map_err(|e| SinkError::new(format!("send: {e:?}")))?;
|
||
Ok(())
|
||
}
|
||
|
||
fn now_us() -> u64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or(std::time::Duration::ZERO)
|
||
.as_micros() as u64
|
||
}
|
||
|
||
/// Encode a `MetricsSnapshot` as a space-separated key=value text blob.
|
||
fn encode_heartbeat(snapshot: &MetricsSnapshot) -> Vec<u8> {
|
||
format!(
|
||
"events_rx={} events_published={} decode_skips={} late_responses={} \
|
||
reconnects={} fragments_reassembled={} warmups={} pings_ok={} \
|
||
pings_missed={} events_dropped={} uptime_s={} state={} degraded={}",
|
||
snapshot.events_rx,
|
||
snapshot.events_published,
|
||
snapshot.decode_skips,
|
||
snapshot.late_responses,
|
||
snapshot.reconnects,
|
||
snapshot.fragments_reassembled,
|
||
snapshot.warmups,
|
||
snapshot.pings_ok,
|
||
snapshot.pings_missed,
|
||
snapshot.events_dropped,
|
||
snapshot.uptime_s,
|
||
snapshot.state,
|
||
snapshot.degraded,
|
||
)
|
||
.into_bytes()
|
||
}
|
||
}
|
||
|
||
impl PlaneSink for Iceoryx2Sink {
|
||
fn publish(
|
||
&mut self,
|
||
stream: &str,
|
||
key: &str,
|
||
value_utf8: &[u8],
|
||
src_ts_us: u64,
|
||
) -> Result<(), SinkError> {
|
||
let ts_us = Self::now_us();
|
||
let pub_ = self.streams.get_mut(stream).ok_or_else(|| {
|
||
SinkError::new(format!("unknown stream: {stream}"))
|
||
})?;
|
||
Self::write_sample(pub_, key, value_utf8, src_ts_us, ts_us, self.max_value_bytes)
|
||
}
|
||
|
||
fn heartbeat(&mut self, snapshot: &MetricsSnapshot) -> Result<(), SinkError> {
|
||
let Some(pub_) = self.heartbeat.as_mut() else {
|
||
return Ok(());
|
||
};
|
||
let ts_us = Self::now_us();
|
||
let value = Self::encode_heartbeat(snapshot);
|
||
Self::write_sample(pub_, "heartbeat", &value, ts_us, ts_us, self.max_value_bytes)
|
||
}
|
||
}
|
||
|
||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn sample_layout_offsets_are_correct() {
|
||
let mut buf = vec![0u8; HEADER_BYTES + 256];
|
||
// seq = 42
|
||
buf[0..8].copy_from_slice(&42u64.to_le_bytes());
|
||
// src_ts_us = 1000
|
||
buf[8..16].copy_from_slice(&1000u64.to_le_bytes());
|
||
// ts_us = 2000
|
||
buf[16..24].copy_from_slice(&2000u64.to_le_bytes());
|
||
// key = "BTCUSDT"
|
||
let k = b"BTCUSDT";
|
||
buf[24..24 + k.len()].copy_from_slice(k);
|
||
// len = 6
|
||
buf[72..76].copy_from_slice(&6u32.to_le_bytes());
|
||
// value = "123456"
|
||
buf[76..82].copy_from_slice(b"123456");
|
||
|
||
assert_eq!(u64::from_le_bytes(buf[0..8].try_into().unwrap()), 42);
|
||
assert_eq!(u64::from_le_bytes(buf[8..16].try_into().unwrap()), 1000);
|
||
assert_eq!(u64::from_le_bytes(buf[16..24].try_into().unwrap()), 2000);
|
||
assert_eq!(&buf[24..31], b"BTCUSDT");
|
||
assert_eq!(u32::from_le_bytes(buf[72..76].try_into().unwrap()), 6);
|
||
assert_eq!(&buf[76..82], b"123456");
|
||
}
|
||
|
||
#[test]
|
||
fn header_byte_count_is_76() {
|
||
assert_eq!(HEADER_BYTES, 76);
|
||
}
|
||
|
||
#[test]
|
||
fn heartbeat_encoding_contains_all_fields() {
|
||
let snap = MetricsSnapshot {
|
||
events_rx: 100,
|
||
events_published: 95,
|
||
decode_skips: 2,
|
||
late_responses: 0,
|
||
reconnects: 1,
|
||
fragments_reassembled: 0,
|
||
warmups: 3,
|
||
pings_ok: 50,
|
||
pings_missed: 1,
|
||
events_dropped: 0,
|
||
uptime_s: 3600,
|
||
state: "Streaming".into(),
|
||
degraded: false,
|
||
};
|
||
let encoded = Iceoryx2Sink::encode_heartbeat(&snap);
|
||
let text = String::from_utf8(encoded).unwrap();
|
||
assert!(text.contains("events_rx=100"));
|
||
assert!(text.contains("events_published=95"));
|
||
assert!(text.contains("decode_skips=2"));
|
||
assert!(text.contains("state=Streaming"));
|
||
assert!(text.contains("degraded=false"));
|
||
assert!(text.contains("uptime_s=3600"));
|
||
}
|
||
|
||
#[test]
|
||
fn key_truncation_fits_48_bytes() {
|
||
let long_key = "a".repeat(100);
|
||
let max_val = 256;
|
||
let mut buf = vec![0u8; HEADER_BYTES + max_val];
|
||
let kbytes = long_key.as_bytes();
|
||
let copy_len = kbytes.len().min(KEY_LEN);
|
||
buf[24..24 + copy_len].copy_from_slice(&kbytes[..copy_len]);
|
||
// Verify the stored key is truncated to KEY_LEN and NUL-padded
|
||
let stored = &buf[24..72];
|
||
assert_eq!(&stored[..KEY_LEN - 1], &[b'a'; 47]);
|
||
assert_eq!(stored[KEY_LEN - 1], b'a');
|
||
}
|
||
|
||
#[test]
|
||
fn value_truncation_fits_max_value() {
|
||
let max_val = 256;
|
||
let big_value = vec![0xFFu8; 500];
|
||
let value_len = big_value.len().min(max_val);
|
||
assert_eq!(value_len, max_val);
|
||
assert_eq!(value_len, 256);
|
||
}
|
||
}
|