M5: implement real Iceoryx2Sink — iceoryx2 0.9.1 production plane sink
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>
This commit is contained in:
87
hzbridge/src/bin/hzbridged.rs
Normal file
87
hzbridge/src/bin/hzbridged.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use hzbridge::config::Config;
|
||||
use hzbridge::sink::PlaneSink;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("hzbridge=info"))
|
||||
.format_timestamp_micros()
|
||||
.init();
|
||||
|
||||
let arguments = match parse_arguments() {
|
||||
Ok(a) => a,
|
||||
Err(detail) => {
|
||||
eprintln!("hzbridged: {detail}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let config = match Config::from_path(&arguments.config_path) {
|
||||
Ok(c) => c,
|
||||
Err(error) => {
|
||||
eprintln!("hzbridged: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let sink: Box<dyn PlaneSink> = build_sink(&config, arguments.allow_null_sink);
|
||||
|
||||
log::info!("fsm=Boot msg=starting name=hzbridge");
|
||||
let bridge = hzbridge::Bridge::new(config, sink);
|
||||
if let Err(error) = bridge.run() {
|
||||
hzbridge::blackbox::abort(&error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "iceoryx2")]
|
||||
fn build_sink(config: &Config, allow_null_sink: bool) -> Box<dyn PlaneSink> {
|
||||
if allow_null_sink {
|
||||
log::warn!("fsm=Boot msg=development_null_sink_enabled");
|
||||
return Box::new(hzbridge::sink::NullSink);
|
||||
}
|
||||
match hzbridge::sink_iceoryx2::Iceoryx2Sink::new(config) {
|
||||
Ok(sink) => {
|
||||
log::info!("fsm=Boot msg=iceoryx2_sink_ready");
|
||||
Box::new(sink)
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("fsm=Boot msg=iceoryx2_sink_failed error={error}");
|
||||
eprintln!("hzbridged: iceoryx2 sink failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "iceoryx2"))]
|
||||
fn build_sink(_config: &Config, allow_null_sink: bool) -> Box<dyn PlaneSink> {
|
||||
if !allow_null_sink {
|
||||
eprintln!(
|
||||
"hzbridged: iceoryx2 feature disabled and no --allow-null-sink; \
|
||||
use --allow-null-sink only for wire/runtime smoke tests"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
log::warn!("fsm=Boot msg=development_null_sink_enabled (iceoryx2 feature not available)");
|
||||
Box::new(hzbridge::sink::NullSink)
|
||||
}
|
||||
|
||||
struct Arguments {
|
||||
config_path: PathBuf,
|
||||
allow_null_sink: bool,
|
||||
}
|
||||
|
||||
fn parse_arguments() -> Result<Arguments, String> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[flag, path] if flag == "--config" => Ok(Arguments {
|
||||
config_path: path.into(),
|
||||
allow_null_sink: false,
|
||||
}),
|
||||
[flag, path, allow] if flag == "--config" && allow == "--allow-null-sink" => {
|
||||
Ok(Arguments {
|
||||
config_path: path.into(),
|
||||
allow_null_sink: true,
|
||||
})
|
||||
}
|
||||
_ => Err("usage: hzbridged --config <path> [--allow-null-sink]".to_owned()),
|
||||
}
|
||||
}
|
||||
336
hzbridge/src/sink_iceoryx2.rs
Normal file
336
hzbridge/src/sink_iceoryx2.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user