1use std::sync::{
33 Arc,
34 atomic::{AtomicBool, Ordering},
35};
36
37use ahash::AHashMap;
38use futures_util::{StreamExt, stream::FuturesUnordered};
39use nautilus_live::{
40 SocketControlFactory,
41 book::snapshot::SnapshotGate,
42 task::{TaskJoinOutcome, TaskSlot, TaskSpawnError, finish_task},
43};
44use nautilus_network::websocket::{TransportBackend, proxy::ProxyUrl};
45use parking_lot::Mutex;
46use ustr::Ustr;
47
48use super::{
49 MARKET_STREAMS_ENDPOINT,
50 client::{PolymarketWebSocketClient, WsSubscriptionHandle},
51 handler::CycleMarketOutcome,
52 messages::{MarketWsMessage, PolymarketWsMessage},
53};
54use crate::common::consts::WS_DEFAULT_SUBSCRIPTIONS;
55
56const PRIMARY_SHARD_ID: usize = 0;
58
59#[derive(Debug)]
61pub struct PolymarketMarketConnectionPool {
62 inner: Arc<PoolInner>,
63}
64
65#[derive(Clone, Debug)]
69pub struct PolymarketMarketPoolHandle {
70 inner: Arc<PoolInner>,
71}
72
73#[derive(Debug)]
74struct PoolInner {
75 base_url: Option<String>,
76 proxy_url: Option<ProxyUrl>,
77 transport_backend: TransportBackend,
78 subscribe_new_markets: bool,
79 max_subscriptions: usize,
80 connect_timeout_secs: Mutex<Option<u64>>,
81 wire_mutex: tokio::sync::Mutex<()>,
83 state: Mutex<PoolState>,
85 out_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<PolymarketWsMessage>>>,
86 out_rx: Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>>>,
87 socket_factory: Mutex<Option<SocketControlFactory>>,
88 closed: AtomicBool,
89}
90
91#[derive(Debug, Default)]
92struct PoolState {
93 shards: AHashMap<usize, ShardEntry>,
94 assignments: AHashMap<Ustr, usize>,
95 shutdown_errors: Vec<String>,
96}
97
98struct PoolDrain<'a> {
99 owner: &'a Mutex<PoolState>,
100 state: PoolState,
101 completed: Vec<usize>,
102}
103
104impl<'a> PoolDrain<'a> {
105 fn take(owner: &'a Mutex<PoolState>) -> Self {
106 let state = std::mem::take(&mut *owner.lock());
107 Self {
108 owner,
109 state,
110 completed: Vec::new(),
111 }
112 }
113}
114
115impl Drop for PoolDrain<'_> {
116 fn drop(&mut self) {
117 for id in &self.completed {
118 self.state.shards.remove(id);
119 }
120
121 self.state
123 .assignments
124 .retain(|_, id| self.state.shards.contains_key(id));
125 *self.owner.lock() = std::mem::take(&mut self.state);
126 }
127}
128
129#[derive(Debug)]
130struct ShardEntry {
131 client: PolymarketWebSocketClient,
132 handle: WsSubscriptionHandle,
133 forwarder: TaskSlot<()>,
134 owned: usize,
135 closing: bool,
136}
137
138enum ReleaseOutcome {
139 NotOwned,
140 Unsubscribe(WsSubscriptionHandle),
141 CloseShard(usize, Box<ShardEntry>),
142}
143
144struct ShardClose<'a> {
145 owner: &'a Mutex<PoolState>,
146 id: usize,
147 shard: Option<Box<ShardEntry>>,
148}
149
150impl<'a> ShardClose<'a> {
151 fn new(owner: &'a Mutex<PoolState>, id: usize, mut shard: Box<ShardEntry>) -> Self {
152 shard.closing = true;
153 Self {
154 owner,
155 id,
156 shard: Some(shard),
157 }
158 }
159
160 fn shard_mut(&mut self) -> &mut ShardEntry {
161 self.shard.as_deref_mut().expect("closing shard present")
162 }
163
164 fn complete(mut self) {
165 self.shard.take();
166 }
167}
168
169impl Drop for ShardClose<'_> {
170 fn drop(&mut self) {
171 if let Some(shard) = self.shard.take() {
172 let replaced = self.owner.lock().shards.insert(self.id, *shard);
173 assert!(replaced.is_none(), "closing shard ID is already present");
174 }
175 }
176}
177
178#[allow(
179 clippy::missing_panics_doc,
180 reason = "internal mutex locks and shard-state invariants are not expected to panic"
181)]
182impl PolymarketMarketConnectionPool {
183 #[must_use]
188 pub fn new(
189 base_url: Option<String>,
190 subscribe_new_markets: bool,
191 transport_backend: TransportBackend,
192 max_subscriptions: usize,
193 ) -> Self {
194 Self::new_with_proxy(
195 base_url,
196 subscribe_new_markets,
197 transport_backend,
198 max_subscriptions,
199 None,
200 )
201 }
202
203 #[must_use]
205 pub fn new_with_proxy(
206 base_url: Option<String>,
207 subscribe_new_markets: bool,
208 transport_backend: TransportBackend,
209 max_subscriptions: usize,
210 proxy_url: Option<ProxyUrl>,
211 ) -> Self {
212 Self {
213 inner: Arc::new(PoolInner::new_with_proxy(
214 base_url,
215 transport_backend,
216 subscribe_new_markets,
217 max_subscriptions,
218 proxy_url,
219 )),
220 }
221 }
222
223 pub(crate) fn with_connect_timeout(self, timeout_secs: u64) -> Self {
224 *self.inner.connect_timeout_secs.lock() = Some(timeout_secs);
225 self
226 }
227
228 #[must_use]
230 pub(crate) fn with_socket_factory(self, factory: SocketControlFactory) -> Self {
231 *self.inner.socket_factory.lock() = Some(factory);
232 self
233 }
234
235 #[cfg(test)]
236 pub(crate) fn proxy_url(&self) -> Option<&ProxyUrl> {
237 self.inner.proxy_url.as_ref()
238 }
239
240 #[must_use]
242 pub fn handle(&self) -> PolymarketMarketPoolHandle {
243 PolymarketMarketPoolHandle {
244 inner: Arc::clone(&self.inner),
245 }
246 }
247
248 pub(crate) async fn prepare(&self) -> anyhow::Result<()> {
249 if self.inner.closed.load(Ordering::Acquire) {
250 self.disconnect().await?;
251 }
252
253 let _wire = self.inner.wire_mutex.lock().await;
254 self.inner.prepare_channel();
255 Ok(())
256 }
257
258 pub async fn connect(&self) -> anyhow::Result<()> {
264 if self.inner.closed.load(Ordering::Acquire) {
265 self.disconnect().await?;
266 }
267
268 let _wire = self.inner.wire_mutex.lock().await;
269
270 if !self.inner.closed.load(Ordering::Acquire) && !self.inner.state.lock().shards.is_empty()
271 {
272 log::warn!("Polymarket market pool already connected");
273 return Ok(());
274 }
275
276 self.inner.prepare_channel();
277 self.inner.connect_new_shard(true).await?;
278 Ok(())
279 }
280
281 pub async fn subscribe_new_markets_feed(&self) -> anyhow::Result<()> {
287 let _wire = self.inner.wire_mutex.lock().await;
288
289 let handle = {
290 let state = self.inner.state.lock();
291 if self.inner.closed.load(Ordering::Acquire) {
292 anyhow::bail!("Market connection pool is closed");
293 }
294 state
295 .shards
296 .get(&PRIMARY_SHARD_ID)
297 .map(|shard| shard.handle.clone())
298 };
299
300 match handle {
301 Some(handle) => handle.subscribe_market(vec![]).await,
302 None => anyhow::bail!("No primary market shard available for new-market discovery"),
303 }
304 }
305
306 #[must_use]
308 pub fn take_message_receiver(
309 &self,
310 ) -> Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>> {
311 self.inner.out_rx.lock().take()
312 }
313
314 pub async fn disconnect(&self) -> anyhow::Result<()> {
321 self.inner.begin_shutdown();
322 let _wire = self.inner.wire_mutex.lock().await;
323
324 let mut drain = PoolDrain::take(&self.inner.state);
325 {
326 let errors = Mutex::new(&mut drain.state.shutdown_errors);
327 let mut disconnects = drain
328 .state
329 .shards
330 .iter_mut()
331 .map(|(&shard_id, shard)| {
332 let errors = &errors;
333 async move {
334 let mut failed = false;
335 shard.forwarder.abort();
336 if let Some(outcome) = finish_task(
337 &mut shard.forwarder,
338 std::time::Duration::ZERO,
339 std::time::Duration::from_secs(2),
340 )
341 .await
342 {
343 match outcome {
344 TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
345 TaskJoinOutcome::Failed(error) => {
346 failed = true;
347 errors.lock().push(format!(
348 "market shard {shard_id} forwarder failed: {error}"
349 ));
350 }
351 TaskJoinOutcome::Incomplete => {
352 failed = true;
353 errors.lock().push(format!(
354 "market shard {shard_id} forwarder did not stop after abort"
355 ));
356 }
357 }
358 }
359
360 if let Err(e) = shard.client.disconnect().await {
361 failed = true;
362 errors
363 .lock()
364 .push(format!("market shard {shard_id} disconnect failed: {e}"));
365 }
366 (shard_id, failed)
367 }
368 })
369 .collect::<FuturesUnordered<_>>();
370
371 while let Some((shard_id, failed)) = disconnects.next().await {
372 if !failed {
373 drain.completed.push(shard_id);
374 }
375 }
376 }
377
378 if !drain.state.shutdown_errors.is_empty() {
379 let errors = std::mem::take(&mut drain.state.shutdown_errors);
380 anyhow::bail!(
381 "Polymarket market pool shutdown failed: {}",
382 errors.join("; ")
383 );
384 }
385
386 *self.inner.out_tx.lock() = None;
387 *self.inner.out_rx.lock() = None;
388 Ok(())
389 }
390
391 pub(crate) fn begin_shutdown(&self) {
392 self.inner.begin_shutdown();
393 }
394
395 pub(crate) fn clear_reconnect_state(&self) {
397 let state = self.inner.state.lock();
398 for shard in state.shards.values() {
399 shard.client.clear_reconnect_state();
400 }
401 }
402
403 #[must_use]
405 pub fn connection_count(&self) -> usize {
406 self.inner.state.lock().shards.len()
407 }
408
409 #[must_use]
411 pub fn subscription_count(&self) -> usize {
412 self.inner.state.lock().assignments.len()
413 }
414}
415
416impl Drop for PoolInner {
417 fn drop(&mut self) {
418 self.closed.store(true, Ordering::Release);
419
420 for shard in self.state.get_mut().shards.values_mut() {
421 shard.forwarder.abort();
422 shard.client.abort();
423 }
424 }
425}
426
427#[allow(
428 clippy::missing_panics_doc,
429 reason = "internal mutex locks and shard-state invariants are not expected to panic"
430)]
431impl PolymarketMarketPoolHandle {
432 pub(crate) fn begin_shutdown(&self) {
433 self.inner.begin_shutdown();
434 }
435
436 pub async fn subscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
442 let _wire = self.inner.wire_mutex.lock().await;
443 self.inner.ensure_open()?;
444 for asset_id in asset_ids {
445 self.inner.subscribe_one(asset_id).await?;
446 }
447 Ok(())
448 }
449
450 pub async fn unsubscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
456 let _wire = self.inner.wire_mutex.lock().await;
457 self.inner.ensure_open()?;
458 for asset_id in asset_ids {
459 self.inner.unsubscribe_one(asset_id).await?;
460 }
461 Ok(())
462 }
463
464 pub async fn resubscribe_market(
476 &self,
477 asset_ids: Vec<String>,
478 cancel: &tokio_util::sync::CancellationToken,
479 gate: &SnapshotGate,
480 ) -> anyhow::Result<CycleMarketOutcome> {
481 let mut outcome = CycleMarketOutcome::Completed;
482
483 for asset_id in asset_ids {
484 outcome = self.inner.cycle_one(&asset_id, cancel, gate).await?;
485
486 if !matches!(outcome, CycleMarketOutcome::Completed) {
487 break;
488 }
489 }
490
491 Ok(outcome)
492 }
493
494 pub(crate) fn tokens_for_shard(&self, shard_id: usize) -> Vec<Ustr> {
496 let state = self.inner.state.lock();
497 state
498 .assignments
499 .iter()
500 .filter_map(|(token, assigned)| (*assigned == shard_id).then_some(*token))
501 .collect()
502 }
503}
504
505impl PoolInner {
506 fn begin_shutdown(&self) {
507 let state = self.state.lock();
508 self.closed.store(true, Ordering::Release);
509
510 for shard in state.shards.values() {
511 shard.client.begin_shutdown();
512 }
513 }
514
515 fn prepare_channel(&self) {
516 {
517 let _state = self.state.lock();
518 self.closed.store(false, Ordering::Release);
519 }
520
521 if self.out_tx.lock().is_some() {
522 return;
523 }
524
525 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel();
526 *self.out_tx.lock() = Some(out_tx);
527 *self.out_rx.lock() = Some(out_rx);
528 }
529
530 fn ensure_open(&self) -> anyhow::Result<()> {
531 let _state = self.state.lock();
532
533 if self.closed.load(Ordering::Acquire) {
534 anyhow::bail!("Market connection pool is closed");
535 }
536 Ok(())
537 }
538}
539
540impl PoolInner {
541 #[cfg(test)]
542 fn new(
543 base_url: Option<String>,
544 transport_backend: TransportBackend,
545 subscribe_new_markets: bool,
546 max_subscriptions: usize,
547 ) -> Self {
548 Self::new_with_proxy(
549 base_url,
550 transport_backend,
551 subscribe_new_markets,
552 max_subscriptions,
553 None,
554 )
555 }
556
557 fn new_with_proxy(
558 base_url: Option<String>,
559 transport_backend: TransportBackend,
560 subscribe_new_markets: bool,
561 max_subscriptions: usize,
562 proxy_url: Option<ProxyUrl>,
563 ) -> Self {
564 let max_subscriptions = if max_subscriptions == 0 {
565 log::warn!(
566 "PolymarketDataClientConfig.ws_max_subscriptions=0 is invalid, using {WS_DEFAULT_SUBSCRIPTIONS}"
567 );
568 WS_DEFAULT_SUBSCRIPTIONS
569 } else {
570 max_subscriptions
571 };
572
573 Self {
574 base_url,
575 proxy_url,
576 transport_backend,
577 subscribe_new_markets,
578 max_subscriptions,
579 connect_timeout_secs: Mutex::new(None),
580 wire_mutex: tokio::sync::Mutex::new(()),
581 state: Mutex::new(PoolState::default()),
582 out_tx: Mutex::new(None),
583 out_rx: Mutex::new(None),
584 socket_factory: Mutex::new(None),
585 closed: AtomicBool::new(false),
586 }
587 }
588
589 async fn subscribe_one(&self, asset_id: String) -> anyhow::Result<()> {
591 let token = Ustr::from(asset_id.as_str());
592
593 let Some(handle) = self.assign(token).await? else {
594 return Ok(());
595 };
596
597 if let Err(e) = self.ensure_open() {
598 if let ReleaseOutcome::CloseShard(id, shard) = self.release(token)
599 && let Err(close_error) = self.close_shard(id, shard).await
600 {
601 anyhow::bail!("{e}; subscription rollback failed: {close_error}");
602 }
603 return Err(e);
604 }
605
606 if let Err(e) = handle.subscribe_market(vec![asset_id]).await {
607 if let ReleaseOutcome::CloseShard(id, shard) = self.release(token)
609 && let Err(close_error) = self.close_shard(id, shard).await
610 {
611 anyhow::bail!("{e}; subscription rollback failed: {close_error}");
612 }
613 return Err(e);
614 }
615 Ok(())
616 }
617
618 async fn unsubscribe_one(&self, asset_id: String) -> anyhow::Result<()> {
620 self.ensure_open()?;
621 let token = Ustr::from(asset_id.as_str());
622
623 match self.release(token) {
624 ReleaseOutcome::NotOwned => Ok(()),
625 ReleaseOutcome::Unsubscribe(handle) => handle.unsubscribe_market(vec![asset_id]).await,
626 ReleaseOutcome::CloseShard(id, shard) => {
627 self.close_shard(id, shard).await
629 }
630 }
631 }
632
633 async fn cycle_one(
634 &self,
635 asset_id: &str,
636 cancel: &tokio_util::sync::CancellationToken,
637 gate: &SnapshotGate,
638 ) -> anyhow::Result<CycleMarketOutcome> {
639 let responder_rx = {
640 let _wire = self.wire_mutex.lock().await;
641 self.ensure_open()?;
642
643 let handle = {
644 let state = self.state.lock();
645 let token = Ustr::from(asset_id);
646
647 let Some(id) = state.assignments.get(&token).copied() else {
648 anyhow::bail!("Market asset {asset_id} is not owned by any shard");
649 };
650
651 state
652 .shards
653 .get(&id)
654 .map(|shard| shard.handle.clone())
655 .ok_or_else(|| anyhow::anyhow!("Market shard {id} is not available"))?
656 };
657
658 let (responder_tx, responder_rx) = tokio::sync::oneshot::channel();
661 handle
662 .cycle_market_subscription(
663 vec![asset_id.to_string()],
664 cancel.clone(),
665 responder_tx,
666 gate.clone(),
667 )
668 .await?;
669 responder_rx
670 };
671
672 responder_rx
673 .await
674 .map_err(|_| anyhow::anyhow!("Market subscription cycle response lost"))
675 }
676
677 async fn assign(&self, token: Ustr) -> anyhow::Result<Option<WsSubscriptionHandle>> {
679 {
680 let mut state = self.state.lock();
681
682 if self.closed.load(Ordering::Acquire) {
683 anyhow::bail!("Market connection pool is closed");
684 }
685
686 if state.assignments.contains_key(&token) {
687 return Ok(None);
688 }
689
690 if let Some(id) = smallest_shard_with_capacity(&state, self.max_subscriptions) {
691 let handle = {
692 let shard = state.shards.get_mut(&id).expect("shard present");
693 shard.owned += 1;
694 shard.handle.clone()
695 };
696 state.assignments.insert(token, id);
697 return Ok(Some(handle));
698 }
699 }
700
701 let id = self.connect_new_shard(false).await?;
702
703 let rejected_shard = {
704 let mut state = self.state.lock();
705
706 if self.closed.load(Ordering::Acquire) {
707 Some(
708 state
709 .shards
710 .remove(&id)
711 .expect("new shard retained for shutdown"),
712 )
713 } else {
714 let handle = {
715 let shard = state.shards.get_mut(&id).expect("new shard present");
716 shard.owned += 1;
717 shard.handle.clone()
718 };
719 state.assignments.insert(token, id);
720 return Ok(Some(handle));
721 }
722 };
723
724 if let Some(shard) = rejected_shard {
725 if let Err(e) = self.close_shard(id, Box::new(shard)).await {
726 anyhow::bail!("Market connection pool is closed; shard rollback failed: {e}");
727 }
728 anyhow::bail!("Market connection pool is closed");
729 }
730 unreachable!("open pool returned from assignment")
731 }
732
733 fn release(&self, token: Ustr) -> ReleaseOutcome {
734 let mut state = self.state.lock();
735
736 let Some(id) = state.assignments.remove(&token) else {
737 return ReleaseOutcome::NotOwned;
738 };
739
740 let owned = {
741 let Some(shard) = state.shards.get_mut(&id) else {
742 return ReleaseOutcome::NotOwned;
743 };
744 shard.owned = shard.owned.saturating_sub(1);
745 shard.owned
746 };
747
748 if id != PRIMARY_SHARD_ID && owned == 0 {
749 let shard = state.shards.remove(&id).expect("shard present");
750 ReleaseOutcome::CloseShard(id, Box::new(shard))
751 } else {
752 let handle = state.shards.get(&id).expect("shard present").handle.clone();
753 ReleaseOutcome::Unsubscribe(handle)
754 }
755 }
756
757 async fn connect_new_shard(&self, is_primary: bool) -> anyhow::Result<usize> {
758 if self.closed.load(Ordering::Acquire) {
759 anyhow::bail!("Market connection pool is closed");
760 }
761
762 let id = if is_primary {
763 PRIMARY_SHARD_ID
764 } else {
765 let state = self.state.lock();
766 available_shard_id(&state)
767 };
768
769 let mut client = self.market_client(self.subscribe_new_markets, id);
770 client.connect().await?;
771
772 let handle = client.clone_subscription_handle();
773 let rx = client
774 .take_message_receiver()
775 .ok_or_else(|| anyhow::anyhow!("Market shard receiver unavailable after connect"))?;
776
777 let forwarder = match self.spawn_forwarder(rx, id) {
778 Ok(forwarder) => forwarder,
779 Err((e, forwarder)) => {
780 let shard = Box::new(ShardEntry {
781 client,
782 handle,
783 forwarder,
784 owned: 0,
785 closing: true,
786 });
787
788 if let Err(close_error) = self.close_shard(id, shard).await {
789 anyhow::bail!(
790 "Failed to start market shard forwarder: {e}; startup rollback failed: \
791 {close_error}"
792 );
793 }
794 anyhow::bail!("Failed to start market shard forwarder: {e}");
795 }
796 };
797
798 let shard = ShardEntry {
799 client,
800 handle,
801 forwarder,
802 owned: 0,
803 closing: false,
804 };
805 let rejected_shard = {
806 let mut state = self.state.lock();
807
808 if self.closed.load(Ordering::Acquire) {
809 Some(shard)
810 } else {
811 state.shards.insert(id, shard);
812 None
813 }
814 };
815
816 if let Some(shard) = rejected_shard {
817 if let Err(e) = self.close_shard(id, Box::new(shard)).await {
818 anyhow::bail!("Market connection pool is closed; shard rollback failed: {e}");
819 }
820 anyhow::bail!("Market connection pool is closed");
821 }
822
823 log::debug!("Opened Polymarket market shard {id}");
824 Ok(id)
825 }
826
827 fn market_client(
828 &self,
829 subscribe_new_markets: bool,
830 shard_id: usize,
831 ) -> PolymarketWebSocketClient {
832 let client = PolymarketWebSocketClient::new_market_with_proxy(
833 self.base_url.clone(),
834 subscribe_new_markets,
835 self.transport_backend,
836 self.proxy_url.clone(),
837 );
838
839 let client = match *self.connect_timeout_secs.lock() {
840 Some(timeout_secs) => client.with_connect_timeout(timeout_secs),
841 None => client,
842 };
843
844 let factory = self.socket_factory.lock().clone();
845
846 if let Some(factory) = factory {
847 let endpoint = if shard_id == PRIMARY_SHARD_ID {
848 MARKET_STREAMS_ENDPOINT.to_string()
849 } else {
850 format!("{MARKET_STREAMS_ENDPOINT}-{shard_id}")
851 };
852 client.with_socket_control(factory.control(endpoint))
853 } else {
854 client
855 }
856 }
857
858 fn spawn_forwarder(
859 &self,
860 mut rx: tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>,
861 shard_id: usize,
862 ) -> Result<TaskSlot<()>, (TaskSpawnError, TaskSlot<()>)> {
863 let out_tx = self.out_tx.lock().clone();
864 let is_primary = shard_id == PRIMARY_SHARD_ID;
865
866 let mut forwarder = TaskSlot::new();
867 if let Err(e) = forwarder.spawn(async move {
868 let Some(out_tx) = out_tx else {
869 return;
870 };
871
872 while let Some(msg) = rx.recv().await {
873 if !should_forward_from_shard(&msg, is_primary) {
874 continue;
875 }
876
877 let msg = match msg {
878 PolymarketWsMessage::Reconnected { .. } => PolymarketWsMessage::Reconnected {
879 shard_id: Some(shard_id),
880 },
881 other => other,
882 };
883
884 if out_tx.send(msg).is_err() {
885 break;
886 }
887 }
888 }) {
889 return Err((e, forwarder));
890 }
891 Ok(forwarder)
892 }
893
894 async fn close_shard(&self, id: usize, shard: Box<ShardEntry>) -> anyhow::Result<()> {
895 let mut close = ShardClose::new(&self.state, id, shard);
896 close.shard_mut().forwarder.abort();
897 let forwarder_stopped = match finish_task(
898 &mut close.shard_mut().forwarder,
899 std::time::Duration::ZERO,
900 std::time::Duration::from_secs(2),
901 )
902 .await
903 {
904 None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => true,
905 Some(TaskJoinOutcome::Failed(error)) => {
906 let error = format!("market shard {id} forwarder failed: {error}");
907 self.state.lock().shutdown_errors.push(error);
908 true
909 }
910 Some(TaskJoinOutcome::Incomplete) => {
911 let error = format!("market shard {id} forwarder did not stop after abort");
912 self.state.lock().shutdown_errors.push(error);
913 false
914 }
915 };
916
917 if let Err(e) = close.shard_mut().client.disconnect().await {
918 let error = format!("market shard {id} disconnect failed: {e}");
919 self.state.lock().shutdown_errors.push(error);
920 }
921
922 if forwarder_stopped && !close.shard_mut().client.has_task() {
923 close.complete();
924 }
925
926 let errors = std::mem::take(&mut self.state.lock().shutdown_errors);
927
928 if errors.is_empty() {
929 Ok(())
930 } else {
931 anyhow::bail!(errors.join("; "))
932 }
933 }
934
935 #[cfg(test)]
936 fn subscription_count_for_test(&self) -> usize {
937 self.state.lock().assignments.len()
938 }
939}
940
941fn should_forward_from_shard(message: &PolymarketWsMessage, is_primary: bool) -> bool {
942 is_primary
943 || !matches!(
944 message,
945 PolymarketWsMessage::Market(
946 MarketWsMessage::NewMarket(_) | MarketWsMessage::MarketResolved(_)
947 )
948 )
949}
950
951fn smallest_shard_with_capacity(state: &PoolState, max_subscriptions: usize) -> Option<usize> {
952 state
953 .shards
954 .iter()
955 .filter(|(_, shard)| !shard.closing && shard.owned < max_subscriptions)
956 .map(|(id, _)| *id)
957 .min()
958}
959
960fn available_shard_id(state: &PoolState) -> usize {
961 let mut id = PRIMARY_SHARD_ID + 1;
962 while state.shards.contains_key(&id) {
963 id = id.checked_add(1).expect("market shard ID space exhausted");
964 }
965 id
966}
967
968#[cfg(test)]
969impl PolymarketMarketPoolHandle {
970 pub(crate) fn test_single_shard(
973 sender: tokio::sync::mpsc::UnboundedSender<super::handler::HandlerCommand>,
974 assigned: &[&str],
975 ) -> Self {
976 let inner = PoolInner::new(
977 None,
978 TransportBackend::default(),
979 false,
980 WS_DEFAULT_SUBSCRIPTIONS,
981 );
982 {
983 let mut state = inner.state.lock();
984 state.shards.insert(
985 PRIMARY_SHARD_ID,
986 ShardEntry {
987 client: PolymarketWebSocketClient::new_market(
988 None,
989 false,
990 TransportBackend::default(),
991 ),
992 handle: WsSubscriptionHandle::from_sender(sender),
993 forwarder: TaskSlot::new(),
994 owned: assigned.len(),
995 closing: false,
996 },
997 );
998
999 for token in assigned {
1000 state
1001 .assignments
1002 .insert(Ustr::from(token), PRIMARY_SHARD_ID);
1003 }
1004 }
1005 Self {
1006 inner: Arc::new(inner),
1007 }
1008 }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use std::{net::SocketAddr, sync::Arc as StdArc, time::Duration};
1014
1015 use PolymarketMarketPoolHandle as Handle;
1016 use axum::{
1017 Router,
1018 extract::ws::{WebSocket, WebSocketUpgrade},
1019 response::Response,
1020 routing::get,
1021 };
1022 use nautilus_common::{
1023 live::runner::replace_system_event_sender,
1024 messages::{SystemEvent, system::SocketState},
1025 };
1026 use nautilus_live::{SocketReconnectRegistry, SocketReconnectRequestOutcome};
1027 use nautilus_model::identifiers::ClientId;
1028 use nautilus_network::transport::TransportError;
1029 use parking_lot::{Condvar, Mutex as TestMutex};
1030 use rstest::rstest;
1031 use tokio_util::sync::CancellationToken;
1032
1033 use super::*;
1034 use crate::websocket::handler::HandlerCommand;
1035
1036 struct BlockingDrop(StdArc<(TestMutex<(bool, bool)>, Condvar)>);
1037
1038 impl Drop for BlockingDrop {
1039 fn drop(&mut self) {
1040 let (state, wake) = &*self.0;
1041 let mut state = state.lock();
1042 state.0 = true;
1043 wake.notify_all();
1044 while !state.1 {
1045 wake.wait(&mut state);
1046 }
1047 }
1048 }
1049
1050 async fn handle_socket_upgrade(ws: WebSocketUpgrade) -> Response {
1051 ws.on_upgrade(handle_socket)
1052 }
1053
1054 async fn handle_socket(mut socket: WebSocket) {
1055 while socket.recv().await.is_some() {}
1056 }
1057
1058 async fn start_socket_server() -> SocketAddr {
1059 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1060 .await
1061 .expect("bind test websocket server");
1062 let addr = listener.local_addr().expect("test websocket address");
1063 let router = Router::new().route("/ws/market", get(handle_socket_upgrade));
1064
1065 tokio::spawn(async move {
1066 axum::serve(listener, router)
1067 .await
1068 .expect("test websocket server failed");
1069 });
1070
1071 addr
1072 }
1073
1074 fn state_with_shards(owned: &[usize]) -> PoolState {
1076 let mut state = PoolState::default();
1077
1078 for (id, owned) in owned.iter().enumerate() {
1079 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1080 state.shards.insert(
1081 id,
1082 ShardEntry {
1083 client: PolymarketWebSocketClient::new_market(
1084 None,
1085 false,
1086 TransportBackend::default(),
1087 ),
1088 handle: WsSubscriptionHandle::from_sender(tx),
1089 forwarder: TaskSlot::new(),
1090 owned: *owned,
1091 closing: false,
1092 },
1093 );
1094 }
1095 state
1096 }
1097
1098 fn market_message(filename: &str) -> PolymarketWsMessage {
1099 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1100 .join("test_data")
1101 .join(filename);
1102 let json = std::fs::read_to_string(path).unwrap();
1103 PolymarketWsMessage::Market(serde_json::from_str(&json).unwrap())
1104 }
1105
1106 #[rstest]
1107 #[case::primary_new_market("ws_market_new_market_msg.json", true, true)]
1108 #[case::secondary_new_market("ws_market_new_market_msg.json", false, false)]
1109 #[case::primary_resolution("ws_market_resolved_msg.json", true, true)]
1110 #[case::secondary_resolution("ws_market_resolved_msg.json", false, false)]
1111 #[case::secondary_best_bid_ask("ws_market_best_bid_ask_msg.json", false, true)]
1112 fn shard_forwarding_keeps_global_events_on_primary(
1113 #[case] filename: &str,
1114 #[case] is_primary: bool,
1115 #[case] expected: bool,
1116 ) {
1117 let message = market_message(filename);
1118 assert_eq!(should_forward_from_shard(&message, is_primary), expected);
1119 }
1120
1121 #[rstest]
1122 #[tokio::test]
1123 async fn secondary_forwarder_drops_global_events_and_keeps_best_bid_ask() {
1124 let inner = PoolInner::new(None, TransportBackend::default(), true, 1);
1125 let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel();
1126 *inner.out_tx.lock() = Some(out_tx);
1127 let (shard_tx, shard_rx) = tokio::sync::mpsc::unbounded_channel();
1128 let mut forwarder = inner
1129 .spawn_forwarder(shard_rx, PRIMARY_SHARD_ID + 1)
1130 .expect("spawn forwarder");
1131
1132 shard_tx
1133 .send(market_message("ws_market_new_market_msg.json"))
1134 .unwrap();
1135 shard_tx
1136 .send(market_message("ws_market_resolved_msg.json"))
1137 .unwrap();
1138 shard_tx
1139 .send(market_message("ws_market_best_bid_ask_msg.json"))
1140 .unwrap();
1141 drop(shard_tx);
1142 let outcome = finish_task(
1143 &mut forwarder,
1144 Duration::from_secs(1),
1145 Duration::from_secs(1),
1146 )
1147 .await
1148 .expect("forwarder task");
1149 assert!(matches!(outcome, TaskJoinOutcome::Completed(())));
1150
1151 let forwarded = out_rx.try_recv().unwrap();
1152 let PolymarketWsMessage::Market(MarketWsMessage::BestBidAsk(message)) = forwarded else {
1153 panic!("unexpected forwarded message: {forwarded:?}");
1154 };
1155 assert_eq!(
1156 message.asset_id,
1157 Ustr::from(
1158 "85354956062430465315924116860125388538595433819574542752031640332592237464430"
1159 ),
1160 );
1161 assert!(out_rx.try_recv().is_err());
1162 }
1163
1164 #[rstest]
1165 #[tokio::test]
1166 async fn pool_drain_restores_remaining_state_on_drop() {
1167 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1168 let handle = Handle::test_single_shard(cmd_tx, &["token-a"]);
1169 handle
1170 .inner
1171 .state
1172 .lock()
1173 .shutdown_errors
1174 .push("before drain".to_string());
1175
1176 {
1177 let mut drain = PoolDrain::take(&handle.inner.state);
1178 let owner = handle.inner.state.lock();
1179 assert!(owner.shards.is_empty());
1180 assert!(owner.assignments.is_empty());
1181 assert!(owner.shutdown_errors.is_empty());
1182 drop(owner);
1183
1184 assert_eq!(drain.state.shards.len(), 1);
1185 assert_eq!(
1186 drain.state.assignments,
1187 AHashMap::from([(Ustr::from("token-a"), PRIMARY_SHARD_ID)]),
1188 );
1189 assert_eq!(drain.state.shutdown_errors, vec!["before drain"]);
1190 drain.state.shutdown_errors.push("during drain".to_string());
1191 }
1192
1193 let restored = handle.inner.state.lock();
1194 assert_eq!(restored.shards.len(), 1);
1195 let shard = restored
1196 .shards
1197 .get(&PRIMARY_SHARD_ID)
1198 .expect("restored shard");
1199 assert_eq!(shard.owned, 1);
1200 assert!(!shard.closing);
1201 assert_eq!(
1202 restored.assignments,
1203 AHashMap::from([(Ustr::from("token-a"), PRIMARY_SHARD_ID)]),
1204 );
1205 assert_eq!(
1206 restored.shutdown_errors,
1207 vec!["before drain", "during drain"]
1208 );
1209 }
1210
1211 async fn blocked_forwarder() -> (TaskSlot<()>, std::sync::mpsc::Sender<()>) {
1212 let (release, blocked) = std::sync::mpsc::channel();
1213 let (started, ready) = tokio::sync::oneshot::channel();
1214 let handle = tokio::task::spawn_blocking(move || {
1215 let _ = started.send(());
1216 let _ = blocked.recv();
1217 });
1218 ready.await.unwrap();
1219 (TaskSlot::from_handle(handle), release)
1220 }
1221
1222 #[rstest]
1223 #[case::single(1)]
1224 #[case::many(64)]
1225 #[tokio::test(start_paused = true)]
1226 async fn pool_disconnects_concurrently_and_preserves_failures(#[case] count: usize) {
1227 let pool = PolymarketMarketConnectionPool::new(None, false, TransportBackend::default(), 1);
1228 let mut state = state_with_shards(&vec![1; count]);
1229 let mut releases = Vec::new();
1230
1231 for (&id, shard) in &mut state.shards {
1232 let (forwarder, release) = blocked_forwarder().await;
1233 shard.forwarder = forwarder;
1234 releases.push(release);
1235 state.assignments.insert(Ustr::from(&id.to_string()), id);
1236 }
1237 *pool.inner.state.lock() = state;
1238
1239 let started = tokio::time::Instant::now();
1240 let expected = Duration::from_secs(2);
1241 let mut disconnect = std::pin::pin!(pool.disconnect());
1242 let result = loop {
1243 if let std::task::Poll::Ready(result) = futures_util::poll!(disconnect.as_mut()) {
1244 break result;
1245 }
1246 assert!(started.elapsed() < expected + Duration::from_secs(1));
1247 tokio::time::advance(Duration::from_millis(100)).await;
1248 };
1249 assert!(started.elapsed() >= expected);
1250 let error = result.unwrap_err().to_string();
1251 let failures = error.matches("forwarder did not stop after abort");
1252 assert_eq!(failures.count(), count);
1253 assert_eq!(pool.connection_count(), count);
1254 assert_eq!(pool.subscription_count(), count);
1255
1256 drop(releases);
1257 pool.disconnect().await.expect("cleanup retry");
1258 assert_eq!(pool.connection_count(), 0);
1259 assert_eq!(pool.subscription_count(), 0);
1260 }
1261
1262 #[rstest]
1263 #[tokio::test]
1264 async fn canceled_pool_disconnect_restores_only_unfinished_ownership() {
1265 let pool = PolymarketMarketConnectionPool::new(None, false, TransportBackend::default(), 1);
1266 let mut state = state_with_shards(&[1, 1]);
1267 let (forwarder, release) = blocked_forwarder().await;
1268 state.shards.get_mut(&1).unwrap().forwarder = forwarder;
1269 state.assignments = AHashMap::from([(Ustr::from("done"), 0), (Ustr::from("pending"), 1)]);
1270 *pool.inner.state.lock() = state;
1271
1272 {
1273 let mut disconnect = std::pin::pin!(pool.disconnect());
1274 assert!(futures_util::poll!(disconnect.as_mut()).is_pending());
1275 }
1276 assert_eq!(pool.connection_count(), 1);
1277 assert_eq!(
1278 pool.inner.state.lock().assignments,
1279 AHashMap::from([(Ustr::from("pending"), 1)])
1280 );
1281
1282 drop(release);
1283 pool.disconnect().await.expect("cleanup retry");
1284 assert_eq!(pool.connection_count(), 0);
1285 assert_eq!(pool.subscription_count(), 0);
1286 }
1287
1288 #[rstest]
1289 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1290 async fn canceled_shard_close_restores_unfinished_ownership() {
1291 let inner = Arc::new(PoolInner::new(None, TransportBackend::default(), false, 1));
1292 let blocking = StdArc::new((TestMutex::new((false, false)), Condvar::new()));
1293 let blocking_task = StdArc::clone(&blocking);
1294 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1295 let mut forwarder = TaskSlot::new();
1296 forwarder
1297 .spawn(async move {
1298 let _blocking = BlockingDrop(blocking_task);
1299 let _ = started_tx.send(());
1300 std::future::pending::<()>().await;
1301 })
1302 .expect("spawn forwarder");
1303 started_rx.await.expect("forwarder should start");
1304
1305 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1306 let shard = Box::new(ShardEntry {
1307 client: PolymarketWebSocketClient::new_market(None, false, TransportBackend::default()),
1308 handle: WsSubscriptionHandle::from_sender(cmd_tx),
1309 forwarder,
1310 owned: 0,
1311 closing: false,
1312 });
1313 let close_inner = Arc::clone(&inner);
1314 let close = tokio::spawn(async move {
1315 let _result = close_inner.close_shard(1, shard).await;
1316 });
1317
1318 loop {
1319 if blocking.0.lock().0 {
1320 break;
1321 }
1322 tokio::task::yield_now().await;
1323 }
1324 close.abort();
1325 let _ = close.await;
1326
1327 {
1328 let restored = inner.state.lock();
1329 let shard = restored.shards.get(&1).expect("closing shard restored");
1330 assert!(shard.closing);
1331 assert!(shard.forwarder.is_some());
1332 assert_eq!(smallest_shard_with_capacity(&restored, 1), None);
1333 }
1334
1335 {
1336 let (state, wake) = &*blocking;
1337 state.lock().1 = true;
1338 wake.notify_all();
1339 }
1340 let shard = inner
1341 .state
1342 .lock()
1343 .shards
1344 .remove(&1)
1345 .expect("restored shard");
1346 inner
1347 .close_shard(1, Box::new(shard))
1348 .await
1349 .expect("restored shard should close");
1350
1351 assert!(inner.state.lock().shards.is_empty());
1352 }
1353
1354 #[rstest]
1355 fn zero_max_subscriptions_clamps_to_default() {
1356 let inner = PoolInner::new(None, TransportBackend::default(), false, 0);
1357 assert_eq!(inner.max_subscriptions, WS_DEFAULT_SUBSCRIPTIONS);
1358 }
1359
1360 #[rstest]
1361 #[tokio::test]
1362 async fn secondary_market_connection_timeout() {
1363 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1364 let url = format!("ws://{}", listener.local_addr().unwrap());
1365
1366 let server = tokio::spawn(async move {
1367 let mut connections = Vec::new();
1368
1369 loop {
1370 let (stream, _) = listener.accept().await.unwrap();
1371 connections.push(stream);
1372 }
1373 });
1374
1375 let pool =
1376 PolymarketMarketConnectionPool::new(Some(url), false, TransportBackend::default(), 1)
1377 .with_connect_timeout(1);
1378 let mut ws_client = pool.inner.market_client(false, PRIMARY_SHARD_ID + 1);
1379
1380 let error = tokio::time::timeout(Duration::from_secs(20), ws_client.connect())
1381 .await
1382 .expect("secondary shard must use the configured connection timeout")
1383 .unwrap_err();
1384 server.abort();
1385
1386 assert!(
1387 matches!(error.downcast_ref::<TransportError>(),
1388 Some(TransportError::Io(e)) if e.kind() == std::io::ErrorKind::TimedOut),
1389 "{error:#}",
1390 );
1391 }
1392
1393 #[rstest]
1394 fn pool_retains_proxy_for_lazily_created_shards() {
1395 const PROXY_URL: &str = "http://pool-user:pool-proxy-secret@127.0.0.1:18088";
1396 let pool = PolymarketMarketConnectionPool::new_with_proxy(
1397 Some("ws://market.example/ws".to_string()),
1398 true,
1399 TransportBackend::Tungstenite,
1400 17,
1401 Some(ProxyUrl::parse(PROXY_URL).unwrap()),
1402 );
1403 let primary = pool.inner.market_client(true, PRIMARY_SHARD_ID);
1404 let secondary = pool.inner.market_client(false, PRIMARY_SHARD_ID + 1);
1405 let debug = format!("{pool:?}");
1406
1407 assert_eq!(pool.inner.proxy_url.as_ref().unwrap().expose(), PROXY_URL);
1408 assert_eq!(primary.proxy_url().unwrap().expose(), PROXY_URL);
1409 assert_eq!(secondary.proxy_url().unwrap().expose(), PROXY_URL);
1410 assert_eq!(pool.inner.max_subscriptions, 17);
1411 assert!(!debug.contains("pool-proxy-secret"));
1412 }
1413
1414 #[rstest]
1415 #[tokio::test]
1416 async fn pool_assigns_distinct_endpoint_sinks_and_handles_before_connect() {
1417 let addr = start_socket_server().await;
1418 let (system_tx, mut system_rx) = tokio::sync::mpsc::unbounded_channel();
1419 replace_system_event_sender(system_tx);
1420 let registry = SocketReconnectRegistry::default();
1421 let factory = SocketControlFactory::with_registry(
1422 ClientId::from("POLYMARKET"),
1423 Some(*crate::common::consts::POLYMARKET_VENUE),
1424 ®istry,
1425 );
1426 let pool = PolymarketMarketConnectionPool::new(
1427 Some(format!("ws://{addr}/ws/market")),
1428 false,
1429 TransportBackend::Tungstenite,
1430 1,
1431 )
1432 .with_socket_factory(factory);
1433
1434 pool.connect().await.expect("connect primary shard");
1435 pool.handle()
1436 .subscribe_market(vec!["asset-0".to_string(), "asset-1".to_string()])
1437 .await
1438 .expect("open secondary shard");
1439
1440 let mut connected = Vec::new();
1441 while connected.len() < 2 {
1442 let event = tokio::time::timeout(Duration::from_secs(2), system_rx.recv())
1443 .await
1444 .expect("wait for socket state event")
1445 .expect("system event channel closed");
1446 let SystemEvent::SocketState(change) = event;
1447 if change.state == SocketState::Connected {
1448 connected.push(change.endpoint);
1449 }
1450 }
1451 connected.sort_unstable();
1452
1453 assert_eq!(
1454 connected,
1455 vec![
1456 Ustr::from(MARKET_STREAMS_ENDPOINT),
1457 Ustr::from("polymarket-market-streams-1"),
1458 ],
1459 );
1460 let client_id = ClientId::from("POLYMARKET");
1461 let primary = registry
1462 .handle(client_id, Ustr::from(MARKET_STREAMS_ENDPOINT))
1463 .expect("primary reconnect handle should be registered");
1464 let secondary = registry
1465 .handle(client_id, Ustr::from("polymarket-market-streams-1"))
1466 .expect("secondary reconnect handle should be registered");
1467 assert_eq!(
1468 primary.request_reconnect(),
1469 SocketReconnectRequestOutcome::Accepted,
1470 );
1471 let event = system_rx
1472 .try_recv()
1473 .expect("selected shard should report reconnect state");
1474 let SystemEvent::SocketState(change) = event;
1475 assert_eq!(change.client_id, client_id);
1476 assert_eq!(change.endpoint, Ustr::from(MARKET_STREAMS_ENDPOINT));
1477 assert_eq!(change.state, SocketState::Disconnected);
1478 assert_eq!(
1479 secondary.request_reconnect(),
1480 SocketReconnectRequestOutcome::Accepted,
1481 );
1482
1483 pool.disconnect().await.expect("disconnect pool");
1484 assert!(
1485 registry
1486 .handle(client_id, Ustr::from(MARKET_STREAMS_ENDPOINT))
1487 .is_none()
1488 );
1489 assert!(
1490 registry
1491 .handle(client_id, Ustr::from("polymarket-market-streams-1"))
1492 .is_none()
1493 );
1494 }
1495
1496 #[rstest]
1497 #[case::first_has_room(&[0, 200], 200, Some(0))]
1498 #[case::prefers_lowest_id(&[200, 5, 5], 200, Some(1))]
1499 #[case::all_full(&[200, 200], 200, None)]
1500 #[case::exact_boundary_is_full(&[1], 1, None)]
1501 fn smallest_shard_with_capacity_picks_lowest_open_id(
1502 #[case] owned: &[usize],
1503 #[case] max: usize,
1504 #[case] expected: Option<usize>,
1505 ) {
1506 let state = state_with_shards(owned);
1507 assert_eq!(smallest_shard_with_capacity(&state, max), expected);
1508 }
1509
1510 #[rstest]
1511 fn available_shard_id_reuses_lowest_closed_shard() {
1512 let mut state = state_with_shards(&[1, 1, 1]);
1513 state.shards.remove(&(PRIMARY_SHARD_ID + 1));
1514
1515 assert_eq!(available_shard_id(&state), PRIMARY_SHARD_ID + 1);
1516 }
1517
1518 #[rstest]
1519 #[tokio::test]
1520 async fn subscribe_routes_command_and_tracks_assignment() {
1521 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1522 let handle = Handle::test_single_shard(tx, &[]);
1523
1524 handle
1525 .subscribe_market(vec!["token-a".to_string()])
1526 .await
1527 .expect("subscribe");
1528
1529 match rx.try_recv().expect("expected SubscribeMarket") {
1530 HandlerCommand::SubscribeMarket(ids) => assert_eq!(ids, vec!["token-a".to_string()]),
1531 other => panic!("unexpected command: {other:?}"),
1532 }
1533 assert_eq!(handle.inner.subscription_count_for_test(), 1);
1534 }
1535
1536 #[rstest]
1537 #[tokio::test]
1538 async fn duplicate_subscribe_does_not_consume_capacity_or_resend() {
1539 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1540 let handle = Handle::test_single_shard(tx, &[]);
1541
1542 handle
1543 .subscribe_market(vec!["token-a".to_string()])
1544 .await
1545 .expect("first subscribe");
1546 handle
1547 .subscribe_market(vec!["token-a".to_string()])
1548 .await
1549 .expect("duplicate subscribe");
1550
1551 assert!(matches!(
1552 rx.try_recv(),
1553 Ok(HandlerCommand::SubscribeMarket(_))
1554 ));
1555 assert!(rx.try_recv().is_err(), "duplicate must not resend");
1556 assert_eq!(handle.inner.subscription_count_for_test(), 1);
1557 }
1558
1559 #[rstest]
1560 #[tokio::test]
1561 async fn unsubscribe_routes_command_and_releases_assignment() {
1562 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1563 let handle = Handle::test_single_shard(tx, &["token-a"]);
1564
1565 handle
1566 .unsubscribe_market(vec!["token-a".to_string()])
1567 .await
1568 .expect("unsubscribe");
1569
1570 match rx.try_recv().expect("expected UnsubscribeMarket") {
1571 HandlerCommand::UnsubscribeMarket(ids) => assert_eq!(ids, vec!["token-a".to_string()]),
1572 other => panic!("unexpected command: {other:?}"),
1573 }
1574 assert_eq!(handle.inner.subscription_count_for_test(), 0);
1575 }
1576
1577 #[rstest]
1578 #[tokio::test]
1579 async fn unsubscribe_unknown_token_is_noop() {
1580 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1581 let handle = Handle::test_single_shard(tx, &[]);
1582
1583 handle
1584 .unsubscribe_market(vec!["token-a".to_string()])
1585 .await
1586 .expect("unsubscribe");
1587
1588 assert!(rx.try_recv().is_err());
1589 }
1590
1591 #[rstest]
1592 #[tokio::test]
1593 async fn subscribe_send_failure_rolls_back_assignment() {
1594 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1595 drop(rx);
1596 let handle = Handle::test_single_shard(tx, &[]);
1597
1598 let result = handle.subscribe_market(vec!["token-a".to_string()]).await;
1599
1600 assert!(result.is_err());
1601 assert_eq!(handle.inner.subscription_count_for_test(), 0);
1602 }
1603
1604 #[rstest]
1605 #[tokio::test]
1606 async fn resubscribe_market_cycles_without_changing_assignment() {
1607 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1608 let handle = Handle::test_single_shard(tx, &["token-a"]);
1609 let cancel = CancellationToken::new();
1610
1611 let task = {
1612 let handle = handle.clone();
1613 let cancel = cancel.clone();
1614
1615 tokio::spawn(async move {
1616 handle
1617 .resubscribe_market(
1618 vec!["token-a".to_string()],
1619 &cancel,
1620 &SnapshotGate::default(),
1621 )
1622 .await
1623 })
1624 };
1625
1626 match rx.recv().await.expect("expected cycle command") {
1627 HandlerCommand::CycleMarketSubscription {
1628 asset_ids,
1629 responder,
1630 ..
1631 } => {
1632 assert_eq!(asset_ids, vec!["token-a".to_string()]);
1633 responder
1634 .send(CycleMarketOutcome::Completed)
1635 .expect("answer cycle");
1636 }
1637 other => panic!("unexpected command: {other:?}"),
1638 }
1639
1640 assert!(matches!(
1641 task.await.expect("cycle task").expect("cycle owned asset"),
1642 CycleMarketOutcome::Completed
1643 ));
1644 assert_eq!(
1645 handle.tokens_for_shard(PRIMARY_SHARD_ID),
1646 vec![Ustr::from("token-a")]
1647 );
1648 assert!(handle.tokens_for_shard(PRIMARY_SHARD_ID + 1).is_empty());
1649 assert_eq!(handle.inner.subscription_count_for_test(), 1);
1650 }
1651
1652 #[rstest]
1653 #[tokio::test]
1654 async fn resubscribe_market_propagates_cycle_outcome() {
1655 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1656 let handle = Handle::test_single_shard(tx, &["token-a"]);
1657 let cancel = CancellationToken::new();
1658
1659 let task = {
1660 let handle = handle.clone();
1661 let cancel = cancel.clone();
1662
1663 tokio::spawn(async move {
1664 handle
1665 .resubscribe_market(
1666 vec!["token-a".to_string()],
1667 &cancel,
1668 &SnapshotGate::default(),
1669 )
1670 .await
1671 })
1672 };
1673
1674 match rx.recv().await.expect("expected cycle command") {
1675 HandlerCommand::CycleMarketSubscription { responder, .. } => {
1676 responder
1677 .send(CycleMarketOutcome::ConnectionChanged)
1678 .expect("answer cycle");
1679 }
1680 other => panic!("unexpected command: {other:?}"),
1681 }
1682
1683 assert!(matches!(
1684 task.await.expect("cycle task").expect("cycle reports"),
1685 CycleMarketOutcome::ConnectionChanged
1686 ));
1687 assert_eq!(
1689 handle.tokens_for_shard(PRIMARY_SHARD_ID),
1690 vec![Ustr::from("token-a")]
1691 );
1692 }
1693
1694 #[rstest]
1695 #[tokio::test]
1696 async fn resubscribe_market_fails_when_cycle_response_lost() {
1697 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1698 let handle = Handle::test_single_shard(tx, &["token-a"]);
1699 let cancel = CancellationToken::new();
1700
1701 let task = {
1702 let handle = handle.clone();
1703 let cancel = cancel.clone();
1704
1705 tokio::spawn(async move {
1706 handle
1707 .resubscribe_market(
1708 vec!["token-a".to_string()],
1709 &cancel,
1710 &SnapshotGate::default(),
1711 )
1712 .await
1713 })
1714 };
1715
1716 assert!(matches!(
1718 rx.recv().await.expect("expected cycle command"),
1719 HandlerCommand::CycleMarketSubscription { .. }
1720 ));
1721
1722 let err = task.await.expect("cycle task").expect_err("lost response");
1723
1724 assert!(
1725 err.to_string().contains("response lost"),
1726 "unexpected error: {err}"
1727 );
1728 }
1729
1730 #[rstest]
1731 #[tokio::test]
1732 async fn resubscribe_market_rejects_unowned_asset() {
1733 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1734 let handle = Handle::test_single_shard(tx, &["token-a"]);
1735
1736 let err = handle
1737 .resubscribe_market(
1738 vec!["token-unknown".to_string()],
1739 &CancellationToken::new(),
1740 &SnapshotGate::default(),
1741 )
1742 .await
1743 .expect_err("unowned asset must fail");
1744
1745 assert!(
1746 err.to_string().contains("not owned by any shard"),
1747 "unexpected error: {err}"
1748 );
1749 assert!(rx.try_recv().is_err());
1750 }
1751
1752 #[rstest]
1753 #[tokio::test]
1754 async fn resubscribe_market_rejects_missing_shard() {
1755 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1756 let handle = Handle::test_single_shard(tx, &["token-a"]);
1757 handle.inner.state.lock().shards.remove(&PRIMARY_SHARD_ID);
1758
1759 let err = handle
1760 .resubscribe_market(
1761 vec!["token-a".to_string()],
1762 &CancellationToken::new(),
1763 &SnapshotGate::default(),
1764 )
1765 .await
1766 .expect_err("missing shard must fail");
1767
1768 assert!(
1769 err.to_string().contains("is not available"),
1770 "unexpected error: {err}"
1771 );
1772 }
1773
1774 #[rstest]
1775 #[tokio::test]
1776 async fn resubscribe_market_rejects_closed_pool() {
1777 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1778 let handle = Handle::test_single_shard(tx, &["token-a"]);
1779 handle.inner.closed.store(true, Ordering::SeqCst);
1780
1781 let err = handle
1782 .resubscribe_market(
1783 vec!["token-a".to_string()],
1784 &CancellationToken::new(),
1785 &SnapshotGate::default(),
1786 )
1787 .await
1788 .expect_err("closed pool must fail");
1789
1790 assert!(
1791 err.to_string().contains("closed"),
1792 "unexpected error: {err}"
1793 );
1794 }
1795
1796 #[rstest]
1797 #[tokio::test]
1798 async fn forwarder_stamps_reconnected_with_shard_id() {
1799 let inner = PoolInner::new(None, TransportBackend::default(), true, 1);
1800 let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel();
1801 *inner.out_tx.lock() = Some(out_tx);
1802 let (shard_tx, shard_rx) = tokio::sync::mpsc::unbounded_channel();
1803 let mut forwarder = inner
1804 .spawn_forwarder(shard_rx, PRIMARY_SHARD_ID + 1)
1805 .expect("spawn forwarder");
1806
1807 shard_tx
1808 .send(PolymarketWsMessage::Reconnected { shard_id: None })
1809 .unwrap();
1810 shard_tx
1811 .send(market_message("ws_market_best_bid_ask_msg.json"))
1812 .unwrap();
1813 drop(shard_tx);
1814 let outcome = finish_task(
1815 &mut forwarder,
1816 Duration::from_secs(1),
1817 Duration::from_secs(1),
1818 )
1819 .await
1820 .expect("forwarder task");
1821 assert!(matches!(outcome, TaskJoinOutcome::Completed(())));
1822
1823 assert!(
1824 matches!(
1825 out_rx.try_recv().unwrap(),
1826 PolymarketWsMessage::Reconnected { shard_id: Some(id) }
1827 if id == PRIMARY_SHARD_ID + 1
1828 ),
1829 "forwarder must stamp the reconnecting shard"
1830 );
1831 assert!(matches!(
1832 out_rx.try_recv().unwrap(),
1833 PolymarketWsMessage::Market(MarketWsMessage::BestBidAsk(_))
1834 ));
1835 assert!(out_rx.try_recv().is_err());
1836 }
1837}