Skip to main content

rustrade_data/exchange/massive/
connection.rs

1//! One WebSocket per Massive cluster, shared by every stream a
2//! [`MassiveSubscriber`](super::MassiveSubscriber) and its clones open on it.
3//!
4//! # Why the connection is shared
5//! Massive allows a key a fixed number of WebSocket connections **per cluster** — stocks, options,
6//! forex, crypto and the rest each count separately — one on an individual plan. Past the limit it
7//! does not refuse the new connection: on every cluster measured it **closes an older one**, with a
8//! `max_connections` status, and the newcomer keeps streaming. One socket per stream could
9//! therefore never stream two kinds from one cluster at once.
10//!
11//! # Shape
12//! A task per cluster owns that cluster's socket. Each stream *attaches* to it and is handed a
13//! [`MassiveAttachment`]: the frames carrying its own subscriptions, which it parses exactly as it
14//! would parse a socket of its own.
15//!
16//! Massive packs messages for several channels and markets into one frame, a JSON array, so the
17//! connection splits frames: it reads only each message's channel, `ev`, and its market, and a
18//! stream receives a frame of only its own messages.
19//!
20//! - **Connect lazily, close when idle.** A cluster's socket opens on its first attach and closes
21//!   once no stream remains attached to it.
22//! - **Subscribe once per channel and market.** A subscription the socket already holds is not
23//!   sent again — Massive would not answer it — and its messages reach every stream holding it.
24//! - **Unsubscribe on the last detach.** Dropping an attachment unsubscribes everything no other
25//!   stream holds.
26//! - **One subscribe at a time.** Massive confirms each subscription by name, but refuses one with
27//!   a bare `not authorized` naming none, so a refusal belongs to the subscribe in flight. Attaches
28//!   and detaches are therefore serialised; frames keep flowing to every attached stream meanwhile.
29//! - **Pinged.** Massive drops sockets without a close, nightly among other times, so the
30//!   connection pings every [`KEEPALIVE`] and treats a socket silent since the last ping as lost.
31//!
32//! # What Massive does not answer
33//! Massive says nothing at all to a subscription for a channel it does not publish, so an attach
34//! naming one waits out its timeout, and the timeout names what was never confirmed. It confirms a
35//! subscription to a market it does not list, and then publishes nothing for it: a misspelt
36//! market is indistinguishable from a quiet one.
37//!
38//! # Reconnect
39//! Every stream on a socket **ends together** when it is lost, and each is re-initialised by the
40//! usual reconnect wrapper. The first to re-attach reconnects and re-subscribes everything the lost
41//! socket held; the rest re-attach to that one reconnect. Massive replays nothing, so what it
42//! published while no socket was open is missed.
43//!
44//! A stream keeps its place for [`REATTACH_GRACE`] after the socket is lost; frames for a stream
45//! that has not re-attached yet are held until then, and discarded with a warning if it has not.
46//!
47//! # Sharing is by clone, and only by clone
48//! Clones of one subscriber share one connection per cluster. A second subscriber built separately
49//! for the same key, or another process, opens a second socket, and Massive closes the older one.
50//! Its streams reconnect — closing the other in turn. The warning logged for each lost connection
51//! quotes Massive's `max_connections` status verbatim, so the contest can be recognised; the
52//! library does not back off from it.
53
54pub use crate::subscriber::shared::REATTACH_GRACE;
55
56use super::MassiveCredentials;
57use crate::{
58    exchange::ExchangeSub,
59    subscriber::{
60        shared::{
61            self, AttachId, Attachment, Connections, Frame, Handshake, Protocol, Registry,
62            elements::{self, Element, Elements, excerpt},
63        },
64        shared_stream::{SharedTransport, sealed},
65    },
66};
67use futures::{SinkExt, Stream, StreamExt};
68use rustrade_instrument::exchange::ExchangeId;
69use rustrade_integration::{
70    error::SocketError,
71    protocol::websocket::{WebSocket, WsError, WsMessage, connect},
72};
73use serde::{Deserialize, Deserializer};
74use serde_json::json;
75use smol_str::SmolStr;
76use std::{
77    fmt,
78    pin::Pin,
79    task::{Context, Poll},
80    time::Duration,
81};
82use tracing::{debug, warn};
83use url::Url;
84
85/// How often the connection pings Massive.
86pub const KEEPALIVE: Duration = Duration::from_secs(20);
87
88/// How long authentication may take, each of its two frames.
89const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
90
91/// What a subscribe confirmation says before the subscription it confirms.
92const SUBSCRIBED: &str = "subscribed to: ";
93
94/// What an unsubscribe confirmation says before the subscription it confirms.
95const UNSUBSCRIBED: &str = "unsubscribed to: ";
96
97/// One subscription on the socket: a channel and a market, `XT.BTC-USD` on the wire.
98#[derive(Clone, PartialEq, Eq, Hash, Debug)]
99pub(super) struct Slot {
100    /// The channel, as the event its messages carry.
101    pub(super) event: SmolStr,
102    pub(super) market: SmolStr,
103}
104
105impl Slot {
106    /// The subscription a confirmation names, `XT.BTC-USD`. Channels hold no dot, so the first
107    /// one ends the channel, and a market holding one, `BRK.B`, survives.
108    fn parse(param: &str) -> Option<Self> {
109        let (event, market) = param.split_once('.')?;
110        (!event.is_empty() && !market.is_empty()).then(|| Self {
111            event: SmolStr::new(event),
112            market: SmolStr::new(market),
113        })
114    }
115}
116
117/// The subscription a channel and market spell.
118impl<Channel, Market> From<&ExchangeSub<Channel, Market>> for Slot
119where
120    Channel: AsRef<str>,
121    Market: AsRef<str>,
122{
123    fn from(sub: &ExchangeSub<Channel, Market>) -> Self {
124        Self {
125            event: SmolStr::new(sub.channel.as_ref()),
126            market: SmolStr::new(sub.market.as_ref()),
127        }
128    }
129}
130
131impl fmt::Display for Slot {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        write!(f, "{}.{}", self.event, self.market)
134    }
135}
136
137/// The `subscribe` or `unsubscribe` payload for `slots`: one message, every subscription in it.
138pub(super) fn action(action: &str, slots: &[Slot]) -> WsMessage {
139    let params = slots
140        .iter()
141        .map(Slot::to_string)
142        .collect::<Vec<_>>()
143        .join(",");
144
145    WsMessage::text(json!({ "action": action, "params": params }).to_string())
146}
147
148/// The Massive side of a shared connection.
149#[derive(Debug)]
150pub(super) struct Massive;
151
152/// The handle a `MassiveSubscriber` and its clones share: the way into each cluster's connection
153/// task.
154#[derive(Debug)]
155pub(super) struct MassiveConnections(Connections<Massive>);
156
157impl MassiveConnections {
158    pub(super) fn new(credentials: MassiveCredentials) -> Self {
159        Self(Connections::new(credentials))
160    }
161
162    /// Attach one stream's batch to its cluster's connection, connecting first if nothing is
163    /// attached.
164    pub(super) async fn attach(
165        &self,
166        request: AttachRequest,
167    ) -> Result<MassiveAttachment, SocketError> {
168        let AttachRequest {
169            exchange,
170            url,
171            kind,
172            slots,
173            timeout,
174        } = request;
175
176        self.0
177            .attach(shared::AttachRequest {
178                exchange,
179                url,
180                kind,
181                slots,
182                timeout,
183                batch: (),
184            })
185            .await
186            .map(MassiveAttachment)
187    }
188}
189
190/// One stream's batch, as the connection needs it.
191#[derive(Debug)]
192pub(super) struct AttachRequest {
193    pub(super) exchange: ExchangeId,
194    pub(super) url: Url,
195    /// [`SubscriptionKind::as_str`](crate::subscription::SubscriptionKind::as_str) of the batch.
196    pub(super) kind: &'static str,
197    /// The distinct subscriptions requested, in request order.
198    pub(super) slots: Vec<Slot>,
199    pub(super) timeout: Duration,
200}
201
202/// Waits for Massive to confirm each subscription sent, by name.
203#[derive(Debug)]
204pub(super) struct Confirmations {
205    /// Those still unconfirmed, in request order.
206    outstanding: Vec<Slot>,
207}
208
209impl Confirmations {
210    fn outstanding(&self) -> String {
211        self.outstanding
212            .iter()
213            .map(Slot::to_string)
214            .collect::<Vec<_>>()
215            .join(", ")
216    }
217}
218
219impl Handshake<Slot> for Confirmations {
220    fn is_settled(&self) -> bool {
221        self.outstanding.is_empty()
222    }
223
224    fn observe(&mut self, confirmed: Slot) {
225        let before = self.outstanding.len();
226        self.outstanding.retain(|slot| *slot != confirmed);
227
228        if self.outstanding.len() == before {
229            debug!(%confirmed, "Massive confirmed a subscription this subscribe did not send");
230        }
231    }
232
233    fn timed_out(&self, timeout: Duration) -> String {
234        format!(
235            "subscription validation timeout reached: {timeout:?}; Massive never confirmed: {}. \
236             Massive does not answer a subscription to a channel it does not publish on the \
237             cluster, so one that waits for ever is usually a channel the cluster does not offer",
238            self.outstanding()
239        )
240    }
241
242    /// Name what the refusal may be for: Massive's names nothing, and whatever it refused is among
243    /// what it never confirmed.
244    fn refusal(&self, error: SocketError) -> SocketError {
245        match error {
246            SocketError::Subscribe(message) if !self.outstanding.is_empty() => {
247                SocketError::Subscribe(format!(
248                    "{message}; Massive refused a subscription among those it did not confirm: {}",
249                    self.outstanding()
250                ))
251            }
252            other => other,
253        }
254    }
255}
256
257impl Protocol for Massive {
258    const NAME: &'static str = "Massive";
259    // Massive caps connections per cluster, and each cluster has an endpoint of its own.
260    const CONNECTION_PER_ENDPOINT: bool = true;
261    // Massive refuses each subscription separately, confirming the rest of the subscribe.
262    const REFUSAL_IS_ATOMIC: bool = false;
263    const KEEPALIVE: Option<Duration> = Some(KEEPALIVE);
264
265    type Credentials = MassiveCredentials;
266    type Slot = Slot;
267    type Batch = ();
268    type Session = ();
269    type Answer = Slot;
270    type Handshake = Confirmations;
271
272    async fn connect(
273        credentials: &MassiveCredentials,
274        url: &Url,
275    ) -> Result<(WebSocket, ()), SocketError> {
276        let mut websocket = connect(url.clone()).await?;
277        authenticate(&mut websocket, credentials).await?;
278        debug!(%url, "authenticated to Massive WebSocket");
279
280        Ok((websocket, ()))
281    }
282
283    fn subscribe(
284        _registry: &mut Registry<Self>,
285        _id: AttachId,
286        sending: &[Slot],
287    ) -> (Vec<WsMessage>, Confirmations) {
288        let messages = if sending.is_empty() {
289            Vec::new()
290        } else {
291            vec![action("subscribe", sending)]
292        };
293
294        (
295            messages,
296            Confirmations {
297                outstanding: sending.to_vec(),
298            },
299        )
300    }
301
302    /// Answered by an `unsubscribed to:` confirmation per subscription, which names it, so no
303    /// later handshake could take it for its own answer, and there is nothing to wait for.
304    fn release(slots: &[Slot]) -> (Vec<WsMessage>, Option<Confirmations>) {
305        (vec![action("unsubscribe", slots)], None)
306    }
307
308    fn read(registry: &mut Registry<Self>, message: &WsMessage) -> Frame<Slot> {
309        let raws = match elements::elements::<Self>(message) {
310            Elements::Array(raws) => raws,
311            Elements::Closed(cause) => return Frame::Closed(cause),
312            Elements::Ignored => return Frame::Answers(Vec::new()),
313        };
314
315        let total = raws.len();
316        let mut data = Vec::with_capacity(total);
317        let mut answers = Vec::new();
318
319        for raw in raws {
320            let envelope = match serde_json::from_str::<Envelope>(raw.get()) {
321                Ok(envelope) => envelope,
322                Err(error) => {
323                    warn!(
324                        %error,
325                        message = %excerpt(raw.get()),
326                        "Massive sent a message that could not be read; ignored",
327                    );
328                    continue;
329                }
330            };
331
332            if envelope.event == "status" {
333                match status(envelope.status.as_deref(), envelope.message.as_deref()) {
334                    Status::Answer(answer) => answers.push(answer),
335                    Status::Evicted(cause) => return Frame::Closed(cause),
336                    Status::Other => {}
337                }
338                continue;
339            }
340
341            let Some(market) = envelope.market() else {
342                warn!(
343                    message = %excerpt(raw.get()),
344                    "Massive sent a market data message naming no market; ignored",
345                );
346                continue;
347            };
348
349            data.push(Element {
350                slot: Slot {
351                    event: envelope.event,
352                    market,
353                },
354                raw,
355            });
356        }
357
358        elements::route(registry, message, total, &data);
359        Frame::Answers(answers)
360    }
361
362    /// Add to a refusal what the stream cannot see: the cluster's subscriptions are shared.
363    fn refused(error: SocketError) -> SocketError {
364        match error {
365            SocketError::Subscribe(message) => SocketError::Subscribe(format!(
366                "{message} (the connection is shared by every stream this subscriber and its \
367                 clones open on the cluster)"
368            )),
369            other => other,
370        }
371    }
372}
373
374/// One stream's view of a Massive cluster's shared connection.
375///
376/// A stream of frames holding only the stream's own subscriptions, in the order the socket
377/// delivered them, so it is parsed exactly as a socket of the stream's own would be. It ends when
378/// the connection is lost, which is what drives the stream's reconnect.
379///
380/// Dropping it detaches the stream: every subscription no other stream holds is unsubscribed, and
381/// the socket closes once nothing remains attached.
382///
383/// # Keep it drained
384/// The queue behind it is unbounded. The connection reads one socket for every stream sharing it,
385/// so it never waits for a slow one — that would stall the rest — and a stream it serves is not
386/// back-pressured the way a socket of its own would be. Frames for a stream that stops being polled
387/// accumulate in memory until it is polled again or dropped.
388///
389/// See [`connection`](self) for how the connection is shared.
390#[derive(Debug)]
391pub struct MassiveAttachment(Attachment<Massive>);
392
393impl Stream for MassiveAttachment {
394    type Item = Result<WsMessage, WsError>;
395
396    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
397        Pin::new(&mut self.0).poll_next(cx)
398    }
399}
400
401impl sealed::Sealed for MassiveAttachment {}
402
403/// Massive hands a stream nothing on attaching.
404impl SharedTransport for MassiveAttachment {
405    type Attached = ();
406
407    fn take_attached(&mut self) {
408        self.0.take_attached()
409    }
410}
411
412/// The part of a message the connection reads.
413///
414/// Short strings deserialise inline — a channel and a market both fit — so this allocates nothing
415/// per market data message.
416#[derive(Deserialize)]
417struct Envelope {
418    #[serde(rename = "ev")]
419    event: SmolStr,
420    /// Stocks and options.
421    #[serde(default)]
422    sym: Option<SmolStr>,
423    /// Crypto, and forex aggregates.
424    #[serde(default)]
425    pair: Option<SmolStr>,
426    /// Forex quotes' pair — but crypto trades' price, so read only when it is a string.
427    #[serde(default, deserialize_with = "text_only")]
428    p: Option<SmolStr>,
429    /// A status message's status.
430    #[serde(default)]
431    status: Option<String>,
432    /// A status message's text.
433    #[serde(default)]
434    message: Option<String>,
435}
436
437impl Envelope {
438    fn market(&self) -> Option<SmolStr> {
439        self.sym
440            .as_ref()
441            .or(self.pair.as_ref())
442            .or(self.p.as_ref())
443            .cloned()
444    }
445}
446
447/// A string, or nothing if the field holds anything else.
448fn text_only<'de, D>(deserializer: D) -> Result<Option<SmolStr>, D::Error>
449where
450    D: Deserializer<'de>,
451{
452    #[derive(Deserialize)]
453    #[serde(untagged)]
454    enum TextOrOther {
455        Text(SmolStr),
456        Other(serde::de::IgnoredAny),
457    }
458
459    Ok(match Option::<TextOrOther>::deserialize(deserializer)? {
460        Some(TextOrOther::Text(text)) => Some(text),
461        Some(TextOrOther::Other(_)) | None => None,
462    })
463}
464
465/// What a status message amounts to.
466enum Status {
467    /// A confirmation of the subscribe in flight, or a refusal of it.
468    Answer(Result<Slot, SocketError>),
469    /// Massive closed this connection for another on the same key and cluster.
470    Evicted(String),
471    /// Nothing a handshake waits for.
472    Other,
473}
474
475fn status(status: Option<&str>, message: Option<&str>) -> Status {
476    let message = message.unwrap_or_default();
477
478    match status {
479        Some("success") => {
480            if let Some(param) = message.strip_prefix(SUBSCRIBED) {
481                match Slot::parse(param) {
482                    Some(slot) => Status::Answer(Ok(slot)),
483                    None => {
484                        warn!(message, "Massive confirmed a subscription it did not name");
485                        Status::Other
486                    }
487                }
488            } else if message.starts_with(UNSUBSCRIBED) {
489                debug!(message, "Massive confirmed an unsubscribe");
490                Status::Other
491            } else {
492                warn!(
493                    message,
494                    "Massive reported a success this client does not recognise"
495                );
496                Status::Other
497            }
498        }
499        Some("error") => Status::Answer(Err(SocketError::Subscribe(format!(
500            "Massive refused a subscription: {message}"
501        )))),
502        Some("max_connections") => Status::Evicted(format!(
503            "Massive closed the connection: max_connections: {message} (another connection opened \
504             with this key on this cluster, by a subscriber built separately or another process, \
505             took its place)"
506        )),
507        Some("connected" | "auth_success") => {
508            debug!(message, "Massive status");
509            Status::Other
510        }
511        other => {
512            warn!(
513                status = other.unwrap_or("<none>"),
514                message, "Massive reported a status this client does not recognise"
515            );
516            Status::Other
517        }
518    }
519}
520
521/// Wait for Massive's `connected` status, send the key, and wait for `auth_success`.
522async fn authenticate(
523    websocket: &mut WebSocket,
524    credentials: &MassiveCredentials,
525) -> Result<(), SocketError> {
526    let connected = next_status(websocket, "connected").await?;
527    if connected.0.as_deref() != Some("connected") {
528        // Not what Massive sends first, but nothing depends on it: authentication decides.
529        debug!(status = ?connected, "Massive's first frame was not the expected connected status");
530    }
531
532    websocket
533        .send(WsMessage::text(
534            json!({ "action": "auth", "params": credentials.api_key() }).to_string(),
535        ))
536        .await
537        .map_err(|error| SocketError::WebSocket(Box::new(error)))?;
538
539    match next_status(websocket, "auth_success").await? {
540        (Some(status), _) if status == "auth_success" => Ok(()),
541        (status, message) => Err(SocketError::Subscribe(format!(
542            "Massive refused authentication: {}: {}",
543            status.as_deref().unwrap_or("<no status>"),
544            message.as_deref().unwrap_or_default(),
545        ))),
546    }
547}
548
549/// The first status of the next text frame, while authenticating.
550///
551/// Control frames are skipped: the socket answers a ping itself, and neither is an answer.
552async fn next_status(
553    websocket: &mut WebSocket,
554    awaiting: &str,
555) -> Result<(Option<String>, Option<String>), SocketError> {
556    let deadline = tokio::time::Instant::now() + AUTH_TIMEOUT;
557    let text = loop {
558        let frame = tokio::time::timeout_at(deadline, websocket.next())
559            .await
560            .map_err(|_| {
561                SocketError::Subscribe(format!(
562                    "Massive sent nothing within {AUTH_TIMEOUT:?} while awaiting {awaiting}"
563                ))
564            })?;
565
566        match frame {
567            Some(Ok(WsMessage::Text(text))) => break text,
568            Some(Ok(WsMessage::Ping(_) | WsMessage::Pong(_))) => continue,
569            Some(Ok(WsMessage::Close(close))) => {
570                return Err(SocketError::Subscribe(format!(
571                    "Massive closed the connection while awaiting {awaiting}: {close:?}"
572                )));
573            }
574            Some(Ok(other)) => {
575                return Err(SocketError::Subscribe(format!(
576                    "Massive sent {other:?} while awaiting {awaiting}"
577                )));
578            }
579            Some(Err(error)) => return Err(SocketError::WebSocket(Box::new(error))),
580            None => {
581                return Err(SocketError::Subscribe(format!(
582                    "Massive ended the connection while awaiting {awaiting}"
583                )));
584            }
585        }
586    };
587
588    let statuses = serde_json::from_str::<Vec<Envelope>>(text.as_str()).map_err(|error| {
589        SocketError::Deserialise {
590            error,
591            payload: text.as_str().to_owned(),
592        }
593    })?;
594
595    Ok(statuses
596        .into_iter()
597        .find(|envelope| envelope.event == "status")
598        .map(|envelope| (envelope.status, envelope.message))
599        .unwrap_or_default())
600}
601
602#[cfg(test)]
603#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
604mod tests {
605    use super::*;
606    use serde_json::{Value, json};
607    use tokio::sync::mpsc;
608
609    fn slot(event: &str, market: &str) -> Slot {
610        Slot {
611            event: SmolStr::new(event),
612            market: SmolStr::new(market),
613        }
614    }
615
616    fn attach(
617        registry: &mut Registry<Massive>,
618        slots: &[(&str, &str)],
619    ) -> (AttachId, mpsc::UnboundedReceiver<WsMessage>) {
620        let (tx, rx) = mpsc::unbounded_channel();
621        let request = shared::AttachRequest {
622            exchange: ExchangeId::MassiveCrypto,
623            url: Url::parse("ws://127.0.0.1:1").unwrap(),
624            kind: "public_trades",
625            slots: slots
626                .iter()
627                .map(|(event, market)| slot(event, market))
628                .collect(),
629            timeout: Duration::from_secs(1),
630            batch: (),
631        };
632
633        (registry.insert_live(request, tx), rx)
634    }
635
636    fn frame(elements: &[Value]) -> WsMessage {
637        WsMessage::text(Value::Array(elements.to_vec()).to_string())
638    }
639
640    fn read(
641        registry: &mut Registry<Massive>,
642        message: &WsMessage,
643    ) -> Vec<Result<Slot, SocketError>> {
644        match Massive::read(registry, message) {
645            Frame::Answers(answers) => answers,
646            Frame::Closed(cause) => panic!("expected a frame of messages, got a close: {cause}"),
647        }
648    }
649
650    fn received(rx: &mut mpsc::UnboundedReceiver<WsMessage>) -> Vec<Vec<Value>> {
651        std::iter::from_fn(|| rx.try_recv().ok())
652            .map(|message| match message {
653                WsMessage::Text(text) => serde_json::from_str(text.as_str()).unwrap(),
654                other => panic!("expected a text frame, got {other:?}"),
655            })
656            .collect()
657    }
658
659    fn trade(pair: &str) -> Value {
660        json!({"ev": "XT", "pair": pair, "p": 82857.53, "s": 0.01, "t": 1, "c": [2], "i": "1"})
661    }
662
663    fn quote(pair: &str) -> Value {
664        json!({"ev": "XQ", "pair": pair, "bp": 1.0, "bs": 1.0, "ap": 2.0, "as": 1.0, "t": 1})
665    }
666
667    fn status(status: &str, message: &str) -> Value {
668        json!({"ev": "status", "status": status, "message": message})
669    }
670
671    #[test]
672    fn a_mixed_frame_is_split_per_stream_and_its_status_answers() {
673        let mut registry = Registry::default();
674        let (_, mut trades) = attach(&mut registry, &[("XT", "BTC-USD")]);
675        let (_, mut quotes) = attach(&mut registry, &[("XQ", "BTC-USD")]);
676
677        let answers = read(
678            &mut registry,
679            &frame(&[
680                trade("BTC-USD"),
681                status("success", "subscribed to: XQ.BTC-USD"),
682                quote("BTC-USD"),
683                trade("ETH-USD"),
684            ]),
685        );
686
687        assert_eq!(answers.len(), 1);
688        assert_eq!(answers[0].as_ref().unwrap(), &slot("XQ", "BTC-USD"));
689        assert_eq!(received(&mut trades), vec![vec![trade("BTC-USD")]]);
690        assert_eq!(received(&mut quotes), vec![vec![quote("BTC-USD")]]);
691    }
692
693    #[test]
694    fn a_frame_wholly_for_one_stream_is_forwarded_as_sent() {
695        let mut registry = Registry::default();
696        let (_, mut trades) = attach(&mut registry, &[("XT", "BTC-USD"), ("XT", "ETH-USD")]);
697
698        let sent = frame(&[trade("BTC-USD"), trade("ETH-USD")]);
699        read(&mut registry, &sent);
700
701        assert_eq!(trades.try_recv().unwrap(), sent);
702    }
703
704    #[test]
705    fn each_cluster_names_its_market_in_its_own_field() {
706        let mut registry = Registry::default();
707        let (_, mut stocks) = attach(&mut registry, &[("T", "BRK.B")]);
708        let (_, mut forex) = attach(&mut registry, &[("C", "EUR/USD")]);
709        let (_, mut aggregates) = attach(&mut registry, &[("CAS", "EUR/USD")]);
710
711        let stock = json!({"ev": "T", "sym": "BRK.B", "p": 1.0, "s": 1, "t": 1});
712        // Crypto's `p` is a price, forex's a pair: only a string is read as a market.
713        let fx = json!({"ev": "C", "p": "EUR/USD", "a": 1.1, "b": 1.0, "t": 1});
714        let bar = json!({"ev": "CAS", "pair": "EUR/USD", "o": 1, "h": 1, "l": 1, "c": 1, "v": 3,
715                         "s": 0, "e": 1000});
716        read(
717            &mut registry,
718            &frame(&[stock.clone(), fx.clone(), bar.clone()]),
719        );
720
721        assert_eq!(received(&mut stocks), vec![vec![stock]]);
722        assert_eq!(received(&mut forex), vec![vec![fx]]);
723        assert_eq!(received(&mut aggregates), vec![vec![bar]]);
724    }
725
726    #[test]
727    fn a_refusal_names_nothing_and_an_unsubscribe_confirmation_answers_nothing() {
728        let answers = read(
729            &mut Registry::default(),
730            &frame(&[
731                status("error", "not authorized"),
732                status("success", "subscribed to: XT.ETH-USD"),
733                status("success", "unsubscribed to: XT.BTC-USD"),
734            ]),
735        );
736
737        assert_eq!(answers.len(), 2);
738        assert!(
739            matches!(&answers[0], Err(SocketError::Subscribe(message)) if message.contains("not authorized"))
740        );
741        assert_eq!(answers[1].as_ref().unwrap(), &slot("XT", "ETH-USD"));
742    }
743
744    #[test]
745    fn an_eviction_closes_the_connection_quoting_massive() {
746        let evicted = frame(&[status(
747            "max_connections",
748            "Maximum number of websocket connections exceeded.",
749        )]);
750
751        match Massive::read(&mut Registry::default(), &evicted) {
752            Frame::Closed(cause) => {
753                assert!(cause.contains("max_connections"), "{cause}");
754                assert!(
755                    cause.contains("Maximum number of websocket connections"),
756                    "{cause}"
757                );
758            }
759            Frame::Answers(_) => panic!("an eviction must close the connection"),
760        }
761    }
762
763    #[test]
764    fn a_confirmation_names_its_subscription_even_when_the_market_holds_a_dot() {
765        assert_eq!(Slot::parse("T.BRK.B"), Some(slot("T", "BRK.B")));
766        assert_eq!(Slot::parse("C.EUR/USD"), Some(slot("C", "EUR/USD")));
767        assert_eq!(Slot::parse("XT"), None);
768        assert_eq!(Slot::parse(".BTC-USD"), None);
769    }
770
771    #[test]
772    fn a_handshake_settles_on_every_confirmation_and_names_what_is_missing() {
773        let (messages, mut handshake) = Massive::subscribe(
774            &mut Registry::default(),
775            0,
776            &[slot("XT", "BTC-USD"), slot("XQ", "BTC-USD")],
777        );
778
779        assert_eq!(
780            messages,
781            vec![WsMessage::text(
782                json!({"action": "subscribe", "params": "XT.BTC-USD,XQ.BTC-USD"}).to_string()
783            )]
784        );
785
786        handshake.observe(slot("XQ", "BTC-USD"));
787        assert!(!handshake.is_settled());
788
789        let timed_out = handshake.timed_out(Duration::from_secs(10));
790        assert!(
791            timed_out.contains("never confirmed: XT.BTC-USD"),
792            "{timed_out}"
793        );
794
795        let refused = handshake
796            .refusal(SocketError::Subscribe("not authorized".to_owned()))
797            .to_string();
798        assert!(refused.contains("not authorized"), "{refused}");
799        assert!(refused.contains("XT.BTC-USD"), "{refused}");
800
801        handshake.observe(slot("XT", "BTC-USD"));
802        assert!(handshake.is_settled());
803    }
804
805    #[test]
806    fn an_attach_sending_nothing_waits_for_nothing() {
807        let (messages, handshake) = Massive::subscribe(&mut Registry::default(), 0, &[]);
808
809        assert!(messages.is_empty());
810        assert!(handshake.is_settled());
811    }
812
813    #[test]
814    fn a_release_is_one_unsubscribe_and_waits_for_nothing() {
815        let (messages, handshake) =
816            Massive::release(&[slot("XT", "BTC-USD"), slot("C", "EUR/USD")]);
817
818        assert_eq!(
819            messages,
820            vec![WsMessage::text(
821                json!({"action": "unsubscribe", "params": "XT.BTC-USD,C.EUR/USD"}).to_string()
822            )]
823        );
824        assert!(handshake.is_none());
825    }
826}