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