Skip to main content

rustrade_data/subscriber/
validator.rs

1use crate::{
2    exchange::Connector,
3    subscription::{Map, SubscriptionKind},
4};
5use fnv::FnvHashSet;
6use futures::{Stream, StreamExt};
7use rustrade_integration::{
8    Validator,
9    error::SocketError,
10    protocol::{
11        StreamParser,
12        websocket::{WebSocket, WebSocketSerdeParser, WsError, WsMessage},
13    },
14    subscription::SubscriptionId,
15};
16use serde::{Deserialize, Serialize};
17use std::{future::Future, time::Duration};
18use tracing::debug;
19
20/// Defines how to validate that actioned market data
21/// [`Subscription`](crate::subscription::Subscription)s were accepted by the exchange.
22pub trait SubscriptionValidator {
23    type Parser;
24
25    fn validate<Exchange, InstrumentKey, Kind>(
26        instrument_map: Map<InstrumentKey>,
27        websocket: &mut WebSocket,
28    ) -> impl Future<Output = Result<(Map<InstrumentKey>, Vec<WsMessage>), SocketError>> + Send
29    where
30        Exchange: Connector + Send,
31        InstrumentKey: Send,
32        Kind: SubscriptionKind + Send;
33}
34
35/// Standard [`SubscriptionValidator`] for [`WebSocket`]s suitable for most exchanges.
36#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
37pub struct WebSocketSubValidator;
38
39impl SubscriptionValidator for WebSocketSubValidator {
40    type Parser = WebSocketSerdeParser;
41
42    async fn validate<Exchange, Instrument, Kind>(
43        instrument_map: Map<Instrument>,
44        websocket: &mut WebSocket,
45    ) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
46    where
47        Exchange: Connector + Send,
48        Instrument: Send,
49        Kind: SubscriptionKind + Send,
50    {
51        validate_responses::<Exchange, _, _>(
52            instrument_map,
53            websocket,
54            Exchange::subscription_timeout(),
55        )
56        .await
57    }
58}
59
60/// Reads `messages` until every expected acknowledgement has arrived, or fails naming the
61/// subscriptions left unacknowledged.
62///
63/// Generic over the message source so the validation logic is testable without a socket.
64async fn validate_responses<Exchange, Instrument, Messages>(
65    instrument_map: Map<Instrument>,
66    messages: &mut Messages,
67    timeout: Duration,
68) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
69where
70    Exchange: Connector,
71    Messages: Stream<Item = Result<WsMessage, WsError>> + Unpin,
72{
73    let expected_responses = Exchange::expected_responses(&instrument_map);
74    let mut acknowledged = Acknowledged::default();
75
76    // Buffer any active Subscription market events that are received during validation
77    let mut buff_active_subscription_events = Vec::new();
78
79    // One deadline for the whole validation. A sleep re-armed per message would never expire
80    // while an acknowledged subscription keeps streaming events.
81    let deadline = tokio::time::sleep(timeout);
82    tokio::pin!(deadline);
83
84    loop {
85        // Break if all Subscriptions were a success
86        if acknowledged.count == expected_responses {
87            debug!(exchange = %Exchange::ID, "validated exchange WebSocket subscriptions");
88            break Ok((instrument_map, buff_active_subscription_events));
89        }
90
91        let failure = tokio::select! {
92            _ = &mut deadline => format!("subscription validation timeout reached: {timeout:?}"),
93            message = messages.next() => {
94                let Some(response) = message else {
95                    break Err(acknowledged.into_error(
96                        "WebSocket stream terminated unexpectedly".to_owned(),
97                        &instrument_map,
98                    ));
99                };
100
101                match <WebSocketSerdeParser as StreamParser<Exchange::SubResponse>>::parse(response) {
102                    Some(Ok(response)) => match response.validate() {
103                        // Subscription success
104                        Ok(response) => {
105                            acknowledged.record(
106                                Exchange::acknowledged_subscription(&response),
107                                &instrument_map,
108                            );
109                            debug!(
110                                exchange = %Exchange::ID,
111                                success_responses = %acknowledged.count,
112                                %expected_responses,
113                                payload = ?response,
114                                "received valid Ok subscription response",
115                            );
116                            continue
117                        }
118
119                        // Subscription failure: the venue's own message, without the
120                        // `Subscribe` wrapper `Unacknowledged` would repeat
121                        Err(SocketError::Subscribe(message)) => message,
122                        Err(error) => error.to_string(),
123                    }
124                    Some(Err(SocketError::Deserialise { error: _, payload })) => {
125                        // Most likely already active subscription payload, so add to market
126                        // event buffer for post validation processing
127                        buff_active_subscription_events.push(WsMessage::text(payload));
128                        continue
129                    }
130                    Some(Err(SocketError::Terminated(close_frame))) => {
131                        format!("received WebSocket CloseFrame: {close_frame}")
132                    }
133                    _ => {
134                        // Pings, Pongs, Frames, etc.
135                        continue
136                    }
137                }
138            }
139        };
140
141        break Err(acknowledged.into_error(failure, &instrument_map));
142    }
143}
144
145/// The acknowledgements a validation has received so far.
146#[derive(Debug, Default)]
147struct Acknowledged {
148    /// Every successful response, matched to a subscription or not.
149    count: usize,
150    /// The subscriptions the venue's responses named.
151    matched: FnvHashSet<SubscriptionId>,
152}
153
154impl Acknowledged {
155    fn record<Instrument>(&mut self, id: Option<SubscriptionId>, map: &Map<Instrument>) {
156        self.count += 1;
157        if let Some(id) = id.filter(|id| map.0.contains_key(id)) {
158            self.matched.insert(id);
159        }
160    }
161
162    /// The error naming every subscription not shown to be acknowledged.
163    ///
164    /// Only when each acknowledgement named a distinct subscription in the batch are the matched
165    /// ones left out. Otherwise — a venue that does not echo the subscription, one acknowledgement
166    /// for the whole batch, or a response that named nothing recognisable — any subscription could
167    /// be the unanswered one, so the whole batch is named.
168    fn into_error<Instrument>(self, reason: String, map: &Map<Instrument>) -> SocketError {
169        let narrowed = self.matched.len() == self.count;
170        let mut unacknowledged = map
171            .0
172            .keys()
173            .filter(|id| !(narrowed && self.matched.contains(*id)))
174            .cloned()
175            .collect::<Vec<_>>();
176        unacknowledged.sort_unstable();
177
178        SocketError::Unacknowledged {
179            reason,
180            unacknowledged,
181        }
182    }
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
187mod tests {
188    use super::*;
189    use crate::exchange::okx::Okx;
190    use futures::stream;
191
192    const TIMEOUT: Duration = Duration::from_secs(10);
193
194    fn map(ids: &[&str]) -> Map<usize> {
195        ids.iter()
196            .enumerate()
197            .map(|(key, id)| (SubscriptionId::from(*id), key))
198            .collect()
199    }
200
201    fn unacknowledged(error: SocketError) -> (String, Vec<SubscriptionId>) {
202        match error {
203            SocketError::Unacknowledged {
204                reason,
205                unacknowledged,
206            } => (reason, unacknowledged),
207            other => panic!("expected Unacknowledged, got {other:?}"),
208        }
209    }
210
211    fn ids(ids: &[&str]) -> Vec<SubscriptionId> {
212        ids.iter().map(|id| SubscriptionId::from(*id)).collect()
213    }
214
215    #[tokio::test(start_paused = true)]
216    async fn a_venue_that_does_not_echo_its_subscriptions_names_the_whole_batch() {
217        let mut messages = stream::iter([Ok(WsMessage::text(r#"{"event":"subscribe"}"#))]);
218
219        let error = validate_responses::<Okx, _, _>(
220            map(&["trades|BTC-USDT", "trades|ETH-USDT"]),
221            &mut messages,
222            TIMEOUT,
223        )
224        .await
225        .unwrap_err();
226
227        let (_, unacknowledged) = unacknowledged(error);
228        assert_eq!(unacknowledged, ids(&["trades|BTC-USDT", "trades|ETH-USDT"]));
229    }
230
231    #[cfg(feature = "hyperliquid")]
232    mod hyperliquid {
233        use super::*;
234        use crate::exchange::hyperliquid::Hyperliquid;
235
236        fn hyperliquid_ack(kind: &str, coin: &str) -> Result<WsMessage, WsError> {
237            Ok(WsMessage::text(format!(
238                r#"{{"channel":"subscriptionResponse","data":{{"method":"subscribe","subscription":{{"type":"{kind}","coin":"{coin}"}}}}}}"#
239            )))
240        }
241
242        fn hyperliquid_trade() -> Result<WsMessage, WsError> {
243            Ok(WsMessage::text(
244                r#"{"channel":"trades","data":[{"coin":"BTC","side":"B","px":"1","sz":"1","time":1,"hash":"0x","tid":1}]}"#,
245            ))
246        }
247
248        #[tokio::test(start_paused = true)]
249        async fn every_acknowledgement_validates_and_keeps_early_events() {
250            let mut messages = stream::iter([
251                hyperliquid_ack("trades", "BTC"),
252                hyperliquid_trade(),
253                hyperliquid_ack("l2Book", "ETH"),
254            ]);
255
256            let (map, buffered) = validate_responses::<Hyperliquid, _, _>(
257                map(&["trades|BTC", "l2Book|ETH"]),
258                &mut messages,
259                TIMEOUT,
260            )
261            .await
262            .unwrap();
263
264            assert_eq!(map.0.len(), 2);
265            assert_eq!(buffered.len(), 1);
266        }
267
268        #[tokio::test(start_paused = true)]
269        async fn a_dropped_connection_names_the_subscriptions_left_unacknowledged() {
270            // As Hyperliquid mainnet behaves: a bad coin is never acknowledged and the connection
271            // drops, so the subscription sent after it goes unanswered too.
272            let mut messages = stream::iter([
273                hyperliquid_ack("trades", "BTC"),
274                hyperliquid_ack("l2Book", "ETH"),
275            ]);
276
277            let error = validate_responses::<Hyperliquid, _, _>(
278                map(&["trades|BTC", "l2Book|ETH", "l2Book|XYZ:TSLA", "trades|@107"]),
279                &mut messages,
280                TIMEOUT,
281            )
282            .await
283            .unwrap_err();
284
285            let (reason, unacknowledged) = unacknowledged(error);
286            assert_eq!(reason, "WebSocket stream terminated unexpectedly");
287            assert_eq!(unacknowledged, ids(&["l2Book|XYZ:TSLA", "trades|@107"]));
288        }
289
290        #[tokio::test(start_paused = true)]
291        async fn the_timeout_fires_while_an_acknowledged_subscription_keeps_streaming() {
292            // One event a second, forever: a sleep re-armed per message would never expire
293            let events = stream::unfold((), |()| async {
294                tokio::time::sleep(Duration::from_secs(1)).await;
295                Some((hyperliquid_trade(), ()))
296            });
297            let mut messages =
298                Box::pin(stream::iter([hyperliquid_ack("trades", "BTC")]).chain(events));
299            let started = tokio::time::Instant::now();
300
301            let error = validate_responses::<Hyperliquid, _, _>(
302                map(&["trades|BTC", "l2Book|ETH"]),
303                &mut messages,
304                TIMEOUT,
305            )
306            .await
307            .unwrap_err();
308
309            assert_eq!(started.elapsed(), TIMEOUT);
310            let (reason, unacknowledged) = unacknowledged(error);
311            assert!(reason.contains("timeout"), "{reason}");
312            assert_eq!(unacknowledged, ids(&["l2Book|ETH"]));
313        }
314
315        #[tokio::test(start_paused = true)]
316        async fn an_error_response_names_the_unacknowledged_subscriptions_with_its_reason() {
317            let mut messages = stream::iter([
318                hyperliquid_ack("trades", "BTC"),
319                Ok(WsMessage::text(
320                    r#"{"channel":"error","data":"invalid subscription"}"#,
321                )),
322            ]);
323
324            let error = validate_responses::<Hyperliquid, _, _>(
325                map(&["trades|BTC", "l2Book|ETH"]),
326                &mut messages,
327                TIMEOUT,
328            )
329            .await
330            .unwrap_err();
331
332            let (reason, unacknowledged) = unacknowledged(error);
333            assert_eq!(
334                reason,
335                "received failure subscription response: invalid subscription"
336            );
337            assert_eq!(unacknowledged, ids(&["l2Book|ETH"]));
338        }
339
340        #[tokio::test(start_paused = true)]
341        async fn a_repeated_acknowledgement_names_the_whole_batch() {
342            // Two acknowledgements, one subscription matched: the second answered something else
343            let mut messages = stream::iter([
344                hyperliquid_ack("trades", "BTC"),
345                hyperliquid_ack("trades", "BTC"),
346            ]);
347
348            let error = validate_responses::<Hyperliquid, _, _>(
349                map(&["trades|BTC", "l2Book|ETH", "l2Book|SOL"]),
350                &mut messages,
351                TIMEOUT,
352            )
353            .await
354            .unwrap_err();
355
356            let (_, unacknowledged) = unacknowledged(error);
357            assert_eq!(
358                unacknowledged,
359                ids(&["l2Book|ETH", "l2Book|SOL", "trades|BTC"])
360            );
361        }
362
363        #[tokio::test(start_paused = true)]
364        async fn an_acknowledgement_naming_no_subscription_in_the_batch_names_the_whole_batch() {
365            // Which subscription it answered is unknown, so any of them may be the unanswered one
366            let mut messages = stream::iter([hyperliquid_ack("trades", "SOL")]);
367
368            let error = validate_responses::<Hyperliquid, _, _>(
369                map(&["trades|BTC", "l2Book|ETH"]),
370                &mut messages,
371                TIMEOUT,
372            )
373            .await
374            .unwrap_err();
375
376            let (_, unacknowledged) = unacknowledged(error);
377            assert_eq!(unacknowledged, ids(&["l2Book|ETH", "trades|BTC"]));
378        }
379    }
380}