Skip to main content

polyoxide_binance/usdm/ws/
supervised.rs

1//! The supervised tier: one kit `Supervisor` per routed path, with keep-alive
2//! on the wall clock, staleness detection, reconnect with a paced replay of
3//! the path's streams, rotation before Binance's 24-hour cutoff and outage
4//! markers, behind a router that sends each membership change to its path's
5//! connection, opens a path for its first stream, and merges every path's
6//! events into one stream.
7
8#[cfg(test)]
9use std::time::Duration;
10use std::{
11    fmt,
12    pin::Pin,
13    sync::Arc,
14    task::{Context, Poll},
15};
16
17use futures_util::Stream;
18use polyoxide_rt::Runtime;
19use polyoxide_ws::{SocketTransport, Timing};
20
21use crate::{
22    shared::ws::{
23        supervised::{self as shared_supervised, Handle, Running, Settings},
24        DEFAULT_MAX_CONNECTION_AGE,
25    },
26    usdm::ws::{
27        error::UsdmWsError, event::Update, protocol::UsdmStreams, stream::StreamName, StreamPath,
28        USDM_WS_BASE,
29    },
30};
31
32/// What the supervised stream yields.
33#[derive(Debug)]
34#[non_exhaustive]
35pub enum Event {
36    /// One frame of one stream, boxed, as the other variants are small.
37    Update(Box<Update>),
38    /// A path's connection was lost. Every update on that path is stale until
39    /// the matching [`Event::Reconnected`], which always follows while the
40    /// client runs.
41    Disconnected {
42        /// The path that went down.
43        path: StreamPath,
44        /// Why.
45        reason: DisconnectReason,
46    },
47    /// The path is current again: a fresh connection has replayed its
48    /// streams, or the path's last stream left during the outage, so nothing
49    /// on it is stale. Sent once per outage, however many attempts it took.
50    /// Drop book state for the path and resync.
51    Reconnected {
52        /// The path that came back.
53        path: StreamPath,
54    },
55}
56
57/// Why a path's connection went down.
58#[derive(Debug)]
59#[non_exhaustive]
60pub enum DisconnectReason {
61    /// The server closed it, with the code and reason it sent, or ended the
62    /// stream with neither.
63    Closed {
64        /// The close code.
65        code: Option<u16>,
66        /// The close reason.
67        reason: String,
68    },
69    /// A transport error, a replay left unanswered for the staleness window,
70    /// or a failed first connect of a path opened by a membership change.
71    Error(UsdmWsError),
72    /// Nothing arrived, pongs included, for the staleness window, or a
73    /// membership change went unanswered for it.
74    Stale,
75    /// Replaced on purpose before Binance's 24-hour cutoff.
76    Rotation,
77}
78
79impl fmt::Display for DisconnectReason {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::Closed { code, reason } => match code {
83                Some(code) => write!(f, "closed by the server ({code} {reason})"),
84                None => f.write_str("closed by the server"),
85            },
86            Self::Error(err) => write!(f, "{err}"),
87            Self::Stale => f.write_str("no frame within the staleness window"),
88            Self::Rotation => f.write_str("rotated before the 24-hour cutoff"),
89        }
90    }
91}
92
93/// Builder for a supervised connection.
94///
95/// Each routed path's connection runs on its own task, on the socket kit's
96/// `Supervisor`, and logs under the `polyoxide_ws` target.
97///
98/// While the consumer leaves 1024 events unread, the paths stop reading but
99/// keep pinging, and that wait is not silence: a slow consumer is never
100/// reported [`Stale`](DisconnectReason::Stale), and no event is dropped. The
101/// server's own pings go unanswered meanwhile. Binance documents that it
102/// closes a connection that has not answered them for 10 minutes; that close
103/// is reconnected like any other, and the events already queued are kept.
104#[derive(Debug, Clone)]
105pub struct UsdmWsBuilder {
106    base_url: String,
107    timing: Timing,
108    streams: Vec<StreamName>,
109    runtime: Option<Arc<dyn Runtime>>,
110    socket_transport: Option<Arc<dyn SocketTransport>>,
111}
112
113impl Default for UsdmWsBuilder {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl UsdmWsBuilder {
120    /// Defaults: the production host, a ping every 20 s, stale after 30 s of
121    /// silence, reconnect backoff from 500 ms doubling to 60 s, a 10 s connect
122    /// timeout, rotation after 23 h 50 min, and no streams.
123    pub fn new() -> Self {
124        let mut timing = Timing::default();
125        timing.max_connection_age = Some(DEFAULT_MAX_CONNECTION_AGE);
126        Self {
127            base_url: USDM_WS_BASE.to_owned(),
128            timing,
129            streams: Vec::new(),
130            runtime: None,
131            socket_transport: None,
132        }
133    }
134
135    /// Connect to another host, such as a local test server.
136    pub fn base_url(mut self, url: impl Into<String>) -> Self {
137        self.base_url = url.into();
138        self
139    }
140
141    polyoxide_ws::timing_setters! {
142        /// How often each connection sends a protocol ping, whatever its
143        /// traffic, and also while the consumer leaves 1024 events unread.
144        /// None goes out while a membership change is in flight, which
145        /// `stale_after` bounds. Binance closes a connection that sends more
146        /// than 10 messages a second.
147        ping_interval;
148        /// Silence after which a connection is presumed dead and replaced.
149        /// Pongs and the server's pings count, so a quiet connection that
150        /// answers stays up, and time the consumer leaves 1024 events unread
151        /// does not count.
152        ///
153        /// Also bounds each replay and each membership change as a whole, its
154        /// 200 ms pacing included: one the server leaves unanswered that long
155        /// replaces the connection. A replay of 1024 streams is six requests,
156        /// about a second.
157        stale_after;
158        /// Reconnect delay schedule: `initial`, doubling to `max`, with no cap
159        /// on attempts, and back to `initial` after a connection that
160        /// delivered an update. Both are raised to at least 1 ms, and an
161        /// `initial` above `max` is lowered to `max`. A reconnect refused with
162        /// a `Retry-After` waits at least that long, up to `max` (DRIFT R11).
163        backoff;
164        /// How long one connection attempt may take to open.
165        connect_timeout;
166        /// How long a connection may live before it is replaced, without
167        /// backoff, ahead of Binance's 24-hour cutoff. Each replacement is a
168        /// new connection, and Binance allows 300 connection attempts per 5
169        /// minutes per IP.
170        max_connection_age;
171    }
172
173    polyoxide_ws::transport_setters! {
174        /// The runtime the paths' supervision tasks, the router and its
175        /// forwarders run on. By default the target's.
176        runtime;
177        /// How each path's connection dials. By default the target's.
178        socket_transport;
179    }
180
181    /// The streams to open with.
182    pub fn streams(mut self, streams: impl IntoIterator<Item = StreamName>) -> Self {
183        self.streams = streams.into_iter().collect();
184        self
185    }
186
187    /// Open a connection for each path the streams need, subscribe, and start
188    /// supervising.
189    ///
190    /// Fails if any first connect fails, closing the others: a setup that is
191    /// wrong is reported, not retried. With no streams it opens nothing until
192    /// the first [`MembershipHandle::subscribe`].
193    pub async fn connect(self) -> Result<SupervisedUsdmWs, UsdmWsError> {
194        let inner = shared_supervised::connect::<UsdmStreams>(Settings {
195            base_url: self.base_url,
196            timing: self.timing,
197            streams: self.streams,
198            runtime: self.runtime,
199            socket_transport: self.socket_transport,
200        })
201        .await?;
202        Ok(SupervisedUsdmWs { inner })
203    }
204}
205
206/// Changes the streams of a running [`SupervisedUsdmWs`].
207///
208/// Each stream is routed to its path's connection, opening it if this is the
209/// path's first stream and closing it when its last leaves. The membership is
210/// a set: subscribing a stream twice holds it once, and counting references is
211/// the caller's job.
212///
213/// A change is recorded before it is sent. During a path's outage it is
214/// answered `Ok` at once, and the replay applies it; so is a change whose
215/// connection fails under it, or one the server leaves unanswered for
216/// `stale_after`, which replaces the connection. A change that arrives while
217/// a connect or a replay is in flight waits for that attempt. Calls are served
218/// one at a time across paths, so a change for a healthy path can wait behind
219/// another path's attempt.
220///
221/// A call fails when the server refuses it ([`UsdmWsError::Refused`]), when it
222/// would pass 1024 streams on a path ([`UsdmWsError::TooManyStreams`], nothing
223/// sent), when the first connect of a path it opens fails in a way retrying
224/// cannot fix ([`UsdmWsError::Connect`]), or when the client has stopped
225/// ([`UsdmWsError::Stopped`]). Its `cause` is the error a path stopped on once
226/// the router has that path's stop; a call in flight when the path stops can
227/// get `Stopped { cause: None }`. A subscription of more than 200 names is sent
228/// in batches, so one refused part-way leaves the earlier batches applied.
229///
230/// A call waits for the path's task, which takes no change while the consumer
231/// leaves 1024 events unread. Make membership changes from a task other than
232/// the one draining the stream, or keep draining while they run.
233#[derive(Debug, Clone)]
234pub struct MembershipHandle {
235    inner: Handle<UsdmStreams>,
236}
237
238impl MembershipHandle {
239    /// Subscribe to more streams.
240    pub async fn subscribe(
241        &self,
242        streams: impl IntoIterator<Item = StreamName>,
243    ) -> Result<(), UsdmWsError> {
244        self.inner.subscribe(streams.into_iter().collect()).await
245    }
246
247    /// Unsubscribe from streams. A stream not subscribed is ignored.
248    pub async fn unsubscribe(
249        &self,
250        streams: impl IntoIterator<Item = StreamName>,
251    ) -> Result<(), UsdmWsError> {
252        self.inner.unsubscribe(streams.into_iter().collect()).await
253    }
254}
255
256/// A supervised set of connections: a `Stream` of [`Event`]s that survives
257/// drops, stalls and Binance's 24-hour cutoff.
258///
259/// Every [`Event::Disconnected`] is followed by an [`Event::Reconnected`] for
260/// the same path while the client runs. A path that closes because its last
261/// stream left, while it is up, yields neither.
262///
263/// A fatal error on any path, such as the server refusing a replay after a
264/// reconnect, is yielded as `Err` and then the stream ends, every path
265/// closed. An `Err(UsdmWsError::Frame { .. })`, a frame that did not decode,
266/// does not end it.
267///
268/// ```no_run
269/// use futures_util::StreamExt;
270/// use polyoxide_binance::usdm::{types::Symbol, ws::{Event, StreamName, UsdmWsBuilder, UsdmWsError}};
271///
272/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
273/// let btc = Symbol::new("BTCUSDT")?;
274/// let mut feed = UsdmWsBuilder::new()
275///     .streams([StreamName::MarkPrice(btc.clone()), StreamName::BookTicker(btc)])
276///     .connect()
277///     .await?;
278/// let membership = feed.membership();
279/// while let Some(event) = feed.next().await {
280///     let event = match event {
281///         Ok(event) => event,
282///         // A frame that did not decode is skipped; anything else ends the stream.
283///         Err(UsdmWsError::Frame { .. }) => continue,
284///         Err(err) => return Err(err.into()),
285///     };
286///     match event {
287///         Event::Update(update) => println!("{}", serde_json::to_string(&update)?),
288///         Event::Disconnected { path, reason } => eprintln!("{path} down: {reason}"),
289///         Event::Reconnected { path } => eprintln!("{path} back"),
290///         _ => {}
291///     }
292/// }
293/// # membership.subscribe([StreamName::AllMarkPrices]).await?;
294/// # Ok(())
295/// # }
296/// ```
297pub struct SupervisedUsdmWs {
298    inner: Running<UsdmStreams>,
299}
300
301impl fmt::Debug for SupervisedUsdmWs {
302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303        f.debug_struct("SupervisedUsdmWs")
304            .field("running", &self.inner.is_running())
305            .finish_non_exhaustive()
306    }
307}
308
309impl SupervisedUsdmWs {
310    /// A handle for changing streams while the stream runs.
311    pub fn membership(&self) -> MembershipHandle {
312        MembershipHandle {
313            inner: self.inner.membership(),
314        }
315    }
316
317    /// Close every connection and stop. Every [`MembershipHandle`] answers
318    /// [`UsdmWsError::Stopped`] afterwards.
319    ///
320    /// Fails with [`UsdmWsError::Stopped`] if a path had stopped the client
321    /// on an error, which the stream yielded: its `cause` is that error, as
322    /// [`Classify`](polyoxide_venue::Classify) read it, and so is the cause
323    /// every later membership call carries. That holds once the router has the
324    /// path's stop: a `close()` made right after the stream yielded the
325    /// stopping error can return `Ok` or `Stopped { cause: None }`. Read the
326    /// stream to its end first to be sure of the cause.
327    pub async fn close(self) -> Result<(), UsdmWsError> {
328        self.inner.close().await
329    }
330}
331
332impl Stream for SupervisedUsdmWs {
333    type Item = Result<Event, UsdmWsError>;
334
335    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
336        self.inner.poll_event(cx)
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use polyoxide_ws::Backoff;
343
344    use super::*;
345
346    #[test]
347    fn defaults_match_the_documented_cadences() {
348        let b = UsdmWsBuilder::new();
349        assert_eq!(b.base_url, USDM_WS_BASE);
350        assert_eq!(b.timing.ping_interval, Some(Duration::from_secs(20)));
351        assert_eq!(b.timing.stale_after, Duration::from_secs(30));
352        assert_eq!(b.timing.initial_backoff, Duration::from_millis(500));
353        assert_eq!(b.timing.max_backoff, Duration::from_secs(60));
354        assert_eq!(b.timing.connect_timeout, Duration::from_secs(10));
355        assert_eq!(
356            b.timing.max_connection_age,
357            Some(Duration::from_secs(85_800))
358        );
359        assert!(b.timing.max_connection_age < Some(Duration::from_secs(24 * 3600)));
360    }
361
362    #[test]
363    fn backoff_doubles_to_the_ceiling_and_resets_after_a_working_connection() {
364        let mut b = Backoff::new(Duration::from_millis(100), Duration::from_millis(350));
365        assert_eq!(b.take(), Duration::from_millis(100));
366        assert_eq!(b.take(), Duration::from_millis(200));
367        assert_eq!(b.take(), Duration::from_millis(350));
368        b.after_connection_ended(true);
369        assert_eq!(b.take(), Duration::from_millis(100));
370        b.after_connection_ended(false);
371        assert_eq!(b.take(), Duration::from_millis(200));
372    }
373
374    #[test]
375    fn a_zero_initial_delay_still_grows_and_a_huge_one_saturates() {
376        let mut b = Backoff::new(Duration::ZERO, Duration::from_secs(1));
377        assert_eq!(b.take(), Duration::from_millis(1));
378        assert_eq!(b.take(), Duration::from_millis(2));
379        let mut b = Backoff::new(Duration::MAX, Duration::MAX);
380        assert_eq!(b.take(), Duration::MAX);
381        assert_eq!(b.take(), Duration::MAX);
382    }
383
384    /// A runtime that counts its spawns and runs them on tokio's.
385    #[derive(Debug, Default)]
386    struct CountingRuntime {
387        inner: polyoxide_rt::TokioRuntime,
388        spawns: std::sync::atomic::AtomicUsize,
389    }
390
391    impl Runtime for CountingRuntime {
392        fn spawn(&self, task: polyoxide_rt::BoxFuture<()>) {
393            self.spawns
394                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
395            self.inner.spawn(task);
396        }
397
398        fn sleep(&self, duration: Duration) -> polyoxide_rt::BoxFuture<()> {
399            self.inner.sleep(duration)
400        }
401
402        fn yield_now(&self) -> polyoxide_rt::BoxFuture<()> {
403            Box::pin(tokio::task::yield_now())
404        }
405
406        fn now(&self) -> polyoxide_rt::Instant {
407            self.inner.now()
408        }
409    }
410
411    #[tokio::test]
412    async fn the_router_and_each_path_are_spawned_on_the_injected_runtime() {
413        use crate::usdm::ws::scripted::{Script, ScriptedServer};
414
415        let server = ScriptedServer::start(vec![Script::default()]).await;
416        let runtime = std::sync::Arc::new(CountingRuntime::default());
417        let btc = crate::usdm::types::Symbol::new("BTCUSDT").unwrap();
418        let feed = UsdmWsBuilder::new()
419            .base_url(server.url.clone())
420            .runtime(runtime.clone())
421            .streams([StreamName::MarkPrice(btc)])
422            .connect()
423            .await
424            .expect("connect");
425        // One spawn for the path's supervision task, one for its forwarder and
426        // one for the router.
427        assert_eq!(runtime.spawns.load(std::sync::atomic::Ordering::SeqCst), 3);
428        feed.close().await.expect("close");
429    }
430}