1use std::sync::{
33 Arc,
34 atomic::{AtomicBool, Ordering},
35};
36
37use ahash::AHashMap;
38use nautilus_live::{
39 SocketControlFactory,
40 task::{TaskJoinOutcome, TaskSlot, TaskSpawnError, finish_task},
41};
42use nautilus_network::websocket::{TransportBackend, proxy::ProxyUrl};
43use parking_lot::Mutex;
44use ustr::Ustr;
45
46use super::{
47 MARKET_STREAMS_ENDPOINT,
48 client::{PolymarketWebSocketClient, WsSubscriptionHandle},
49 messages::{MarketWsMessage, PolymarketWsMessage},
50};
51use crate::common::consts::WS_DEFAULT_SUBSCRIPTIONS;
52
53const PRIMARY_SHARD_ID: usize = 0;
55
56#[derive(Debug)]
58pub struct PolymarketMarketConnectionPool {
59 inner: Arc<PoolInner>,
60}
61
62#[derive(Clone, Debug)]
66pub struct PolymarketMarketPoolHandle {
67 inner: Arc<PoolInner>,
68}
69
70#[derive(Debug)]
71struct PoolInner {
72 base_url: Option<String>,
73 proxy_url: Option<ProxyUrl>,
74 transport_backend: TransportBackend,
75 subscribe_new_markets: bool,
76 max_subscriptions: usize,
77 wire_mutex: tokio::sync::Mutex<()>,
79 state: Mutex<PoolState>,
81 out_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<PolymarketWsMessage>>>,
82 out_rx: Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>>>,
83 socket_factory: Mutex<Option<SocketControlFactory>>,
84 closed: AtomicBool,
85}
86
87#[derive(Debug)]
88struct PoolState {
89 shards: AHashMap<usize, ShardEntry>,
90 assignments: AHashMap<Ustr, usize>,
91 shutdown_errors: Vec<String>,
92}
93
94struct PoolDrain<'a> {
95 owner: &'a Mutex<PoolState>,
96 state: PoolState,
97}
98
99impl<'a> PoolDrain<'a> {
100 fn take(owner: &'a Mutex<PoolState>) -> Self {
101 let state = std::mem::replace(&mut *owner.lock(), PoolState::new());
102 Self { owner, state }
103 }
104}
105
106impl Drop for PoolDrain<'_> {
107 fn drop(&mut self) {
108 *self.owner.lock() = std::mem::replace(&mut self.state, PoolState::new());
109 }
110}
111
112impl PoolState {
113 fn new() -> Self {
114 Self {
115 shards: AHashMap::new(),
116 assignments: AHashMap::new(),
117 shutdown_errors: Vec::new(),
118 }
119 }
120}
121
122#[derive(Debug)]
123struct ShardEntry {
124 client: PolymarketWebSocketClient,
125 handle: WsSubscriptionHandle,
126 forwarder: TaskSlot<()>,
127 owned: usize,
128 closing: bool,
129}
130
131enum ReleaseOutcome {
132 NotOwned,
133 Unsubscribe(WsSubscriptionHandle),
134 CloseShard(usize, Box<ShardEntry>),
135}
136
137struct ShardClose<'a> {
138 owner: &'a Mutex<PoolState>,
139 id: usize,
140 shard: Option<Box<ShardEntry>>,
141}
142
143impl<'a> ShardClose<'a> {
144 fn new(owner: &'a Mutex<PoolState>, id: usize, mut shard: Box<ShardEntry>) -> Self {
145 shard.closing = true;
146 Self {
147 owner,
148 id,
149 shard: Some(shard),
150 }
151 }
152
153 fn shard_mut(&mut self) -> &mut ShardEntry {
154 self.shard.as_deref_mut().expect("closing shard present")
155 }
156
157 fn complete(mut self) {
158 self.shard.take();
159 }
160}
161
162impl Drop for ShardClose<'_> {
163 fn drop(&mut self) {
164 if let Some(shard) = self.shard.take() {
165 let replaced = self.owner.lock().shards.insert(self.id, *shard);
166 assert!(replaced.is_none(), "closing shard ID is already present");
167 }
168 }
169}
170
171#[allow(
172 clippy::missing_panics_doc,
173 reason = "internal mutex locks and shard-state invariants are not expected to panic"
174)]
175impl PolymarketMarketConnectionPool {
176 #[must_use]
181 pub fn new(
182 base_url: Option<String>,
183 subscribe_new_markets: bool,
184 transport_backend: TransportBackend,
185 max_subscriptions: usize,
186 ) -> Self {
187 Self::new_with_proxy(
188 base_url,
189 subscribe_new_markets,
190 transport_backend,
191 max_subscriptions,
192 None,
193 )
194 }
195
196 #[must_use]
198 pub fn new_with_proxy(
199 base_url: Option<String>,
200 subscribe_new_markets: bool,
201 transport_backend: TransportBackend,
202 max_subscriptions: usize,
203 proxy_url: Option<ProxyUrl>,
204 ) -> Self {
205 Self {
206 inner: Arc::new(PoolInner::new_with_proxy(
207 base_url,
208 transport_backend,
209 subscribe_new_markets,
210 max_subscriptions,
211 proxy_url,
212 )),
213 }
214 }
215
216 #[must_use]
218 pub(crate) fn with_socket_factory(self, factory: SocketControlFactory) -> Self {
219 *self.inner.socket_factory.lock() = Some(factory);
220 self
221 }
222
223 #[cfg(test)]
224 pub(crate) fn proxy_url(&self) -> Option<&ProxyUrl> {
225 self.inner.proxy_url.as_ref()
226 }
227
228 #[must_use]
230 pub fn handle(&self) -> PolymarketMarketPoolHandle {
231 PolymarketMarketPoolHandle {
232 inner: Arc::clone(&self.inner),
233 }
234 }
235
236 pub async fn connect(&self) -> anyhow::Result<()> {
242 if self.inner.closed.load(Ordering::Acquire) {
243 self.disconnect().await?;
244 }
245
246 let _wire = self.inner.wire_mutex.lock().await;
247
248 if !self.inner.closed.load(Ordering::Acquire) && !self.inner.state.lock().shards.is_empty()
249 {
250 log::warn!("Polymarket market pool already connected");
251 return Ok(());
252 }
253
254 {
255 let _state = self.inner.state.lock();
256 self.inner.closed.store(false, Ordering::Release);
257 }
258
259 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel();
260 *self.inner.out_tx.lock() = Some(out_tx);
261 *self.inner.out_rx.lock() = Some(out_rx);
262
263 self.inner.connect_new_shard(true).await?;
264 Ok(())
265 }
266
267 pub async fn subscribe_new_markets_feed(&self) -> anyhow::Result<()> {
273 let _wire = self.inner.wire_mutex.lock().await;
274
275 let handle = {
276 let state = self.inner.state.lock();
277 if self.inner.closed.load(Ordering::Acquire) {
278 anyhow::bail!("Market connection pool is closed");
279 }
280 state
281 .shards
282 .get(&PRIMARY_SHARD_ID)
283 .map(|shard| shard.handle.clone())
284 };
285
286 match handle {
287 Some(handle) => handle.subscribe_market(vec![]).await,
288 None => anyhow::bail!("No primary market shard available for new-market discovery"),
289 }
290 }
291
292 #[must_use]
294 pub fn take_message_receiver(
295 &self,
296 ) -> Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>> {
297 self.inner.out_rx.lock().take()
298 }
299
300 pub async fn disconnect(&self) -> anyhow::Result<()> {
306 self.inner.begin_shutdown();
307 let _wire = self.inner.wire_mutex.lock().await;
308
309 let mut drain = PoolDrain::take(&self.inner.state);
310 let shard_ids = drain.state.shards.keys().copied().collect::<Vec<_>>();
311 for shard_id in shard_ids {
312 let shard = drain
313 .state
314 .shards
315 .get_mut(&shard_id)
316 .expect("market shard ID collected from pool state");
317 let mut shard_failed = false;
318
319 shard.forwarder.abort();
320 if let Some(outcome) = finish_task(
321 &mut shard.forwarder,
322 std::time::Duration::ZERO,
323 std::time::Duration::from_secs(2),
324 )
325 .await
326 {
327 match outcome {
328 TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
329 TaskJoinOutcome::Failed(error) => {
330 shard_failed = true;
331 drain
332 .state
333 .shutdown_errors
334 .push(format!("market shard {shard_id} forwarder failed: {error}"));
335 }
336 TaskJoinOutcome::Incomplete => {
337 shard_failed = true;
338 drain.state.shutdown_errors.push(format!(
339 "market shard {shard_id} forwarder did not stop after abort"
340 ));
341 }
342 }
343 }
344
345 if let Err(e) = shard.client.disconnect().await {
346 shard_failed = true;
347 drain
348 .state
349 .shutdown_errors
350 .push(format!("market shard {shard_id} disconnect failed: {e}"));
351 }
352
353 if !shard_failed {
354 drain.state.shards.remove(&shard_id);
355 drain
356 .state
357 .assignments
358 .retain(|_, assigned_id| *assigned_id != shard_id);
359 }
360 }
361
362 if !drain.state.shutdown_errors.is_empty() {
363 let errors = std::mem::take(&mut drain.state.shutdown_errors);
364 anyhow::bail!(
365 "Polymarket market pool shutdown failed: {}",
366 errors.join("; ")
367 );
368 }
369
370 *self.inner.out_tx.lock() = None;
371 *self.inner.out_rx.lock() = None;
372 Ok(())
373 }
374
375 pub(crate) fn begin_shutdown(&self) {
376 self.inner.begin_shutdown();
377 }
378
379 pub(crate) fn clear_reconnect_state(&self) {
381 let state = self.inner.state.lock();
382 for shard in state.shards.values() {
383 shard.client.clear_reconnect_state();
384 }
385 }
386
387 #[must_use]
389 pub fn connection_count(&self) -> usize {
390 self.inner.state.lock().shards.len()
391 }
392
393 #[must_use]
395 pub fn subscription_count(&self) -> usize {
396 self.inner.state.lock().assignments.len()
397 }
398}
399
400impl Drop for PoolInner {
401 fn drop(&mut self) {
402 self.closed.store(true, Ordering::Release);
403
404 for shard in self.state.get_mut().shards.values_mut() {
405 shard.forwarder.abort();
406 shard.client.abort();
407 }
408 }
409}
410
411#[allow(
412 clippy::missing_panics_doc,
413 reason = "internal mutex locks and shard-state invariants are not expected to panic"
414)]
415impl PolymarketMarketPoolHandle {
416 pub(crate) fn begin_shutdown(&self) {
417 self.inner.begin_shutdown();
418 }
419
420 pub async fn subscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
426 let _wire = self.inner.wire_mutex.lock().await;
427 self.inner.ensure_open()?;
428 for asset_id in asset_ids {
429 self.inner.subscribe_one(asset_id).await?;
430 }
431 Ok(())
432 }
433
434 pub async fn unsubscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
440 let _wire = self.inner.wire_mutex.lock().await;
441 self.inner.ensure_open()?;
442 for asset_id in asset_ids {
443 self.inner.unsubscribe_one(asset_id).await?;
444 }
445 Ok(())
446 }
447}
448
449impl PoolInner {
450 fn begin_shutdown(&self) {
451 let state = self.state.lock();
452 self.closed.store(true, Ordering::Release);
453
454 for shard in state.shards.values() {
455 shard.client.begin_shutdown();
456 }
457 }
458
459 fn ensure_open(&self) -> anyhow::Result<()> {
460 let _state = self.state.lock();
461
462 if self.closed.load(Ordering::Acquire) {
463 anyhow::bail!("Market connection pool is closed");
464 }
465 Ok(())
466 }
467}
468
469impl PoolInner {
470 #[cfg(test)]
471 fn new(
472 base_url: Option<String>,
473 transport_backend: TransportBackend,
474 subscribe_new_markets: bool,
475 max_subscriptions: usize,
476 ) -> Self {
477 Self::new_with_proxy(
478 base_url,
479 transport_backend,
480 subscribe_new_markets,
481 max_subscriptions,
482 None,
483 )
484 }
485
486 fn new_with_proxy(
487 base_url: Option<String>,
488 transport_backend: TransportBackend,
489 subscribe_new_markets: bool,
490 max_subscriptions: usize,
491 proxy_url: Option<ProxyUrl>,
492 ) -> Self {
493 let max_subscriptions = if max_subscriptions == 0 {
494 log::warn!(
495 "PolymarketDataClientConfig.ws_max_subscriptions=0 is invalid, using {WS_DEFAULT_SUBSCRIPTIONS}"
496 );
497 WS_DEFAULT_SUBSCRIPTIONS
498 } else {
499 max_subscriptions
500 };
501
502 Self {
503 base_url,
504 proxy_url,
505 transport_backend,
506 subscribe_new_markets,
507 max_subscriptions,
508 wire_mutex: tokio::sync::Mutex::new(()),
509 state: Mutex::new(PoolState::new()),
510 out_tx: Mutex::new(None),
511 out_rx: Mutex::new(None),
512 socket_factory: Mutex::new(None),
513 closed: AtomicBool::new(false),
514 }
515 }
516
517 async fn subscribe_one(&self, asset_id: String) -> anyhow::Result<()> {
519 let token = Ustr::from(asset_id.as_str());
520
521 let Some(handle) = self.assign(token).await? else {
522 return Ok(());
523 };
524
525 if let Err(e) = self.ensure_open() {
526 if let ReleaseOutcome::CloseShard(id, shard) = self.release(token)
527 && let Err(close_error) = self.close_shard(id, shard).await
528 {
529 anyhow::bail!("{e}; subscription rollback failed: {close_error}");
530 }
531 return Err(e);
532 }
533
534 if let Err(e) = handle.subscribe_market(vec![asset_id]).await {
535 if let ReleaseOutcome::CloseShard(id, shard) = self.release(token)
537 && let Err(close_error) = self.close_shard(id, shard).await
538 {
539 anyhow::bail!("{e}; subscription rollback failed: {close_error}");
540 }
541 return Err(e);
542 }
543 Ok(())
544 }
545
546 async fn unsubscribe_one(&self, asset_id: String) -> anyhow::Result<()> {
548 self.ensure_open()?;
549 let token = Ustr::from(asset_id.as_str());
550
551 match self.release(token) {
552 ReleaseOutcome::NotOwned => Ok(()),
553 ReleaseOutcome::Unsubscribe(handle) => handle.unsubscribe_market(vec![asset_id]).await,
554 ReleaseOutcome::CloseShard(id, shard) => {
555 self.close_shard(id, shard).await
557 }
558 }
559 }
560
561 async fn assign(&self, token: Ustr) -> anyhow::Result<Option<WsSubscriptionHandle>> {
563 {
564 let mut state = self.state.lock();
565
566 if self.closed.load(Ordering::Acquire) {
567 anyhow::bail!("Market connection pool is closed");
568 }
569
570 if state.assignments.contains_key(&token) {
571 return Ok(None);
572 }
573
574 if let Some(id) = smallest_shard_with_capacity(&state, self.max_subscriptions) {
575 let handle = {
576 let shard = state.shards.get_mut(&id).expect("shard present");
577 shard.owned += 1;
578 shard.handle.clone()
579 };
580 state.assignments.insert(token, id);
581 return Ok(Some(handle));
582 }
583 }
584
585 let id = self.connect_new_shard(false).await?;
586
587 let rejected_shard = {
588 let mut state = self.state.lock();
589
590 if self.closed.load(Ordering::Acquire) {
591 Some(
592 state
593 .shards
594 .remove(&id)
595 .expect("new shard retained for shutdown"),
596 )
597 } else {
598 let handle = {
599 let shard = state.shards.get_mut(&id).expect("new shard present");
600 shard.owned += 1;
601 shard.handle.clone()
602 };
603 state.assignments.insert(token, id);
604 return Ok(Some(handle));
605 }
606 };
607
608 if let Some(shard) = rejected_shard {
609 if let Err(e) = self.close_shard(id, Box::new(shard)).await {
610 anyhow::bail!("Market connection pool is closed; shard rollback failed: {e}");
611 }
612 anyhow::bail!("Market connection pool is closed");
613 }
614 unreachable!("open pool returned from assignment")
615 }
616
617 fn release(&self, token: Ustr) -> ReleaseOutcome {
618 let mut state = self.state.lock();
619
620 let Some(id) = state.assignments.remove(&token) else {
621 return ReleaseOutcome::NotOwned;
622 };
623
624 let owned = {
625 let Some(shard) = state.shards.get_mut(&id) else {
626 return ReleaseOutcome::NotOwned;
627 };
628 shard.owned = shard.owned.saturating_sub(1);
629 shard.owned
630 };
631
632 if id != PRIMARY_SHARD_ID && owned == 0 {
633 let shard = state.shards.remove(&id).expect("shard present");
634 ReleaseOutcome::CloseShard(id, Box::new(shard))
635 } else {
636 let handle = state.shards.get(&id).expect("shard present").handle.clone();
637 ReleaseOutcome::Unsubscribe(handle)
638 }
639 }
640
641 async fn connect_new_shard(&self, is_primary: bool) -> anyhow::Result<usize> {
642 if self.closed.load(Ordering::Acquire) {
643 anyhow::bail!("Market connection pool is closed");
644 }
645
646 let id = if is_primary {
647 PRIMARY_SHARD_ID
648 } else {
649 let state = self.state.lock();
650 available_shard_id(&state)
651 };
652
653 let mut client = self.market_client(self.subscribe_new_markets, id);
654 client.connect().await?;
655
656 let handle = client.clone_subscription_handle();
657 let rx = client
658 .take_message_receiver()
659 .ok_or_else(|| anyhow::anyhow!("Market shard receiver unavailable after connect"))?;
660 let forwarder = match self.spawn_forwarder(rx, is_primary) {
661 Ok(forwarder) => forwarder,
662 Err((e, forwarder)) => {
663 let shard = Box::new(ShardEntry {
664 client,
665 handle,
666 forwarder,
667 owned: 0,
668 closing: true,
669 });
670
671 if let Err(close_error) = self.close_shard(id, shard).await {
672 anyhow::bail!(
673 "Failed to start market shard forwarder: {e}; startup rollback failed: \
674 {close_error}"
675 );
676 }
677 anyhow::bail!("Failed to start market shard forwarder: {e}");
678 }
679 };
680
681 let shard = ShardEntry {
682 client,
683 handle,
684 forwarder,
685 owned: 0,
686 closing: false,
687 };
688 let rejected_shard = {
689 let mut state = self.state.lock();
690
691 if self.closed.load(Ordering::Acquire) {
692 Some(shard)
693 } else {
694 state.shards.insert(id, shard);
695 None
696 }
697 };
698
699 if let Some(shard) = rejected_shard {
700 if let Err(e) = self.close_shard(id, Box::new(shard)).await {
701 anyhow::bail!("Market connection pool is closed; shard rollback failed: {e}");
702 }
703 anyhow::bail!("Market connection pool is closed");
704 }
705
706 log::debug!("Opened Polymarket market shard {id}");
707 Ok(id)
708 }
709
710 fn market_client(
711 &self,
712 subscribe_new_markets: bool,
713 shard_id: usize,
714 ) -> PolymarketWebSocketClient {
715 let client = PolymarketWebSocketClient::new_market_with_proxy(
716 self.base_url.clone(),
717 subscribe_new_markets,
718 self.transport_backend,
719 self.proxy_url.clone(),
720 );
721 let factory = self.socket_factory.lock().clone();
722
723 if let Some(factory) = factory {
724 let endpoint = if shard_id == PRIMARY_SHARD_ID {
725 MARKET_STREAMS_ENDPOINT.to_string()
726 } else {
727 format!("{MARKET_STREAMS_ENDPOINT}-{shard_id}")
728 };
729 client.with_socket_control(factory.control(endpoint))
730 } else {
731 client
732 }
733 }
734
735 fn spawn_forwarder(
736 &self,
737 mut rx: tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>,
738 is_primary: bool,
739 ) -> Result<TaskSlot<()>, (TaskSpawnError, TaskSlot<()>)> {
740 let out_tx = self.out_tx.lock().clone();
741
742 let mut forwarder = TaskSlot::new();
743 if let Err(e) = forwarder.spawn(async move {
744 let Some(out_tx) = out_tx else {
745 return;
746 };
747
748 while let Some(msg) = rx.recv().await {
749 if !should_forward_from_shard(&msg, is_primary) {
750 continue;
751 }
752
753 if out_tx.send(msg).is_err() {
754 break;
755 }
756 }
757 }) {
758 return Err((e, forwarder));
759 }
760 Ok(forwarder)
761 }
762
763 async fn close_shard(&self, id: usize, shard: Box<ShardEntry>) -> anyhow::Result<()> {
764 let mut close = ShardClose::new(&self.state, id, shard);
765 close.shard_mut().forwarder.abort();
766 let forwarder_stopped = match finish_task(
767 &mut close.shard_mut().forwarder,
768 std::time::Duration::ZERO,
769 std::time::Duration::from_secs(2),
770 )
771 .await
772 {
773 None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => true,
774 Some(TaskJoinOutcome::Failed(error)) => {
775 let error = format!("market shard {id} forwarder failed: {error}");
776 self.state.lock().shutdown_errors.push(error);
777 true
778 }
779 Some(TaskJoinOutcome::Incomplete) => {
780 let error = format!("market shard {id} forwarder did not stop after abort");
781 self.state.lock().shutdown_errors.push(error);
782 false
783 }
784 };
785
786 if let Err(e) = close.shard_mut().client.disconnect().await {
787 let error = format!("market shard {id} disconnect failed: {e}");
788 self.state.lock().shutdown_errors.push(error);
789 }
790
791 if forwarder_stopped && !close.shard_mut().client.has_task() {
792 close.complete();
793 }
794
795 let errors = std::mem::take(&mut self.state.lock().shutdown_errors);
796
797 if errors.is_empty() {
798 Ok(())
799 } else {
800 anyhow::bail!(errors.join("; "))
801 }
802 }
803
804 #[cfg(test)]
805 fn subscription_count_for_test(&self) -> usize {
806 self.state.lock().assignments.len()
807 }
808}
809
810fn should_forward_from_shard(message: &PolymarketWsMessage, is_primary: bool) -> bool {
811 is_primary
812 || !matches!(
813 message,
814 PolymarketWsMessage::Market(
815 MarketWsMessage::NewMarket(_) | MarketWsMessage::MarketResolved(_)
816 )
817 )
818}
819
820fn smallest_shard_with_capacity(state: &PoolState, max_subscriptions: usize) -> Option<usize> {
821 state
822 .shards
823 .iter()
824 .filter(|(_, shard)| !shard.closing && shard.owned < max_subscriptions)
825 .map(|(id, _)| *id)
826 .min()
827}
828
829fn available_shard_id(state: &PoolState) -> usize {
830 let mut id = PRIMARY_SHARD_ID + 1;
831 while state.shards.contains_key(&id) {
832 id = id.checked_add(1).expect("market shard ID space exhausted");
833 }
834 id
835}
836
837#[cfg(test)]
838impl PolymarketMarketPoolHandle {
839 pub(crate) fn test_single_shard(
842 sender: tokio::sync::mpsc::UnboundedSender<super::handler::HandlerCommand>,
843 assigned: &[&str],
844 ) -> Self {
845 let inner = PoolInner::new(
846 None,
847 TransportBackend::default(),
848 false,
849 WS_DEFAULT_SUBSCRIPTIONS,
850 );
851 {
852 let mut state = inner.state.lock();
853 state.shards.insert(
854 PRIMARY_SHARD_ID,
855 ShardEntry {
856 client: PolymarketWebSocketClient::new_market(
857 None,
858 false,
859 TransportBackend::default(),
860 ),
861 handle: WsSubscriptionHandle::from_sender(sender),
862 forwarder: TaskSlot::new(),
863 owned: assigned.len(),
864 closing: false,
865 },
866 );
867
868 for token in assigned {
869 state
870 .assignments
871 .insert(Ustr::from(token), PRIMARY_SHARD_ID);
872 }
873 }
874 Self {
875 inner: Arc::new(inner),
876 }
877 }
878}
879
880#[cfg(test)]
881mod tests {
882 use std::{net::SocketAddr, sync::Arc as StdArc, time::Duration};
883
884 use PolymarketMarketPoolHandle as Handle;
885 use axum::{
886 Router,
887 extract::ws::{WebSocket, WebSocketUpgrade},
888 response::Response,
889 routing::get,
890 };
891 use nautilus_common::{
892 live::runner::replace_system_event_sender,
893 messages::{SystemEvent, system::SocketState},
894 };
895 use nautilus_live::{SocketReconnectRegistry, SocketReconnectRequestOutcome};
896 use nautilus_model::identifiers::ClientId;
897 use parking_lot::{Condvar, Mutex as TestMutex};
898 use rstest::rstest;
899
900 use super::*;
901 use crate::websocket::handler::HandlerCommand;
902
903 struct BlockingDrop(StdArc<(TestMutex<(bool, bool)>, Condvar)>);
904
905 impl Drop for BlockingDrop {
906 fn drop(&mut self) {
907 let (state, wake) = &*self.0;
908 let mut state = state.lock();
909 state.0 = true;
910 wake.notify_all();
911 while !state.1 {
912 wake.wait(&mut state);
913 }
914 }
915 }
916
917 async fn handle_socket_upgrade(ws: WebSocketUpgrade) -> Response {
918 ws.on_upgrade(handle_socket)
919 }
920
921 async fn handle_socket(mut socket: WebSocket) {
922 while socket.recv().await.is_some() {}
923 }
924
925 async fn start_socket_server() -> SocketAddr {
926 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
927 .await
928 .expect("bind test websocket server");
929 let addr = listener.local_addr().expect("test websocket address");
930 let router = Router::new().route("/ws/market", get(handle_socket_upgrade));
931
932 tokio::spawn(async move {
933 axum::serve(listener, router)
934 .await
935 .expect("test websocket server failed");
936 });
937
938 addr
939 }
940
941 fn state_with_shards(owned: &[usize]) -> PoolState {
943 let mut state = PoolState::new();
944
945 for (id, owned) in owned.iter().enumerate() {
946 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
947 state.shards.insert(
948 id,
949 ShardEntry {
950 client: PolymarketWebSocketClient::new_market(
951 None,
952 false,
953 TransportBackend::default(),
954 ),
955 handle: WsSubscriptionHandle::from_sender(tx),
956 forwarder: TaskSlot::new(),
957 owned: *owned,
958 closing: false,
959 },
960 );
961 }
962 state
963 }
964
965 fn market_message(filename: &str) -> PolymarketWsMessage {
966 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
967 .join("test_data")
968 .join(filename);
969 let json = std::fs::read_to_string(path).unwrap();
970 PolymarketWsMessage::Market(serde_json::from_str(&json).unwrap())
971 }
972
973 #[rstest]
974 #[case::primary_new_market("ws_market_new_market_msg.json", true, true)]
975 #[case::secondary_new_market("ws_market_new_market_msg.json", false, false)]
976 #[case::primary_resolution("ws_market_resolved_msg.json", true, true)]
977 #[case::secondary_resolution("ws_market_resolved_msg.json", false, false)]
978 #[case::secondary_best_bid_ask("ws_market_best_bid_ask_msg.json", false, true)]
979 fn shard_forwarding_keeps_global_events_on_primary(
980 #[case] filename: &str,
981 #[case] is_primary: bool,
982 #[case] expected: bool,
983 ) {
984 let message = market_message(filename);
985 assert_eq!(should_forward_from_shard(&message, is_primary), expected);
986 }
987
988 #[rstest]
989 #[tokio::test]
990 async fn secondary_forwarder_drops_global_events_and_keeps_best_bid_ask() {
991 let inner = PoolInner::new(None, TransportBackend::default(), true, 1);
992 let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel();
993 *inner.out_tx.lock() = Some(out_tx);
994 let (shard_tx, shard_rx) = tokio::sync::mpsc::unbounded_channel();
995 let mut forwarder = inner
996 .spawn_forwarder(shard_rx, false)
997 .expect("spawn forwarder");
998
999 shard_tx
1000 .send(market_message("ws_market_new_market_msg.json"))
1001 .unwrap();
1002 shard_tx
1003 .send(market_message("ws_market_resolved_msg.json"))
1004 .unwrap();
1005 shard_tx
1006 .send(market_message("ws_market_best_bid_ask_msg.json"))
1007 .unwrap();
1008 drop(shard_tx);
1009 let outcome = finish_task(
1010 &mut forwarder,
1011 Duration::from_secs(1),
1012 Duration::from_secs(1),
1013 )
1014 .await
1015 .expect("forwarder task");
1016 assert!(matches!(outcome, TaskJoinOutcome::Completed(())));
1017
1018 let forwarded = out_rx.try_recv().unwrap();
1019 let PolymarketWsMessage::Market(MarketWsMessage::BestBidAsk(message)) = forwarded else {
1020 panic!("unexpected forwarded message: {forwarded:?}");
1021 };
1022 assert_eq!(
1023 message.asset_id,
1024 Ustr::from(
1025 "85354956062430465315924116860125388538595433819574542752031640332592237464430"
1026 ),
1027 );
1028 assert!(out_rx.try_recv().is_err());
1029 }
1030
1031 #[rstest]
1032 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1033 async fn canceled_shard_close_restores_unfinished_ownership() {
1034 let inner = Arc::new(PoolInner::new(None, TransportBackend::default(), false, 1));
1035 let blocking = StdArc::new((TestMutex::new((false, false)), Condvar::new()));
1036 let blocking_task = StdArc::clone(&blocking);
1037 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1038 let mut forwarder = TaskSlot::new();
1039 forwarder
1040 .spawn(async move {
1041 let _blocking = BlockingDrop(blocking_task);
1042 let _ = started_tx.send(());
1043 std::future::pending::<()>().await;
1044 })
1045 .expect("spawn forwarder");
1046 started_rx.await.expect("forwarder should start");
1047
1048 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1049 let shard = Box::new(ShardEntry {
1050 client: PolymarketWebSocketClient::new_market(None, false, TransportBackend::default()),
1051 handle: WsSubscriptionHandle::from_sender(cmd_tx),
1052 forwarder,
1053 owned: 0,
1054 closing: false,
1055 });
1056 let close_inner = Arc::clone(&inner);
1057 let close = tokio::spawn(async move {
1058 let _result = close_inner.close_shard(1, shard).await;
1059 });
1060
1061 loop {
1062 if blocking.0.lock().0 {
1063 break;
1064 }
1065 tokio::task::yield_now().await;
1066 }
1067 close.abort();
1068 let _ = close.await;
1069
1070 {
1071 let restored = inner.state.lock();
1072 let shard = restored.shards.get(&1).expect("closing shard restored");
1073 assert!(shard.closing);
1074 assert!(shard.forwarder.is_some());
1075 assert_eq!(smallest_shard_with_capacity(&restored, 1), None);
1076 }
1077
1078 {
1079 let (state, wake) = &*blocking;
1080 state.lock().1 = true;
1081 wake.notify_all();
1082 }
1083 let shard = inner
1084 .state
1085 .lock()
1086 .shards
1087 .remove(&1)
1088 .expect("restored shard");
1089 inner
1090 .close_shard(1, Box::new(shard))
1091 .await
1092 .expect("restored shard should close");
1093
1094 assert!(inner.state.lock().shards.is_empty());
1095 }
1096
1097 #[rstest]
1098 fn zero_max_subscriptions_clamps_to_default() {
1099 let inner = PoolInner::new(None, TransportBackend::default(), false, 0);
1100 assert_eq!(inner.max_subscriptions, WS_DEFAULT_SUBSCRIPTIONS);
1101 }
1102
1103 #[rstest]
1104 fn pool_retains_proxy_for_lazily_created_shards() {
1105 const PROXY_URL: &str = "http://pool-user:pool-proxy-secret@127.0.0.1:18088";
1106 let pool = PolymarketMarketConnectionPool::new_with_proxy(
1107 Some("ws://market.example/ws".to_string()),
1108 true,
1109 TransportBackend::Tungstenite,
1110 17,
1111 Some(ProxyUrl::parse(PROXY_URL).unwrap()),
1112 );
1113 let primary = pool.inner.market_client(true, PRIMARY_SHARD_ID);
1114 let secondary = pool.inner.market_client(false, PRIMARY_SHARD_ID + 1);
1115 let debug = format!("{pool:?}");
1116
1117 assert_eq!(pool.inner.proxy_url.as_ref().unwrap().expose(), PROXY_URL);
1118 assert_eq!(primary.proxy_url().unwrap().expose(), PROXY_URL);
1119 assert_eq!(secondary.proxy_url().unwrap().expose(), PROXY_URL);
1120 assert_eq!(pool.inner.max_subscriptions, 17);
1121 assert!(!debug.contains("pool-proxy-secret"));
1122 }
1123
1124 #[rstest]
1125 #[tokio::test]
1126 async fn pool_assigns_distinct_endpoint_sinks_and_handles_before_connect() {
1127 let addr = start_socket_server().await;
1128 let (system_tx, mut system_rx) = tokio::sync::mpsc::unbounded_channel();
1129 replace_system_event_sender(system_tx);
1130 let registry = SocketReconnectRegistry::default();
1131 let factory = SocketControlFactory::with_registry(
1132 ClientId::from("POLYMARKET"),
1133 Some(*crate::common::consts::POLYMARKET_VENUE),
1134 ®istry,
1135 );
1136 let pool = PolymarketMarketConnectionPool::new(
1137 Some(format!("ws://{addr}/ws/market")),
1138 false,
1139 TransportBackend::Tungstenite,
1140 1,
1141 )
1142 .with_socket_factory(factory);
1143
1144 pool.connect().await.expect("connect primary shard");
1145 pool.handle()
1146 .subscribe_market(vec!["asset-0".to_string(), "asset-1".to_string()])
1147 .await
1148 .expect("open secondary shard");
1149
1150 let mut connected = Vec::new();
1151 while connected.len() < 2 {
1152 let event = tokio::time::timeout(Duration::from_secs(2), system_rx.recv())
1153 .await
1154 .expect("wait for socket state event")
1155 .expect("system event channel closed");
1156 let SystemEvent::SocketState(change) = event;
1157 if change.state == SocketState::Connected {
1158 connected.push(change.endpoint);
1159 }
1160 }
1161 connected.sort_unstable();
1162
1163 assert_eq!(
1164 connected,
1165 vec![
1166 Ustr::from(MARKET_STREAMS_ENDPOINT),
1167 Ustr::from("polymarket-market-streams-1"),
1168 ],
1169 );
1170 let client_id = ClientId::from("POLYMARKET");
1171 let primary = registry
1172 .handle(client_id, Ustr::from(MARKET_STREAMS_ENDPOINT))
1173 .expect("primary reconnect handle should be registered");
1174 let secondary = registry
1175 .handle(client_id, Ustr::from("polymarket-market-streams-1"))
1176 .expect("secondary reconnect handle should be registered");
1177 assert_eq!(
1178 primary.request_reconnect(),
1179 SocketReconnectRequestOutcome::Accepted,
1180 );
1181 let event = system_rx
1182 .try_recv()
1183 .expect("selected shard should report reconnect state");
1184 let SystemEvent::SocketState(change) = event;
1185 assert_eq!(change.client_id, client_id);
1186 assert_eq!(change.endpoint, Ustr::from(MARKET_STREAMS_ENDPOINT));
1187 assert_eq!(change.state, SocketState::Disconnected);
1188 assert_eq!(
1189 secondary.request_reconnect(),
1190 SocketReconnectRequestOutcome::Accepted,
1191 );
1192
1193 pool.disconnect().await.expect("disconnect pool");
1194 assert!(
1195 registry
1196 .handle(client_id, Ustr::from(MARKET_STREAMS_ENDPOINT))
1197 .is_none()
1198 );
1199 assert!(
1200 registry
1201 .handle(client_id, Ustr::from("polymarket-market-streams-1"))
1202 .is_none()
1203 );
1204 }
1205
1206 #[rstest]
1207 #[case::first_has_room(&[0, 200], 200, Some(0))]
1208 #[case::prefers_lowest_id(&[200, 5, 5], 200, Some(1))]
1209 #[case::all_full(&[200, 200], 200, None)]
1210 #[case::exact_boundary_is_full(&[1], 1, None)]
1211 fn smallest_shard_with_capacity_picks_lowest_open_id(
1212 #[case] owned: &[usize],
1213 #[case] max: usize,
1214 #[case] expected: Option<usize>,
1215 ) {
1216 let state = state_with_shards(owned);
1217 assert_eq!(smallest_shard_with_capacity(&state, max), expected);
1218 }
1219
1220 #[rstest]
1221 fn available_shard_id_reuses_lowest_closed_shard() {
1222 let mut state = state_with_shards(&[1, 1, 1]);
1223 state.shards.remove(&(PRIMARY_SHARD_ID + 1));
1224
1225 assert_eq!(available_shard_id(&state), PRIMARY_SHARD_ID + 1);
1226 }
1227
1228 #[rstest]
1229 #[tokio::test]
1230 async fn subscribe_routes_command_and_tracks_assignment() {
1231 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1232 let handle = Handle::test_single_shard(tx, &[]);
1233
1234 handle
1235 .subscribe_market(vec!["token-a".to_string()])
1236 .await
1237 .expect("subscribe");
1238
1239 match rx.try_recv().expect("expected SubscribeMarket") {
1240 HandlerCommand::SubscribeMarket(ids) => assert_eq!(ids, vec!["token-a".to_string()]),
1241 other => panic!("unexpected command: {other:?}"),
1242 }
1243 assert_eq!(handle.inner.subscription_count_for_test(), 1);
1244 }
1245
1246 #[rstest]
1247 #[tokio::test]
1248 async fn duplicate_subscribe_does_not_consume_capacity_or_resend() {
1249 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1250 let handle = Handle::test_single_shard(tx, &[]);
1251
1252 handle
1253 .subscribe_market(vec!["token-a".to_string()])
1254 .await
1255 .expect("first subscribe");
1256 handle
1257 .subscribe_market(vec!["token-a".to_string()])
1258 .await
1259 .expect("duplicate subscribe");
1260
1261 assert!(matches!(
1262 rx.try_recv(),
1263 Ok(HandlerCommand::SubscribeMarket(_))
1264 ));
1265 assert!(rx.try_recv().is_err(), "duplicate must not resend");
1266 assert_eq!(handle.inner.subscription_count_for_test(), 1);
1267 }
1268
1269 #[rstest]
1270 #[tokio::test]
1271 async fn unsubscribe_routes_command_and_releases_assignment() {
1272 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1273 let handle = Handle::test_single_shard(tx, &["token-a"]);
1274
1275 handle
1276 .unsubscribe_market(vec!["token-a".to_string()])
1277 .await
1278 .expect("unsubscribe");
1279
1280 match rx.try_recv().expect("expected UnsubscribeMarket") {
1281 HandlerCommand::UnsubscribeMarket(ids) => assert_eq!(ids, vec!["token-a".to_string()]),
1282 other => panic!("unexpected command: {other:?}"),
1283 }
1284 assert_eq!(handle.inner.subscription_count_for_test(), 0);
1285 }
1286
1287 #[rstest]
1288 #[tokio::test]
1289 async fn unsubscribe_unknown_token_is_noop() {
1290 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1291 let handle = Handle::test_single_shard(tx, &[]);
1292
1293 handle
1294 .unsubscribe_market(vec!["token-a".to_string()])
1295 .await
1296 .expect("unsubscribe");
1297
1298 assert!(rx.try_recv().is_err());
1299 }
1300
1301 #[rstest]
1302 #[tokio::test]
1303 async fn subscribe_send_failure_rolls_back_assignment() {
1304 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1305 drop(rx);
1306 let handle = Handle::test_single_shard(tx, &[]);
1307
1308 let result = handle.subscribe_market(vec!["token-a".to_string()]).await;
1309
1310 assert!(result.is_err());
1311 assert_eq!(handle.inner.subscription_count_for_test(), 0);
1312 }
1313}