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: bump `reset_epoch`, push a synthetic `gap` event
12//!    into every active ring, sleep with backoff, GOTO 1.
13//!
14//! Two things CPAPI does that the loop above did not survive, and now does:
15//!
16//! - It REFUSES a subscribe. `sor+{}` is answered with
17//!   `{"error":"unable to subscribe","code":500,"topic":"sor"}` on most
18//!   reconnects (17 of 20 attempts over Aug–Sep 2026 on one Gateway). That
19//!   frame used to be filed as an order event and the subscribe was never
20//!   retried, so the socket stayed up for days with `pnl` flowing and
21//!   `orders` dead. A refusal is now a control frame: it marks the topic
22//!   [`SubscriptionState::Refused`] and schedules a resubscribe with
23//!   backoff, primed by `GET /iserver/accounts` (the call CPAPI documents
24//!   as the precondition for order queries).
25//! - It re-logs in underneath the socket. The Gateway's nightly re-auth,
26//!   or an assisted re-login, mints a new session; a socket bound to the
27//!   old one keeps heartbeating — so the heartbeat timeout never fires —
28//!   while every subscription on it is dead. The connector now compares
29//!   the socket's session id against `/tickle` periodically and reconnects
30//!   when it changes.
31//!
32//! [`EventsHandle`] is what axum handlers get. Cloneable, cheap, exposes
33//! reads against the rings and a command channel into the actor task.
34
35use std::collections::{BTreeMap, BTreeSet, HashMap};
36use std::sync::Arc;
37use std::time::{Duration, Instant};
38
39use bezant::{MarketDataFields, WsClient, WsMessage};
40use serde_json::json;
41use tokio::sync::{mpsc, oneshot, RwLock};
42use tokio::time::{sleep, sleep_until, timeout, Instant as TokioInstant};
43use tracing::{debug, info, warn};
44
45use super::persistence::{EventLog, RetentionPolicy};
46use super::ring::{ReadResult, TopicRing};
47use super::{EventsStatus, GapReason, ObservedEvent, SubscriptionState};
48
49/// Configurable knobs for the connector.
50#[derive(Clone, Debug)]
51pub struct ConnectorCfg {
52    /// Capacity of the `orders` ring.
53    pub orders_capacity: usize,
54    /// Capacity of the `pnl` ring.
55    pub pnl_capacity: usize,
56    /// Per-conid market-data ring capacity.
57    pub marketdata_capacity: usize,
58    /// Min reconnect backoff.
59    pub backoff_min: Duration,
60    /// Max reconnect backoff.
61    pub backoff_max: Duration,
62    /// If no frame arrives in this long, the connector tears the socket
63    /// down and reconnects (assumes the socket is dead, not idle).
64    pub heartbeat_timeout: Duration,
65    /// Idle market-data subs auto-unsubscribe after this long with no
66    /// `ensure_market_data` call (P2 — currently unused, plumbed for
67    /// forward compatibility).
68    pub marketdata_idle_unsubscribe: Duration,
69    /// Optional sqlite-backed historical event log. When set, every
70    /// pushed event is also appended here so `/events/{topic}/history`
71    /// can serve reads beyond ring capacity.
72    pub event_log: Option<Arc<EventLog>>,
73    /// Retention policy used by the periodic prune task.
74    pub retention: RetentionPolicy,
75    /// How often to run the prune task. Defaults to once per hour.
76    pub prune_every: Duration,
77    /// First retry delay after CPAPI refuses a standing subscription.
78    pub resubscribe_min: Duration,
79    /// Retry delay ceiling. A `sor+{}` every five minutes is a harmless ask
80    /// of a Gateway that keeps saying no; the fund's confirmer no longer
81    /// depends on it, but a healthy stream is still the fast path.
82    pub resubscribe_max: Duration,
83    /// How often to ask `/tickle` whether the Gateway session the socket
84    /// was opened under is still the current one.
85    pub session_check_every: Duration,
86}
87
88impl Default for ConnectorCfg {
89    fn default() -> Self {
90        Self {
91            orders_capacity: 1_000,
92            pnl_capacity: 5_000,
93            marketdata_capacity: 2_000,
94            backoff_min: Duration::from_secs(1),
95            backoff_max: Duration::from_secs(60),
96            heartbeat_timeout: Duration::from_secs(90),
97            marketdata_idle_unsubscribe: Duration::from_secs(300),
98            event_log: None,
99            retention: RetentionPolicy::default(),
100            prune_every: Duration::from_secs(3_600),
101            resubscribe_min: Duration::from_secs(5),
102            resubscribe_max: Duration::from_secs(300),
103            session_check_every: Duration::from_secs(60),
104        }
105    }
106}
107
108/// Commands the connector accepts from outside.
109#[derive(Debug)]
110enum ConnectorCmd {
111    EnsureMarketData {
112        conid: i64,
113        reply: oneshot::Sender<Result<(), String>>,
114    },
115}
116
117/// Cloneable handle the axum handlers use. Reads come straight off the
118/// shared ring map; writes go through the command channel.
119#[derive(Clone)]
120pub struct EventsHandle {
121    rings: Arc<RwLock<HashMap<String, TopicRing>>>,
122    status: Arc<RwLock<StatusState>>,
123    cmd_tx: mpsc::Sender<ConnectorCmd>,
124    started_at: Instant,
125    event_log: Option<Arc<EventLog>>,
126}
127
128impl EventsHandle {
129    /// Borrow the optional sqlite event log so `/events/{topic}/history`
130    /// route handlers can query historical events.
131    #[must_use]
132    pub fn event_log(&self) -> Option<&Arc<EventLog>> {
133        self.event_log.as_ref()
134    }
135}
136
137#[derive(Debug, Default)]
138struct StatusState {
139    connected: bool,
140    last_message_at: Option<String>,
141    reconnect_count: u64,
142    reset_epoch: u64,
143    topics_subscribed: BTreeSet<String>,
144    subscriptions: BTreeMap<String, SubscriptionState>,
145    subscribe_refusals: u64,
146    session_rollovers: u64,
147}
148
149impl EventsHandle {
150    /// Read events from a topic. Returns [`None`] if the topic isn't
151    /// known (no events ever arrived for it). For `marketdata:<conid>`,
152    /// callers should call [`Self::ensure_market_data`] first to register
153    /// interest.
154    pub async fn read_topic(&self, topic: &str, since: u64, limit: usize) -> Option<ReadResult> {
155        let rings = self.rings.read().await;
156        rings.get(topic).map(|r| r.read_since(since, limit))
157    }
158
159    /// Snapshot of status. `uptime_seconds` is computed at call time
160    /// from the connector's start instant.
161    pub async fn status(&self) -> EventsStatus {
162        let s = self.status.read().await;
163        let buffer_sizes: BTreeMap<String, usize> = self
164            .rings
165            .read()
166            .await
167            .iter()
168            .map(|(k, v)| (k.clone(), v.len()))
169            .collect();
170        EventsStatus {
171            connected: s.connected,
172            last_message_at: s.last_message_at.clone(),
173            reconnect_count: s.reconnect_count,
174            uptime_seconds: self.started_at.elapsed().as_secs(),
175            reset_epoch: s.reset_epoch,
176            topics_subscribed: s.topics_subscribed.iter().cloned().collect(),
177            buffer_sizes,
178            subscriptions: s.subscriptions.clone(),
179            subscribe_refusals: s.subscribe_refusals,
180            session_rollovers: s.session_rollovers,
181        }
182    }
183
184    /// Ensure the upstream WS is subscribed to market data for `conid`.
185    /// Idempotent; multiple callers can request the same conid and only
186    /// one upstream subscribe is sent. Returns `Err` if the connector
187    /// task is dead or if the subscribe send failed.
188    pub async fn ensure_market_data(&self, conid: i64) -> Result<(), String> {
189        let (tx, rx) = oneshot::channel();
190        self.cmd_tx
191            .send(ConnectorCmd::EnsureMarketData { conid, reply: tx })
192            .await
193            .map_err(|_| "connector task is not running".to_string())?;
194        rx.await
195            .map_err(|_| "connector task dropped reply channel".to_string())?
196    }
197}
198
199impl EventsHandle {
200    /// Build a handle that's not backed by a live connector — used by
201    /// integration tests to populate rings synthetically and verify the
202    /// HTTP surface without standing up a CPAPI WS mock.
203    ///
204    /// Returns the handle plus a `TestSink` that lets the test push
205    /// pre-decoded events into named topics.
206    #[doc(hidden)]
207    #[must_use]
208    pub fn for_test() -> (Self, TestSink) {
209        Self::for_test_with_log(None)
210    }
211
212    /// Same as [`Self::for_test`] but with an attached event log so
213    /// tests can exercise the `/history` route.
214    #[doc(hidden)]
215    #[must_use]
216    pub fn for_test_with_log(event_log: Option<Arc<EventLog>>) -> (Self, TestSink) {
217        let rings: Arc<RwLock<HashMap<String, TopicRing>>> = Arc::new(RwLock::new(HashMap::new()));
218        let status = Arc::new(RwLock::new(StatusState {
219            connected: true,
220            reset_epoch: 1,
221            ..Default::default()
222        }));
223        let (cmd_tx, _cmd_rx) = mpsc::channel::<ConnectorCmd>(1);
224        let started_at = Instant::now();
225        let handle = Self {
226            rings: rings.clone(),
227            status: status.clone(),
228            cmd_tx,
229            started_at,
230            event_log: event_log.clone(),
231        };
232        let sink = TestSink {
233            rings,
234            status,
235            event_log,
236        };
237        (handle, sink)
238    }
239}
240
241/// Sink-side counterpart of [`EventsHandle::for_test`]. Lets integration
242/// tests push events into a topic ring, mark the connection
243/// disconnected, and bump the reset epoch — without involving a real
244/// WebSocket. Public solely so `tests/events_routes.rs` can drive the
245/// routes; not part of the production API surface.
246#[doc(hidden)]
247#[derive(Clone)]
248pub struct TestSink {
249    rings: Arc<RwLock<HashMap<String, TopicRing>>>,
250    status: Arc<RwLock<StatusState>>,
251    event_log: Option<Arc<EventLog>>,
252}
253
254#[doc(hidden)]
255impl TestSink {
256    /// Push `payload` into the named topic's ring. Creates the ring on
257    /// first call. Reports the assigned cursor. If a log is attached,
258    /// also appends to sqlite.
259    pub async fn push(&self, topic: &str, payload: serde_json::Value) -> u64 {
260        let cap = match topic {
261            "orders" => 1_000,
262            "pnl" => 5_000,
263            t if t.starts_with("marketdata:") => 2_000,
264            _ => 256,
265        };
266        let epoch = self.status.read().await.reset_epoch;
267        let received_at = now_iso();
268        let mut rings = self.rings.write().await;
269        let ring = rings
270            .entry(topic.to_string())
271            .or_insert_with(|| TopicRing::new(topic, cap, epoch));
272        let cursor = ring.push(payload.clone(), received_at.clone());
273        drop(rings);
274        if let Some(log) = &self.event_log {
275            let _ = log.append(&ObservedEvent {
276                cursor,
277                topic: topic.to_string(),
278                received_at: received_at.clone(),
279                reset_epoch: epoch,
280                payload,
281            });
282        }
283        self.status
284            .write()
285            .await
286            .topics_subscribed
287            .insert(topic.to_string());
288        cursor
289    }
290
291    /// Force a specific reset_epoch — used to test cursor-expired flows.
292    pub async fn set_reset_epoch(&self, epoch: u64) {
293        self.status.write().await.reset_epoch = epoch;
294    }
295
296    /// Force the `connected` flag — useful for `_status` shape testing.
297    pub async fn set_connected(&self, connected: bool) {
298        self.status.write().await.connected = connected;
299    }
300}
301
302/// Spawn the connector task and return a [`EventsHandle`] for the
303/// axum side. The task owns the [`bezant::Client`] reference (cheap, it's
304/// already `Arc`-wrapped internally) and runs until the binary exits.
305///
306/// If `cfg.event_log` is `Some`, also spawns a periodic prune task that
307/// trims the sqlite store to retention policy every `cfg.prune_every`.
308pub fn spawn_connector(client: bezant::Client, cfg: ConnectorCfg) -> EventsHandle {
309    let rings: Arc<RwLock<HashMap<String, TopicRing>>> = Arc::new(RwLock::new(HashMap::new()));
310    let status = Arc::new(RwLock::new(StatusState::default()));
311    let (cmd_tx, cmd_rx) = mpsc::channel::<ConnectorCmd>(64);
312    let started_at = Instant::now();
313    let event_log = cfg.event_log.clone();
314
315    if let Some(log) = event_log.clone() {
316        let prune_every = cfg.prune_every;
317        let policy = cfg.retention.clone();
318        tokio::spawn(async move {
319            loop {
320                sleep(prune_every).await;
321                let log = log.clone();
322                let policy = policy.clone();
323                let dropped = tokio::task::spawn_blocking(move || log.prune(&policy)).await;
324                match dropped {
325                    Ok(Ok(n)) if n > 0 => info!(rows = n, "events sqlite: prune dropped rows"),
326                    Ok(Err(e)) => warn!(error = %e, "events sqlite: prune failed"),
327                    _ => {}
328                }
329            }
330        });
331    }
332
333    let actor = ConnectorActor {
334        client,
335        cfg,
336        rings: rings.clone(),
337        status: status.clone(),
338        cmd_rx,
339        active_marketdata_subs: BTreeSet::new(),
340        resubscribe: Resubscribe::default(),
341    };
342
343    tokio::spawn(actor.run());
344
345    EventsHandle {
346        rings,
347        status,
348        cmd_tx,
349        started_at,
350        event_log,
351    }
352}
353
354struct ConnectorActor {
355    client: bezant::Client,
356    cfg: ConnectorCfg,
357    rings: Arc<RwLock<HashMap<String, TopicRing>>>,
358    status: Arc<RwLock<StatusState>>,
359    cmd_rx: mpsc::Receiver<ConnectorCmd>,
360    active_marketdata_subs: BTreeSet<i64>,
361    resubscribe: Resubscribe,
362}
363
364/// The standing subscriptions CPAPI has refused (or not yet honoured) on the
365/// current socket, and when to ask again. Reset on every connect.
366#[derive(Debug, Default)]
367struct Resubscribe {
368    /// Topics waiting on a subscribe that has not been honoured.
369    unconfirmed: BTreeSet<&'static str>,
370    /// When to send the next round, if any is due.
371    due: Option<TokioInstant>,
372    /// Delay to use for the NEXT round; doubles per round up to the ceiling.
373    backoff: Option<Duration>,
374}
375
376/// The wire command that establishes each standing subscription.
377const fn subscribe_command(topic: &str) -> Option<&'static str> {
378    match topic.as_bytes() {
379        b"orders" => Some("sor+{}"),
380        b"pnl" => Some("spl+{}"),
381        _ => None,
382    }
383}
384
385impl ConnectorActor {
386    async fn run(mut self) {
387        info!("events connector starting");
388        let mut backoff = self.cfg.backoff_min;
389        loop {
390            // Bump epoch + emit gap markers BEFORE attempting connect, so
391            // any events arriving during this run are tagged with the
392            // correct epoch from the very first frame.
393            self.bump_epoch_with_gap(GapReason::ReconnectedAfterDisconnect)
394                .await;
395
396            match self.connect_and_run().await {
397                Ok(()) => {
398                    info!("events connector: ws closed cleanly, reconnecting");
399                    backoff = self.cfg.backoff_min;
400                }
401                Err(e) => {
402                    warn!(error = %e, "events connector: ws failed, will retry");
403                    self.set_disconnected().await;
404                }
405            }
406
407            sleep(backoff).await;
408            backoff = (backoff * 2).min(self.cfg.backoff_max);
409        }
410    }
411
412    /// One full connect cycle. Returns `Ok(())` on clean close, `Err`
413    /// otherwise. Caller handles backoff + reconnect.
414    async fn connect_and_run(&mut self) -> Result<(), bezant::Error> {
415        let mut ws = WsClient::connect(&self.client).await?;
416
417        // CPAPI WS quirk: subscribes sent *before* the server's initial
418        // `system+success` "ready" frame are silently discarded. Pump
419        // frames until that frame arrives (or 5s timeout) so our
420        // `sor`/`spl` subscribes actually take effect — without this,
421        // we get heartbeats forever and no order/PnL events.
422        let ready = timeout(Duration::from_secs(5), pump_until_ready(&mut ws)).await;
423        match ready {
424            Ok(Ok(())) => debug!("events connector: server ready signal received"),
425            Ok(Err(e)) => return Err(e),
426            Err(_) => warn!(
427                "events connector: didn't see server-ready frame within 5s; \
428                 subscribing anyway (CPAPI may silently drop these)"
429            ),
430        }
431
432        ws.subscribe_orders().await?;
433        ws.subscribe_pnl().await?;
434        // Neither is honoured until CPAPI says so with a real frame; a
435        // refusal or silence schedules a retry (see `handle_topic_frame`).
436        self.resubscribe = Resubscribe::default();
437        for topic in ["orders", "pnl"] {
438            self.resubscribe.unconfirmed.insert(topic);
439            self.set_subscription(topic, SubscriptionState::Pending)
440                .await;
441        }
442        self.schedule_resubscribe();
443        // Re-establish any market data subs that were active before the
444        // disconnect.
445        for conid in self.active_marketdata_subs.clone() {
446            if let Err(e) = ws
447                .subscribe_market_data(conid, &MarketDataFields::default_l1())
448                .await
449            {
450                warn!(conid, error = %e, "events connector: re-subscribe market data failed");
451            }
452        }
453
454        self.set_connected().await;
455        info!("events connector: connected, orders + pnl subscribed");
456
457        let result = self.dispatch_loop(&mut ws).await;
458        self.set_disconnected().await;
459        result
460    }
461
462    /// The hot loop: read frames, dispatch into rings, handle commands,
463    /// detect heartbeat timeout. Returns when the socket closes or any
464    /// fatal error occurs.
465    async fn dispatch_loop(&mut self, ws: &mut WsClient) -> Result<(), bezant::Error> {
466        let session = ws.session().to_owned();
467        let mut session_check = tokio::time::interval(self.cfg.session_check_every);
468        session_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
469        session_check.tick().await; // the first tick fires immediately; the socket was just opened on this session
470        loop {
471            // A pending resubscribe is a timer; no pending resubscribe is a
472            // future that never resolves. `sleep_until` needs an instant
473            // either way, so park it a year out when there is nothing due.
474            let resub_at = self
475                .resubscribe
476                .due
477                .unwrap_or_else(|| TokioInstant::now() + Duration::from_secs(365 * 24 * 3_600));
478            tokio::select! {
479                // Frame from the upstream WS — dispatch or detect close.
480                msg = timeout(self.cfg.heartbeat_timeout, ws.next_message()) => {
481                    match msg {
482                        Err(_elapsed) => {
483                            warn!(
484                                timeout_secs = self.cfg.heartbeat_timeout.as_secs(),
485                                "events connector: heartbeat timeout, killing socket"
486                            );
487                            return Err(bezant::Error::WsProtocol(
488                                "heartbeat timeout".into(),
489                            ));
490                        }
491                        Ok(Ok(None)) => {
492                            info!("events connector: upstream ws closed");
493                            return Ok(());
494                        }
495                        Ok(Ok(Some(frame))) => {
496                            self.handle_frame(frame).await;
497                        }
498                        Ok(Err(e)) => {
499                            return Err(e);
500                        }
501                    }
502                }
503                // Command from the REST side (lazy market data subs).
504                Some(cmd) = self.cmd_rx.recv() => {
505                    self.handle_command(ws, cmd).await;
506                }
507                // A standing subscription CPAPI refused (or never answered).
508                () = sleep_until(resub_at), if self.resubscribe.due.is_some() => {
509                    self.resubscribe(ws).await;
510                }
511                // Has the Gateway re-logged in underneath this socket?
512                _ = session_check.tick() => {
513                    if self.session_rolled_over(&session).await {
514                        self.status.write().await.session_rollovers += 1;
515                        warn!("events connector: gateway session changed under the socket (re-login); reconnecting so subscriptions bind to the live session");
516                        return Err(bezant::Error::WsProtocol("gateway session rolled over".into()));
517                    }
518                }
519            }
520        }
521    }
522
523    /// `/tickle` reports the Gateway's current session id. If it is not the
524    /// one this socket was opened under, the Gateway has re-authenticated
525    /// and everything subscribed on this socket is dead — however healthy
526    /// its heartbeats look. A tickle that fails proves nothing, so it does
527    /// not count.
528    async fn session_rolled_over(&self, socket_session: &str) -> bool {
529        match self.client.tickle().await {
530            Ok(t) => matches!(t.session.as_deref(), Some(live) if live != socket_session),
531            Err(e) => {
532                debug!(error = %e, "events connector: session check tickle failed; assuming unchanged");
533                false
534            }
535        }
536    }
537
538    /// Ask again for every standing subscription CPAPI has not honoured,
539    /// after priming the brokerage session with `/iserver/accounts`. CPAPI
540    /// documents that call as the precondition for order queries, and the
541    /// refusals cluster right after a (re-)login — before anything has made
542    /// it. Best-effort: a failed prime still sends the subscribe.
543    async fn resubscribe(&mut self, ws: &mut WsClient) {
544        self.resubscribe.due = None;
545        let topics: Vec<&'static str> = self.resubscribe.unconfirmed.iter().copied().collect();
546        if topics.is_empty() {
547            return;
548        }
549        if let Err(e) = self
550            .client
551            .api()
552            .get_brokerage_accounts(bezant::api::GetBrokerageAccountsRequest::default())
553            .await
554        {
555            debug!(error = %e, "events connector: /iserver/accounts prime failed before resubscribe");
556        }
557        for topic in topics {
558            let Some(cmd) = subscribe_command(topic) else {
559                continue;
560            };
561            match ws.send_text(cmd.to_owned()).await {
562                Ok(()) => info!(topic, "events connector: resubscribing"),
563                Err(e) => warn!(topic, error = %e, "events connector: resubscribe send failed"),
564            }
565            self.set_subscription(topic, SubscriptionState::Pending)
566                .await;
567        }
568        // Whatever CPAPI says next — a refusal, a snapshot, or nothing —
569        // the next round is already booked. Confirmation cancels it.
570        self.schedule_resubscribe();
571    }
572
573    /// Book the next resubscribe round, doubling the delay each time up to
574    /// the ceiling. Idempotent while a round is already booked.
575    fn schedule_resubscribe(&mut self) {
576        if self.resubscribe.unconfirmed.is_empty() || self.resubscribe.due.is_some() {
577            return;
578        }
579        let delay = self.resubscribe.backoff.unwrap_or(self.cfg.resubscribe_min);
580        self.resubscribe.due = Some(TokioInstant::now() + delay);
581        self.resubscribe.backoff = Some((delay * 2).min(self.cfg.resubscribe_max));
582    }
583
584    async fn set_subscription(&self, topic: &str, state: SubscriptionState) {
585        self.status
586            .write()
587            .await
588            .subscriptions
589            .insert(topic.to_owned(), state);
590    }
591
592    /// A frame on a standing topic is either CPAPI honouring the subscribe
593    /// (any real payload) or refusing it (`{"error": …}`). Only the former
594    /// is an event.
595    async fn handle_topic_frame(
596        &mut self,
597        topic: &'static str,
598        value: serde_json::Value,
599        now: String,
600    ) {
601        if let Some(err) = value.get("error") {
602            let code = value.get("code").and_then(serde_json::Value::as_i64);
603            warn!(
604                topic,
605                error = %err,
606                code,
607                "events connector: CPAPI refused the subscription; will retry with backoff"
608            );
609            {
610                let mut s = self.status.write().await;
611                s.subscribe_refusals += 1;
612                s.last_message_at = Some(now);
613            }
614            self.set_subscription(topic, SubscriptionState::Refused)
615                .await;
616            self.resubscribe.unconfirmed.insert(topic);
617            self.schedule_resubscribe();
618            return;
619        }
620        if self.resubscribe.unconfirmed.remove(topic) {
621            info!(
622                topic,
623                "events connector: subscription confirmed by first frame"
624            );
625            self.set_subscription(topic, SubscriptionState::Subscribed)
626                .await;
627            if self.resubscribe.unconfirmed.is_empty() {
628                self.resubscribe.due = None;
629                self.resubscribe.backoff = None;
630            }
631        }
632        self.push_to_topic(topic, value, now).await;
633    }
634
635    /// Decode + push a single WS frame into the appropriate ring.
636    async fn handle_frame(&mut self, frame: WsMessage) {
637        let now = now_iso();
638        // Diagnostic: trace topic + first 200 chars of payload for every
639        // frame so we can see CPAPI's actual topic strings for unrecognised
640        // sor/spl variants. Cheap; gated to debug level so prod is silent.
641        if let Some(v) = frame.as_value() {
642            let snippet = serde_json::to_string(v).unwrap_or_default();
643            let topic_str = v
644                .get("topic")
645                .and_then(|t| t.as_str())
646                .unwrap_or("<no-topic>");
647            debug!(
648                variant = frame.topic(),
649                topic = topic_str,
650                payload = %truncate(&snippet, 200),
651                "events connector: frame"
652            );
653        }
654        match frame {
655            WsMessage::Heartbeat | WsMessage::System(_) | WsMessage::Other(_) => {
656                // Not interesting for downstream consumers; just record
657                // last-message time so the connector status reflects life.
658                self.touch_last_message(now).await;
659            }
660            WsMessage::Order(value) => {
661                self.handle_topic_frame("orders", value, now).await;
662            }
663            WsMessage::Pnl(value) => {
664                self.handle_topic_frame("pnl", value, now).await;
665            }
666            WsMessage::MarketData { conid, payload } => {
667                let topic = format!("marketdata:{conid}");
668                self.push_to_topic(&topic, payload, now).await;
669            }
670            WsMessage::Malformed { text, error } => {
671                warn!(error = %error, sample = %truncate(&text, 200), "events connector: malformed frame");
672                self.touch_last_message(now).await;
673            }
674            // `WsMessage` is `#[non_exhaustive]` — future variants
675            // surface as unstructured "other" data so we don't silently
676            // drop them.
677            other => {
678                debug!(
679                    topic = other.topic(),
680                    "events connector: unhandled ws message variant"
681                );
682                self.touch_last_message(now).await;
683            }
684        }
685    }
686
687    async fn handle_command(&mut self, ws: &mut WsClient, cmd: ConnectorCmd) {
688        match cmd {
689            ConnectorCmd::EnsureMarketData { conid, reply } => {
690                if self.active_marketdata_subs.insert(conid) {
691                    debug!(conid, "events connector: subscribing market data");
692                    let result = ws
693                        .subscribe_market_data(conid, &MarketDataFields::default_l1())
694                        .await;
695                    match result {
696                        Ok(_) => {
697                            self.add_topic_to_status(format!("marketdata:{conid}"))
698                                .await;
699                            let _ = reply.send(Ok(()));
700                        }
701                        Err(e) => {
702                            // Roll back the optimistic insert — next caller
703                            // can retry.
704                            self.active_marketdata_subs.remove(&conid);
705                            let _ = reply.send(Err(format!("subscribe failed: {e}")));
706                        }
707                    }
708                } else {
709                    // Already subscribed.
710                    let _ = reply.send(Ok(()));
711                }
712            }
713        }
714    }
715
716    async fn push_to_topic(&self, topic: &str, payload: serde_json::Value, received_at: String) {
717        let cap = match topic {
718            "orders" => self.cfg.orders_capacity,
719            "pnl" => self.cfg.pnl_capacity,
720            t if t.starts_with("marketdata:") => self.cfg.marketdata_capacity,
721            _ => 1_000,
722        };
723        let epoch = self.status.read().await.reset_epoch;
724
725        let mut rings = self.rings.write().await;
726        let ring = rings
727            .entry(topic.to_string())
728            .or_insert_with(|| TopicRing::new(topic, cap, epoch));
729        let cursor = ring.push(payload.clone(), received_at.clone());
730        drop(rings);
731
732        // Mirror to sqlite if configured. Best-effort — log on failure;
733        // the in-memory ring remains the canonical fast-path read.
734        if let Some(log) = &self.cfg.event_log {
735            let log = log.clone();
736            let evt = ObservedEvent {
737                cursor,
738                topic: topic.to_string(),
739                received_at: received_at.clone(),
740                reset_epoch: epoch,
741                payload,
742            };
743            // sqlite writes are blocking; offload to a worker so we
744            // don't block the connector loop on I/O.
745            tokio::task::spawn_blocking(move || log.append(&evt));
746        }
747
748        // Update last_message_at + ensure topic shows in status.
749        let mut s = self.status.write().await;
750        s.last_message_at = Some(received_at);
751        s.topics_subscribed.insert(topic.to_string());
752    }
753
754    async fn touch_last_message(&self, now: String) {
755        self.status.write().await.last_message_at = Some(now);
756    }
757
758    async fn add_topic_to_status(&self, topic: String) {
759        self.status.write().await.topics_subscribed.insert(topic);
760    }
761
762    async fn set_connected(&self) {
763        let mut s = self.status.write().await;
764        s.connected = true;
765        s.reconnect_count = s.reconnect_count.saturating_add(1);
766        s.topics_subscribed.insert("orders".into());
767        s.topics_subscribed.insert("pnl".into());
768    }
769
770    async fn set_disconnected(&self) {
771        self.status.write().await.connected = false;
772    }
773
774    /// Increment `reset_epoch` and inject a synthetic gap event into
775    /// every existing ring + a new "gap" topic ring so consumers
776    /// polling any topic see the reset.
777    async fn bump_epoch_with_gap(&self, reason: GapReason) {
778        let mut s = self.status.write().await;
779        // First boot: no previous events, nothing to gap-mark.
780        let is_first_boot = s.reset_epoch == 0 && s.last_message_at.is_none();
781        s.reset_epoch = s.reset_epoch.saturating_add(1);
782        let new_epoch = s.reset_epoch;
783        drop(s);
784
785        if is_first_boot {
786            return;
787        }
788
789        // Inject a gap event into every active topic so cursor-advancing
790        // consumers see it on their next poll.
791        let now = now_iso();
792        let payload = json!({
793            "reason": match reason {
794                GapReason::ReconnectedAfterDisconnect => "reconnected_after_disconnect",
795                GapReason::ProcessRestart => "process_restart",
796            },
797            "new_reset_epoch": new_epoch,
798        });
799
800        let mut rings = self.rings.write().await;
801        let topics: Vec<String> = rings.keys().cloned().collect();
802        for topic in topics {
803            // Each ring keeps its own reset_epoch matching the time of
804            // its first push; we DON'T retro-bump that. The gap event's
805            // own `reset_epoch` reflects the new value via a fresh ring
806            // entry if needed.
807            if let Some(r) = rings.get_mut(&topic) {
808                r.push(payload.clone(), now.clone());
809            }
810        }
811        // Always ensure a 'gap' topic exists so consumers that don't
812        // poll any other topic still learn about resets.
813        rings
814            .entry("gap".to_string())
815            .or_insert_with(|| TopicRing::new("gap", 256, new_epoch))
816            .push(payload, now);
817    }
818}
819
820/// Pump WS frames until we see a `system` topic frame containing
821/// `success` (CPAPI's username-ack — see the IBKR campus WS lesson).
822/// That frame indicates the server has finished session bootstrap and
823/// will accept and broadcast on `sor`/`spl` subscribes. Frames seen
824/// before the ready signal are discarded — they're just connection
825/// metadata (`act`, `sts`) we don't need to surface to consumers.
826async fn pump_until_ready(ws: &mut WsClient) -> Result<(), bezant::Error> {
827    while let Some(msg) = ws.next_message().await? {
828        if let WsMessage::System(value) = &msg {
829            if value.get("success").is_some() {
830                return Ok(());
831            }
832        }
833        // `act`, `sts`, anything else — ignore. We're only gating on
834        // the `success` ack which CPAPI sends once per session.
835    }
836    // Stream closed before we saw ready.
837    Err(bezant::Error::WsProtocol(
838        "ws closed before server-ready frame".into(),
839    ))
840}
841
842fn now_iso() -> String {
843    use std::time::SystemTime;
844    let now: chrono::DateTime<chrono::Utc> = SystemTime::now().into();
845    now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
846}
847
848fn truncate(s: &str, max: usize) -> String {
849    if s.len() <= max {
850        s.to_string()
851    } else {
852        format!("{}…", &s[..max])
853    }
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use serde_json::json;
860
861    #[tokio::test]
862    async fn handle_frame_dispatches_to_correct_topic() {
863        // Build an actor with a closed cmd channel — we won't drive run().
864        let (_tx, cmd_rx) = mpsc::channel(1);
865        let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
866        let mut actor = ConnectorActor {
867            client,
868            cfg: ConnectorCfg::default(),
869            rings: Arc::new(RwLock::new(HashMap::new())),
870            status: Arc::new(RwLock::new(StatusState::default())),
871            cmd_rx,
872            active_marketdata_subs: BTreeSet::new(),
873            resubscribe: Resubscribe::default(),
874        };
875
876        actor
877            .handle_frame(WsMessage::Order(json!({"orderId": 1})))
878            .await;
879        actor
880            .handle_frame(WsMessage::Pnl(json!({"upnl": 1.0})))
881            .await;
882        actor
883            .handle_frame(WsMessage::MarketData {
884                conid: 265598,
885                payload: json!({"31": "150.25"}),
886            })
887            .await;
888
889        let rings = actor.rings.read().await;
890        assert_eq!(rings.get("orders").unwrap().len(), 1);
891        assert_eq!(rings.get("pnl").unwrap().len(), 1);
892        assert_eq!(rings.get("marketdata:265598").unwrap().len(), 1);
893    }
894
895    fn test_actor() -> ConnectorActor {
896        let (_tx, cmd_rx) = mpsc::channel(1);
897        std::mem::forget(_tx);
898        let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
899        ConnectorActor {
900            client,
901            cfg: ConnectorCfg::default(),
902            rings: Arc::new(RwLock::new(HashMap::new())),
903            status: Arc::new(RwLock::new(StatusState::default())),
904            cmd_rx,
905            active_marketdata_subs: BTreeSet::new(),
906            resubscribe: Resubscribe::default(),
907        }
908    }
909
910    // The frame CPAPI actually sends — 17 of 20 subscribe attempts on one
911    // Gateway over Aug–Sep 2026 got this, and every one was filed as an
912    // order event while the subscribe was never retried.
913    fn refusal() -> serde_json::Value {
914        json!({"error": "unable to subscribe", "code": 500, "topic": "sor"})
915    }
916
917    #[tokio::test]
918    async fn a_subscribe_refusal_is_not_an_event() {
919        let mut actor = test_actor();
920        actor.resubscribe.unconfirmed.insert("orders");
921
922        actor.handle_frame(WsMessage::Order(refusal())).await;
923
924        let rings = actor.rings.read().await;
925        assert!(
926            rings.get("orders").is_none(),
927            "a refusal must not create or fill the orders ring — consumers would read it as an order frame"
928        );
929        let s = actor.status.read().await;
930        assert_eq!(
931            s.subscriptions.get("orders"),
932            Some(&SubscriptionState::Refused)
933        );
934        assert_eq!(s.subscribe_refusals, 1);
935        assert!(
936            s.last_message_at.is_some(),
937            "it is still a sign of life on the socket"
938        );
939    }
940
941    #[tokio::test]
942    async fn a_refusal_books_a_retry_and_the_first_real_frame_cancels_it() {
943        let mut actor = test_actor();
944        actor.resubscribe.unconfirmed.insert("orders");
945
946        actor.handle_frame(WsMessage::Order(refusal())).await;
947        assert!(actor.resubscribe.due.is_some(), "a retry must be scheduled");
948        assert!(actor.resubscribe.unconfirmed.contains("orders"));
949
950        // CPAPI honouring the subscribe looks like a snapshot: {"topic":"sor","args":[…]}.
951        actor
952            .handle_frame(WsMessage::Order(json!({"topic": "sor", "args": []})))
953            .await;
954        assert!(
955            actor.resubscribe.due.is_none(),
956            "confirmed: nothing left to retry"
957        );
958        assert!(actor.resubscribe.unconfirmed.is_empty());
959        assert_eq!(
960            actor.status.read().await.subscriptions.get("orders"),
961            Some(&SubscriptionState::Subscribed)
962        );
963        assert_eq!(
964            actor.rings.read().await.get("orders").unwrap().len(),
965            1,
966            "the snapshot IS an event"
967        );
968    }
969
970    #[tokio::test]
971    async fn retry_delay_doubles_to_the_ceiling_and_resets_on_confirmation() {
972        let mut actor = test_actor();
973        actor.cfg.resubscribe_min = Duration::from_secs(5);
974        actor.cfg.resubscribe_max = Duration::from_secs(12);
975        actor.resubscribe.unconfirmed.insert("pnl");
976
977        actor.schedule_resubscribe();
978        assert_eq!(actor.resubscribe.backoff, Some(Duration::from_secs(10)));
979        actor.resubscribe.due = None; // as `resubscribe()` does when a round is sent
980        actor.schedule_resubscribe();
981        assert_eq!(
982            actor.resubscribe.backoff,
983            Some(Duration::from_secs(12)),
984            "capped"
985        );
986
987        actor
988            .handle_frame(WsMessage::Pnl(
989                json!({"topic": "spl", "args": {"upnl": 1.0}}),
990            ))
991            .await;
992        assert_eq!(
993            actor.resubscribe.backoff, None,
994            "the next socket starts from the minimum again"
995        );
996    }
997
998    #[tokio::test]
999    async fn one_topic_confirming_does_not_cancel_the_other_topic_retry() {
1000        let mut actor = test_actor();
1001        actor.resubscribe.unconfirmed.insert("orders");
1002        actor.resubscribe.unconfirmed.insert("pnl");
1003        actor.schedule_resubscribe();
1004
1005        // pnl confirms (it nearly always does); orders is still refused.
1006        actor
1007            .handle_frame(WsMessage::Pnl(json!({"upnl": 1.0})))
1008            .await;
1009        assert!(
1010            actor.resubscribe.due.is_some(),
1011            "orders still needs its retry"
1012        );
1013        assert_eq!(
1014            actor
1015                .resubscribe
1016                .unconfirmed
1017                .iter()
1018                .copied()
1019                .collect::<Vec<_>>(),
1020            vec!["orders"]
1021        );
1022    }
1023
1024    #[test]
1025    fn standing_topics_map_to_their_wire_commands() {
1026        assert_eq!(subscribe_command("orders"), Some("sor+{}"));
1027        assert_eq!(subscribe_command("pnl"), Some("spl+{}"));
1028        assert_eq!(subscribe_command("marketdata:1"), None);
1029    }
1030
1031    #[tokio::test]
1032    async fn heartbeat_and_system_frames_dont_create_topics() {
1033        let (_tx, cmd_rx) = mpsc::channel(1);
1034        let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1035        let mut actor = ConnectorActor {
1036            client,
1037            cfg: ConnectorCfg::default(),
1038            rings: Arc::new(RwLock::new(HashMap::new())),
1039            status: Arc::new(RwLock::new(StatusState::default())),
1040            cmd_rx,
1041            active_marketdata_subs: BTreeSet::new(),
1042            resubscribe: Resubscribe::default(),
1043        };
1044
1045        actor.handle_frame(WsMessage::Heartbeat).await;
1046        actor
1047            .handle_frame(WsMessage::System(json!({"msg": "ready"})))
1048            .await;
1049
1050        let rings = actor.rings.read().await;
1051        assert!(rings.is_empty());
1052
1053        // But last_message_at IS updated.
1054        let s = actor.status.read().await;
1055        assert!(s.last_message_at.is_some());
1056    }
1057
1058    #[tokio::test]
1059    async fn bump_epoch_skips_gap_on_first_boot() {
1060        let (_tx, cmd_rx) = mpsc::channel(1);
1061        let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1062        let actor = ConnectorActor {
1063            client,
1064            cfg: ConnectorCfg::default(),
1065            rings: Arc::new(RwLock::new(HashMap::new())),
1066            status: Arc::new(RwLock::new(StatusState::default())),
1067            cmd_rx,
1068            active_marketdata_subs: BTreeSet::new(),
1069            resubscribe: Resubscribe::default(),
1070        };
1071
1072        actor
1073            .bump_epoch_with_gap(GapReason::ReconnectedAfterDisconnect)
1074            .await;
1075
1076        // No gap event injected on first boot.
1077        let rings = actor.rings.read().await;
1078        assert!(rings.is_empty());
1079        // But reset_epoch did bump.
1080        assert_eq!(actor.status.read().await.reset_epoch, 1);
1081    }
1082
1083    #[tokio::test]
1084    async fn bump_epoch_injects_gap_into_existing_topics() {
1085        let (_tx, cmd_rx) = mpsc::channel(1);
1086        let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1087        let mut actor = ConnectorActor {
1088            client,
1089            cfg: ConnectorCfg::default(),
1090            rings: Arc::new(RwLock::new(HashMap::new())),
1091            status: Arc::new(RwLock::new(StatusState::default())),
1092            cmd_rx,
1093            active_marketdata_subs: BTreeSet::new(),
1094            resubscribe: Resubscribe::default(),
1095        };
1096
1097        // Seed an order event so the actor knows about the orders topic.
1098        actor.handle_frame(WsMessage::Order(json!({"id": 1}))).await;
1099        // Now bump epoch as if we'd reconnected.
1100        actor
1101            .bump_epoch_with_gap(GapReason::ReconnectedAfterDisconnect)
1102            .await;
1103
1104        let rings = actor.rings.read().await;
1105        let orders_ring = rings.get("orders").unwrap();
1106        // Original event + gap marker = 2.
1107        assert_eq!(orders_ring.len(), 2);
1108        // 'gap' topic also got a marker.
1109        assert_eq!(rings.get("gap").unwrap().len(), 1);
1110    }
1111
1112    #[tokio::test]
1113    async fn ensure_market_data_dedups_repeat_calls() {
1114        // We can't easily test the WS sub call without a mock socket,
1115        // but we can verify the dedup logic at the actor level by
1116        // hand-mutating the active_marketdata_subs set.
1117        let (_tx, cmd_rx) = mpsc::channel(1);
1118        let client = bezant::Client::new("https://localhost:5000/v1/api").unwrap();
1119        let mut actor = ConnectorActor {
1120            client,
1121            cfg: ConnectorCfg::default(),
1122            rings: Arc::new(RwLock::new(HashMap::new())),
1123            status: Arc::new(RwLock::new(StatusState::default())),
1124            cmd_rx,
1125            active_marketdata_subs: BTreeSet::new(),
1126            resubscribe: Resubscribe::default(),
1127        };
1128
1129        assert!(actor.active_marketdata_subs.insert(265_598));
1130        // Re-insert returns false (already present).
1131        assert!(!actor.active_marketdata_subs.insert(265_598));
1132        assert_eq!(actor.active_marketdata_subs.len(), 1);
1133    }
1134}