1use std::collections::{BTreeMap, BTreeSet, HashMap};
36use std::sync::Arc;
37use std::time::{Duration, Instant};
38
39use bezant::{MarketDataFields, WsClient, WsMessage};
40use serde_json::json;
41use tokio::sync::{mpsc, oneshot, RwLock};
42use tokio::time::{sleep, sleep_until, timeout, Instant as TokioInstant};
43use tracing::{debug, info, warn};
44
45use super::persistence::{EventLog, RetentionPolicy};
46use super::ring::{ReadResult, TopicRing};
47use super::{EventsStatus, GapReason, ObservedEvent, SubscriptionState};
48
49#[derive(Clone, Debug)]
51pub struct ConnectorCfg {
52 pub orders_capacity: usize,
54 pub pnl_capacity: usize,
56 pub marketdata_capacity: usize,
58 pub backoff_min: Duration,
60 pub backoff_max: Duration,
62 pub heartbeat_timeout: Duration,
65 pub marketdata_idle_unsubscribe: Duration,
69 pub event_log: Option<Arc<EventLog>>,
73 pub retention: RetentionPolicy,
75 pub prune_every: Duration,
77 pub resubscribe_min: Duration,
79 pub resubscribe_max: Duration,
83 pub session_check_every: Duration,
86}
87
88impl Default for ConnectorCfg {
89 fn default() -> Self {
90 Self {
91 orders_capacity: 1_000,
92 pnl_capacity: 5_000,
93 marketdata_capacity: 2_000,
94 backoff_min: Duration::from_secs(1),
95 backoff_max: Duration::from_secs(60),
96 heartbeat_timeout: Duration::from_secs(90),
97 marketdata_idle_unsubscribe: Duration::from_secs(300),
98 event_log: None,
99 retention: RetentionPolicy::default(),
100 prune_every: Duration::from_secs(3_600),
101 resubscribe_min: Duration::from_secs(5),
102 resubscribe_max: Duration::from_secs(300),
103 session_check_every: Duration::from_secs(60),
104 }
105 }
106}
107
108#[derive(Debug)]
110enum ConnectorCmd {
111 EnsureMarketData {
112 conid: i64,
113 reply: oneshot::Sender<Result<(), String>>,
114 },
115}
116
117#[derive(Clone)]
120pub struct EventsHandle {
121 rings: Arc<RwLock<HashMap<String, TopicRing>>>,
122 status: Arc<RwLock<StatusState>>,
123 cmd_tx: mpsc::Sender<ConnectorCmd>,
124 started_at: Instant,
125 event_log: Option<Arc<EventLog>>,
126}
127
128impl EventsHandle {
129 #[must_use]
132 pub fn event_log(&self) -> Option<&Arc<EventLog>> {
133 self.event_log.as_ref()
134 }
135}
136
137#[derive(Debug, Default)]
138struct StatusState {
139 connected: bool,
140 last_message_at: Option<String>,
141 reconnect_count: u64,
142 reset_epoch: u64,
143 topics_subscribed: BTreeSet<String>,
144 subscriptions: BTreeMap<String, SubscriptionState>,
145 subscribe_refusals: u64,
146 session_rollovers: u64,
147}
148
149impl EventsHandle {
150 pub async fn read_topic(&self, topic: &str, since: u64, limit: usize) -> Option<ReadResult> {
155 let rings = self.rings.read().await;
156 rings.get(topic).map(|r| r.read_since(since, limit))
157 }
158
159 pub async fn status(&self) -> EventsStatus {
162 let s = self.status.read().await;
163 let buffer_sizes: BTreeMap<String, usize> = self
164 .rings
165 .read()
166 .await
167 .iter()
168 .map(|(k, v)| (k.clone(), v.len()))
169 .collect();
170 EventsStatus {
171 connected: s.connected,
172 last_message_at: s.last_message_at.clone(),
173 reconnect_count: s.reconnect_count,
174 uptime_seconds: self.started_at.elapsed().as_secs(),
175 reset_epoch: s.reset_epoch,
176 topics_subscribed: s.topics_subscribed.iter().cloned().collect(),
177 buffer_sizes,
178 subscriptions: s.subscriptions.clone(),
179 subscribe_refusals: s.subscribe_refusals,
180 session_rollovers: s.session_rollovers,
181 }
182 }
183
184 pub async fn ensure_market_data(&self, conid: i64) -> Result<(), String> {
189 let (tx, rx) = oneshot::channel();
190 self.cmd_tx
191 .send(ConnectorCmd::EnsureMarketData { conid, reply: tx })
192 .await
193 .map_err(|_| "connector task is not running".to_string())?;
194 rx.await
195 .map_err(|_| "connector task dropped reply channel".to_string())?
196 }
197}
198
199impl EventsHandle {
200 #[doc(hidden)]
207 #[must_use]
208 pub fn for_test() -> (Self, TestSink) {
209 Self::for_test_with_log(None)
210 }
211
212 #[doc(hidden)]
215 #[must_use]
216 pub fn for_test_with_log(event_log: Option<Arc<EventLog>>) -> (Self, TestSink) {
217 let rings: Arc<RwLock<HashMap<String, TopicRing>>> = Arc::new(RwLock::new(HashMap::new()));
218 let status = Arc::new(RwLock::new(StatusState {
219 connected: true,
220 reset_epoch: 1,
221 ..Default::default()
222 }));
223 let (cmd_tx, _cmd_rx) = mpsc::channel::<ConnectorCmd>(1);
224 let started_at = Instant::now();
225 let handle = Self {
226 rings: rings.clone(),
227 status: status.clone(),
228 cmd_tx,
229 started_at,
230 event_log: event_log.clone(),
231 };
232 let sink = TestSink {
233 rings,
234 status,
235 event_log,
236 };
237 (handle, sink)
238 }
239}
240
241#[doc(hidden)]
247#[derive(Clone)]
248pub struct TestSink {
249 rings: Arc<RwLock<HashMap<String, TopicRing>>>,
250 status: Arc<RwLock<StatusState>>,
251 event_log: Option<Arc<EventLog>>,
252}
253
254#[doc(hidden)]
255impl TestSink {
256 pub async fn push(&self, topic: &str, payload: serde_json::Value) -> u64 {
260 let cap = match topic {
261 "orders" => 1_000,
262 "pnl" => 5_000,
263 t if t.starts_with("marketdata:") => 2_000,
264 _ => 256,
265 };
266 let epoch = self.status.read().await.reset_epoch;
267 let received_at = now_iso();
268 let mut rings = self.rings.write().await;
269 let ring = rings
270 .entry(topic.to_string())
271 .or_insert_with(|| TopicRing::new(topic, cap, epoch));
272 let cursor = ring.push(payload.clone(), received_at.clone());
273 drop(rings);
274 if let Some(log) = &self.event_log {
275 let _ = log.append(&ObservedEvent {
276 cursor,
277 topic: topic.to_string(),
278 received_at: received_at.clone(),
279 reset_epoch: epoch,
280 payload,
281 });
282 }
283 self.status
284 .write()
285 .await
286 .topics_subscribed
287 .insert(topic.to_string());
288 cursor
289 }
290
291 pub async fn set_reset_epoch(&self, epoch: u64) {
293 self.status.write().await.reset_epoch = epoch;
294 }
295
296 pub async fn set_connected(&self, connected: bool) {
298 self.status.write().await.connected = connected;
299 }
300}
301
302pub fn spawn_connector(client: bezant::Client, cfg: ConnectorCfg) -> EventsHandle {
309 let rings: Arc<RwLock<HashMap<String, TopicRing>>> = Arc::new(RwLock::new(HashMap::new()));
310 let status = Arc::new(RwLock::new(StatusState::default()));
311 let (cmd_tx, cmd_rx) = mpsc::channel::<ConnectorCmd>(64);
312 let started_at = Instant::now();
313 let event_log = cfg.event_log.clone();
314
315 if let Some(log) = event_log.clone() {
316 let prune_every = cfg.prune_every;
317 let policy = cfg.retention.clone();
318 tokio::spawn(async move {
319 loop {
320 sleep(prune_every).await;
321 let log = log.clone();
322 let policy = policy.clone();
323 let dropped = tokio::task::spawn_blocking(move || log.prune(&policy)).await;
324 match dropped {
325 Ok(Ok(n)) if n > 0 => info!(rows = n, "events sqlite: prune dropped rows"),
326 Ok(Err(e)) => warn!(error = %e, "events sqlite: prune failed"),
327 _ => {}
328 }
329 }
330 });
331 }
332
333 let actor = ConnectorActor {
334 client,
335 cfg,
336 rings: rings.clone(),
337 status: status.clone(),
338 cmd_rx,
339 active_marketdata_subs: BTreeSet::new(),
340 resubscribe: Resubscribe::default(),
341 };
342
343 tokio::spawn(actor.run());
344
345 EventsHandle {
346 rings,
347 status,
348 cmd_tx,
349 started_at,
350 event_log,
351 }
352}
353
354struct ConnectorActor {
355 client: bezant::Client,
356 cfg: ConnectorCfg,
357 rings: Arc<RwLock<HashMap<String, TopicRing>>>,
358 status: Arc<RwLock<StatusState>>,
359 cmd_rx: mpsc::Receiver<ConnectorCmd>,
360 active_marketdata_subs: BTreeSet<i64>,
361 resubscribe: Resubscribe,
362}
363
364#[derive(Debug, Default)]
367struct Resubscribe {
368 unconfirmed: BTreeSet<&'static str>,
370 due: Option<TokioInstant>,
372 backoff: Option<Duration>,
374}
375
376const fn subscribe_command(topic: &str) -> Option<&'static str> {
378 match topic.as_bytes() {
379 b"orders" => Some("sor+{}"),
380 b"pnl" => Some("spl+{}"),
381 _ => None,
382 }
383}
384
385impl ConnectorActor {
386 async fn run(mut self) {
387 info!("events connector starting");
388 let mut backoff = self.cfg.backoff_min;
389 loop {
390 self.bump_epoch_with_gap(GapReason::ReconnectedAfterDisconnect)
394 .await;
395
396 match self.connect_and_run().await {
397 Ok(()) => {
398 info!("events connector: ws closed cleanly, reconnecting");
399 backoff = self.cfg.backoff_min;
400 }
401 Err(e) => {
402 warn!(error = %e, "events connector: ws failed, will retry");
403 self.set_disconnected().await;
404 }
405 }
406
407 sleep(backoff).await;
408 backoff = (backoff * 2).min(self.cfg.backoff_max);
409 }
410 }
411
412 async fn connect_and_run(&mut self) -> Result<(), bezant::Error> {
415 let mut ws = WsClient::connect(&self.client).await?;
416
417 let ready = timeout(Duration::from_secs(5), pump_until_ready(&mut ws)).await;
423 match ready {
424 Ok(Ok(())) => debug!("events connector: server ready signal received"),
425 Ok(Err(e)) => return Err(e),
426 Err(_) => warn!(
427 "events connector: didn't see server-ready frame within 5s; \
428 subscribing anyway (CPAPI may silently drop these)"
429 ),
430 }
431
432 ws.subscribe_orders().await?;
433 ws.subscribe_pnl().await?;
434 self.resubscribe = Resubscribe::default();
437 for topic in ["orders", "pnl"] {
438 self.resubscribe.unconfirmed.insert(topic);
439 self.set_subscription(topic, SubscriptionState::Pending)
440 .await;
441 }
442 self.schedule_resubscribe();
443 for conid in self.active_marketdata_subs.clone() {
446 if let Err(e) = ws
447 .subscribe_market_data(conid, &MarketDataFields::default_l1())
448 .await
449 {
450 warn!(conid, error = %e, "events connector: re-subscribe market data failed");
451 }
452 }
453
454 self.set_connected().await;
455 info!("events connector: connected, orders + pnl subscribed");
456
457 let result = self.dispatch_loop(&mut ws).await;
458 self.set_disconnected().await;
459 result
460 }
461
462 async fn dispatch_loop(&mut self, ws: &mut WsClient) -> Result<(), bezant::Error> {
466 let session = ws.session().to_owned();
467 let mut session_check = tokio::time::interval(self.cfg.session_check_every);
468 session_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
469 session_check.tick().await; loop {
471 let resub_at = self
475 .resubscribe
476 .due
477 .unwrap_or_else(|| TokioInstant::now() + Duration::from_secs(365 * 24 * 3_600));
478 tokio::select! {
479 msg = timeout(self.cfg.heartbeat_timeout, ws.next_message()) => {
481 match msg {
482 Err(_elapsed) => {
483 warn!(
484 timeout_secs = self.cfg.heartbeat_timeout.as_secs(),
485 "events connector: heartbeat timeout, killing socket"
486 );
487 return Err(bezant::Error::WsProtocol(
488 "heartbeat timeout".into(),
489 ));
490 }
491 Ok(Ok(None)) => {
492 info!("events connector: upstream ws closed");
493 return Ok(());
494 }
495 Ok(Ok(Some(frame))) => {
496 self.handle_frame(frame).await;
497 }
498 Ok(Err(e)) => {
499 return Err(e);
500 }
501 }
502 }
503 Some(cmd) = self.cmd_rx.recv() => {
505 self.handle_command(ws, cmd).await;
506 }
507 () = sleep_until(resub_at), if self.resubscribe.due.is_some() => {
509 self.resubscribe(ws).await;
510 }
511 _ = session_check.tick() => {
513 if self.session_rolled_over(&session).await {
514 self.status.write().await.session_rollovers += 1;
515 warn!("events connector: gateway session changed under the socket (re-login); reconnecting so subscriptions bind to the live session");
516 return Err(bezant::Error::WsProtocol("gateway session rolled over".into()));
517 }
518 }
519 }
520 }
521 }
522
523 async fn session_rolled_over(&self, socket_session: &str) -> bool {
529 match self.client.tickle().await {
530 Ok(t) => matches!(t.session.as_deref(), Some(live) if live != socket_session),
531 Err(e) => {
532 debug!(error = %e, "events connector: session check tickle failed; assuming unchanged");
533 false
534 }
535 }
536 }
537
538 async fn resubscribe(&mut self, ws: &mut WsClient) {
544 self.resubscribe.due = None;
545 let topics: Vec<&'static str> = self.resubscribe.unconfirmed.iter().copied().collect();
546 if topics.is_empty() {
547 return;
548 }
549 if let Err(e) = self
550 .client
551 .api()
552 .get_brokerage_accounts(bezant::api::GetBrokerageAccountsRequest::default())
553 .await
554 {
555 debug!(error = %e, "events connector: /iserver/accounts prime failed before resubscribe");
556 }
557 for topic in topics {
558 let Some(cmd) = subscribe_command(topic) else {
559 continue;
560 };
561 match ws.send_text(cmd.to_owned()).await {
562 Ok(()) => info!(topic, "events connector: resubscribing"),
563 Err(e) => warn!(topic, error = %e, "events connector: resubscribe send failed"),
564 }
565 self.set_subscription(topic, SubscriptionState::Pending)
566 .await;
567 }
568 self.schedule_resubscribe();
571 }
572
573 fn schedule_resubscribe(&mut self) {
576 if self.resubscribe.unconfirmed.is_empty() || self.resubscribe.due.is_some() {
577 return;
578 }
579 let delay = self.resubscribe.backoff.unwrap_or(self.cfg.resubscribe_min);
580 self.resubscribe.due = Some(TokioInstant::now() + delay);
581 self.resubscribe.backoff = Some((delay * 2).min(self.cfg.resubscribe_max));
582 }
583
584 async fn set_subscription(&self, topic: &str, state: SubscriptionState) {
585 self.status
586 .write()
587 .await
588 .subscriptions
589 .insert(topic.to_owned(), state);
590 }
591
592 async fn handle_topic_frame(
596 &mut self,
597 topic: &'static str,
598 value: serde_json::Value,
599 now: String,
600 ) {
601 if let Some(err) = value.get("error") {
602 let code = value.get("code").and_then(serde_json::Value::as_i64);
603 warn!(
604 topic,
605 error = %err,
606 code,
607 "events connector: CPAPI refused the subscription; will retry with backoff"
608 );
609 {
610 let mut s = self.status.write().await;
611 s.subscribe_refusals += 1;
612 s.last_message_at = Some(now);
613 }
614 self.set_subscription(topic, SubscriptionState::Refused)
615 .await;
616 self.resubscribe.unconfirmed.insert(topic);
617 self.schedule_resubscribe();
618 return;
619 }
620 if self.resubscribe.unconfirmed.remove(topic) {
621 info!(
622 topic,
623 "events connector: subscription confirmed by first frame"
624 );
625 self.set_subscription(topic, SubscriptionState::Subscribed)
626 .await;
627 if self.resubscribe.unconfirmed.is_empty() {
628 self.resubscribe.due = None;
629 self.resubscribe.backoff = None;
630 }
631 }
632 self.push_to_topic(topic, value, now).await;
633 }
634
635 async fn handle_frame(&mut self, frame: WsMessage) {
637 let now = now_iso();
638 if let Some(v) = frame.as_value() {
642 let snippet = serde_json::to_string(v).unwrap_or_default();
643 let topic_str = v
644 .get("topic")
645 .and_then(|t| t.as_str())
646 .unwrap_or("<no-topic>");
647 debug!(
648 variant = frame.topic(),
649 topic = topic_str,
650 payload = %truncate(&snippet, 200),
651 "events connector: frame"
652 );
653 }
654 match frame {
655 WsMessage::Heartbeat | WsMessage::System(_) | WsMessage::Other(_) => {
656 self.touch_last_message(now).await;
659 }
660 WsMessage::Order(value) => {
661 self.handle_topic_frame("orders", value, now).await;
662 }
663 WsMessage::Pnl(value) => {
664 self.handle_topic_frame("pnl", value, now).await;
665 }
666 WsMessage::MarketData { conid, payload } => {
667 let topic = format!("marketdata:{conid}");
668 self.push_to_topic(&topic, payload, now).await;
669 }
670 WsMessage::Malformed { text, error } => {
671 warn!(error = %error, sample = %truncate(&text, 200), "events connector: malformed frame");
672 self.touch_last_message(now).await;
673 }
674 other => {
678 debug!(
679 topic = other.topic(),
680 "events connector: unhandled ws message variant"
681 );
682 self.touch_last_message(now).await;
683 }
684 }
685 }
686
687 async fn handle_command(&mut self, ws: &mut WsClient, cmd: ConnectorCmd) {
688 match cmd {
689 ConnectorCmd::EnsureMarketData { conid, reply } => {
690 if self.active_marketdata_subs.insert(conid) {
691 debug!(conid, "events connector: subscribing market data");
692 let result = ws
693 .subscribe_market_data(conid, &MarketDataFields::default_l1())
694 .await;
695 match result {
696 Ok(_) => {
697 self.add_topic_to_status(format!("marketdata:{conid}"))
698 .await;
699 let _ = reply.send(Ok(()));
700 }
701 Err(e) => {
702 self.active_marketdata_subs.remove(&conid);
705 let _ = reply.send(Err(format!("subscribe failed: {e}")));
706 }
707 }
708 } else {
709 let _ = reply.send(Ok(()));
711 }
712 }
713 }
714 }
715
716 async fn push_to_topic(&self, topic: &str, payload: serde_json::Value, received_at: String) {
717 let cap = match topic {
718 "orders" => self.cfg.orders_capacity,
719 "pnl" => self.cfg.pnl_capacity,
720 t if t.starts_with("marketdata:") => self.cfg.marketdata_capacity,
721 _ => 1_000,
722 };
723 let epoch = self.status.read().await.reset_epoch;
724
725 let mut rings = self.rings.write().await;
726 let ring = rings
727 .entry(topic.to_string())
728 .or_insert_with(|| TopicRing::new(topic, cap, epoch));
729 let cursor = ring.push(payload.clone(), received_at.clone());
730 drop(rings);
731
732 if let Some(log) = &self.cfg.event_log {
735 let log = log.clone();
736 let evt = ObservedEvent {
737 cursor,
738 topic: topic.to_string(),
739 received_at: received_at.clone(),
740 reset_epoch: epoch,
741 payload,
742 };
743 tokio::task::spawn_blocking(move || log.append(&evt));
746 }
747
748 let mut s = self.status.write().await;
750 s.last_message_at = Some(received_at);
751 s.topics_subscribed.insert(topic.to_string());
752 }
753
754 async fn touch_last_message(&self, now: String) {
755 self.status.write().await.last_message_at = Some(now);
756 }
757
758 async fn add_topic_to_status(&self, topic: String) {
759 self.status.write().await.topics_subscribed.insert(topic);
760 }
761
762 async fn set_connected(&self) {
763 let mut s = self.status.write().await;
764 s.connected = true;
765 s.reconnect_count = s.reconnect_count.saturating_add(1);
766 s.topics_subscribed.insert("orders".into());
767 s.topics_subscribed.insert("pnl".into());
768 }
769
770 async fn set_disconnected(&self) {
771 self.status.write().await.connected = false;
772 }
773
774 async fn bump_epoch_with_gap(&self, reason: GapReason) {
778 let mut s = self.status.write().await;
779 let is_first_boot = s.reset_epoch == 0 && s.last_message_at.is_none();
781 s.reset_epoch = s.reset_epoch.saturating_add(1);
782 let new_epoch = s.reset_epoch;
783 drop(s);
784
785 if is_first_boot {
786 return;
787 }
788
789 let now = now_iso();
792 let payload = json!({
793 "reason": match reason {
794 GapReason::ReconnectedAfterDisconnect => "reconnected_after_disconnect",
795 GapReason::ProcessRestart => "process_restart",
796 },
797 "new_reset_epoch": new_epoch,
798 });
799
800 let mut rings = self.rings.write().await;
801 let topics: Vec<String> = rings.keys().cloned().collect();
802 for topic in topics {
803 if let Some(r) = rings.get_mut(&topic) {
808 r.push(payload.clone(), now.clone());
809 }
810 }
811 rings
814 .entry("gap".to_string())
815 .or_insert_with(|| TopicRing::new("gap", 256, new_epoch))
816 .push(payload, now);
817 }
818}
819
820async fn pump_until_ready(ws: &mut WsClient) -> Result<(), bezant::Error> {
827 while let Some(msg) = ws.next_message().await? {
828 if let WsMessage::System(value) = &msg {
829 if value.get("success").is_some() {
830 return Ok(());
831 }
832 }
833 }
836 Err(bezant::Error::WsProtocol(
838 "ws closed before server-ready frame".into(),
839 ))
840}
841
842fn now_iso() -> String {
843 use std::time::SystemTime;
844 let now: chrono::DateTime<chrono::Utc> = SystemTime::now().into();
845 now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
846}
847
848fn truncate(s: &str, max: usize) -> String {
849 if s.len() <= max {
850 s.to_string()
851 } else {
852 format!("{}…", &s[..max])
853 }
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859 use serde_json::json;
860
861 #[tokio::test]
862 async fn handle_frame_dispatches_to_correct_topic() {
863 let (_tx, cmd_rx) = mpsc::channel(1);
865 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
866 let mut actor = ConnectorActor {
867 client,
868 cfg: ConnectorCfg::default(),
869 rings: Arc::new(RwLock::new(HashMap::new())),
870 status: Arc::new(RwLock::new(StatusState::default())),
871 cmd_rx,
872 active_marketdata_subs: BTreeSet::new(),
873 resubscribe: Resubscribe::default(),
874 };
875
876 actor
877 .handle_frame(WsMessage::Order(json!({"orderId": 1})))
878 .await;
879 actor
880 .handle_frame(WsMessage::Pnl(json!({"upnl": 1.0})))
881 .await;
882 actor
883 .handle_frame(WsMessage::MarketData {
884 conid: 265598,
885 payload: json!({"31": "150.25"}),
886 })
887 .await;
888
889 let rings = actor.rings.read().await;
890 assert_eq!(rings.get("orders").unwrap().len(), 1);
891 assert_eq!(rings.get("pnl").unwrap().len(), 1);
892 assert_eq!(rings.get("marketdata:265598").unwrap().len(), 1);
893 }
894
895 fn test_actor() -> ConnectorActor {
896 let (_tx, cmd_rx) = mpsc::channel(1);
897 std::mem::forget(_tx);
898 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
899 ConnectorActor {
900 client,
901 cfg: ConnectorCfg::default(),
902 rings: Arc::new(RwLock::new(HashMap::new())),
903 status: Arc::new(RwLock::new(StatusState::default())),
904 cmd_rx,
905 active_marketdata_subs: BTreeSet::new(),
906 resubscribe: Resubscribe::default(),
907 }
908 }
909
910 fn refusal() -> serde_json::Value {
914 json!({"error": "unable to subscribe", "code": 500, "topic": "sor"})
915 }
916
917 #[tokio::test]
918 async fn a_subscribe_refusal_is_not_an_event() {
919 let mut actor = test_actor();
920 actor.resubscribe.unconfirmed.insert("orders");
921
922 actor.handle_frame(WsMessage::Order(refusal())).await;
923
924 let rings = actor.rings.read().await;
925 assert!(
926 rings.get("orders").is_none(),
927 "a refusal must not create or fill the orders ring — consumers would read it as an order frame"
928 );
929 let s = actor.status.read().await;
930 assert_eq!(
931 s.subscriptions.get("orders"),
932 Some(&SubscriptionState::Refused)
933 );
934 assert_eq!(s.subscribe_refusals, 1);
935 assert!(
936 s.last_message_at.is_some(),
937 "it is still a sign of life on the socket"
938 );
939 }
940
941 #[tokio::test]
942 async fn a_refusal_books_a_retry_and_the_first_real_frame_cancels_it() {
943 let mut actor = test_actor();
944 actor.resubscribe.unconfirmed.insert("orders");
945
946 actor.handle_frame(WsMessage::Order(refusal())).await;
947 assert!(actor.resubscribe.due.is_some(), "a retry must be scheduled");
948 assert!(actor.resubscribe.unconfirmed.contains("orders"));
949
950 actor
952 .handle_frame(WsMessage::Order(json!({"topic": "sor", "args": []})))
953 .await;
954 assert!(
955 actor.resubscribe.due.is_none(),
956 "confirmed: nothing left to retry"
957 );
958 assert!(actor.resubscribe.unconfirmed.is_empty());
959 assert_eq!(
960 actor.status.read().await.subscriptions.get("orders"),
961 Some(&SubscriptionState::Subscribed)
962 );
963 assert_eq!(
964 actor.rings.read().await.get("orders").unwrap().len(),
965 1,
966 "the snapshot IS an event"
967 );
968 }
969
970 #[tokio::test]
971 async fn retry_delay_doubles_to_the_ceiling_and_resets_on_confirmation() {
972 let mut actor = test_actor();
973 actor.cfg.resubscribe_min = Duration::from_secs(5);
974 actor.cfg.resubscribe_max = Duration::from_secs(12);
975 actor.resubscribe.unconfirmed.insert("pnl");
976
977 actor.schedule_resubscribe();
978 assert_eq!(actor.resubscribe.backoff, Some(Duration::from_secs(10)));
979 actor.resubscribe.due = None; actor.schedule_resubscribe();
981 assert_eq!(
982 actor.resubscribe.backoff,
983 Some(Duration::from_secs(12)),
984 "capped"
985 );
986
987 actor
988 .handle_frame(WsMessage::Pnl(
989 json!({"topic": "spl", "args": {"upnl": 1.0}}),
990 ))
991 .await;
992 assert_eq!(
993 actor.resubscribe.backoff, None,
994 "the next socket starts from the minimum again"
995 );
996 }
997
998 #[tokio::test]
999 async fn one_topic_confirming_does_not_cancel_the_other_topic_retry() {
1000 let mut actor = test_actor();
1001 actor.resubscribe.unconfirmed.insert("orders");
1002 actor.resubscribe.unconfirmed.insert("pnl");
1003 actor.schedule_resubscribe();
1004
1005 actor
1007 .handle_frame(WsMessage::Pnl(json!({"upnl": 1.0})))
1008 .await;
1009 assert!(
1010 actor.resubscribe.due.is_some(),
1011 "orders still needs its retry"
1012 );
1013 assert_eq!(
1014 actor
1015 .resubscribe
1016 .unconfirmed
1017 .iter()
1018 .copied()
1019 .collect::<Vec<_>>(),
1020 vec!["orders"]
1021 );
1022 }
1023
1024 #[test]
1025 fn standing_topics_map_to_their_wire_commands() {
1026 assert_eq!(subscribe_command("orders"), Some("sor+{}"));
1027 assert_eq!(subscribe_command("pnl"), Some("spl+{}"));
1028 assert_eq!(subscribe_command("marketdata:1"), None);
1029 }
1030
1031 #[tokio::test]
1032 async fn heartbeat_and_system_frames_dont_create_topics() {
1033 let (_tx, cmd_rx) = mpsc::channel(1);
1034 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1035 let mut actor = ConnectorActor {
1036 client,
1037 cfg: ConnectorCfg::default(),
1038 rings: Arc::new(RwLock::new(HashMap::new())),
1039 status: Arc::new(RwLock::new(StatusState::default())),
1040 cmd_rx,
1041 active_marketdata_subs: BTreeSet::new(),
1042 resubscribe: Resubscribe::default(),
1043 };
1044
1045 actor.handle_frame(WsMessage::Heartbeat).await;
1046 actor
1047 .handle_frame(WsMessage::System(json!({"msg": "ready"})))
1048 .await;
1049
1050 let rings = actor.rings.read().await;
1051 assert!(rings.is_empty());
1052
1053 let s = actor.status.read().await;
1055 assert!(s.last_message_at.is_some());
1056 }
1057
1058 #[tokio::test]
1059 async fn bump_epoch_skips_gap_on_first_boot() {
1060 let (_tx, cmd_rx) = mpsc::channel(1);
1061 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1062 let actor = ConnectorActor {
1063 client,
1064 cfg: ConnectorCfg::default(),
1065 rings: Arc::new(RwLock::new(HashMap::new())),
1066 status: Arc::new(RwLock::new(StatusState::default())),
1067 cmd_rx,
1068 active_marketdata_subs: BTreeSet::new(),
1069 resubscribe: Resubscribe::default(),
1070 };
1071
1072 actor
1073 .bump_epoch_with_gap(GapReason::ReconnectedAfterDisconnect)
1074 .await;
1075
1076 let rings = actor.rings.read().await;
1078 assert!(rings.is_empty());
1079 assert_eq!(actor.status.read().await.reset_epoch, 1);
1081 }
1082
1083 #[tokio::test]
1084 async fn bump_epoch_injects_gap_into_existing_topics() {
1085 let (_tx, cmd_rx) = mpsc::channel(1);
1086 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1087 let mut actor = ConnectorActor {
1088 client,
1089 cfg: ConnectorCfg::default(),
1090 rings: Arc::new(RwLock::new(HashMap::new())),
1091 status: Arc::new(RwLock::new(StatusState::default())),
1092 cmd_rx,
1093 active_marketdata_subs: BTreeSet::new(),
1094 resubscribe: Resubscribe::default(),
1095 };
1096
1097 actor.handle_frame(WsMessage::Order(json!({"id": 1}))).await;
1099 actor
1101 .bump_epoch_with_gap(GapReason::ReconnectedAfterDisconnect)
1102 .await;
1103
1104 let rings = actor.rings.read().await;
1105 let orders_ring = rings.get("orders").unwrap();
1106 assert_eq!(orders_ring.len(), 2);
1108 assert_eq!(rings.get("gap").unwrap().len(), 1);
1110 }
1111
1112 #[tokio::test]
1113 async fn ensure_market_data_dedups_repeat_calls() {
1114 let (_tx, cmd_rx) = mpsc::channel(1);
1118 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1119 let mut actor = ConnectorActor {
1120 client,
1121 cfg: ConnectorCfg::default(),
1122 rings: Arc::new(RwLock::new(HashMap::new())),
1123 status: Arc::new(RwLock::new(StatusState::default())),
1124 cmd_rx,
1125 active_marketdata_subs: BTreeSet::new(),
1126 resubscribe: Resubscribe::default(),
1127 };
1128
1129 assert!(actor.active_marketdata_subs.insert(265_598));
1130 assert!(!actor.active_marketdata_subs.insert(265_598));
1132 assert_eq!(actor.active_marketdata_subs.len(), 1);
1133 }
1134}