Skip to main content

bezant_server/events/
connector.rs

1//! Long-lived WebSocket connector + REST-side handle.
2//!
3//! The connector is an actor that owns a single [`bezant::WsClient`] and
4//! drives it forever:
5//!
6//! 1. Connect (with exponential-backoff retry).
7//! 2. Subscribe to `orders` + `pnl` (always, on every connect).
8//! 3. Loop: dispatch frames into per-topic rings, accept
9//!    subscribe/unsubscribe commands for market data, watch a heartbeat
10//!    timeout to detect a stalled socket.
11//! 4. On any disconnect: sleep with backoff, GOTO 1. The next SUCCESSFUL
12//!    connect bumps `reset_epoch` on every ring and records one synthetic
13//!    event on the `gap` topic for the whole outage — failed attempts in
14//!    between move nothing. A connection that lived 5 minutes resets the
15//!    backoff; a failure repeating the last one's kind logs at DEBUG.
16//!
17//! Two things CPAPI does that the loop above did not survive, and now does:
18//!
19//! - It REFUSES a subscribe. `sor+{}` is answered with
20//!   `{"error":"unable to subscribe","code":500,"topic":"sor"}` on most
21//!   reconnects (17 of 20 attempts over Aug–Sep 2026 on one Gateway). That
22//!   frame used to be filed as an order event and the subscribe was never
23//!   retried, so the socket stayed up for days with `pnl` flowing and
24//!   `orders` dead. A refusal is now a control frame: it marks the topic
25//!   [`SubscriptionState::Refused`] and schedules a resubscribe with
26//!   backoff, primed by `GET /iserver/accounts` (the call CPAPI documents
27//!   as the precondition for order queries).
28//! - It re-logs in underneath the socket. The Gateway's nightly re-auth,
29//!   or an assisted re-login, mints a new session; a socket bound to the
30//!   old one keeps heartbeating — so the heartbeat timeout never fires —
31//!   while every subscription on it is dead. The connector now compares
32//!   the socket's session id against `/tickle` periodically and reconnects
33//!   when it changes.
34//!
35//! [`EventsHandle`] is what axum handlers get. Cloneable, cheap, exposes
36//! reads against the rings and a command channel into the actor task.
37
38use 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/// Configurable knobs for the connector.
57#[derive(Clone, Debug)]
58pub struct ConnectorCfg {
59    /// Capacity of the `orders` ring.
60    pub orders_capacity: usize,
61    /// Capacity of the `pnl` ring.
62    pub pnl_capacity: usize,
63    /// Per-conid market-data ring capacity.
64    pub marketdata_capacity: usize,
65    /// Min reconnect backoff.
66    pub backoff_min: Duration,
67    /// Max reconnect backoff.
68    pub backoff_max: Duration,
69    /// If no frame arrives in this long, the connector tears the socket
70    /// down and reconnects (assumes the socket is dead, not idle).
71    pub heartbeat_timeout: Duration,
72    /// Idle market-data subs auto-unsubscribe after this long with no
73    /// `ensure_market_data` call (P2 — currently unused, plumbed for
74    /// forward compatibility).
75    pub marketdata_idle_unsubscribe: Duration,
76    /// Optional sqlite-backed historical event log. When set, every
77    /// pushed event is also appended here so `/events/{topic}/history`
78    /// can serve reads beyond ring capacity.
79    pub event_log: Option<Arc<EventLog>>,
80    /// Retention policy used by the periodic prune task.
81    pub retention: RetentionPolicy,
82    /// How often to run the prune task. Defaults to once per hour.
83    pub prune_every: Duration,
84    /// First retry delay after CPAPI refuses a standing subscription.
85    pub resubscribe_min: Duration,
86    /// Retry delay ceiling. A `sor+{}` every five minutes is a harmless ask
87    /// of a Gateway that keeps saying no; the fund's confirmer no longer
88    /// depends on it, but a healthy stream is still the fast path.
89    pub resubscribe_max: Duration,
90    /// How often to ask `/tickle` whether the Gateway session the socket
91    /// was opened under is still the current one.
92    pub session_check_every: Duration,
93    /// A connection that stayed up at least this long resets the reconnect
94    /// backoff to `backoff_min`. Shorter-lived ones keep doubling it, so a
95    /// socket that connects and dies at once cannot spin.
96    pub backoff_reset_after: Duration,
97    /// Resubscribe rounds a standing topic may go unanswered — no frame, no
98    /// refusal — before it is reported `quiet` and no longer re-asked.
99    /// `0` re-asks forever (the behaviour before this knob existed).
100    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/// Commands the connector accepts from outside.
126#[derive(Debug)]
127enum ConnectorCmd {
128    EnsureMarketData {
129        conid: i64,
130        reply: oneshot::Sender<Result<(), String>>,
131    },
132    /// Drop the socket (if any) and connect again now, skipping any backoff.
133    /// Replies with whether a socket was up when the request arrived.
134    Reconnect { reply: oneshot::Sender<bool> },
135}
136
137/// How long `POST /events/_reconnect` waits for the connector to take the
138/// request. The connector reads commands between frames and while backing
139/// off, so this only runs out if it is stuck mid-connect.
140const RECONNECT_ACK_TIMEOUT: Duration = Duration::from_secs(10);
141
142/// Cloneable handle the axum handlers use. Reads come straight off the
143/// shared ring map; writes go through the command channel.
144#[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    /// Borrow the optional sqlite event log so `/events/{topic}/history`
156    /// route handlers can query historical events.
157    #[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    /// First cursor of every ring this process creates. See [`boot_seeds`].
170    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    /// Read events from a topic. Returns [`None`] if the topic isn't
180    /// known (no events ever arrived for it). For `marketdata:<conid>`,
181    /// callers should call [`Self::ensure_market_data`] first to register
182    /// interest.
183    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    /// What a read of a topic that has no ring yet should answer: exactly
189    /// what an empty ring created right now would. The caller gets this
190    /// process's cursor space and live epoch instead of its own cursor
191    /// echoed back — an echo keeps a cursor from a previous process alive,
192    /// and the first real event then lands below it.
193    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    /// Snapshot of status. `uptime_seconds` is computed at call time
199    /// from the connector's start instant.
200    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    /// Ask the connector to drop its socket and reconnect now, bypassing
226    /// the backoff — the soft alternative to restarting the container when
227    /// the stream has gone quiet. Returns whether a socket was up when the
228    /// request was taken.
229    ///
230    /// # Errors
231    /// The connector task is gone, or did not take the request within
232    /// ten seconds.
233    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    /// Ensure the upstream WS is subscribed to market data for `conid`.
247    /// Idempotent; multiple callers can request the same conid and only
248    /// one upstream subscribe is sent. Returns `Err` if the connector
249    /// task is dead or if the subscribe send failed.
250    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    /// Build a handle that's not backed by a live connector — used by
263    /// integration tests to populate rings synthetically and verify the
264    /// HTTP surface without standing up a CPAPI WS mock.
265    ///
266    /// Returns the handle plus a `TestSink` that lets the test push
267    /// pre-decoded events into named topics.
268    #[doc(hidden)]
269    #[must_use]
270    pub fn for_test() -> (Self, TestSink) {
271        Self::for_test_with_log(None)
272    }
273
274    /// Same as [`Self::for_test`] but with an attached event log so
275    /// tests can exercise the `/history` route.
276    #[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    /// Same as [`Self::for_test_with_log`], seeded the way a process boot
283    /// seeds a real connector — see [`boot_seeds`]. Two harnesses with
284    /// different seeds stand in for a process before and after a restart.
285    #[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/// Sink-side counterpart of [`EventsHandle::for_test`]. Lets integration
321/// tests push events into a topic ring, mark the connection
322/// disconnected, and bump the reset epoch — without involving a real
323/// WebSocket. Public solely so `tests/events_routes.rs` can drive the
324/// routes; not part of the production API surface.
325#[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    /// The command channel's far end, until [`Self::serve_commands`] takes it.
332    cmd_rx: Arc<std::sync::Mutex<Option<mpsc::Receiver<ConnectorCmd>>>>,
333    reconnects: Arc<AtomicU64>,
334}
335
336#[doc(hidden)]
337impl TestSink {
338    /// Push `payload` into the named topic's ring. Creates the ring on
339    /// first call. Reports the assigned cursor. If a log is attached,
340    /// also appends to sqlite.
341    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    /// Force a specific reset_epoch, the way a reconnect moves it: every
377    /// existing ring follows.
378    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    /// Force the `connected` flag — useful for `_status` shape testing.
386    pub async fn set_connected(&self, connected: bool) {
387        self.status.write().await.connected = connected;
388    }
389
390    /// Stand in for the connector's command loop: answer every command the
391    /// handle sends, as a connected connector would, counting reconnect
392    /// requests. Without this, commands queue and are never answered.
393    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    /// Reconnect requests the connector has taken (see [`Self::serve_commands`]).
418    #[must_use]
419    pub fn reconnect_requests(&self) -> u64 {
420        self.reconnects.load(Ordering::SeqCst)
421    }
422}
423
424/// Spawn the connector task and return a [`EventsHandle`] for the
425/// axum side. The task owns the [`bezant::Client`] reference (cheap, it's
426/// already `Arc`-wrapped internally) and runs until the binary exits.
427///
428/// If `cfg.event_log` is `Some`, also spawns a periodic prune task that
429/// trims the sqlite store to retention policy every `cfg.prune_every`.
430pub 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
486/// Largest integer a JavaScript `number` holds exactly. Cursors and epochs
487/// are read by a TypeScript client, so neither may pass it.
488const JS_MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
489
490/// Cursor headroom kept below [`JS_MAX_SAFE_INTEGER`]: 2^52 events is more
491/// than any ring will see in one process lifetime.
492const CURSOR_BASE_CEILING: u64 = JS_MAX_SAFE_INTEGER - (1 << 52);
493
494/// How many cursors one millisecond of wall clock is worth. A restarted
495/// process starts its cursors above the previous one as long as that one
496/// averaged fewer than this many events per millisecond of uptime.
497const CURSORS_PER_MS: u64 = 1_000;
498
499/// The `reset_epoch` and first ring cursor for a process starting at
500/// `now_ms` (Unix milliseconds).
501///
502/// Both used to start at 1 on every boot, so a client holding cursor 500
503/// from the previous process was told "caught up" (204) until the new
504/// process had seen 500 events on that topic — for `orders`, days. Seeding
505/// from the clock puts the new process's cursors and epoch above the old
506/// one's. `high_water` is the largest `(cursor, reset_epoch)` in the
507/// persisted event log, if there is one, which covers a clock that came up
508/// behind the last run (a Pi has no RTC). Where neither holds, the ring
509/// answers a cursor it never issued with 412 instead (see [`TopicRing`]).
510///
511/// Epochs are milliseconds (~1.8e12) and cursors are milliseconds × 1000
512/// (~1.8e15); both stay below 2^53 for the next two centuries, and the
513/// cursor base is clamped to leave 2^52 of headroom regardless.
514#[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    /// sqlite appends that failed since boot (see [`Self::persist`]).
544    persist_failures: Arc<AtomicU64>,
545    /// Set when the socket was dropped on request: reconnect without
546    /// waiting out the backoff.
547    reconnect_now: bool,
548}
549
550/// The connector's view of its own connection history — what decides when
551/// the epoch moves and what gets logged.
552#[derive(Debug, Default)]
553struct Link {
554    /// Whether any connect has ever succeeded in this process. The first
555    /// one is not a reconnect: the boot seed already gave it a fresh epoch.
556    ever_connected: bool,
557    /// When the current connection came up, while it is up.
558    connected_at: Option<Instant>,
559    /// The outage in progress, if the link is down after having been up.
560    outage: Option<Outage>,
561    /// The kind of the last failure logged at WARN. A repeat of the same
562    /// kind (the Gateway is still logged out, a minute later) goes to
563    /// DEBUG; a change is news.
564    last_failure: Option<String>,
565}
566
567/// One outage: from losing a connection to the next successful connect,
568/// however many attempts that takes. It ends in exactly one gap event.
569#[derive(Debug)]
570struct Outage {
571    /// RFC 3339 time the connection was lost.
572    since: String,
573    /// Connect attempts that failed during it.
574    failed_attempts: u64,
575}
576
577/// A coarse, stable name for a connect/stream failure, for deciding
578/// whether a failure is the same condition as the last one.
579fn 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/// The standing subscriptions CPAPI has refused (or not yet honoured) on the
596/// current socket, and when to ask again. Reset on every connect.
597#[derive(Debug, Default)]
598struct Resubscribe {
599    /// Topics waiting on a subscribe that has not been honoured.
600    unconfirmed: BTreeSet<&'static str>,
601    /// When to send the next round, if any is due.
602    due: Option<TokioInstant>,
603    /// Delay to use for the NEXT round; doubles per round up to the ceiling.
604    backoff: Option<Duration>,
605    /// Rounds sent per topic since its last refusal.
606    silent_rounds: BTreeMap<&'static str, u32>,
607    /// Topics given up on as quiet-but-alive (see [`SubscriptionState::Quiet`]).
608    quiet: BTreeSet<&'static str>,
609}
610
611impl Resubscribe {
612    /// A round is due: decide, per unconfirmed topic, whether to ask again
613    /// or to stop asking. Returns `(ask_again, gone_quiet)`.
614    ///
615    /// CPAPI refuses a subscribe it will not honour with an error frame, but
616    /// honours `sor+{}` in silence when there are no live orders to
617    /// snapshot — there is no ack. A topic that has been asked
618    /// `quiet_after` times with no refusal is therefore subscribed as far as
619    /// anyone can tell, and asking forever only keeps it `pending` in
620    /// `/events/_status`. `quiet_after == 0` keeps asking forever.
621    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    /// CPAPI said no: the topic is back on the retry list and its silence
643    /// count starts over.
644    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    /// A real frame arrived. Returns whether the topic was waiting on one
651    /// (unconfirmed or quiet), i.e. whether this frame is the confirmation.
652    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
663/// The wire command that establishes each standing subscription.
664const 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    /// Supervise [`Self::run_links`]. Nothing used to watch the connector
695    /// task: a panic in it (the byte-slicing `truncate` could do it) ended
696    /// the task, left `/events/_status` claiming `connected: true` forever
697    /// and every ring frozen. Now a panic is caught, the link is marked
698    /// down, counted in `connector_restarts`, and the loop starts over with
699    /// the rings, epoch and command channel intact.
700    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    /// The connector task died mid-flight. Whatever it held is gone — the
718    /// socket was dropped while unwinding — so report the link down and
719    /// open an outage, and the next connect records it like any other.
720    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    /// Connect, run, back off, repeat — forever.
736    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            // A connection that held for a while was a healthy one; the
745            // next failure is a fresh incident, not the tail of a storm.
746            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                // Asked to reconnect: go now, and start the backoff over.
751                backoff = self.cfg.backoff_min;
752                continue;
753            }
754            backoff = (backoff * 2).min(self.cfg.backoff_max);
755        }
756    }
757
758    /// Sleep out a backoff while still answering commands — they used to
759    /// queue unanswered until the next connect, so `/events/marketdata`
760    /// hung for the whole backoff. Market-data requests are booked for the
761    /// next connect. Returns `true` if a reconnect was requested, cutting
762    /// the sleep short.
763    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    /// Book-keeping after a connect attempt or a connection ends. Opens an
786    /// outage when a live connection was lost, counts a failed attempt
787    /// otherwise, and logs a failure at WARN only when it is a different
788    /// condition from the last one logged.
789    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    /// A connect just succeeded. The first in the process keeps the boot
814    /// epoch; every later one ends an outage, so the epoch moves exactly
815    /// once and exactly one gap event records it — however many attempts
816    /// failed in between (a logged-out Gateway fails one a minute for
817    /// hours).
818    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    /// One full connect cycle. Returns `Ok(())` on clean close, `Err`
833    /// otherwise. Caller handles backoff + reconnect.
834    async fn connect_and_run(&mut self) -> Result<(), bezant::Error> {
835        let mut ws = WsClient::connect(&self.client).await?;
836
837        // CPAPI WS quirk: subscribes sent *before* the server's initial
838        // `system+success` "ready" frame are silently discarded. Pump
839        // frames until that frame arrives (or 5s timeout) so our
840        // `sor`/`spl` subscribes actually take effect — without this,
841        // we get heartbeats forever and no order/PnL events.
842        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        // Neither is honoured until CPAPI says so with a real frame; a
855        // refusal or silence schedules a retry (see `handle_topic_frame`).
856        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        // Re-establish any market data subs that were active before the
864        // disconnect.
865        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    /// The hot loop: read frames, dispatch into rings, handle commands,
884    /// detect heartbeat timeout. Returns when the socket closes or any
885    /// fatal error occurs.
886    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; // the first tick fires immediately; the socket was just opened on this session
891        loop {
892            // A pending resubscribe is a timer; no pending resubscribe is a
893            // future that never resolves. `sleep_until` needs an instant
894            // either way, so park it a year out when there is nothing due.
895            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                // Frame from the upstream WS — dispatch or detect close.
901                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                // Command from the REST side (lazy market data subs).
925                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                // A standing subscription CPAPI refused (or never answered).
933                () = sleep_until(resub_at), if self.resubscribe.due.is_some() => {
934                    self.resubscribe(ws).await;
935                }
936                // Has the Gateway re-logged in underneath this socket?
937                _ = 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    /// `/tickle` reports the Gateway's current session id. If it is not the
949    /// one this socket was opened under, the Gateway has re-authenticated
950    /// and everything subscribed on this socket is dead — however healthy
951    /// its heartbeats look. A tickle that fails proves nothing, so it does
952    /// not count.
953    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    /// Ask again for every standing subscription CPAPI has not honoured,
964    /// after priming the brokerage session with `/iserver/accounts`. CPAPI
965    /// documents that call as the precondition for order queries, and the
966    /// refusals cluster right after a (re-)login — before anything has made
967    /// it. Best-effort: a failed prime still sends the subscribe.
968    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        // Whatever CPAPI says next — a refusal, a snapshot, or nothing —
1001        // the next round is already booked. Confirmation cancels it.
1002        self.schedule_resubscribe();
1003    }
1004
1005    /// Book the next resubscribe round, doubling the delay each time up to
1006    /// the ceiling. Idempotent while a round is already booked.
1007    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    /// A frame on a standing topic is either CPAPI honouring the subscribe
1025    /// (any real payload) or refusing it (`{"error": …}`). Only the former
1026    /// is an event.
1027    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    /// Decode + push a single WS frame into the appropriate ring.
1064    async fn handle_frame(&mut self, frame: WsMessage) {
1065        let now = now_iso();
1066        // Diagnostic: trace topic + first 200 chars of payload for every
1067        // frame so we can see CPAPI's actual topic strings for unrecognised
1068        // sor/spl variants. Cheap; gated to debug level so prod is silent.
1069        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                // Not interesting for downstream consumers; just record
1085                // last-message time so the connector status reflects life.
1086                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            // `WsMessage` is `#[non_exhaustive]` — future variants
1103            // surface as unstructured "other" data so we don't silently
1104            // drop them.
1105            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    /// Returns `true` when the command asks for the socket to be dropped.
1116    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                            // Roll back the optimistic insert — next caller
1136                            // can retry.
1137                            self.active_marketdata_subs.remove(&conid);
1138                            let _ = reply.send(Err(format!("subscribe failed: {e}")));
1139                        }
1140                    }
1141                } else {
1142                    // Already subscribed.
1143                    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        // Update last_message_at + ensure topic shows in status.
1179        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    /// Mirror an event to sqlite if configured. Best-effort — the in-memory
1185    /// ring remains the canonical fast-path read.
1186    ///
1187    /// A failed append used to vanish (the `spawn_blocking` result was
1188    /// dropped). It is now counted in `persist_failures` and logged at
1189    /// WARN on the 1st, 2nd, 4th, 8th… failure, so a full disk is loud
1190    /// once without a line per P&L tick.
1191    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            // sqlite writes are blocking; offload to a worker so we
1196            // don't block the connector loop on I/O.
1197            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    /// Increment `reset_epoch`, move every ring to it, and record the reset
1229    /// as one event on the `gap` topic.
1230    ///
1231    /// The gap goes ONLY to `gap`. It used to be pushed into every ring as
1232    /// well, where an `orders` reader found a frame that was not an order
1233    /// and a `pnl` reader one that was not P&L — while the epoch change on
1234    /// every ring already tells each reader its topic had a gap.
1235    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
1275/// Pump WS frames until we see a `system` topic frame containing
1276/// `success` (CPAPI's username-ack — see the IBKR campus WS lesson).
1277/// That frame indicates the server has finished session bootstrap and
1278/// will accept and broadcast on `sor`/`spl` subscribes. Frames seen
1279/// before the ready signal are discarded — they're just connection
1280/// metadata (`act`, `sts`) we don't need to surface to consumers.
1281async 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        // `act`, `sts`, anything else — ignore. We're only gating on
1289        // the `success` ack which CPAPI sends once per session.
1290    }
1291    // Stream closed before we saw ready.
1292    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
1303/// At most `max` bytes of `s`, cut on a char boundary. Slicing at a raw
1304/// byte offset panicked the connector task on any frame whose 200th byte
1305/// fell inside a multi-byte character — an accented company name in an
1306/// order frame was enough.
1307fn 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        // Build an actor with a closed cmd channel — we won't drive run().
1326        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    // The frame CPAPI actually sends — 17 of 20 subscribe attempts on one
1369    // Gateway over Aug–Sep 2026 got this, and every one was filed as an
1370    // order event while the subscribe was never retried.
1371    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        // CPAPI honouring the subscribe looks like a snapshot: {"topic":"sor","args":[…]}.
1409        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; // as `resubscribe()` does when a round is sent
1438        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        // pnl confirms (it nearly always does); orders is still refused.
1464        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    /// `orders` with no live orders: CPAPI honours `sor+{}` in silence, so
1483    /// the topic sat `pending` and was re-asked every 5 minutes forever.
1484    #[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        // The actor reports it, and a later order frame confirms it.
1500        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        // Two more asks are owed after the refusal before it can go quiet.
1522        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        // And a refusal while quiet puts it back on the retry list.
1526        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        // 'é' is two bytes; a cut at an odd byte used to panic.
1543        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        // Every cut point of a mixed string is safe.
1549        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        // The next connect is a reconnect with its one gap.
1570        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        // The shape `run` relies on: a panic inside a future borrowing the
1578        // actor is caught, and the actor is usable afterwards.
1579        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        // But last_message_at IS updated.
1661        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    /// The epoch used to bump BEFORE every connect attempt, so a Gateway
1677    /// that was logged out for an hour (one failed attempt a minute) moved
1678    /// it sixty times and left sixty gap events.
1679    #[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        // The connection drops, then five attempts fail.
1686        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        // The real loop against a Gateway that refuses connections: the
1717        // epoch must stay at its boot seed however many attempts fail.
1718        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        // But every ring reports the new epoch, so its readers see the reset.
1758        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; // 2026
1778        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        // A process that ran a day and saw a million events per topic
1782        // still hands out cursors below the next boot's base.
1783        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        // Far future: clamped, never past 2^53.
1789        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        // The Pi booted before NTP synced: the clock reads an hour behind
1796        // the last run, whose log holds these values.
1797        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        // We can't easily test the WS sub call without a mock socket,
1807        // but we can verify the dedup logic at the actor level by
1808        // hand-mutating the active_marketdata_subs set.
1809        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        // Re-insert returns false (already present).
1821        assert!(!actor.active_marketdata_subs.insert(265_598));
1822        assert_eq!(actor.active_marketdata_subs.len(), 1);
1823    }
1824}