use crate::orderbook::serialization::{EventSerializer, JsonEventSerializer};
use crate::orderbook::trade::{TradeListener, TradeResult};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use tracing::{error, trace, warn};
fn drain_buffered<T>(rx: &mut mpsc::Receiver<T>, out: &mut Vec<T>, limit: usize) -> usize {
let mut drained = 0;
while out.len() < limit {
match rx.try_recv() {
Ok(item) => {
out.push(item);
drained += 1;
}
Err(_) => break,
}
}
drained
}
fn account_publish_outcome(
publish_count: &AtomicU64,
error_count: &AtomicU64,
symbol_ok: bool,
all_ok: bool,
) -> bool {
if symbol_ok && all_ok {
publish_count.fetch_add(1, Ordering::Relaxed);
true
} else {
error_count.fetch_add(1, Ordering::Relaxed);
false
}
}
fn clamp_channel_capacity(requested: usize) -> usize {
if requested == 0 {
warn!("with_channel_capacity(0) is invalid; clamping to 1");
1
} else {
requested
}
}
const DEFAULT_BATCH_WINDOW_MS: u64 = 1;
const DEFAULT_MAX_BATCH_SIZE: usize = 100;
const DEFAULT_CHANNEL_CAPACITY: usize = 10_000;
const DEFAULT_MIN_PUBLISH_INTERVAL_MS: u64 = 0;
const DEFAULT_MAX_RETRIES: u32 = 3;
const BASE_RETRY_DELAY_MS: u64 = 10;
pub struct NatsTradePublisher {
jetstream: async_nats::jetstream::Context,
subject_prefix: String,
all_subject: String,
runtime: tokio::runtime::Handle,
batch_window_ms: u64,
max_batch_size: usize,
channel_capacity: usize,
min_publish_interval_ms: u64,
max_retries: u32,
sequence: AtomicU64,
publish_count: AtomicU64,
error_count: AtomicU64,
events_received: AtomicU64,
batches_published: AtomicU64,
dropped_events: AtomicU64,
serializer: Arc<dyn EventSerializer>,
task_handle: Mutex<Option<JoinHandle<()>>>,
shutdown_tx: Mutex<Option<oneshot::Sender<()>>>,
}
impl NatsTradePublisher {
#[inline]
pub fn new(
jetstream: async_nats::jetstream::Context,
subject_prefix: String,
runtime: tokio::runtime::Handle,
) -> Self {
let all_subject = format!("{subject_prefix}.all");
Self {
jetstream,
subject_prefix,
all_subject,
runtime,
batch_window_ms: DEFAULT_BATCH_WINDOW_MS,
max_batch_size: DEFAULT_MAX_BATCH_SIZE,
channel_capacity: DEFAULT_CHANNEL_CAPACITY,
min_publish_interval_ms: DEFAULT_MIN_PUBLISH_INTERVAL_MS,
max_retries: DEFAULT_MAX_RETRIES,
sequence: AtomicU64::new(0),
publish_count: AtomicU64::new(0),
error_count: AtomicU64::new(0),
events_received: AtomicU64::new(0),
batches_published: AtomicU64::new(0),
dropped_events: AtomicU64::new(0),
serializer: Arc::new(JsonEventSerializer),
task_handle: Mutex::new(None),
shutdown_tx: Mutex::new(None),
}
}
#[must_use = "builders do nothing unless consumed"]
#[inline]
pub fn with_batch_window_ms(mut self, batch_window_ms: u64) -> Self {
self.batch_window_ms = batch_window_ms;
self
}
#[must_use = "builders do nothing unless consumed"]
#[inline]
pub fn with_max_batch_size(mut self, max_batch_size: usize) -> Self {
self.max_batch_size = max_batch_size;
self
}
#[must_use = "builders do nothing unless consumed"]
#[inline]
pub fn with_channel_capacity(mut self, channel_capacity: usize) -> Self {
self.channel_capacity = clamp_channel_capacity(channel_capacity);
self
}
#[must_use = "builders do nothing unless consumed"]
#[inline]
pub fn with_min_publish_interval_ms(mut self, min_publish_interval_ms: u64) -> Self {
self.min_publish_interval_ms = min_publish_interval_ms;
self
}
#[must_use = "builders do nothing unless consumed"]
#[inline]
pub fn with_max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = max_retries;
self
}
#[must_use = "builders do nothing unless consumed"]
#[inline]
pub fn with_serializer(mut self, serializer: Arc<dyn EventSerializer>) -> Self {
self.serializer = serializer;
self
}
#[must_use]
#[inline]
pub fn publish_count(&self) -> u64 {
self.publish_count.load(Ordering::Relaxed)
}
#[must_use]
#[inline]
pub fn error_count(&self) -> u64 {
self.error_count.load(Ordering::Relaxed)
}
#[must_use]
#[inline]
pub fn events_received(&self) -> u64 {
self.events_received.load(Ordering::Relaxed)
}
#[must_use]
#[inline]
pub fn batches_published(&self) -> u64 {
self.batches_published.load(Ordering::Relaxed)
}
#[must_use]
#[inline]
pub fn dropped_events(&self) -> u64 {
self.dropped_events.load(Ordering::Relaxed)
}
#[must_use]
#[inline]
pub fn sequence(&self) -> u64 {
self.sequence.load(Ordering::Relaxed)
}
#[must_use]
#[inline]
pub fn serializer(&self) -> &dyn EventSerializer {
self.serializer.as_ref()
}
pub fn into_listener(self) -> (Arc<Self>, TradeListener) {
let channel_capacity = self.channel_capacity;
let publisher = Arc::new(self);
let handle = Arc::clone(&publisher);
let (tx, rx) = mpsc::channel::<TradeResult>(channel_capacity);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let task_publisher = Arc::clone(&publisher);
let join = publisher
.runtime
.spawn(Self::publish_task(task_publisher, rx, shutdown_rx));
if let Ok(mut slot) = publisher.task_handle.lock() {
*slot = Some(join);
}
if let Ok(mut slot) = publisher.shutdown_tx.lock() {
*slot = Some(shutdown_tx);
}
let listener_publisher = Arc::clone(&publisher);
let listener = Arc::new(move |trade_result: &TradeResult| {
listener_publisher
.events_received
.fetch_add(1, Ordering::Relaxed);
if tx.try_send(trade_result.clone()).is_err() {
listener_publisher
.dropped_events
.fetch_add(1, Ordering::Relaxed);
warn!("trade channel full, event dropped");
}
});
(handle, listener)
}
pub async fn shutdown(&self) {
if let Ok(mut slot) = self.shutdown_tx.lock()
&& let Some(tx) = slot.take()
{
let _ = tx.send(());
}
let handle = self
.task_handle
.lock()
.ok()
.and_then(|mut slot| slot.take());
if let Some(handle) = handle {
let _ = handle.await;
}
}
async fn publish_task(
publisher: Arc<Self>,
mut rx: mpsc::Receiver<TradeResult>,
mut shutdown_rx: oneshot::Receiver<()>,
) {
let batch_window = std::time::Duration::from_millis(publisher.batch_window_ms);
let min_interval = if publisher.min_publish_interval_ms > 0 {
Some(std::time::Duration::from_millis(
publisher.min_publish_interval_ms,
))
} else {
None
};
let mut batch: Vec<TradeResult> = Vec::with_capacity(publisher.max_batch_size);
let mut last_publish = tokio::time::Instant::now();
loop {
if batch.is_empty() {
tokio::select! {
biased;
_ = &mut shutdown_rx => {
loop {
drain_buffered(&mut rx, &mut batch, publisher.max_batch_size);
if batch.is_empty() {
break;
}
Self::flush_batch(
&publisher,
&mut batch,
&mut last_publish,
min_interval,
)
.await;
}
return;
}
maybe = rx.recv() => match maybe {
Some(trade) => batch.push(trade),
None => break, },
}
}
let deadline = tokio::time::Instant::now() + batch_window;
while batch.len() < publisher.max_batch_size {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(trade)) => batch.push(trade),
Ok(None) => {
Self::flush_batch(&publisher, &mut batch, &mut last_publish, min_interval)
.await;
return;
}
Err(_) => break, }
}
Self::flush_batch(&publisher, &mut batch, &mut last_publish, min_interval).await;
}
Self::flush_batch(&publisher, &mut batch, &mut last_publish, min_interval).await;
}
async fn flush_batch(
publisher: &Arc<Self>,
batch: &mut Vec<TradeResult>,
last_publish: &mut tokio::time::Instant,
min_interval: Option<std::time::Duration>,
) {
if batch.is_empty() {
return;
}
let trades = std::mem::take(batch);
for trade in trades {
let payload = match publisher.serializer.serialize_trade(&trade) {
Ok(bytes) => bytes,
Err(e) => {
publisher.error_count.fetch_add(1, Ordering::Relaxed);
error!(error = %e, "failed to serialize trade result for NATS");
continue;
}
};
let symbol_seq = publisher.sequence.fetch_add(1, Ordering::Relaxed);
let all_seq = publisher.sequence.fetch_add(1, Ordering::Relaxed);
let symbol_subject = format!("{}.{}", publisher.subject_prefix, trade.symbol);
let all_subject = publisher.all_subject.clone();
let payload_bytes: bytes::Bytes = payload.into();
Self::publish_with_retry(
Arc::clone(publisher),
symbol_subject,
all_subject,
payload_bytes,
symbol_seq,
all_seq,
)
.await;
}
publisher.batches_published.fetch_add(1, Ordering::Relaxed);
if let Some(interval) = min_interval {
let elapsed = last_publish.elapsed();
if elapsed < interval {
tokio::time::sleep(interval - elapsed).await;
}
}
*last_publish = tokio::time::Instant::now();
}
async fn publish_with_retry(
publisher: Arc<Self>,
symbol_subject: String,
all_subject: String,
payload: bytes::Bytes,
symbol_seq: u64,
all_seq: u64,
) {
let content_type = publisher.serializer.content_type();
let mut symbol_headers = async_nats::HeaderMap::new();
symbol_headers.insert("Nats-Sequence", symbol_seq.to_string().as_str());
symbol_headers.insert("Content-Type", content_type);
let mut all_headers = async_nats::HeaderMap::new();
all_headers.insert("Nats-Sequence", all_seq.to_string().as_str());
all_headers.insert("Content-Type", content_type);
let symbol_ok =
Self::publish_single(&publisher, &symbol_subject, payload.clone(), symbol_headers)
.await;
let all_ok = Self::publish_single(&publisher, &all_subject, payload, all_headers).await;
if account_publish_outcome(
&publisher.publish_count,
&publisher.error_count,
symbol_ok,
all_ok,
) {
trace!(symbol_seq, all_seq, symbol = %symbol_subject, "trade event published to NATS");
}
}
async fn publish_single(
publisher: &Arc<Self>,
subject: &str,
payload: bytes::Bytes,
headers: async_nats::HeaderMap,
) -> bool {
let max_attempts = u64::from(publisher.max_retries) + 1;
for attempt in 0..max_attempts {
let publish_result = publisher
.jetstream
.publish_with_headers(subject.to_string(), headers.clone(), payload.clone())
.await;
match publish_result {
Ok(ack_future) => {
match ack_future.await {
Ok(_) => return true,
Err(e) => {
warn!(
attempt = attempt + 1,
max = max_attempts,
subject,
error = %e,
"NATS ack failed, retrying"
);
}
}
}
Err(e) => {
warn!(
attempt = attempt + 1,
max = max_attempts,
subject,
error = %e,
"NATS publish failed, retrying"
);
}
}
if attempt + 1 < max_attempts {
let shift = attempt.min(63) as u32;
let delay_ms =
BASE_RETRY_DELAY_MS.saturating_mul(1u64.checked_shl(shift).unwrap_or(u64::MAX));
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
}
error!(subject, "NATS publish failed after all retries");
false
}
}
impl std::fmt::Debug for NatsTradePublisher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NatsTradePublisher")
.field("subject_prefix", &self.subject_prefix)
.field("batch_window_ms", &self.batch_window_ms)
.field("max_batch_size", &self.max_batch_size)
.field("channel_capacity", &self.channel_capacity)
.field("min_publish_interval_ms", &self.min_publish_interval_ms)
.field("max_retries", &self.max_retries)
.field("sequence", &self.sequence.load(Ordering::Relaxed))
.field("publish_count", &self.publish_count.load(Ordering::Relaxed))
.field("error_count", &self.error_count.load(Ordering::Relaxed))
.field(
"events_received",
&self.events_received.load(Ordering::Relaxed),
)
.field(
"batches_published",
&self.batches_published.load(Ordering::Relaxed),
)
.field(
"dropped_events",
&self.dropped_events.load(Ordering::Relaxed),
)
.field("serializer", &self.serializer.content_type())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use pricelevel::{Id, MatchResult, Quantity};
fn make_trade_result(symbol: &str) -> TradeResult {
let order_id = Id::new_uuid();
let match_result = MatchResult::new(order_id, Quantity::new(100));
TradeResult::new(symbol.to_string(), match_result)
}
#[test]
fn test_trade_result_serializes_to_json() {
let tr = make_trade_result("BTC/USD");
let result = serde_json::to_vec(&tr);
assert!(result.is_ok());
let bytes = result.unwrap_or_default();
assert!(!bytes.is_empty());
let json_str = String::from_utf8(bytes).unwrap_or_default();
assert!(json_str.contains("BTC/USD"));
assert!(json_str.contains("match_result"));
}
#[test]
fn test_trade_result_serialize_roundtrip_fields() {
let tr = make_trade_result("ETH/USDT");
let json = serde_json::to_value(&tr);
assert!(json.is_ok());
let value = json.unwrap_or(serde_json::Value::Null);
assert_eq!(
value.get("symbol").and_then(|v| v.as_str()),
Some("ETH/USDT")
);
assert_eq!(
value.get("total_maker_fees").and_then(|v| v.as_i64()),
Some(0)
);
assert_eq!(
value.get("total_taker_fees").and_then(|v| v.as_i64()),
Some(0)
);
}
#[test]
fn test_subject_formatting() {
let prefix = "trades";
let symbol = "BTC/USD";
let symbol_subject = format!("{prefix}.{symbol}");
let all_subject = format!("{prefix}.all");
assert_eq!(symbol_subject, "trades.BTC/USD");
assert_eq!(all_subject, "trades.all");
}
#[test]
fn test_subject_formatting_with_custom_prefix() {
let prefix = "orderbook.events.trades";
let symbol = "ETH-PERP";
let symbol_subject = format!("{prefix}.{symbol}");
let all_subject = format!("{prefix}.all");
assert_eq!(symbol_subject, "orderbook.events.trades.ETH-PERP");
assert_eq!(all_subject, "orderbook.events.trades.all");
}
#[test]
fn test_precomputed_all_subject_matches_format() {
let prefix = "trades";
let precomputed = format!("{prefix}.all");
assert_eq!(precomputed, "trades.all");
}
#[test]
fn test_default_max_retries() {
assert_eq!(DEFAULT_MAX_RETRIES, 3);
}
#[test]
fn test_base_retry_delay() {
assert_eq!(BASE_RETRY_DELAY_MS, 10);
}
#[test]
fn test_default_batch_constants() {
assert_eq!(DEFAULT_BATCH_WINDOW_MS, 1);
assert_eq!(DEFAULT_MAX_BATCH_SIZE, 100);
assert_eq!(DEFAULT_CHANNEL_CAPACITY, 10_000);
assert_eq!(DEFAULT_MIN_PUBLISH_INTERVAL_MS, 0);
}
#[test]
fn test_exponential_backoff_calculation() {
for attempt in 0u32..4 {
let shift = u32::min(attempt, 63);
let delay =
BASE_RETRY_DELAY_MS.saturating_mul(1u64.checked_shl(shift).unwrap_or(u64::MAX));
let expected = BASE_RETRY_DELAY_MS * 2u64.pow(attempt);
assert_eq!(delay, expected);
}
}
#[test]
fn test_exponential_backoff_high_retry_count_does_not_panic() {
for attempt in [63u32, 64, 100, u32::MAX] {
let shift = u32::min(attempt, 63);
let delay =
BASE_RETRY_DELAY_MS.saturating_mul(1u64.checked_shl(shift).unwrap_or(u64::MAX));
assert!(delay >= BASE_RETRY_DELAY_MS);
}
}
#[test]
fn test_clamp_channel_capacity_handles_zero_without_panicking() {
assert_eq!(clamp_channel_capacity(0), 1);
assert_eq!(clamp_channel_capacity(1), 1);
assert_eq!(clamp_channel_capacity(10_000), 10_000);
}
#[test]
fn test_publish_outcome_accounting_is_per_trade() {
let publish_count = AtomicU64::new(0);
let error_count = AtomicU64::new(0);
assert!(account_publish_outcome(
&publish_count,
&error_count,
true,
true
));
assert!(!account_publish_outcome(
&publish_count,
&error_count,
true,
false
));
assert!(!account_publish_outcome(
&publish_count,
&error_count,
false,
true
));
assert!(!account_publish_outcome(
&publish_count,
&error_count,
false,
false
));
let pc = publish_count.load(Ordering::Relaxed);
let ec = error_count.load(Ordering::Relaxed);
assert_eq!(pc, 1, "exactly one successful trade");
assert_eq!(
ec, 3,
"three failed trades (two partial, one full), one increment each"
);
assert_eq!(
pc + ec,
4,
"publish_count + error_count == trades processed"
);
}
#[test]
fn test_drain_buffered_collects_all_pending_items() {
let (tx, mut rx) = mpsc::channel::<u32>(4);
for i in 0..3u32 {
tx.try_send(i).expect("channel has room");
}
let mut out = Vec::new();
let drained = drain_buffered(&mut rx, &mut out, 100);
assert_eq!(drained, 3, "all buffered items must be drained");
assert_eq!(out, vec![0, 1, 2], "drain preserves FIFO order");
let mut out2 = Vec::new();
assert_eq!(drain_buffered(&mut rx, &mut out2, 100), 0);
assert!(out2.is_empty());
}
#[test]
fn test_drain_buffered_respects_limit() {
let (tx, mut rx) = mpsc::channel::<u32>(8);
for i in 0..5u32 {
tx.try_send(i).expect("channel has room");
}
let mut out = Vec::new();
let drained = drain_buffered(&mut rx, &mut out, 2);
assert_eq!(drained, 2, "drain stops at the limit");
assert_eq!(out, vec![0, 1]);
let mut rest = Vec::new();
assert_eq!(drain_buffered(&mut rx, &mut rest, 100), 3);
assert_eq!(rest, vec![2, 3, 4]);
}
#[test]
fn test_nats_publish_error_display() {
let err = crate::orderbook::OrderBookError::NatsPublishError {
message: "connection refused".to_string(),
};
let display = format!("{err}");
assert!(display.contains("nats publish error"));
assert!(display.contains("connection refused"));
}
#[test]
fn test_nats_serialization_error_display() {
let err = crate::orderbook::OrderBookError::NatsSerializationError {
message: "invalid utf-8".to_string(),
};
let display = format!("{err}");
assert!(display.contains("nats serialization error"));
assert!(display.contains("invalid utf-8"));
}
}