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}