use std::collections::HashMap;
use super::error::{TransportError, TransportResult};
use super::factory::AnySender;
use super::traits::{TransportBase, TransportSender};
use super::types::SendResult;
use super::work_batch::Record;
pub struct RoutedSender {
routes: HashMap<String, AnySender>,
default: Option<AnySender>,
closed: std::sync::atomic::AtomicBool,
}
impl RoutedSender {
#[must_use]
pub fn new(routes: HashMap<String, AnySender>, default: Option<AnySender>) -> Self {
Self {
routes,
default,
closed: std::sync::atomic::AtomicBool::new(false),
}
}
pub async fn from_route_configs(
routes: HashMap<String, super::TransportConfig>,
default_config: Option<super::TransportConfig>,
) -> TransportResult<Self> {
let mut senders = HashMap::with_capacity(routes.len());
for (key, config) in routes {
let sender = AnySender::from_transport_config(&config).await?;
senders.insert(key, sender);
}
let default = match default_config {
Some(cfg) => Some(AnySender::from_transport_config(&cfg).await?),
None => None,
};
Ok(Self::new(senders, default))
}
#[must_use]
pub fn route_keys(&self) -> Vec<&str> {
self.routes.keys().map(String::as_str).collect()
}
#[must_use]
pub fn has_route(&self, key: &str) -> bool {
self.routes.contains_key(key)
}
#[must_use]
pub fn has_default(&self) -> bool {
self.default.is_some()
}
#[must_use]
pub fn destination_health(&self) -> Vec<(&str, bool)> {
let mut out: Vec<(&str, bool)> = self
.routes
.iter()
.map(|(name, sender)| (name.as_str(), sender.is_healthy()))
.collect();
if let Some(ref default) = self.default {
out.push(("default", default.is_healthy()));
}
out
}
#[must_use]
pub fn is_destination_healthy(&self, destination: &str) -> bool {
self.resolve(destination)
.is_some_and(|(_, sender)| sender.is_healthy())
}
#[must_use]
pub fn any_healthy(&self) -> bool {
if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
return false;
}
self.routes.values().any(AnySender::is_healthy)
|| self.default.as_ref().is_some_and(AnySender::is_healthy)
}
pub async fn send_to(&self, destination: &str, key: &str, payload: bytes::Bytes) -> SendResult {
if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
return SendResult::Fatal(TransportError::Closed);
}
let Some((route_name, sender)) = self.resolve(destination) else {
return unroutable(destination);
};
record_route_send(route_name, payload.len());
sender.send(key, payload).await
}
pub async fn send_fanout(
&self,
destinations: &[&str],
key: &str,
payload: bytes::Bytes,
) -> SendResult {
for destination in destinations {
match self.send_to(destination, key, payload.clone()).await {
SendResult::Ok | SendResult::FilteredDlq => {}
other => return other,
}
}
SendResult::Ok
}
pub async fn send_batch_fanout(&self, destinations: &[&str], records: &[Record]) -> SendResult {
if destinations.is_empty() || records.is_empty() {
return SendResult::Ok;
}
if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
return SendResult::Fatal(TransportError::Closed);
}
for destination in destinations {
let Some((route_name, sender)) = self.resolve(destination) else {
return unroutable(destination);
};
for record in records {
record_route_send(route_name, record.payload.len());
}
match sender.send_batch(records).await {
SendResult::Ok | SendResult::FilteredDlq => {}
other => return other,
}
}
SendResult::Ok
}
fn resolve(&self, key: &str) -> Option<(&str, &AnySender)> {
if let Some((name, sender)) = self.routes.get_key_value(key) {
Some((name.as_str(), sender))
} else {
self.default.as_ref().map(|s| ("default", s))
}
}
}
impl TransportBase for RoutedSender {
async fn close(&self) -> TransportResult<()> {
self.closed
.store(true, std::sync::atomic::Ordering::Relaxed);
for sender in self.routes.values() {
sender.close().await?;
}
if let Some(ref default) = self.default {
default.close().await?;
}
Ok(())
}
fn is_healthy(&self) -> bool {
if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
return false;
}
let routes_healthy = self.routes.values().all(|s| s.is_healthy());
let default_healthy = self.default.as_ref().is_none_or(|s| s.is_healthy());
routes_healthy && default_healthy
}
fn name(&self) -> &'static str {
"routed"
}
}
fn unroutable(destination: &str) -> SendResult {
SendResult::Fatal(TransportError::Config(format!(
"no route configured for destination '{destination}' and no default sender"
)))
}
async fn send_grouped<'a, S, F>(records: &[Record], resolve: F) -> SendResult
where
S: TransportSender + 'a,
F: Fn(&str) -> Option<(&'a str, &'a S)>,
{
let mut one_route: Option<(&'a str, &'a S)> = None;
let mut mixed = false;
for record in records {
let destination = record.key.as_deref().unwrap_or("");
let Some(resolved) = resolve(destination) else {
return unroutable(destination);
};
match one_route {
None => one_route = Some(resolved),
Some((_, sender)) => mixed |= !std::ptr::eq(sender, resolved.1),
}
}
let Some((first_route, first_sender)) = one_route else {
return SendResult::Ok; };
if !mixed {
for record in records {
record_route_send(first_route, record.payload.len());
}
return match first_sender.send_batch(records).await {
SendResult::FilteredDlq => SendResult::Ok,
other => other,
};
}
let mut groups: Vec<(&'a str, &'a S, Vec<Record>)> = Vec::new();
for record in records {
let destination = record.key.as_deref().unwrap_or("");
let Some((route_name, sender)) = resolve(destination) else {
return unroutable(destination);
};
if let Some((_, _, group)) = groups.iter_mut().find(|(_, s, _)| std::ptr::eq(*s, sender)) {
group.push(record.clone());
} else {
groups.push((route_name, sender, vec![record.clone()]));
}
}
for (route_name, sender, group) in groups {
for record in &group {
record_route_send(route_name, record.payload.len());
}
match sender.send_batch(&group).await {
SendResult::Ok | SendResult::FilteredDlq => {}
other => return other,
}
}
SendResult::Ok
}
fn record_route_send(route_name: &str, payload_len: usize) {
#[cfg(feature = "metrics")]
{
metrics::counter!(
"transport_sent_total",
"transport" => "routed",
"route" => route_name.to_string()
)
.increment(1);
metrics::counter!(
"transport_sent_bytes_total",
"transport" => "routed",
"route" => route_name.to_string()
)
.increment(payload_len as u64);
}
#[cfg(not(feature = "metrics"))]
let _ = (route_name, payload_len);
}
impl TransportSender for RoutedSender {
async fn send(&self, destination: &str, payload: bytes::Bytes) -> SendResult {
self.send_to(destination, destination, payload).await
}
async fn send_batch(&self, records: &[Record]) -> SendResult {
if records.is_empty() {
return SendResult::Ok;
}
if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
return SendResult::Fatal(TransportError::Closed);
}
send_grouped(records, |destination| self.resolve(destination)).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "transport-memory")]
use crate::transport::memory::{MemoryConfig, MemoryTransport};
#[cfg(feature = "transport-memory")]
fn make_memory_sender() -> AnySender {
AnySender::Memory(
MemoryTransport::new(&MemoryConfig::default())
.expect("memory transport with valid config must construct"),
)
}
#[tokio::test]
#[cfg(feature = "transport-memory")]
async fn routes_to_correct_sender() {
let mut route_map = HashMap::new();
route_map.insert("events.land".into(), make_memory_sender());
route_map.insert("events.load".into(), make_memory_sender());
let sender = RoutedSender::new(route_map, Some(make_memory_sender()));
let result_land = sender
.send("events.land", bytes::Bytes::from_static(b"land-payload"))
.await;
assert!(result_land.is_ok());
let result_load = sender
.send("events.load", bytes::Bytes::from_static(b"load-payload"))
.await;
assert!(result_load.is_ok());
let result_default = sender
.send("unknown.key", bytes::Bytes::from_static(b"default-payload"))
.await;
assert!(result_default.is_ok());
assert!(sender.is_healthy());
assert_eq!(sender.name(), "routed");
}
#[tokio::test]
async fn no_route_no_default_returns_fatal() {
let sender = RoutedSender::new(HashMap::new(), None);
let result = sender
.send("unknown", bytes::Bytes::from_static(b"payload"))
.await;
assert!(result.is_fatal());
}
#[tokio::test]
#[cfg(feature = "transport-memory")]
async fn close_propagates_to_all_senders() {
let mut route_map = HashMap::new();
route_map.insert("a".into(), make_memory_sender());
let sender = RoutedSender::new(route_map, Some(make_memory_sender()));
assert!(sender.is_healthy());
sender.close().await.unwrap();
assert!(!sender.is_healthy());
}
#[test]
fn route_keys_and_has_route() {
let sender = RoutedSender::new(HashMap::new(), None);
assert!(sender.route_keys().is_empty());
assert!(!sender.has_route("anything"));
assert!(!sender.has_default());
}
#[tokio::test]
async fn send_after_close_returns_fatal() {
let sender = RoutedSender::new(HashMap::new(), None);
sender.close().await.unwrap();
let result = sender
.send("key", bytes::Bytes::from_static(b"payload"))
.await;
assert!(result.is_fatal());
}
#[tokio::test]
#[cfg(feature = "transport-memory")]
async fn send_to_routes_by_name_and_carries_the_wire_key() {
let mut route_map = HashMap::new();
route_map.insert("loader".into(), make_memory_sender());
let sender = RoutedSender::new(route_map, None);
let result = sender
.send_to("loader", "orders_land", bytes::Bytes::from_static(b"p"))
.await;
assert!(result.is_ok());
let result = sender
.send_to("nowhere", "orders_land", bytes::Bytes::from_static(b"p"))
.await;
assert!(result.is_fatal());
}
#[tokio::test]
#[cfg(feature = "transport-memory")]
async fn fanout_delivers_to_every_destination() {
let mut route_map = HashMap::new();
route_map.insert("loader".into(), make_memory_sender());
route_map.insert("archiver".into(), make_memory_sender());
let sender = RoutedSender::new(route_map, None);
let result = sender
.send_fanout(
&["loader", "archiver"],
"orders_land",
bytes::Bytes::from_static(b"p"),
)
.await;
assert!(result.is_ok());
assert!(
sender
.send_fanout(&[], "orders_land", bytes::Bytes::from_static(b"p"))
.await
.is_ok()
);
}
#[tokio::test]
#[cfg(feature = "transport-memory")]
async fn fanout_short_circuits_on_an_unroutable_destination() {
let mut route_map = HashMap::new();
route_map.insert("loader".into(), make_memory_sender());
let sender = RoutedSender::new(route_map, None);
let result = sender
.send_fanout(
&["loader", "missing"],
"orders_land",
bytes::Bytes::from_static(b"p"),
)
.await;
assert!(
result.is_fatal(),
"a fan-out is acknowledged only when EVERY destination accepts"
);
}
#[tokio::test]
#[cfg(feature = "transport-memory")]
async fn destination_health_reports_each_route_and_the_default() {
let mut route_map = HashMap::new();
route_map.insert("loader".into(), make_memory_sender());
let sender = RoutedSender::new(route_map, Some(make_memory_sender()));
let mut health = sender.destination_health();
health.sort_unstable();
assert_eq!(health, vec![("default", true), ("loader", true)]);
assert!(sender.is_destination_healthy("loader"));
assert!(sender.is_destination_healthy("anything-else"));
assert!(sender.any_healthy());
sender.close().await.unwrap();
assert!(!sender.any_healthy(), "closed set is healthy nowhere");
}
#[test]
fn any_healthy_is_false_with_no_senders() {
let sender = RoutedSender::new(HashMap::new(), None);
assert!(!sender.any_healthy());
assert!(!sender.is_destination_healthy("loader"));
}
use super::super::types::PayloadFormat;
use super::super::work_batch::RecordMeta;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Default)]
struct CountingSender {
batches: Mutex<Vec<Vec<Vec<u8>>>>,
sends: AtomicUsize,
backpressure: bool,
}
impl CountingSender {
fn backpressured() -> Self {
Self {
backpressure: true,
..Self::default()
}
}
fn calls(&self) -> Vec<Vec<String>> {
self.batches
.lock()
.expect("test mutex is never poisoned")
.iter()
.map(|batch| {
batch
.iter()
.map(|p| String::from_utf8_lossy(p).into_owned())
.collect()
})
.collect()
}
}
impl TransportBase for CountingSender {
async fn close(&self) -> TransportResult<()> {
Ok(())
}
fn is_healthy(&self) -> bool {
true
}
fn name(&self) -> &'static str {
"counting"
}
}
impl TransportSender for CountingSender {
async fn send(&self, _destination: &str, _payload: bytes::Bytes) -> SendResult {
let _ = self.sends.fetch_add(1, Ordering::Relaxed);
SendResult::Ok
}
async fn send_batch(&self, records: &[Record]) -> SendResult {
self.batches
.lock()
.expect("test mutex is never poisoned")
.push(records.iter().map(|r| r.payload.to_vec()).collect());
if self.backpressure {
SendResult::Backpressured
} else {
SendResult::Ok
}
}
}
fn rec(key: Option<&str>, payload: &'static [u8]) -> Record {
Record {
payload: bytes::Bytes::from_static(payload),
key: key.map(std::sync::Arc::from),
headers: Vec::new(),
metadata: RecordMeta {
timestamp_ms: None,
format: PayloadFormat::Json,
},
}
}
fn resolve_fake<'a>(
routes: &'a HashMap<String, CountingSender>,
default: Option<&'a CountingSender>,
key: &str,
) -> Option<(&'a str, &'a CountingSender)> {
routes.get_key_value(key).map_or_else(
|| default.map(|s| ("default", s)),
|(name, sender)| Some((name.as_str(), sender)),
)
}
#[tokio::test]
async fn send_batch_reaches_each_destination_in_one_call() {
let mut routes = HashMap::new();
routes.insert("loader".to_string(), CountingSender::default());
routes.insert("archiver".to_string(), CountingSender::default());
let records = vec![
rec(Some("loader"), b"l1"),
rec(Some("archiver"), b"a1"),
rec(Some("loader"), b"l2"),
rec(Some("archiver"), b"a2"),
];
let result = send_grouped(&records, |k| resolve_fake(&routes, None, k)).await;
assert!(result.is_ok(), "every group accepted: {result:?}");
let loader = &routes["loader"];
let archiver = &routes["archiver"];
assert_eq!(
loader.calls(),
vec![vec!["l1".to_string(), "l2".to_string()]],
"one send_batch call carrying both loader records in input order"
);
assert_eq!(
archiver.calls(),
vec![vec!["a1".to_string(), "a2".to_string()]],
"one send_batch call carrying both archiver records in input order"
);
assert_eq!(
loader.sends.load(Ordering::Relaxed) + archiver.sends.load(Ordering::Relaxed),
0,
"the batch path must never degrade to per-record send"
);
}
#[tokio::test]
async fn send_batch_single_destination_sends_the_block_untouched() {
let mut routes = HashMap::new();
routes.insert("loader".to_string(), CountingSender::default());
let records = vec![rec(Some("loader"), b"one"), rec(Some("loader"), b"two")];
let result = send_grouped(&records, |k| resolve_fake(&routes, None, k)).await;
assert!(result.is_ok());
assert_eq!(
routes["loader"].calls(),
vec![vec!["one".to_string(), "two".to_string()]]
);
}
#[tokio::test]
async fn send_batch_backpressure_surfaces_for_that_group_only() {
let mut routes = HashMap::new();
routes.insert("loader".to_string(), CountingSender::default());
routes.insert("archiver".to_string(), CountingSender::backpressured());
routes.insert("audit".to_string(), CountingSender::default());
let records = vec![
rec(Some("loader"), b"l1"),
rec(Some("archiver"), b"a1"),
rec(Some("audit"), b"x1"),
rec(Some("archiver"), b"a2"),
];
let result = send_grouped(&records, |k| resolve_fake(&routes, None, k)).await;
assert!(
result.is_backpressured(),
"the failing destination's result is the block's result: {result:?}"
);
assert_eq!(
routes["loader"].calls(),
vec![vec!["l1".to_string()]],
"the group before the failure was sent"
);
assert_eq!(
routes["archiver"].calls(),
vec![vec!["a1".to_string(), "a2".to_string()]],
"the backpressure covers exactly the failing destination's records"
);
assert!(
routes["audit"].calls().is_empty(),
"a group after the failure stays unsent for the caller's retry"
);
}
#[tokio::test]
async fn send_batch_unroutable_record_sends_nothing_at_all() {
let mut routes = HashMap::new();
routes.insert("loader".to_string(), CountingSender::default());
let records = vec![rec(Some("loader"), b"l1"), rec(Some("nowhere"), b"n1")];
let result = send_grouped(&records, |k| resolve_fake(&routes, None, k)).await;
assert!(result.is_fatal(), "unroutable block is fatal: {result:?}");
assert!(
routes["loader"].calls().is_empty(),
"nothing is sent when any record in the block is unroutable"
);
}
#[tokio::test]
async fn send_batch_keyless_records_go_to_the_default() {
let routes = HashMap::new();
let default = CountingSender::default();
let records = vec![rec(None, b"d1"), rec(Some("unknown"), b"d2")];
let result = send_grouped(&records, |k| resolve_fake(&routes, Some(&default), k)).await;
assert!(result.is_ok());
assert_eq!(
default.calls(),
vec![vec!["d1".to_string(), "d2".to_string()]],
"both fall through to the default as ONE group"
);
}
#[tokio::test]
async fn send_batch_splits_a_default_named_route_from_the_fallback() {
let mut routes = HashMap::new();
routes.insert("default".to_string(), CountingSender::default());
let fallback = CountingSender::default();
let records = vec![
rec(Some("default"), b"named"),
rec(Some("unknown"), b"fell"),
];
let result = send_grouped(&records, |k| resolve_fake(&routes, Some(&fallback), k)).await;
assert!(result.is_ok());
assert_eq!(routes["default"].calls(), vec![vec!["named".to_string()]]);
assert_eq!(fallback.calls(), vec![vec!["fell".to_string()]]);
}
#[tokio::test]
#[cfg(feature = "transport-memory")]
async fn send_batch_empty_block_is_a_no_op() {
let mut route_map = HashMap::new();
route_map.insert("loader".into(), make_memory_sender());
let sender = RoutedSender::new(route_map, None);
assert!(sender.send_batch(&[]).await.is_ok());
sender.close().await.unwrap();
assert!(sender.send_batch(&[]).await.is_ok());
}
#[tokio::test]
#[cfg(feature = "transport-memory")]
async fn send_batch_surfaces_the_backpressured_destination() {
let slow = AnySender::Memory(
MemoryTransport::new(&MemoryConfig {
buffer_size: 1,
..MemoryConfig::default()
})
.expect("memory transport with valid config must construct"),
);
let mut route_map = HashMap::new();
route_map.insert("slow".to_string(), slow);
route_map.insert("fast".to_string(), make_memory_sender());
let sender = RoutedSender::new(route_map, None);
assert!(
sender
.send("slow", bytes::Bytes::from_static(b"fill"))
.await
.is_ok()
);
let records = vec![rec(Some("fast"), b"f1"), rec(Some("slow"), b"s1")];
assert!(
sender.send_batch(&records).await.is_backpressured(),
"the routed block carries the failing destination's result up"
);
}
#[tokio::test]
#[cfg(feature = "transport-memory")]
async fn send_batch_after_close_is_fatal() {
let mut route_map = HashMap::new();
route_map.insert("loader".into(), make_memory_sender());
let sender = RoutedSender::new(route_map, None);
sender.close().await.unwrap();
let records = vec![rec(Some("loader"), b"l1")];
assert!(sender.send_batch(&records).await.is_fatal());
}
#[tokio::test]
#[cfg(feature = "transport-memory")]
async fn batch_fanout_delivers_the_whole_block_to_every_destination() {
let mut route_map = HashMap::new();
route_map.insert("loader".into(), make_memory_sender());
route_map.insert("archiver".into(), make_memory_sender());
let sender = RoutedSender::new(route_map, None);
let records = vec![
rec(Some("orders_land"), b"r1"),
rec(Some("orders_land"), b"r2"),
];
assert!(
sender
.send_batch_fanout(&["loader", "archiver"], &records)
.await
.is_ok()
);
assert!(sender.send_batch_fanout(&[], &records).await.is_ok());
assert!(sender.send_batch_fanout(&["loader"], &[]).await.is_ok());
assert!(
sender
.send_batch_fanout(&["loader", "missing"], &records)
.await
.is_fatal()
);
}
#[test]
#[cfg(feature = "transport-memory")]
fn resolve_returns_route_name_not_message_key() {
let mut route_map = HashMap::new();
route_map.insert("events.land".into(), make_memory_sender());
let sender = RoutedSender::new(route_map, Some(make_memory_sender()));
let (name, _) = sender.resolve("events.land").unwrap();
assert_eq!(name, "events.land");
let (name, _) = sender.resolve("arbitrary-user-key-12345").unwrap();
assert_eq!(name, "default");
}
}