1use std::collections::{BTreeMap, BTreeSet, HashMap};
39use std::panic::AssertUnwindSafe;
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::Arc;
42use std::time::{Duration, Instant};
43
44use futures_util::FutureExt;
45
46use bezant::{MarketDataFields, WsClient, WsMessage};
47use serde_json::json;
48use tokio::sync::{mpsc, oneshot, RwLock};
49use tokio::time::{sleep, sleep_until, timeout, Instant as TokioInstant};
50use tracing::{debug, info, warn};
51
52use super::persistence::{EventLog, RetentionPolicy};
53use super::ring::{ReadResult, TopicRing};
54use super::{EventsStatus, GapReason, ObservedEvent, SubscriptionState};
55
56#[derive(Clone, Debug)]
58pub struct ConnectorCfg {
59 pub orders_capacity: usize,
61 pub pnl_capacity: usize,
63 pub marketdata_capacity: usize,
65 pub backoff_min: Duration,
67 pub backoff_max: Duration,
69 pub heartbeat_timeout: Duration,
72 pub marketdata_idle_unsubscribe: Duration,
76 pub event_log: Option<Arc<EventLog>>,
80 pub retention: RetentionPolicy,
82 pub prune_every: Duration,
84 pub resubscribe_min: Duration,
86 pub resubscribe_max: Duration,
90 pub session_check_every: Duration,
93 pub backoff_reset_after: Duration,
97 pub quiet_after_rounds: u32,
101}
102
103impl Default for ConnectorCfg {
104 fn default() -> Self {
105 Self {
106 orders_capacity: 1_000,
107 pnl_capacity: 5_000,
108 marketdata_capacity: 2_000,
109 backoff_min: Duration::from_secs(1),
110 backoff_max: Duration::from_secs(60),
111 heartbeat_timeout: Duration::from_secs(90),
112 marketdata_idle_unsubscribe: Duration::from_secs(300),
113 event_log: None,
114 retention: RetentionPolicy::default(),
115 prune_every: Duration::from_secs(3_600),
116 resubscribe_min: Duration::from_secs(5),
117 resubscribe_max: Duration::from_secs(300),
118 session_check_every: Duration::from_secs(60),
119 backoff_reset_after: Duration::from_secs(300),
120 quiet_after_rounds: 3,
121 }
122 }
123}
124
125#[derive(Debug)]
127enum ConnectorCmd {
128 EnsureMarketData {
129 conid: i64,
130 reply: oneshot::Sender<Result<(), String>>,
131 },
132 Reconnect { reply: oneshot::Sender<bool> },
135}
136
137const RECONNECT_ACK_TIMEOUT: Duration = Duration::from_secs(10);
141
142#[derive(Clone)]
145pub struct EventsHandle {
146 rings: Arc<RwLock<HashMap<String, TopicRing>>>,
147 status: Arc<RwLock<StatusState>>,
148 cmd_tx: mpsc::Sender<ConnectorCmd>,
149 started_at: Instant,
150 event_log: Option<Arc<EventLog>>,
151 persist_failures: Arc<AtomicU64>,
152}
153
154impl EventsHandle {
155 #[must_use]
158 pub fn event_log(&self) -> Option<&Arc<EventLog>> {
159 self.event_log.as_ref()
160 }
161}
162
163#[derive(Debug, Default)]
164struct StatusState {
165 connected: bool,
166 last_message_at: Option<String>,
167 reconnect_count: u64,
168 reset_epoch: u64,
169 cursor_base: u64,
171 topics_subscribed: BTreeSet<String>,
172 subscriptions: BTreeMap<String, SubscriptionState>,
173 subscribe_refusals: u64,
174 session_rollovers: u64,
175 connector_restarts: u64,
176}
177
178impl EventsHandle {
179 pub async fn read_topic(&self, topic: &str, since: u64, limit: usize) -> Option<ReadResult> {
184 let rings = self.rings.read().await;
185 rings.get(topic).map(|r| r.read_since(since, limit))
186 }
187
188 pub async fn read_empty_topic(&self, topic: &str, since: u64) -> ReadResult {
194 let s = self.status.read().await;
195 TopicRing::with_base(topic, 1, s.reset_epoch, s.cursor_base).read_since(since, 1)
196 }
197
198 pub async fn status(&self) -> EventsStatus {
201 let s = self.status.read().await;
202 let buffer_sizes: BTreeMap<String, usize> = self
203 .rings
204 .read()
205 .await
206 .iter()
207 .map(|(k, v)| (k.clone(), v.len()))
208 .collect();
209 EventsStatus {
210 connected: s.connected,
211 last_message_at: s.last_message_at.clone(),
212 reconnect_count: s.reconnect_count,
213 uptime_seconds: self.started_at.elapsed().as_secs(),
214 reset_epoch: s.reset_epoch,
215 topics_subscribed: s.topics_subscribed.iter().cloned().collect(),
216 buffer_sizes,
217 subscriptions: s.subscriptions.clone(),
218 subscribe_refusals: s.subscribe_refusals,
219 session_rollovers: s.session_rollovers,
220 connector_restarts: s.connector_restarts,
221 persist_failures: self.persist_failures.load(Ordering::Relaxed),
222 }
223 }
224
225 pub async fn request_reconnect(&self) -> Result<bool, String> {
234 let (tx, rx) = oneshot::channel();
235 self.cmd_tx
236 .send(ConnectorCmd::Reconnect { reply: tx })
237 .await
238 .map_err(|_| "connector task is not running".to_string())?;
239 match timeout(RECONNECT_ACK_TIMEOUT, rx).await {
240 Ok(Ok(was_connected)) => Ok(was_connected),
241 Ok(Err(_)) => Err("connector task dropped reply channel".to_string()),
242 Err(_) => Err("connector did not take the request in time".to_string()),
243 }
244 }
245
246 pub async fn ensure_market_data(&self, conid: i64) -> Result<(), String> {
251 let (tx, rx) = oneshot::channel();
252 self.cmd_tx
253 .send(ConnectorCmd::EnsureMarketData { conid, reply: tx })
254 .await
255 .map_err(|_| "connector task is not running".to_string())?;
256 rx.await
257 .map_err(|_| "connector task dropped reply channel".to_string())?
258 }
259}
260
261impl EventsHandle {
262 #[doc(hidden)]
269 #[must_use]
270 pub fn for_test() -> (Self, TestSink) {
271 Self::for_test_with_log(None)
272 }
273
274 #[doc(hidden)]
277 #[must_use]
278 pub fn for_test_with_log(event_log: Option<Arc<EventLog>>) -> (Self, TestSink) {
279 Self::for_test_seeded(event_log, 1, 1)
280 }
281
282 #[doc(hidden)]
286 #[must_use]
287 pub fn for_test_seeded(
288 event_log: Option<Arc<EventLog>>,
289 reset_epoch: u64,
290 cursor_base: u64,
291 ) -> (Self, TestSink) {
292 let rings: Arc<RwLock<HashMap<String, TopicRing>>> = Arc::new(RwLock::new(HashMap::new()));
293 let status = Arc::new(RwLock::new(StatusState {
294 connected: true,
295 reset_epoch,
296 cursor_base,
297 ..Default::default()
298 }));
299 let (cmd_tx, cmd_rx) = mpsc::channel::<ConnectorCmd>(8);
300 let started_at = Instant::now();
301 let handle = Self {
302 rings: rings.clone(),
303 status: status.clone(),
304 cmd_tx,
305 started_at,
306 event_log: event_log.clone(),
307 persist_failures: Arc::default(),
308 };
309 let sink = TestSink {
310 rings,
311 status,
312 event_log,
313 cmd_rx: Arc::new(std::sync::Mutex::new(Some(cmd_rx))),
314 reconnects: Arc::default(),
315 };
316 (handle, sink)
317 }
318}
319
320#[doc(hidden)]
326#[derive(Clone)]
327pub struct TestSink {
328 rings: Arc<RwLock<HashMap<String, TopicRing>>>,
329 status: Arc<RwLock<StatusState>>,
330 event_log: Option<Arc<EventLog>>,
331 cmd_rx: Arc<std::sync::Mutex<Option<mpsc::Receiver<ConnectorCmd>>>>,
333 reconnects: Arc<AtomicU64>,
334}
335
336#[doc(hidden)]
337impl TestSink {
338 pub async fn push(&self, topic: &str, payload: serde_json::Value) -> u64 {
342 let cap = match topic {
343 "orders" => 1_000,
344 "pnl" => 5_000,
345 t if t.starts_with("marketdata:") => 2_000,
346 _ => 256,
347 };
348 let (epoch, base) = {
349 let s = self.status.read().await;
350 (s.reset_epoch, s.cursor_base)
351 };
352 let received_at = now_iso();
353 let mut rings = self.rings.write().await;
354 let ring = rings
355 .entry(topic.to_string())
356 .or_insert_with(|| TopicRing::with_base(topic, cap, epoch, base));
357 let cursor = ring.push(payload.clone(), received_at.clone());
358 drop(rings);
359 if let Some(log) = &self.event_log {
360 let _ = log.append(&ObservedEvent {
361 cursor,
362 topic: topic.to_string(),
363 received_at: received_at.clone(),
364 reset_epoch: epoch,
365 payload,
366 });
367 }
368 self.status
369 .write()
370 .await
371 .topics_subscribed
372 .insert(topic.to_string());
373 cursor
374 }
375
376 pub async fn set_reset_epoch(&self, epoch: u64) {
379 self.status.write().await.reset_epoch = epoch;
380 for ring in self.rings.write().await.values_mut() {
381 ring.set_reset_epoch(epoch);
382 }
383 }
384
385 pub async fn set_connected(&self, connected: bool) {
387 self.status.write().await.connected = connected;
388 }
389
390 pub fn serve_commands(&self) {
394 let rx = self
395 .cmd_rx
396 .lock()
397 .unwrap_or_else(std::sync::PoisonError::into_inner)
398 .take();
399 let Some(mut rx) = rx else { return };
400 let status = self.status.clone();
401 let reconnects = self.reconnects.clone();
402 tokio::spawn(async move {
403 while let Some(cmd) = rx.recv().await {
404 match cmd {
405 ConnectorCmd::EnsureMarketData { reply, .. } => {
406 let _ = reply.send(Ok(()));
407 }
408 ConnectorCmd::Reconnect { reply } => {
409 reconnects.fetch_add(1, Ordering::SeqCst);
410 let _ = reply.send(status.read().await.connected);
411 }
412 }
413 }
414 });
415 }
416
417 #[must_use]
419 pub fn reconnect_requests(&self) -> u64 {
420 self.reconnects.load(Ordering::SeqCst)
421 }
422}
423
424pub fn spawn_connector(client: bezant::Client, cfg: ConnectorCfg) -> EventsHandle {
431 let rings: Arc<RwLock<HashMap<String, TopicRing>>> = Arc::new(RwLock::new(HashMap::new()));
432 let high_water = cfg.event_log.as_ref().and_then(|log| match log.high_water() {
433 Ok(hw) => hw,
434 Err(e) => {
435 warn!(error = %e, "events sqlite: could not read the previous run's cursors; seeding from the clock alone");
436 None
437 }
438 });
439 let (reset_epoch, cursor_base) = boot_seeds(unix_millis(), high_water);
440 info!(
441 reset_epoch,
442 cursor_base, "events connector: seeded epoch and cursors from boot time"
443 );
444 let status = Arc::new(RwLock::new(StatusState {
445 reset_epoch,
446 cursor_base,
447 ..StatusState::default()
448 }));
449 let (cmd_tx, cmd_rx) = mpsc::channel::<ConnectorCmd>(64);
450 let started_at = Instant::now();
451 let event_log = cfg.event_log.clone();
452
453 if let Some(log) = event_log.clone() {
454 let prune_every = cfg.prune_every;
455 let policy = cfg.retention.clone();
456 tokio::spawn(async move {
457 loop {
458 sleep(prune_every).await;
459 let log = log.clone();
460 let policy = policy.clone();
461 let dropped = tokio::task::spawn_blocking(move || log.prune(&policy)).await;
462 match dropped {
463 Ok(Ok(n)) if n > 0 => info!(rows = n, "events sqlite: prune dropped rows"),
464 Ok(Err(e)) => warn!(error = %e, "events sqlite: prune failed"),
465 _ => {}
466 }
467 }
468 });
469 }
470
471 let actor = ConnectorActor::new(client, cfg, rings.clone(), status.clone(), cmd_rx);
472 let persist_failures = actor.persist_failures.clone();
473
474 tokio::spawn(actor.run());
475
476 EventsHandle {
477 rings,
478 status,
479 cmd_tx,
480 started_at,
481 event_log,
482 persist_failures,
483 }
484}
485
486const JS_MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
489
490const CURSOR_BASE_CEILING: u64 = JS_MAX_SAFE_INTEGER - (1 << 52);
493
494const CURSORS_PER_MS: u64 = 1_000;
498
499#[doc(hidden)]
515#[must_use]
516pub fn boot_seeds(now_ms: u64, high_water: Option<(u64, u64)>) -> (u64, u64) {
517 let (hw_cursor, hw_epoch) = high_water.unwrap_or((0, 0));
518 let epoch = now_ms
519 .max(hw_epoch.saturating_add(1))
520 .clamp(1, JS_MAX_SAFE_INTEGER);
521 let base = now_ms
522 .saturating_mul(CURSORS_PER_MS)
523 .max(hw_cursor.saturating_add(1))
524 .clamp(1, CURSOR_BASE_CEILING);
525 (epoch, base)
526}
527
528fn unix_millis() -> u64 {
529 std::time::SystemTime::now()
530 .duration_since(std::time::UNIX_EPOCH)
531 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
532}
533
534struct ConnectorActor {
535 client: bezant::Client,
536 cfg: ConnectorCfg,
537 rings: Arc<RwLock<HashMap<String, TopicRing>>>,
538 status: Arc<RwLock<StatusState>>,
539 cmd_rx: mpsc::Receiver<ConnectorCmd>,
540 active_marketdata_subs: BTreeSet<i64>,
541 resubscribe: Resubscribe,
542 link: Link,
543 persist_failures: Arc<AtomicU64>,
545 reconnect_now: bool,
548}
549
550#[derive(Debug, Default)]
553struct Link {
554 ever_connected: bool,
557 connected_at: Option<Instant>,
559 outage: Option<Outage>,
561 last_failure: Option<String>,
565}
566
567#[derive(Debug)]
570struct Outage {
571 since: String,
573 failed_attempts: u64,
575}
576
577fn failure_kind(e: &bezant::Error) -> String {
580 use bezant::Error as E;
581 match e {
582 E::NotAuthenticated => "not_authenticated".into(),
583 E::NoSession => "no_session".into(),
584 E::Http(h) if h.is_connect() => "gateway_unreachable".into(),
585 E::Http(h) if h.is_timeout() => "gateway_timeout".into(),
586 E::Http(_) => "http".into(),
587 E::UpstreamStatus { status, .. } => format!("upstream_status_{status}"),
588 E::WsHandshake { .. } => "ws_handshake".into(),
589 E::WsTransport { .. } => "ws_transport".into(),
590 E::WsProtocol(m) => format!("ws_protocol: {m}"),
591 other => format!("other: {other}"),
592 }
593}
594
595#[derive(Debug, Default)]
598struct Resubscribe {
599 unconfirmed: BTreeSet<&'static str>,
601 due: Option<TokioInstant>,
603 backoff: Option<Duration>,
605 silent_rounds: BTreeMap<&'static str, u32>,
607 quiet: BTreeSet<&'static str>,
609}
610
611impl Resubscribe {
612 fn take_round(&mut self, quiet_after: u32) -> (Vec<&'static str>, Vec<&'static str>) {
622 self.due = None;
623 let mut ask = Vec::new();
624 let mut gone_quiet = Vec::new();
625 for topic in self.unconfirmed.clone() {
626 let rounds = self.silent_rounds.entry(topic).or_insert(0);
627 if quiet_after > 0 && *rounds >= quiet_after {
628 self.unconfirmed.remove(topic);
629 self.quiet.insert(topic);
630 gone_quiet.push(topic);
631 } else {
632 *rounds += 1;
633 ask.push(topic);
634 }
635 }
636 if self.unconfirmed.is_empty() {
637 self.backoff = None;
638 }
639 (ask, gone_quiet)
640 }
641
642 fn refused(&mut self, topic: &'static str) {
645 self.unconfirmed.insert(topic);
646 self.quiet.remove(topic);
647 self.silent_rounds.insert(topic, 0);
648 }
649
650 fn confirmed(&mut self, topic: &'static str) -> bool {
653 self.silent_rounds.remove(topic);
654 let was_waiting = self.unconfirmed.remove(topic) | self.quiet.remove(topic);
655 if self.unconfirmed.is_empty() {
656 self.due = None;
657 self.backoff = None;
658 }
659 was_waiting
660 }
661}
662
663const fn subscribe_command(topic: &str) -> Option<&'static str> {
665 match topic.as_bytes() {
666 b"orders" => Some("sor+{}"),
667 b"pnl" => Some("spl+{}"),
668 _ => None,
669 }
670}
671
672impl ConnectorActor {
673 fn new(
674 client: bezant::Client,
675 cfg: ConnectorCfg,
676 rings: Arc<RwLock<HashMap<String, TopicRing>>>,
677 status: Arc<RwLock<StatusState>>,
678 cmd_rx: mpsc::Receiver<ConnectorCmd>,
679 ) -> Self {
680 Self {
681 client,
682 cfg,
683 rings,
684 status,
685 cmd_rx,
686 active_marketdata_subs: BTreeSet::new(),
687 resubscribe: Resubscribe::default(),
688 link: Link::default(),
689 persist_failures: Arc::default(),
690 reconnect_now: false,
691 }
692 }
693
694 async fn run(mut self) {
701 info!("events connector starting");
702 loop {
703 let outcome = AssertUnwindSafe(self.run_links()).catch_unwind().await;
704 let reason = match outcome {
705 Ok(()) => "connector loop returned".to_owned(),
706 Err(panic) => panic
707 .downcast_ref::<&str>()
708 .map(|s| (*s).to_owned())
709 .or_else(|| panic.downcast_ref::<String>().cloned())
710 .unwrap_or_else(|| "non-string panic".to_owned()),
711 };
712 self.on_crash(&reason).await;
713 sleep(self.cfg.backoff_min).await;
714 }
715 }
716
717 async fn on_crash(&mut self, reason: &str) {
721 tracing::error!(reason, "events connector crashed; restarting it");
722 let was_connected = self.link.connected_at.take().is_some();
723 if was_connected {
724 self.link.outage = Some(Outage {
725 since: now_iso(),
726 failed_attempts: 0,
727 });
728 }
729 self.resubscribe = Resubscribe::default();
730 let mut s = self.status.write().await;
731 s.connected = false;
732 s.connector_restarts += 1;
733 }
734
735 async fn run_links(&mut self) {
737 let mut backoff = self.cfg.backoff_min;
738 loop {
739 let result = self.connect_and_run().await;
740 let lived = self.link.connected_at.take().map(|t| t.elapsed());
741 self.set_disconnected().await;
742 self.note_link_down(lived.is_some(), result.as_ref().err());
743
744 if lived.is_some_and(|d| d >= self.cfg.backoff_reset_after) {
747 backoff = self.cfg.backoff_min;
748 }
749 if std::mem::take(&mut self.reconnect_now) || self.idle(backoff).await {
750 backoff = self.cfg.backoff_min;
752 continue;
753 }
754 backoff = (backoff * 2).min(self.cfg.backoff_max);
755 }
756 }
757
758 async fn idle(&mut self, backoff: Duration) -> bool {
764 let wake = sleep(backoff);
765 tokio::pin!(wake);
766 loop {
767 tokio::select! {
768 () = &mut wake => return false,
769 Some(cmd) = self.cmd_rx.recv() => match cmd {
770 ConnectorCmd::Reconnect { reply } => {
771 info!("events connector: reconnect requested while down; connecting now");
772 let _ = reply.send(false);
773 return true;
774 }
775 ConnectorCmd::EnsureMarketData { conid, reply } => {
776 self.active_marketdata_subs.insert(conid);
777 self.add_topic_to_status(format!("marketdata:{conid}")).await;
778 let _ = reply.send(Ok(()));
779 }
780 },
781 }
782 }
783 }
784
785 fn note_link_down(&mut self, was_connected: bool, error: Option<&bezant::Error>) {
790 if was_connected {
791 self.link.outage = Some(Outage {
792 since: now_iso(),
793 failed_attempts: 0,
794 });
795 } else if let Some(outage) = self.link.outage.as_mut() {
796 outage.failed_attempts += 1;
797 }
798 let Some(e) = error else {
799 if !self.reconnect_now {
800 info!("events connector: ws closed cleanly, reconnecting");
801 }
802 return;
803 };
804 let kind = failure_kind(e);
805 if self.link.last_failure.as_deref() == Some(kind.as_str()) {
806 debug!(error = %e, kind, "events connector: still failing the same way, will retry");
807 } else {
808 warn!(error = %e, kind, "events connector: ws failed, will retry");
809 self.link.last_failure = Some(kind);
810 }
811 }
812
813 async fn on_connected(&mut self) {
819 self.link.connected_at = Some(Instant::now());
820 if self.link.last_failure.take().is_some() {
821 info!("events connector: recovered");
822 }
823 if !self.link.ever_connected {
824 self.link.ever_connected = true;
825 return;
826 }
827 let outage = self.link.outage.take();
828 self.bump_epoch_with_gap(GapReason::ReconnectedAfterDisconnect, outage.as_ref())
829 .await;
830 }
831
832 async fn connect_and_run(&mut self) -> Result<(), bezant::Error> {
835 let mut ws = WsClient::connect(&self.client).await?;
836
837 let ready = timeout(Duration::from_secs(5), pump_until_ready(&mut ws)).await;
843 match ready {
844 Ok(Ok(())) => debug!("events connector: server ready signal received"),
845 Ok(Err(e)) => return Err(e),
846 Err(_) => warn!(
847 "events connector: didn't see server-ready frame within 5s; \
848 subscribing anyway (CPAPI may silently drop these)"
849 ),
850 }
851
852 ws.subscribe_orders().await?;
853 ws.subscribe_pnl().await?;
854 self.resubscribe = Resubscribe::default();
857 for topic in ["orders", "pnl"] {
858 self.resubscribe.unconfirmed.insert(topic);
859 self.set_subscription(topic, SubscriptionState::Pending)
860 .await;
861 }
862 self.schedule_resubscribe();
863 for conid in self.active_marketdata_subs.clone() {
866 if let Err(e) = ws
867 .subscribe_market_data(conid, &MarketDataFields::default_l1())
868 .await
869 {
870 warn!(conid, error = %e, "events connector: re-subscribe market data failed");
871 }
872 }
873
874 self.on_connected().await;
875 self.set_connected().await;
876 info!("events connector: connected, orders + pnl subscribed");
877
878 let result = self.dispatch_loop(&mut ws).await;
879 self.set_disconnected().await;
880 result
881 }
882
883 async fn dispatch_loop(&mut self, ws: &mut WsClient) -> Result<(), bezant::Error> {
887 let session = ws.session().to_owned();
888 let mut session_check = tokio::time::interval(self.cfg.session_check_every);
889 session_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
890 session_check.tick().await; loop {
892 let resub_at = self
896 .resubscribe
897 .due
898 .unwrap_or_else(|| TokioInstant::now() + Duration::from_secs(365 * 24 * 3_600));
899 tokio::select! {
900 msg = timeout(self.cfg.heartbeat_timeout, ws.next_message()) => {
902 match msg {
903 Err(_elapsed) => {
904 warn!(
905 timeout_secs = self.cfg.heartbeat_timeout.as_secs(),
906 "events connector: heartbeat timeout, killing socket"
907 );
908 return Err(bezant::Error::WsProtocol(
909 "heartbeat timeout".into(),
910 ));
911 }
912 Ok(Ok(None)) => {
913 info!("events connector: upstream ws closed");
914 return Ok(());
915 }
916 Ok(Ok(Some(frame))) => {
917 self.handle_frame(frame).await;
918 }
919 Ok(Err(e)) => {
920 return Err(e);
921 }
922 }
923 }
924 Some(cmd) = self.cmd_rx.recv() => {
926 if self.handle_command(ws, cmd).await {
927 info!("events connector: reconnect requested; dropping the socket");
928 self.reconnect_now = true;
929 return Ok(());
930 }
931 }
932 () = sleep_until(resub_at), if self.resubscribe.due.is_some() => {
934 self.resubscribe(ws).await;
935 }
936 _ = session_check.tick() => {
938 if self.session_rolled_over(&session).await {
939 self.status.write().await.session_rollovers += 1;
940 warn!("events connector: gateway session changed under the socket (re-login); reconnecting so subscriptions bind to the live session");
941 return Err(bezant::Error::WsProtocol("gateway session rolled over".into()));
942 }
943 }
944 }
945 }
946 }
947
948 async fn session_rolled_over(&self, socket_session: &str) -> bool {
954 match self.client.tickle().await {
955 Ok(t) => matches!(t.session.as_deref(), Some(live) if live != socket_session),
956 Err(e) => {
957 debug!(error = %e, "events connector: session check tickle failed; assuming unchanged");
958 false
959 }
960 }
961 }
962
963 async fn resubscribe(&mut self, ws: &mut WsClient) {
969 let (topics, gone_quiet) = self.resubscribe.take_round(self.cfg.quiet_after_rounds);
970 for topic in gone_quiet {
971 info!(
972 topic,
973 rounds = self.cfg.quiet_after_rounds,
974 "events connector: no refusal after repeated subscribes; treating the topic as quiet but alive"
975 );
976 self.set_subscription(topic, SubscriptionState::Quiet).await;
977 }
978 if topics.is_empty() {
979 return;
980 }
981 if let Err(e) = self
982 .client
983 .api()
984 .get_brokerage_accounts(bezant::api::GetBrokerageAccountsRequest::default())
985 .await
986 {
987 debug!(error = %e, "events connector: /iserver/accounts prime failed before resubscribe");
988 }
989 for topic in topics {
990 let Some(cmd) = subscribe_command(topic) else {
991 continue;
992 };
993 match ws.send_text(cmd.to_owned()).await {
994 Ok(()) => info!(topic, "events connector: resubscribing"),
995 Err(e) => warn!(topic, error = %e, "events connector: resubscribe send failed"),
996 }
997 self.set_subscription(topic, SubscriptionState::Pending)
998 .await;
999 }
1000 self.schedule_resubscribe();
1003 }
1004
1005 fn schedule_resubscribe(&mut self) {
1008 if self.resubscribe.unconfirmed.is_empty() || self.resubscribe.due.is_some() {
1009 return;
1010 }
1011 let delay = self.resubscribe.backoff.unwrap_or(self.cfg.resubscribe_min);
1012 self.resubscribe.due = Some(TokioInstant::now() + delay);
1013 self.resubscribe.backoff = Some((delay * 2).min(self.cfg.resubscribe_max));
1014 }
1015
1016 async fn set_subscription(&self, topic: &str, state: SubscriptionState) {
1017 self.status
1018 .write()
1019 .await
1020 .subscriptions
1021 .insert(topic.to_owned(), state);
1022 }
1023
1024 async fn handle_topic_frame(
1028 &mut self,
1029 topic: &'static str,
1030 value: serde_json::Value,
1031 now: String,
1032 ) {
1033 if let Some(err) = value.get("error") {
1034 let code = value.get("code").and_then(serde_json::Value::as_i64);
1035 warn!(
1036 topic,
1037 error = %err,
1038 code,
1039 "events connector: CPAPI refused the subscription; will retry with backoff"
1040 );
1041 {
1042 let mut s = self.status.write().await;
1043 s.subscribe_refusals += 1;
1044 s.last_message_at = Some(now);
1045 }
1046 self.set_subscription(topic, SubscriptionState::Refused)
1047 .await;
1048 self.resubscribe.refused(topic);
1049 self.schedule_resubscribe();
1050 return;
1051 }
1052 if self.resubscribe.confirmed(topic) {
1053 info!(
1054 topic,
1055 "events connector: subscription confirmed by first frame"
1056 );
1057 self.set_subscription(topic, SubscriptionState::Subscribed)
1058 .await;
1059 }
1060 self.push_to_topic(topic, value, now).await;
1061 }
1062
1063 async fn handle_frame(&mut self, frame: WsMessage) {
1065 let now = now_iso();
1066 if let Some(v) = frame.as_value() {
1070 let snippet = serde_json::to_string(v).unwrap_or_default();
1071 let topic_str = v
1072 .get("topic")
1073 .and_then(|t| t.as_str())
1074 .unwrap_or("<no-topic>");
1075 debug!(
1076 variant = frame.topic(),
1077 topic = topic_str,
1078 payload = %truncate(&snippet, 200),
1079 "events connector: frame"
1080 );
1081 }
1082 match frame {
1083 WsMessage::Heartbeat | WsMessage::System(_) | WsMessage::Other(_) => {
1084 self.touch_last_message(now).await;
1087 }
1088 WsMessage::Order(value) => {
1089 self.handle_topic_frame("orders", value, now).await;
1090 }
1091 WsMessage::Pnl(value) => {
1092 self.handle_topic_frame("pnl", value, now).await;
1093 }
1094 WsMessage::MarketData { conid, payload } => {
1095 let topic = format!("marketdata:{conid}");
1096 self.push_to_topic(&topic, payload, now).await;
1097 }
1098 WsMessage::Malformed { text, error } => {
1099 warn!(error = %error, sample = %truncate(&text, 200), "events connector: malformed frame");
1100 self.touch_last_message(now).await;
1101 }
1102 other => {
1106 debug!(
1107 topic = other.topic(),
1108 "events connector: unhandled ws message variant"
1109 );
1110 self.touch_last_message(now).await;
1111 }
1112 }
1113 }
1114
1115 async fn handle_command(&mut self, ws: &mut WsClient, cmd: ConnectorCmd) -> bool {
1117 match cmd {
1118 ConnectorCmd::Reconnect { reply } => {
1119 let _ = reply.send(true);
1120 return true;
1121 }
1122 ConnectorCmd::EnsureMarketData { conid, reply } => {
1123 if self.active_marketdata_subs.insert(conid) {
1124 debug!(conid, "events connector: subscribing market data");
1125 let result = ws
1126 .subscribe_market_data(conid, &MarketDataFields::default_l1())
1127 .await;
1128 match result {
1129 Ok(_) => {
1130 self.add_topic_to_status(format!("marketdata:{conid}"))
1131 .await;
1132 let _ = reply.send(Ok(()));
1133 }
1134 Err(e) => {
1135 self.active_marketdata_subs.remove(&conid);
1138 let _ = reply.send(Err(format!("subscribe failed: {e}")));
1139 }
1140 }
1141 } else {
1142 let _ = reply.send(Ok(()));
1144 }
1145 }
1146 }
1147 false
1148 }
1149
1150 async fn push_to_topic(&self, topic: &str, payload: serde_json::Value, received_at: String) {
1151 let cap = match topic {
1152 "orders" => self.cfg.orders_capacity,
1153 "pnl" => self.cfg.pnl_capacity,
1154 t if t.starts_with("marketdata:") => self.cfg.marketdata_capacity,
1155 _ => 1_000,
1156 };
1157 let (epoch, base) = {
1158 let s = self.status.read().await;
1159 (s.reset_epoch, s.cursor_base)
1160 };
1161
1162 let mut rings = self.rings.write().await;
1163 let ring = rings
1164 .entry(topic.to_string())
1165 .or_insert_with(|| TopicRing::with_base(topic, cap, epoch, base));
1166 let cursor = ring.push(payload.clone(), received_at.clone());
1167 let epoch = ring.reset_epoch();
1168 drop(rings);
1169
1170 self.persist(ObservedEvent {
1171 cursor,
1172 topic: topic.to_string(),
1173 received_at: received_at.clone(),
1174 reset_epoch: epoch,
1175 payload,
1176 });
1177
1178 let mut s = self.status.write().await;
1180 s.last_message_at = Some(received_at);
1181 s.topics_subscribed.insert(topic.to_string());
1182 }
1183
1184 fn persist(&self, evt: ObservedEvent) {
1192 if let Some(log) = &self.cfg.event_log {
1193 let log = log.clone();
1194 let failures = self.persist_failures.clone();
1195 tokio::task::spawn_blocking(move || {
1198 if let Err(e) = log.append(&evt) {
1199 let n = failures.fetch_add(1, Ordering::Relaxed) + 1;
1200 if n.is_power_of_two() {
1201 warn!(error = %e, topic = %evt.topic, failures = n, "events sqlite: append failed; history will be missing events");
1202 }
1203 }
1204 });
1205 }
1206 }
1207
1208 async fn touch_last_message(&self, now: String) {
1209 self.status.write().await.last_message_at = Some(now);
1210 }
1211
1212 async fn add_topic_to_status(&self, topic: String) {
1213 self.status.write().await.topics_subscribed.insert(topic);
1214 }
1215
1216 async fn set_connected(&self) {
1217 let mut s = self.status.write().await;
1218 s.connected = true;
1219 s.reconnect_count = s.reconnect_count.saturating_add(1);
1220 s.topics_subscribed.insert("orders".into());
1221 s.topics_subscribed.insert("pnl".into());
1222 }
1223
1224 async fn set_disconnected(&self) {
1225 self.status.write().await.connected = false;
1226 }
1227
1228 async fn bump_epoch_with_gap(&self, reason: GapReason, outage: Option<&Outage>) {
1236 let (new_epoch, base) = {
1237 let mut s = self.status.write().await;
1238 s.reset_epoch = s.reset_epoch.saturating_add(1);
1239 (s.reset_epoch, s.cursor_base)
1240 };
1241
1242 let now = now_iso();
1243 let mut payload = json!({
1244 "reason": match reason {
1245 GapReason::ReconnectedAfterDisconnect => "reconnected_after_disconnect",
1246 GapReason::ProcessRestart => "process_restart",
1247 },
1248 "previous_reset_epoch": new_epoch - 1,
1249 "new_reset_epoch": new_epoch,
1250 });
1251 if let Some(o) = outage {
1252 payload["disconnected_at"] = json!(o.since);
1253 payload["failed_attempts"] = json!(o.failed_attempts);
1254 }
1255
1256 let mut rings = self.rings.write().await;
1257 for ring in rings.values_mut() {
1258 ring.set_reset_epoch(new_epoch);
1259 }
1260 let cursor = rings
1261 .entry("gap".to_string())
1262 .or_insert_with(|| TopicRing::with_base("gap", 256, new_epoch, base))
1263 .push(payload.clone(), now.clone());
1264 drop(rings);
1265 self.persist(ObservedEvent {
1266 cursor,
1267 topic: "gap".into(),
1268 received_at: now,
1269 reset_epoch: new_epoch,
1270 payload,
1271 });
1272 }
1273}
1274
1275async fn pump_until_ready(ws: &mut WsClient) -> Result<(), bezant::Error> {
1282 while let Some(msg) = ws.next_message().await? {
1283 if let WsMessage::System(value) = &msg {
1284 if value.get("success").is_some() {
1285 return Ok(());
1286 }
1287 }
1288 }
1291 Err(bezant::Error::WsProtocol(
1293 "ws closed before server-ready frame".into(),
1294 ))
1295}
1296
1297fn now_iso() -> String {
1298 use std::time::SystemTime;
1299 let now: chrono::DateTime<chrono::Utc> = SystemTime::now().into();
1300 now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1301}
1302
1303fn truncate(s: &str, max: usize) -> String {
1308 if s.len() <= max {
1309 return s.to_string();
1310 }
1311 let mut end = max;
1312 while !s.is_char_boundary(end) {
1313 end -= 1;
1314 }
1315 format!("{}…", &s[..end])
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use super::*;
1321 use serde_json::json;
1322
1323 #[tokio::test]
1324 async fn handle_frame_dispatches_to_correct_topic() {
1325 let (_tx, cmd_rx) = mpsc::channel(1);
1327 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1328 let mut actor = ConnectorActor::new(
1329 client,
1330 ConnectorCfg::default(),
1331 Arc::new(RwLock::new(HashMap::new())),
1332 Arc::new(RwLock::new(StatusState::default())),
1333 cmd_rx,
1334 );
1335
1336 actor
1337 .handle_frame(WsMessage::Order(json!({"orderId": 1})))
1338 .await;
1339 actor
1340 .handle_frame(WsMessage::Pnl(json!({"upnl": 1.0})))
1341 .await;
1342 actor
1343 .handle_frame(WsMessage::MarketData {
1344 conid: 265598,
1345 payload: json!({"31": "150.25"}),
1346 })
1347 .await;
1348
1349 let rings = actor.rings.read().await;
1350 assert_eq!(rings.get("orders").unwrap().len(), 1);
1351 assert_eq!(rings.get("pnl").unwrap().len(), 1);
1352 assert_eq!(rings.get("marketdata:265598").unwrap().len(), 1);
1353 }
1354
1355 fn test_actor() -> ConnectorActor {
1356 let (tx, cmd_rx) = mpsc::channel(1);
1357 std::mem::forget(tx);
1358 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1359 ConnectorActor::new(
1360 client,
1361 ConnectorCfg::default(),
1362 Arc::new(RwLock::new(HashMap::new())),
1363 Arc::new(RwLock::new(StatusState::default())),
1364 cmd_rx,
1365 )
1366 }
1367
1368 fn refusal() -> serde_json::Value {
1372 json!({"error": "unable to subscribe", "code": 500, "topic": "sor"})
1373 }
1374
1375 #[tokio::test]
1376 async fn a_subscribe_refusal_is_not_an_event() {
1377 let mut actor = test_actor();
1378 actor.resubscribe.unconfirmed.insert("orders");
1379
1380 actor.handle_frame(WsMessage::Order(refusal())).await;
1381
1382 let rings = actor.rings.read().await;
1383 assert!(
1384 rings.get("orders").is_none(),
1385 "a refusal must not create or fill the orders ring — consumers would read it as an order frame"
1386 );
1387 let s = actor.status.read().await;
1388 assert_eq!(
1389 s.subscriptions.get("orders"),
1390 Some(&SubscriptionState::Refused)
1391 );
1392 assert_eq!(s.subscribe_refusals, 1);
1393 assert!(
1394 s.last_message_at.is_some(),
1395 "it is still a sign of life on the socket"
1396 );
1397 }
1398
1399 #[tokio::test]
1400 async fn a_refusal_books_a_retry_and_the_first_real_frame_cancels_it() {
1401 let mut actor = test_actor();
1402 actor.resubscribe.unconfirmed.insert("orders");
1403
1404 actor.handle_frame(WsMessage::Order(refusal())).await;
1405 assert!(actor.resubscribe.due.is_some(), "a retry must be scheduled");
1406 assert!(actor.resubscribe.unconfirmed.contains("orders"));
1407
1408 actor
1410 .handle_frame(WsMessage::Order(json!({"topic": "sor", "args": []})))
1411 .await;
1412 assert!(
1413 actor.resubscribe.due.is_none(),
1414 "confirmed: nothing left to retry"
1415 );
1416 assert!(actor.resubscribe.unconfirmed.is_empty());
1417 assert_eq!(
1418 actor.status.read().await.subscriptions.get("orders"),
1419 Some(&SubscriptionState::Subscribed)
1420 );
1421 assert_eq!(
1422 actor.rings.read().await.get("orders").unwrap().len(),
1423 1,
1424 "the snapshot IS an event"
1425 );
1426 }
1427
1428 #[tokio::test]
1429 async fn retry_delay_doubles_to_the_ceiling_and_resets_on_confirmation() {
1430 let mut actor = test_actor();
1431 actor.cfg.resubscribe_min = Duration::from_secs(5);
1432 actor.cfg.resubscribe_max = Duration::from_secs(12);
1433 actor.resubscribe.unconfirmed.insert("pnl");
1434
1435 actor.schedule_resubscribe();
1436 assert_eq!(actor.resubscribe.backoff, Some(Duration::from_secs(10)));
1437 actor.resubscribe.due = None; actor.schedule_resubscribe();
1439 assert_eq!(
1440 actor.resubscribe.backoff,
1441 Some(Duration::from_secs(12)),
1442 "capped"
1443 );
1444
1445 actor
1446 .handle_frame(WsMessage::Pnl(
1447 json!({"topic": "spl", "args": {"upnl": 1.0}}),
1448 ))
1449 .await;
1450 assert_eq!(
1451 actor.resubscribe.backoff, None,
1452 "the next socket starts from the minimum again"
1453 );
1454 }
1455
1456 #[tokio::test]
1457 async fn one_topic_confirming_does_not_cancel_the_other_topic_retry() {
1458 let mut actor = test_actor();
1459 actor.resubscribe.unconfirmed.insert("orders");
1460 actor.resubscribe.unconfirmed.insert("pnl");
1461 actor.schedule_resubscribe();
1462
1463 actor
1465 .handle_frame(WsMessage::Pnl(json!({"upnl": 1.0})))
1466 .await;
1467 assert!(
1468 actor.resubscribe.due.is_some(),
1469 "orders still needs its retry"
1470 );
1471 assert_eq!(
1472 actor
1473 .resubscribe
1474 .unconfirmed
1475 .iter()
1476 .copied()
1477 .collect::<Vec<_>>(),
1478 vec!["orders"]
1479 );
1480 }
1481
1482 #[tokio::test]
1485 async fn orders_goes_quiet_after_n_silent_rounds() {
1486 let mut r = Resubscribe::default();
1487 r.unconfirmed.insert("orders");
1488 for round in 1..=3 {
1489 let (ask, quiet) = r.take_round(3);
1490 assert_eq!(ask, vec!["orders"], "round {round} still asks");
1491 assert!(quiet.is_empty());
1492 }
1493 let (ask, quiet) = r.take_round(3);
1494 assert!(ask.is_empty(), "no fourth ask");
1495 assert_eq!(quiet, vec!["orders"]);
1496 assert!(r.unconfirmed.is_empty());
1497 assert_eq!(r.backoff, None, "nothing left to schedule");
1498
1499 let mut actor = test_actor();
1501 actor.resubscribe = r;
1502 actor
1503 .set_subscription("orders", SubscriptionState::Quiet)
1504 .await;
1505 actor
1506 .handle_frame(WsMessage::Order(json!({"orderId": 1})))
1507 .await;
1508 assert_eq!(
1509 actor.status.read().await.subscriptions.get("orders"),
1510 Some(&SubscriptionState::Subscribed)
1511 );
1512 }
1513
1514 #[test]
1515 fn a_refusal_restarts_the_silence_count_and_leaves_quiet() {
1516 let mut r = Resubscribe::default();
1517 r.unconfirmed.insert("orders");
1518 r.take_round(2);
1519 r.take_round(2);
1520 r.refused("orders");
1521 assert_eq!(r.take_round(2).0, vec!["orders"]);
1523 assert_eq!(r.take_round(2).0, vec!["orders"]);
1524 assert_eq!(r.take_round(2).1, vec!["orders"]);
1525 r.refused("orders");
1527 assert!(r.unconfirmed.contains("orders"));
1528 assert!(!r.quiet.contains("orders"));
1529 }
1530
1531 #[test]
1532 fn zero_rounds_means_ask_forever() {
1533 let mut r = Resubscribe::default();
1534 r.unconfirmed.insert("orders");
1535 for _ in 0..50 {
1536 assert_eq!(r.take_round(0).0, vec!["orders"]);
1537 }
1538 }
1539
1540 #[test]
1541 fn truncate_cuts_on_a_char_boundary() {
1542 let s = "é".repeat(150);
1544 let t = truncate(&s, 199);
1545 assert!(t.ends_with('…'));
1546 assert_eq!(t.trim_end_matches('…').len(), 198);
1547 assert_eq!(truncate("ok", 200), "ok");
1548 let mixed = "a€b😀c";
1550 for max in 0..mixed.len() {
1551 let _ = truncate(mixed, max);
1552 }
1553 }
1554
1555 #[tokio::test]
1556 async fn a_crash_marks_the_link_down_and_opens_an_outage() {
1557 let mut actor = test_actor();
1558 actor.on_connected().await;
1559 actor.status.write().await.connected = true;
1560 let epoch = actor.status.read().await.reset_epoch;
1561
1562 actor.on_crash("boom").await;
1563
1564 {
1565 let s = actor.status.read().await;
1566 assert!(!s.connected);
1567 assert_eq!(s.connector_restarts, 1);
1568 }
1569 actor.on_connected().await;
1571 assert_eq!(actor.status.read().await.reset_epoch, epoch + 1);
1572 assert_eq!(actor.rings.read().await.get("gap").unwrap().len(), 1);
1573 }
1574
1575 #[tokio::test]
1576 async fn the_supervisor_survives_a_panic_in_the_loop() {
1577 let mut actor = test_actor();
1580 let caught = AssertUnwindSafe(async {
1581 actor.link.ever_connected = true;
1582 panic!("inside the loop");
1583 })
1584 .catch_unwind()
1585 .await;
1586 assert!(caught.is_err());
1587 actor.on_crash("inside the loop").await;
1588 assert_eq!(actor.status.read().await.connector_restarts, 1);
1589 }
1590
1591 #[tokio::test]
1592 async fn a_reconnect_request_cuts_the_backoff_short() {
1593 let (tx, cmd_rx) = mpsc::channel(4);
1594 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1595 let mut actor = ConnectorActor::new(
1596 client,
1597 ConnectorCfg::default(),
1598 Arc::new(RwLock::new(HashMap::new())),
1599 Arc::new(RwLock::new(StatusState::default())),
1600 cmd_rx,
1601 );
1602 let (reply, ack) = oneshot::channel();
1603 tx.send(ConnectorCmd::Reconnect { reply }).await.unwrap();
1604 let started = Instant::now();
1605 assert!(actor.idle(Duration::from_secs(60)).await);
1606 assert!(started.elapsed() < Duration::from_secs(5));
1607 assert!(!ack.await.unwrap(), "no socket was up");
1608 }
1609
1610 #[tokio::test]
1611 async fn market_data_requested_while_down_is_booked_not_hung() {
1612 let (tx, cmd_rx) = mpsc::channel(4);
1613 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1614 let mut actor = ConnectorActor::new(
1615 client,
1616 ConnectorCfg::default(),
1617 Arc::new(RwLock::new(HashMap::new())),
1618 Arc::new(RwLock::new(StatusState::default())),
1619 cmd_rx,
1620 );
1621 let (reply, ack) = oneshot::channel();
1622 tx.send(ConnectorCmd::EnsureMarketData {
1623 conid: 265_598,
1624 reply,
1625 })
1626 .await
1627 .unwrap();
1628 assert!(!actor.idle(Duration::from_millis(50)).await);
1629 assert_eq!(ack.await.unwrap(), Ok(()));
1630 assert!(actor.active_marketdata_subs.contains(&265_598));
1631 }
1632
1633 #[test]
1634 fn standing_topics_map_to_their_wire_commands() {
1635 assert_eq!(subscribe_command("orders"), Some("sor+{}"));
1636 assert_eq!(subscribe_command("pnl"), Some("spl+{}"));
1637 assert_eq!(subscribe_command("marketdata:1"), None);
1638 }
1639
1640 #[tokio::test]
1641 async fn heartbeat_and_system_frames_dont_create_topics() {
1642 let (_tx, cmd_rx) = mpsc::channel(1);
1643 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1644 let mut actor = ConnectorActor::new(
1645 client,
1646 ConnectorCfg::default(),
1647 Arc::new(RwLock::new(HashMap::new())),
1648 Arc::new(RwLock::new(StatusState::default())),
1649 cmd_rx,
1650 );
1651
1652 actor.handle_frame(WsMessage::Heartbeat).await;
1653 actor
1654 .handle_frame(WsMessage::System(json!({"msg": "ready"})))
1655 .await;
1656
1657 let rings = actor.rings.read().await;
1658 assert!(rings.is_empty());
1659
1660 let s = actor.status.read().await;
1662 assert!(s.last_message_at.is_some());
1663 }
1664
1665 #[tokio::test]
1666 async fn the_first_connect_keeps_the_boot_epoch_and_leaves_no_gap() {
1667 let mut actor = test_actor();
1668 actor.status.write().await.reset_epoch = 42;
1669
1670 actor.on_connected().await;
1671
1672 assert!(actor.rings.read().await.is_empty(), "no gap on first boot");
1673 assert_eq!(actor.status.read().await.reset_epoch, 42);
1674 }
1675
1676 #[tokio::test]
1680 async fn failed_connects_do_not_move_the_epoch_and_one_outage_is_one_gap() {
1681 let mut actor = test_actor();
1682 actor.status.write().await.reset_epoch = 42;
1683 actor.on_connected().await;
1684
1685 actor.note_link_down(true, Some(&bezant::Error::WsProtocol("closed".into())));
1687 for _ in 0..5 {
1688 actor.note_link_down(false, Some(&bezant::Error::NotAuthenticated));
1689 }
1690 assert_eq!(
1691 actor.status.read().await.reset_epoch,
1692 42,
1693 "failed attempts leave the epoch alone"
1694 );
1695 assert!(actor.rings.read().await.get("gap").is_none());
1696
1697 actor.on_connected().await;
1698
1699 assert_eq!(actor.status.read().await.reset_epoch, 43);
1700 let rings = actor.rings.read().await;
1701 let gap = rings.get("gap").unwrap();
1702 assert_eq!(gap.len(), 1, "one outage, one gap");
1703 match gap.read_since(0, 10) {
1704 ReadResult::Ok { events, .. } => {
1705 assert_eq!(events[0].payload["failed_attempts"], json!(5));
1706 assert_eq!(events[0].payload["previous_reset_epoch"], json!(42));
1707 assert_eq!(events[0].payload["new_reset_epoch"], json!(43));
1708 assert!(events[0].payload["disconnected_at"].is_string());
1709 }
1710 other => panic!("expected Ok, got {other:?}"),
1711 }
1712 }
1713
1714 #[tokio::test]
1715 async fn the_run_loop_does_not_bump_the_epoch_on_failed_connects() {
1716 let (_tx, cmd_rx) = mpsc::channel(1);
1719 let client = bezant::Client::new("https://127.0.0.1:1/v1/api").unwrap();
1720 let cfg = ConnectorCfg {
1721 backoff_min: Duration::from_millis(1),
1722 backoff_max: Duration::from_millis(2),
1723 ..ConnectorCfg::default()
1724 };
1725 let status = Arc::new(RwLock::new(StatusState {
1726 reset_epoch: 1_000,
1727 ..StatusState::default()
1728 }));
1729 let rings = Arc::new(RwLock::new(HashMap::new()));
1730 let actor = ConnectorActor::new(client, cfg, rings.clone(), status.clone(), cmd_rx);
1731 let task = tokio::spawn(actor.run());
1732 sleep(Duration::from_millis(200)).await;
1733 task.abort();
1734
1735 assert_eq!(status.read().await.reset_epoch, 1_000);
1736 assert!(!status.read().await.connected);
1737 assert!(rings.read().await.is_empty(), "no gap events either");
1738 }
1739
1740 #[tokio::test]
1741 async fn gap_events_land_only_in_the_gap_topic() {
1742 let mut actor = test_actor();
1743 actor.on_connected().await;
1744 actor.handle_frame(WsMessage::Order(json!({"id": 1}))).await;
1745 actor
1746 .handle_frame(WsMessage::Pnl(json!({"upnl": 1.0})))
1747 .await;
1748 let before = actor.status.read().await.reset_epoch;
1749
1750 actor.note_link_down(true, None);
1751 actor.on_connected().await;
1752
1753 let rings = actor.rings.read().await;
1754 assert_eq!(rings.get("orders").unwrap().len(), 1, "no gap in orders");
1755 assert_eq!(rings.get("pnl").unwrap().len(), 1, "no gap in pnl");
1756 assert_eq!(rings.get("gap").unwrap().len(), 1);
1757 for topic in ["orders", "pnl", "gap"] {
1759 assert_eq!(rings.get(topic).unwrap().reset_epoch(), before + 1);
1760 }
1761 }
1762
1763 #[test]
1764 fn failure_kinds_group_repeats_of_the_same_condition() {
1765 assert_eq!(
1766 failure_kind(&bezant::Error::NotAuthenticated),
1767 failure_kind(&bezant::Error::NotAuthenticated)
1768 );
1769 assert_ne!(
1770 failure_kind(&bezant::Error::NotAuthenticated),
1771 failure_kind(&bezant::Error::NoSession)
1772 );
1773 }
1774
1775 #[test]
1776 fn boot_seeds_rise_with_the_clock_and_stay_js_safe() {
1777 let now_ms = 1_790_000_000_000; let (e1, b1) = boot_seeds(now_ms, None);
1779 let (e2, b2) = boot_seeds(now_ms + 1, None);
1780 assert!(e2 > e1 && b2 > b1);
1781 let (_, b_next) = boot_seeds(now_ms + 86_400_000, None);
1784 assert!(b1 + 1_000_000 < b_next);
1785 for v in [e1, b1] {
1786 assert!(v <= JS_MAX_SAFE_INTEGER);
1787 }
1788 let (e, b) = boot_seeds(u64::MAX / 2, None);
1790 assert!(e <= JS_MAX_SAFE_INTEGER && b <= JS_MAX_SAFE_INTEGER);
1791 }
1792
1793 #[test]
1794 fn boot_seeds_stay_above_the_log_when_the_clock_is_behind() {
1795 let last_run = boot_seeds(1_790_000_000_000, None);
1798 let hw = (last_run.1 + 42, last_run.0 + 3);
1799 let (epoch, base) = boot_seeds(1_790_000_000_000 - 3_600_000, Some(hw));
1800 assert!(epoch > hw.1);
1801 assert!(base > hw.0);
1802 }
1803
1804 #[tokio::test]
1805 async fn ensure_market_data_dedups_repeat_calls() {
1806 let (_tx, cmd_rx) = mpsc::channel(1);
1810 let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1811 let mut actor = ConnectorActor::new(
1812 client,
1813 ConnectorCfg::default(),
1814 Arc::new(RwLock::new(HashMap::new())),
1815 Arc::new(RwLock::new(StatusState::default())),
1816 cmd_rx,
1817 );
1818
1819 assert!(actor.active_marketdata_subs.insert(265_598));
1820 assert!(!actor.active_marketdata_subs.insert(265_598));
1822 assert_eq!(actor.active_marketdata_subs.len(), 1);
1823 }
1824}