use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{RwLock, broadcast, mpsc};
use tracing::{error, info};
use super::client::StreamResult;
pub(crate) enum StreamCommand {
Subscribe(Vec<String>),
Unsubscribe(Vec<String>),
Close,
}
#[async_trait::async_trait]
pub(crate) trait StreamSource<T>: Send + Sync + 'static
where
T: Clone + Send + 'static,
{
fn id(&self) -> &'static str;
async fn run_session(
&self,
subscriptions: &Arc<RwLock<HashSet<String>>>,
broadcast_tx: &broadcast::Sender<T>,
command_rx: &mut mpsc::Receiver<StreamCommand>,
) -> StreamResult<()>;
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ReconnectConfig {
base_delay: Duration,
max_delay: Duration,
multiplier: f64,
jitter: f64,
max_attempts: Option<u32>,
healthy_after: Duration,
}
impl ReconnectConfig {
pub(crate) fn new(base_delay: Duration) -> Self {
Self {
base_delay,
max_delay: Duration::from_secs(60),
multiplier: 2.0,
jitter: 0.2,
max_attempts: None,
healthy_after: Duration::from_secs(60),
}
}
pub(crate) fn max_attempts(mut self, max: Option<u32>) -> Self {
self.max_attempts = max;
self
}
#[cfg(test)]
pub(crate) fn healthy_after(mut self, healthy_after: Duration) -> Self {
self.healthy_after = healthy_after;
self
}
fn delay_for(&self, attempt: u32, seed: &mut u64) -> Duration {
crate::backoff::BackoffParams {
base: self.base_delay,
max: self.max_delay,
multiplier: self.multiplier,
jitter: self.jitter,
}
.delay_for(attempt, seed)
}
}
pub(crate) async fn run_stream_loop<T>(
source: Arc<dyn StreamSource<T>>,
initial_symbols: Vec<String>,
broadcast_tx: broadcast::Sender<T>,
mut command_rx: mpsc::Receiver<StreamCommand>,
reconnect: ReconnectConfig,
) -> StreamResult<()>
where
T: Clone + Send + 'static,
{
let subscriptions = Arc::new(RwLock::new(HashSet::<String>::from_iter(initial_symbols)));
let mut attempt: u32 = 0;
let mut seed = crate::backoff::seed_from_time();
loop {
let session_start = tokio::time::Instant::now();
match source
.run_session(&subscriptions, &broadcast_tx, &mut command_rx)
.await
{
Ok(()) => {
info!("{} stream closed gracefully", source.id());
break;
}
Err(e) => {
if session_start.elapsed() >= reconnect.healthy_after {
attempt = 0;
}
if let Some(max) = reconnect.max_attempts
&& attempt >= max
{
error!(
"{} stream error: {}, giving up after {} reconnect attempts",
source.id(),
e,
max
);
break;
}
let delay = reconnect.delay_for(attempt, &mut seed);
error!(
"{} stream error: {}, reconnecting in {:.1}s (attempt {})...",
source.id(),
e,
delay.as_secs_f32(),
attempt + 1
);
tokio::time::sleep(delay).await;
attempt += 1;
}
}
}
Ok(())
}
pub(crate) async fn apply_command(
command: &StreamCommand,
subscriptions: &Arc<RwLock<HashSet<String>>>,
) -> Option<Vec<String>> {
match command {
StreamCommand::Subscribe(symbols) => {
let mut subs = subscriptions.write().await;
Some(
symbols
.iter()
.filter(|s| subs.insert((*s).clone()))
.cloned()
.collect(),
)
}
StreamCommand::Unsubscribe(symbols) => {
let mut subs = subscriptions.write().await;
Some(
symbols
.iter()
.filter(|s| subs.remove(*s))
.cloned()
.collect(),
)
}
StreamCommand::Close => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::streaming::client::PriceStream;
use crate::streaming::pricing::{PriceUpdate, PricingData};
use futures::StreamExt;
struct MockSource;
#[async_trait::async_trait]
impl StreamSource<PriceUpdate> for MockSource {
fn id(&self) -> &'static str {
"mock"
}
async fn run_session(
&self,
subscriptions: &Arc<RwLock<HashSet<String>>>,
broadcast_tx: &broadcast::Sender<PriceUpdate>,
command_rx: &mut mpsc::Receiver<StreamCommand>,
) -> StreamResult<()> {
let subs: Vec<String> = subscriptions.read().await.iter().cloned().collect();
for sym in subs {
let data = PricingData {
id: sym,
price: 42.0,
..Default::default()
};
let _ = broadcast_tx.send(data.into());
}
while let Some(cmd) = command_rx.recv().await {
if matches!(cmd, StreamCommand::Close) {
return Ok(());
}
}
Ok(())
}
}
#[tokio::test]
async fn pluggable_source_delivers_updates() {
let mut stream = PriceStream::subscribe_with_source(
Arc::new(MockSource),
["AAPL"],
ReconnectConfig::new(Duration::from_millis(50)),
)
.await
.unwrap();
let update = tokio::time::timeout(Duration::from_secs(2), stream.next())
.await
.expect("timed out waiting for update")
.expect("stream ended without an update");
assert_eq!(update.id, "AAPL");
assert_eq!(update.price, 42.0);
stream.close().await;
}
#[tokio::test]
async fn apply_command_reports_only_real_deltas() {
let subs = Arc::new(RwLock::new(HashSet::from(["AAPL".to_string()])));
let added = apply_command(
&StreamCommand::Subscribe(vec!["AAPL".into(), "NVDA".into()]),
&subs,
)
.await;
assert_eq!(added, Some(vec!["NVDA".to_string()]));
let removed = apply_command(
&StreamCommand::Unsubscribe(vec!["NVDA".into(), "TSLA".into()]),
&subs,
)
.await;
assert_eq!(removed, Some(vec!["NVDA".to_string()]));
assert!(apply_command(&StreamCommand::Close, &subs).await.is_none());
}
struct AlwaysFailSource;
#[async_trait::async_trait]
impl StreamSource<PriceUpdate> for AlwaysFailSource {
fn id(&self) -> &'static str {
"always-fail"
}
async fn run_session(
&self,
_subscriptions: &Arc<RwLock<HashSet<String>>>,
_broadcast_tx: &broadcast::Sender<PriceUpdate>,
_command_rx: &mut mpsc::Receiver<StreamCommand>,
) -> StreamResult<()> {
Err(super::super::client::StreamError::ConnectionFailed(
"boom".to_string(),
))
}
}
struct BrieflyUpThenFailSource;
#[async_trait::async_trait]
impl StreamSource<PriceUpdate> for BrieflyUpThenFailSource {
fn id(&self) -> &'static str {
"briefly-up"
}
async fn run_session(
&self,
_subscriptions: &Arc<RwLock<HashSet<String>>>,
_broadcast_tx: &broadcast::Sender<PriceUpdate>,
_command_rx: &mut mpsc::Receiver<StreamCommand>,
) -> StreamResult<()> {
tokio::time::sleep(Duration::from_millis(20)).await;
Err(super::super::client::StreamError::ConnectionFailed(
"dropped".to_string(),
))
}
}
#[tokio::test]
async fn a_session_shorter_than_healthy_after_still_counts_toward_max_attempts() {
let reconnect = ReconnectConfig::new(Duration::from_millis(1))
.max_attempts(Some(2))
.healthy_after(Duration::from_secs(60));
let mut stream = PriceStream::subscribe_with_source(
Arc::new(BrieflyUpThenFailSource),
Vec::<String>::new(),
reconnect,
)
.await
.unwrap();
let ended = tokio::time::timeout(Duration::from_secs(5), stream.next()).await;
assert!(
matches!(ended, Ok(None)),
"stream should have given up, not reconnected forever"
);
}
#[tokio::test]
async fn reconnect_loop_gives_up_after_max_attempts() {
let reconnect = ReconnectConfig::new(Duration::from_millis(1)).max_attempts(Some(2));
let mut stream = PriceStream::subscribe_with_source(
Arc::new(AlwaysFailSource),
Vec::<String>::new(),
reconnect,
)
.await
.unwrap();
let ended = tokio::time::timeout(Duration::from_secs(5), stream.next()).await;
assert!(matches!(ended, Ok(None)), "stream should have ended");
}
#[test]
fn reconnect_config_delay_grows_and_caps() {
let reconnect = ReconnectConfig::new(Duration::from_secs(1));
let mut seed = 1u64;
for attempt in 0..12 {
let delay = reconnect.delay_for(attempt, &mut seed);
assert!(
delay <= Duration::from_secs(72),
"attempt {attempt}: {delay:?}"
);
}
let late = reconnect.delay_for(20, &mut seed);
assert!(
late >= Duration::from_secs(48),
"expected the delay capped near max_delay, got {late:?}"
);
}
}