1use std::{
34 num::NonZeroUsize,
35 ops::Deref,
36 sync::{Arc, LazyLock},
37};
38
39use ahash::{AHashMap, AHashSet};
40use dashmap::DashMap;
41use parking_lot::RwLock;
42use ustr::Ustr;
43
44pub(crate) static CHANNEL_LEVEL_MARKER: LazyLock<Ustr> = LazyLock::new(|| Ustr::from(""));
49
50#[derive(Clone, Debug, Default, Eq, PartialEq)]
63pub struct SubscriptionSnapshot(AHashMap<Ustr, AHashSet<Ustr>>);
64
65impl Deref for SubscriptionSnapshot {
66 type Target = AHashMap<Ustr, AHashSet<Ustr>>;
67
68 fn deref(&self) -> &Self::Target {
69 &self.0
70 }
71}
72
73#[derive(Clone, Debug)]
103pub struct SubscriptionState {
104 confirmed: Arc<DashMap<Ustr, AHashSet<Ustr>>>,
105 pending_subscribe: Arc<DashMap<Ustr, AHashSet<Ustr>>>,
106 pending_unsubscribe: Arc<DashMap<Ustr, AHashSet<Ustr>>>,
107 desired: Arc<DashMap<Ustr, AHashSet<Ustr>>>,
108 reference_counts: Arc<DashMap<Ustr, NonZeroUsize>>,
109 state_lock: Arc<RwLock<()>>,
110 delimiter: char,
111}
112
113impl SubscriptionState {
114 #[must_use]
116 pub fn new(delimiter: char) -> Self {
117 Self {
118 confirmed: Arc::new(DashMap::new()),
119 pending_subscribe: Arc::new(DashMap::new()),
120 pending_unsubscribe: Arc::new(DashMap::new()),
121 desired: Arc::new(DashMap::new()),
122 reference_counts: Arc::new(DashMap::new()),
123 state_lock: Arc::new(RwLock::new(())),
124 delimiter,
125 }
126 }
127
128 #[must_use]
130 pub fn delimiter(&self) -> char {
131 self.delimiter
132 }
133
134 #[must_use]
136 pub fn confirmed(&self) -> SubscriptionSnapshot {
137 let _guard = self.state_lock.read();
138 snapshot(&self.confirmed)
139 }
140
141 #[must_use]
143 pub fn pending_subscribe(&self) -> SubscriptionSnapshot {
144 let _guard = self.state_lock.read();
145 snapshot(&self.pending_subscribe)
146 }
147
148 #[must_use]
150 pub fn pending_unsubscribe(&self) -> SubscriptionSnapshot {
151 let _guard = self.state_lock.read();
152 snapshot(&self.pending_unsubscribe)
153 }
154
155 #[must_use]
159 pub fn len(&self) -> usize {
160 let _guard = self.state_lock.read();
161 self.confirmed.iter().map(|entry| entry.value().len()).sum()
162 }
163
164 #[must_use]
166 pub fn is_empty(&self) -> bool {
167 let _guard = self.state_lock.read();
168 self.confirmed.is_empty()
169 && self.pending_subscribe.is_empty()
170 && self.pending_unsubscribe.is_empty()
171 && self.desired.is_empty()
172 }
173
174 #[must_use]
176 pub fn is_subscribed(&self, channel: &Ustr, symbol: &Ustr) -> bool {
177 let _guard = self.state_lock.read();
178
179 if let Some(symbols) = self.confirmed.get(channel)
180 && symbols.contains(symbol)
181 {
182 return true;
183 }
184
185 if let Some(symbols) = self.pending_subscribe.get(channel)
186 && symbols.contains(symbol)
187 {
188 return true;
189 }
190
191 false
192 }
193
194 #[must_use]
196 pub fn pending_subscribe_topics(&self) -> Vec<String> {
197 let _guard = self.state_lock.read();
198 self.topics_from_map(&self.pending_subscribe)
199 }
200
201 #[must_use]
203 pub fn pending_unsubscribe_topics(&self) -> Vec<String> {
204 let _guard = self.state_lock.read();
205 self.topics_from_map(&self.pending_unsubscribe)
206 }
207
208 #[must_use]
213 pub fn all_topics(&self) -> Vec<String> {
214 let _guard = self.state_lock.read();
215 let mut topics = self.topics_from_map(&self.confirmed);
216 topics.extend(self.topics_from_map(&self.pending_subscribe));
217 topics
218 }
219
220 pub fn mark_subscribe(&self, topic: &str) {
225 let _guard = self.state_lock.write();
226 let (channel, symbol) = split_topic(topic, self.delimiter);
227 track_topic(&self.desired, channel, symbol);
228
229 if is_tracked(&self.confirmed, channel, symbol) {
231 return;
232 }
233
234 untrack_topic(&self.pending_unsubscribe, channel, symbol);
236
237 track_topic(&self.pending_subscribe, channel, symbol);
238 }
239
240 #[must_use]
247 pub fn try_mark_subscribe(&self, topic: &str) -> bool {
248 let _guard = self.state_lock.write();
249 let (channel, symbol) = split_topic(topic, self.delimiter);
250
251 if !track_topic(&self.desired, channel, symbol) {
253 return false;
254 }
255
256 let inserted = track_topic(&self.pending_subscribe, channel, symbol);
257 if inserted {
258 untrack_topic(&self.pending_unsubscribe, channel, symbol);
259 }
260
261 inserted
262 }
263
264 pub fn confirm_subscribe(&self, topic: &str) {
269 let _guard = self.state_lock.write();
270 let (channel, symbol) = split_topic(topic, self.delimiter);
271
272 if !is_tracked(&self.desired, channel, symbol)
273 || is_tracked(&self.pending_unsubscribe, channel, symbol)
274 {
275 return;
276 }
277
278 untrack_topic(&self.pending_subscribe, channel, symbol);
279 track_topic(&self.confirmed, channel, symbol);
280 }
281
282 pub fn mark_unsubscribe(&self, topic: &str) {
287 let _guard = self.state_lock.write();
288 let (channel, symbol) = split_topic(topic, self.delimiter);
289 untrack_topic(&self.desired, channel, symbol);
290 track_topic(&self.pending_unsubscribe, channel, symbol);
291 untrack_topic(&self.confirmed, channel, symbol);
292 untrack_topic(&self.pending_subscribe, channel, symbol);
293 }
294
295 pub fn confirm_unsubscribe(&self, topic: &str) {
301 let _guard = self.state_lock.write();
302 let (channel, symbol) = split_topic(topic, self.delimiter);
303
304 if !is_tracked(&self.pending_unsubscribe, channel, symbol) {
307 return; }
309
310 untrack_topic(&self.pending_unsubscribe, channel, symbol);
311 untrack_topic(&self.confirmed, channel, symbol);
312 }
314
315 pub fn mark_failure(&self, topic: &str) {
320 let _guard = self.state_lock.write();
321 let (channel, symbol) = split_topic(topic, self.delimiter);
322
323 if !is_tracked(&self.desired, channel, symbol)
324 || is_tracked(&self.pending_unsubscribe, channel, symbol)
325 {
326 return;
327 }
328
329 untrack_topic(&self.confirmed, channel, symbol);
330 track_topic(&self.pending_subscribe, channel, symbol);
331 }
332
333 #[allow(
339 clippy::must_use_candidate,
340 reason = "some adapters replay from separate subscription registries"
341 )]
342 pub fn reset_after_reconnect(&self) -> Vec<String> {
343 let _guard = self.state_lock.write();
344 let mut topics = self.topics_from_map(&self.confirmed);
345 topics.extend(self.topics_from_map(&self.pending_subscribe));
346
347 self.confirmed.clear();
348 self.pending_subscribe.clear();
349 self.pending_unsubscribe.clear();
350
351 for topic in &topics {
352 let (channel, symbol) = split_topic(topic, self.delimiter);
353 track_topic(&self.pending_subscribe, channel, symbol);
354 }
355
356 topics
357 }
358
359 #[allow(
368 clippy::must_use_candidate,
369 reason = "callers use this for side effects"
370 )]
371 pub fn add_reference(&self, topic: &str) -> bool {
372 let mut should_subscribe = false;
373 let topic_ustr = Ustr::from(topic);
374
375 self.reference_counts
376 .entry(topic_ustr)
377 .and_modify(|count| {
378 *count = NonZeroUsize::new(count.get() + 1).expect("reference count overflow");
379 })
380 .or_insert_with(|| {
381 should_subscribe = true;
382 NonZeroUsize::MIN
383 });
384
385 should_subscribe
386 }
387
388 #[allow(
398 clippy::must_use_candidate,
399 reason = "callers use this for side effects"
400 )]
401 pub fn remove_reference(&self, topic: &str) -> bool {
402 let Some(topic_ustr) = Ustr::from_existing(topic) else {
403 return false;
404 };
405
406 if let dashmap::mapref::entry::Entry::Occupied(mut entry) =
409 self.reference_counts.entry(topic_ustr)
410 {
411 let current = entry.get().get();
412 if current == 1 {
413 entry.remove();
414 return true;
415 }
416
417 *entry.get_mut() = NonZeroUsize::new(current - 1)
418 .expect("reference count should never reach zero here");
419 }
420
421 false
422 }
423
424 #[must_use]
428 pub fn get_reference_count(&self, topic: &str) -> usize {
429 let Some(topic_ustr) = Ustr::from_existing(topic) else {
430 return 0;
431 };
432
433 self.reference_counts
434 .get(&topic_ustr)
435 .map_or(0, |count| count.get())
436 }
437
438 pub fn clear(&self) {
442 let _guard = self.state_lock.write();
443 self.confirmed.clear();
444 self.pending_subscribe.clear();
445 self.pending_unsubscribe.clear();
446 self.desired.clear();
447 self.reference_counts.clear();
448 }
449
450 fn topics_from_map(&self, map: &DashMap<Ustr, AHashSet<Ustr>>) -> Vec<String> {
452 let mut topics = Vec::new();
453 let marker = *CHANNEL_LEVEL_MARKER;
454
455 for entry in map {
456 let channel = entry.key();
457 let symbols = entry.value();
458
459 if symbols.contains(&marker) {
461 topics.push(channel.to_string());
462 }
463
464 for symbol in symbols {
466 if *symbol != marker {
467 topics.push(format!("{channel}{}{symbol}", self.delimiter));
468 }
469 }
470 }
471
472 topics.sort();
475 topics
476 }
477}
478
479#[must_use]
481pub fn split_topic(topic: &str, delimiter: char) -> (&str, Option<&str>) {
482 topic
483 .split_once(delimiter)
484 .map_or((topic, None), |(channel, symbol)| (channel, Some(symbol)))
485}
486
487fn snapshot(map: &DashMap<Ustr, AHashSet<Ustr>>) -> SubscriptionSnapshot {
488 SubscriptionSnapshot(
489 map.iter()
490 .map(|entry| (*entry.key(), entry.value().clone()))
491 .collect(),
492 )
493}
494
495fn track_topic(map: &DashMap<Ustr, AHashSet<Ustr>>, channel: &str, symbol: Option<&str>) -> bool {
500 map.entry(Ustr::from(channel))
501 .or_default()
502 .insert(topic_symbol(symbol))
503}
504
505fn untrack_topic(map: &DashMap<Ustr, AHashSet<Ustr>>, channel: &str, symbol: Option<&str>) {
509 let Some((channel, symbol)) = existing_topic(channel, symbol) else {
510 return;
511 };
512
513 if let dashmap::mapref::entry::Entry::Occupied(mut entry) = map.entry(channel) {
516 entry.get_mut().remove(&symbol);
517 if entry.get().is_empty() {
518 entry.remove();
519 }
520 }
521}
522
523fn is_tracked(map: &DashMap<Ustr, AHashSet<Ustr>>, channel: &str, symbol: Option<&str>) -> bool {
525 let Some((channel, symbol)) = existing_topic(channel, symbol) else {
526 return false;
527 };
528
529 map.get(&channel)
530 .is_some_and(|entry| entry.contains(&symbol))
531}
532
533fn existing_topic(channel: &str, symbol: Option<&str>) -> Option<(Ustr, Ustr)> {
534 let channel = Ustr::from_existing(channel)?;
535
536 let symbol = match symbol {
537 Some(symbol) => Ustr::from_existing(symbol)?,
538 None => *CHANNEL_LEVEL_MARKER,
539 };
540
541 Some((channel, symbol))
542}
543
544fn topic_symbol(symbol: Option<&str>) -> Ustr {
545 symbol.map_or(*CHANNEL_LEVEL_MARKER, Ustr::from)
546}
547
548#[cfg(test)]
549mod tests {
550 use std::sync::{
551 Barrier,
552 atomic::{AtomicUsize, Ordering},
553 };
554
555 use rstest::rstest;
556
557 use super::*;
558
559 #[rstest]
560 fn test_split_topic_with_symbol() {
561 let (channel, symbol) = split_topic("tickers.BTCUSDT", '.');
562 assert_eq!(channel, "tickers");
563 assert_eq!(symbol, Some("BTCUSDT"));
564
565 let (channel, symbol) = split_topic("orderBookL2:XBTUSD", ':');
566 assert_eq!(channel, "orderBookL2");
567 assert_eq!(symbol, Some("XBTUSD"));
568 }
569
570 #[rstest]
571 fn test_split_topic_without_symbol() {
572 let (channel, symbol) = split_topic("orderbook", '.');
573 assert_eq!(channel, "orderbook");
574 assert_eq!(symbol, None);
575 }
576
577 #[rstest]
578 fn test_topic_without_symbol() {
579 let state = SubscriptionState::new('.');
580 state.mark_subscribe("orderbook");
581 state.confirm_subscribe("orderbook");
582
583 assert_eq!(state.len(), 1);
584 assert_eq!(state.all_topics(), vec!["orderbook"]);
585 }
586
587 #[rstest]
588 fn test_different_delimiters() {
589 let state_dot = SubscriptionState::new('.');
590 state_dot.mark_subscribe("tickers.BTCUSDT");
591 assert_eq!(
592 state_dot.pending_subscribe_topics(),
593 vec!["tickers.BTCUSDT"]
594 );
595
596 let state_colon = SubscriptionState::new(':');
597 state_colon.mark_subscribe("orderBookL2:XBTUSD");
598 assert_eq!(
599 state_colon.pending_subscribe_topics(),
600 vec!["orderBookL2:XBTUSD"]
601 );
602 }
603
604 #[rstest]
605 fn test_different_delimiter_does_not_affect_storage() {
606 let state_dot = SubscriptionState::new('.');
608 let state_colon = SubscriptionState::new(':');
609
610 state_dot.mark_subscribe("channel.SYMBOL");
612 state_colon.mark_subscribe("channel:SYMBOL");
613
614 assert_eq!(state_dot.pending_subscribe_topics(), vec!["channel.SYMBOL"]);
616 assert_eq!(
617 state_colon.pending_subscribe_topics(),
618 vec!["channel:SYMBOL"]
619 );
620 }
621
622 #[rstest]
623 fn test_multiple_symbols_same_channel() {
624 let state = SubscriptionState::new('.');
625 state.mark_subscribe("tickers.BTCUSDT");
626 state.mark_subscribe("tickers.ETHUSDT");
627 state.confirm_subscribe("tickers.BTCUSDT");
628 state.confirm_subscribe("tickers.ETHUSDT");
629
630 assert_eq!(state.len(), 2);
631 assert_eq!(
632 state.all_topics(),
633 vec!["tickers.BTCUSDT", "tickers.ETHUSDT"]
634 );
635 }
636
637 #[rstest]
638 fn test_mixed_channel_and_symbol_subscriptions() {
639 let state = SubscriptionState::new('.');
640
641 state.mark_subscribe("tickers");
643 state.confirm_subscribe("tickers");
644 assert_eq!(state.len(), 1);
645 assert_eq!(state.all_topics(), vec!["tickers"]);
646
647 state.mark_subscribe("tickers.BTCUSDT");
649 state.confirm_subscribe("tickers.BTCUSDT");
650 assert_eq!(state.len(), 2);
651
652 assert_eq!(state.all_topics(), vec!["tickers", "tickers.BTCUSDT"]);
654
655 state.mark_subscribe("tickers.ETHUSDT");
657 state.confirm_subscribe("tickers.ETHUSDT");
658 assert_eq!(state.len(), 3);
659
660 assert_eq!(
661 state.all_topics(),
662 vec!["tickers", "tickers.BTCUSDT", "tickers.ETHUSDT"]
663 );
664
665 state.mark_unsubscribe("tickers");
667 state.confirm_unsubscribe("tickers");
668 assert_eq!(state.len(), 2);
669
670 assert_eq!(
671 state.all_topics(),
672 vec!["tickers.BTCUSDT", "tickers.ETHUSDT"]
673 );
674 }
675
676 #[rstest]
677 fn test_symbol_subscription_before_channel() {
678 let state = SubscriptionState::new('.');
679
680 state.mark_subscribe("tickers.BTCUSDT");
682 state.confirm_subscribe("tickers.BTCUSDT");
683 assert_eq!(state.len(), 1);
684
685 state.mark_subscribe("tickers");
687 state.confirm_subscribe("tickers");
688 assert_eq!(state.len(), 2);
689
690 assert_eq!(state.all_topics(), vec!["tickers", "tickers.BTCUSDT"]);
692 }
693
694 #[rstest]
695 fn test_edge_case_empty_channel_name() {
696 let state = SubscriptionState::new('.');
697
698 state.mark_subscribe("");
700 state.confirm_subscribe("");
701
702 assert_eq!(state.len(), 1);
703 assert_eq!(state.all_topics(), vec![""]);
704 }
705
706 #[rstest]
707 fn test_special_characters_in_topics() {
708 let state = SubscriptionState::new('.');
709
710 let special_topics = vec![
712 "channel.symbol-with-dash",
713 "channel.SYMBOL_WITH_UNDERSCORE",
714 "channel.symbol123",
715 "channel.symbol@special",
716 ];
717
718 for topic in &special_topics {
719 state.mark_subscribe(topic);
720 state.confirm_subscribe(topic);
721 }
722
723 assert_eq!(state.len(), special_topics.len());
724
725 let all_topics = state.all_topics();
726
727 for topic in &special_topics {
728 assert!(
729 all_topics.contains(&(*topic).to_string()),
730 "Missing topic: {topic}"
731 );
732 }
733 }
734
735 #[rstest]
736 fn test_edge_case_malformed_topics() {
737 let state = SubscriptionState::new('.');
738
739 state.mark_subscribe("channel.symbol.extra");
741 state.confirm_subscribe("channel.symbol.extra");
742 let topics = state.all_topics();
743 assert!(topics.contains(&"channel.symbol.extra".to_string()));
744
745 state.mark_subscribe(".channel");
747 state.confirm_subscribe(".channel");
748 assert_eq!(state.len(), 2);
749
750 state.mark_subscribe("channel.");
753 state.confirm_subscribe("channel.");
754 assert_eq!(state.len(), 3);
755
756 state.mark_subscribe("tickers");
758 state.confirm_subscribe("tickers");
759 assert_eq!(state.len(), 4);
760
761 let all = state.all_topics();
763 assert_eq!(all.len(), 4);
764 assert!(all.contains(&"channel.symbol.extra".to_string()));
765 assert!(all.contains(&".channel".to_string()));
766 assert!(all.contains(&"channel".to_string())); assert!(all.contains(&"tickers".to_string()));
768 }
769
770 #[rstest]
771 fn test_new_state_is_empty() {
772 let state = SubscriptionState::new('.');
773 assert!(state.is_empty());
774 assert_eq!(state.len(), 0);
775 }
776
777 #[rstest]
778 fn test_subscription_map_accessors_return_isolated_snapshots() {
779 let state = SubscriptionState::new('.');
780 state.mark_subscribe("tickers.BTCUSDT");
781 state.confirm_subscribe("tickers.BTCUSDT");
782
783 let confirmed = state.confirmed();
784 let pending_subscribe = state.pending_subscribe();
785 let pending_unsubscribe = state.pending_unsubscribe();
786
787 state.mark_unsubscribe("tickers.BTCUSDT");
788
789 assert_eq!(
790 confirmed.get(&Ustr::from("tickers")),
791 Some(&AHashSet::from_iter([Ustr::from("BTCUSDT")]))
792 );
793 assert!(pending_subscribe.is_empty());
794 assert!(pending_unsubscribe.is_empty());
795 assert!(state.confirmed().is_empty());
796 assert_eq!(
797 state.pending_unsubscribe().get(&Ustr::from("tickers")),
798 Some(&AHashSet::from_iter([Ustr::from("BTCUSDT")]))
799 );
800 }
801
802 #[rstest]
803 fn test_is_subscribed_empty_state() {
804 let state = SubscriptionState::new('.');
805 let channel = Ustr::from("tickers");
806 let symbol = Ustr::from("BTCUSDT");
807
808 assert!(!state.is_subscribed(&channel, &symbol));
809 }
810
811 #[rstest]
812 fn test_is_subscribed_pending() {
813 let state = SubscriptionState::new('.');
814 let channel = Ustr::from("tickers");
815 let symbol = Ustr::from("BTCUSDT");
816
817 state.mark_subscribe("tickers.BTCUSDT");
818
819 assert!(state.is_subscribed(&channel, &symbol));
820 }
821
822 #[rstest]
823 fn test_is_subscribed_confirmed() {
824 let state = SubscriptionState::new('.');
825 let channel = Ustr::from("tickers");
826 let symbol = Ustr::from("BTCUSDT");
827
828 state.mark_subscribe("tickers.BTCUSDT");
829 state.confirm_subscribe("tickers.BTCUSDT");
830
831 assert!(state.is_subscribed(&channel, &symbol));
832 }
833
834 #[rstest]
835 fn test_is_subscribed_after_unsubscribe() {
836 let state = SubscriptionState::new('.');
837 let channel = Ustr::from("tickers");
838 let symbol = Ustr::from("BTCUSDT");
839
840 state.mark_subscribe("tickers.BTCUSDT");
841 state.confirm_subscribe("tickers.BTCUSDT");
842 state.mark_unsubscribe("tickers.BTCUSDT");
843
844 assert!(!state.is_subscribed(&channel, &symbol));
846 }
847
848 #[rstest]
849 fn test_is_subscribed_after_confirm_unsubscribe() {
850 let state = SubscriptionState::new('.');
851 let channel = Ustr::from("tickers");
852 let symbol = Ustr::from("BTCUSDT");
853
854 state.mark_subscribe("tickers.BTCUSDT");
855 state.confirm_subscribe("tickers.BTCUSDT");
856 state.mark_unsubscribe("tickers.BTCUSDT");
857 state.confirm_unsubscribe("tickers.BTCUSDT");
858
859 assert!(!state.is_subscribed(&channel, &symbol));
860 }
861
862 #[rstest]
863 fn test_all_topics_includes_confirmed_and_pending_subscribe() {
864 let state = SubscriptionState::new('.');
865 state.mark_subscribe("tickers.BTCUSDT");
866 state.confirm_subscribe("tickers.BTCUSDT");
867 state.mark_subscribe("tickers.ETHUSDT");
868
869 assert_eq!(
870 state.all_topics(),
871 vec!["tickers.BTCUSDT", "tickers.ETHUSDT"]
872 );
873 }
874
875 #[rstest]
876 fn test_all_topics_excludes_pending_unsubscribe() {
877 let state = SubscriptionState::new('.');
878 state.mark_subscribe("tickers.BTCUSDT");
879 state.confirm_subscribe("tickers.BTCUSDT");
880 state.mark_unsubscribe("tickers.BTCUSDT");
881
882 let topics = state.all_topics();
883 assert!(topics.is_empty());
884 }
885
886 #[rstest]
887 fn test_all_topics_is_sorted_within_each_group() {
888 let state = SubscriptionState::new('.');
889
890 for topic in [
892 "trades.SOLUSDT",
893 "tickers.ETHUSDT",
894 "trades.BTCUSDT",
895 "tickers.BTCUSDT",
896 ] {
897 state.mark_subscribe(topic);
898 state.confirm_subscribe(topic);
899 }
900
901 state.mark_subscribe("orders.XRPUSDT");
902 state.mark_subscribe("orders.ADAUSDT");
903
904 assert_eq!(
906 state.all_topics(),
907 vec![
908 "tickers.BTCUSDT",
909 "tickers.ETHUSDT",
910 "trades.BTCUSDT",
911 "trades.SOLUSDT",
912 "orders.ADAUSDT",
913 "orders.XRPUSDT",
914 ]
915 );
916 }
917
918 #[rstest]
919 fn test_pending_subscribe_excludes_pending_unsubscribe() {
920 let state = SubscriptionState::new('.');
921
922 state.mark_subscribe("tickers.BTCUSDT");
924 state.confirm_subscribe("tickers.BTCUSDT");
925
926 state.mark_unsubscribe("tickers.BTCUSDT");
928
929 assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
931 assert!(state.all_topics().is_empty());
932 assert_eq!(state.len(), 0);
933 }
934
935 #[rstest]
936 fn test_mark_subscribe() {
937 let state = SubscriptionState::new('.');
938 state.mark_subscribe("tickers.BTCUSDT");
939
940 assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]);
941 assert_eq!(state.len(), 0); }
943
944 #[rstest]
945 fn test_try_mark_subscribe_returns_true_once_across_concurrent_calls() {
946 const CALLERS: usize = 32;
947
948 let state = Arc::new(SubscriptionState::new('.'));
949 let start = Arc::new(Barrier::new(CALLERS));
950 let send_count = Arc::new(AtomicUsize::new(0));
951
952 std::thread::scope(|scope| {
953 for _ in 0..CALLERS {
954 let state = Arc::clone(&state);
955 let start = Arc::clone(&start);
956 let send_count = Arc::clone(&send_count);
957
958 scope.spawn(move || {
959 start.wait();
960
961 if state.try_mark_subscribe("tickers.BTCUSDT") {
962 send_count.fetch_add(1, Ordering::SeqCst);
963 }
964 });
965 }
966 });
967
968 assert_eq!(send_count.load(Ordering::SeqCst), 1);
969 assert_eq!(state.pending_subscribe_topics(), ["tickers.BTCUSDT"]);
970 assert!(state.pending_unsubscribe_topics().is_empty());
971 }
972
973 #[rstest]
974 fn test_try_mark_subscribe_respects_lifecycle_state() {
975 let state = SubscriptionState::new('.');
976 let topic = "tickers.BTCUSDT";
977
978 assert!(state.try_mark_subscribe(topic));
979 assert!(!state.try_mark_subscribe(topic));
980
981 state.confirm_subscribe(topic);
982 assert!(!state.try_mark_subscribe(topic));
983
984 state.mark_unsubscribe(topic);
985 assert!(state.try_mark_subscribe(topic));
986 assert_eq!(state.pending_subscribe_topics(), [topic]);
987 assert!(state.pending_unsubscribe_topics().is_empty());
988 }
989
990 #[rstest]
991 fn test_confirm_subscribe() {
992 let state = SubscriptionState::new('.');
993 state.mark_subscribe("tickers.BTCUSDT");
994 state.confirm_subscribe("tickers.BTCUSDT");
995
996 assert!(state.pending_subscribe_topics().is_empty());
997 assert_eq!(state.len(), 1);
998 }
999
1000 #[rstest]
1001 fn test_mark_unsubscribe() {
1002 let state = SubscriptionState::new('.');
1003 state.mark_subscribe("tickers.BTCUSDT");
1004 state.confirm_subscribe("tickers.BTCUSDT");
1005 state.mark_unsubscribe("tickers.BTCUSDT");
1006
1007 assert_eq!(state.len(), 0); assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1009 }
1010
1011 #[rstest]
1012 fn test_confirm_unsubscribe() {
1013 let state = SubscriptionState::new('.');
1014 state.mark_subscribe("tickers.BTCUSDT");
1015 state.confirm_subscribe("tickers.BTCUSDT");
1016 state.mark_unsubscribe("tickers.BTCUSDT");
1017 state.confirm_unsubscribe("tickers.BTCUSDT");
1018
1019 assert!(state.is_empty());
1020 }
1021
1022 #[rstest]
1023 fn test_unsubscribe_before_subscribe_confirmed() {
1024 let state = SubscriptionState::new('.');
1025
1026 state.mark_subscribe("tickers.BTCUSDT");
1028 assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]);
1029
1030 state.mark_unsubscribe("tickers.BTCUSDT");
1032
1033 assert!(state.pending_subscribe_topics().is_empty());
1035 assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1036
1037 state.confirm_unsubscribe("tickers.BTCUSDT");
1039
1040 assert!(state.is_empty());
1042 assert!(state.all_topics().is_empty());
1043 assert_eq!(state.len(), 0);
1044 }
1045
1046 #[rstest]
1047 fn test_late_subscribe_confirmation_after_unsubscribe() {
1048 let state = SubscriptionState::new('.');
1049
1050 state.mark_subscribe("tickers.BTCUSDT");
1052
1053 state.mark_unsubscribe("tickers.BTCUSDT");
1055
1056 state.confirm_subscribe("tickers.BTCUSDT");
1058
1059 assert_eq!(state.len(), 0);
1061 assert!(state.pending_subscribe_topics().is_empty());
1062
1063 state.confirm_unsubscribe("tickers.BTCUSDT");
1065
1066 assert!(state.is_empty());
1068 assert!(state.all_topics().is_empty());
1069 }
1070
1071 #[rstest]
1072 fn test_late_subscribe_ack_after_unsubscribe_ack_does_not_restore_topic() {
1073 let state = SubscriptionState::new('.');
1074 state.mark_subscribe("tickers.BTCUSDT");
1075 state.mark_unsubscribe("tickers.BTCUSDT");
1076
1077 state.confirm_unsubscribe("tickers.BTCUSDT");
1078 state.confirm_subscribe("tickers.BTCUSDT");
1079
1080 assert!(state.is_empty());
1081 assert!(state.all_topics().is_empty());
1082 assert!(state.pending_subscribe_topics().is_empty());
1083 assert!(state.pending_unsubscribe_topics().is_empty());
1084 }
1085
1086 #[rstest]
1087 fn test_resubscribe_before_unsubscribe_ack() {
1088 let state = SubscriptionState::new('.');
1092
1093 state.mark_subscribe("tickers.BTCUSDT");
1094 state.confirm_subscribe("tickers.BTCUSDT");
1095 assert_eq!(state.len(), 1);
1096
1097 state.mark_unsubscribe("tickers.BTCUSDT");
1098 assert_eq!(state.len(), 0);
1099 assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1100
1101 state.mark_subscribe("tickers.BTCUSDT");
1103 assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]);
1104
1105 state.confirm_unsubscribe("tickers.BTCUSDT");
1107 assert!(state.pending_unsubscribe_topics().is_empty());
1108 assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]); state.confirm_subscribe("tickers.BTCUSDT");
1112 assert_eq!(state.len(), 1);
1113 assert!(state.pending_subscribe_topics().is_empty());
1114
1115 let all = state.all_topics();
1117 assert_eq!(all.len(), 1);
1118 assert!(all.contains(&"tickers.BTCUSDT".to_string()));
1119 }
1120
1121 #[rstest]
1122 fn test_stale_unsubscribe_ack_after_resubscribe_confirmed() {
1123 let state = SubscriptionState::new('.');
1128
1129 state.mark_subscribe("tickers.BTCUSDT");
1131 state.confirm_subscribe("tickers.BTCUSDT");
1132 assert_eq!(state.len(), 1);
1133
1134 state.mark_unsubscribe("tickers.BTCUSDT");
1136 assert_eq!(state.len(), 0);
1137 assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1138
1139 state.mark_subscribe("tickers.BTCUSDT");
1141 assert!(state.pending_unsubscribe_topics().is_empty()); assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]);
1143
1144 state.confirm_subscribe("tickers.BTCUSDT");
1146 assert_eq!(state.len(), 1); assert!(state.pending_subscribe_topics().is_empty());
1148
1149 state.confirm_unsubscribe("tickers.BTCUSDT");
1152
1153 assert_eq!(state.len(), 1); assert!(state.pending_unsubscribe_topics().is_empty());
1156 assert!(state.pending_subscribe_topics().is_empty());
1157
1158 let all = state.all_topics();
1160 assert_eq!(all.len(), 1);
1161 assert!(all.contains(&"tickers.BTCUSDT".to_string()));
1162 }
1163
1164 #[rstest]
1165 fn test_unsubscribe_clears_all_states() {
1166 let state = SubscriptionState::new('.');
1167
1168 state.mark_subscribe("tickers.BTCUSDT");
1170 state.confirm_subscribe("tickers.BTCUSDT");
1171 assert_eq!(state.len(), 1);
1172
1173 state.mark_unsubscribe("tickers.BTCUSDT");
1175
1176 assert_eq!(state.len(), 0);
1178 assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1179
1180 state.confirm_subscribe("tickers.BTCUSDT");
1182
1183 state.confirm_unsubscribe("tickers.BTCUSDT");
1185
1186 assert!(state.is_empty());
1188 assert_eq!(state.len(), 0);
1189 assert!(state.pending_subscribe_topics().is_empty());
1190 assert!(state.pending_unsubscribe_topics().is_empty());
1191 assert!(state.all_topics().is_empty());
1192 }
1193
1194 #[rstest]
1195 fn test_concurrent_subscribe_confirmation_and_unsubscribe_preserve_intent() {
1196 const ITERATIONS: usize = 10_000;
1197
1198 let state = Arc::new(SubscriptionState::new('.'));
1199 let start = Arc::new(Barrier::new(3));
1200 let finish = Arc::new(Barrier::new(3));
1201 let failed_iteration = Arc::new(AtomicUsize::new(usize::MAX));
1202
1203 std::thread::scope(|scope| {
1204 let confirming_state = Arc::clone(&state);
1205 let confirming_start = Arc::clone(&start);
1206 let confirming_finish = Arc::clone(&finish);
1207
1208 scope.spawn(move || {
1209 for _ in 0..ITERATIONS {
1210 confirming_start.wait();
1211 confirming_state.confirm_subscribe("tickers.BTCUSDT");
1212 confirming_finish.wait();
1213 }
1214 });
1215
1216 let unsubscribing_state = Arc::clone(&state);
1217 let unsubscribing_start = Arc::clone(&start);
1218 let unsubscribing_finish = Arc::clone(&finish);
1219
1220 scope.spawn(move || {
1221 for _ in 0..ITERATIONS {
1222 unsubscribing_start.wait();
1223 unsubscribing_state.mark_unsubscribe("tickers.BTCUSDT");
1224 unsubscribing_finish.wait();
1225 }
1226 });
1227
1228 for iteration in 0..ITERATIONS {
1229 state.clear();
1230 state.mark_subscribe("tickers.BTCUSDT");
1231 start.wait();
1232 finish.wait();
1233
1234 if !state.all_topics().is_empty()
1235 || state.pending_unsubscribe_topics() != ["tickers.BTCUSDT"]
1236 {
1237 _ = failed_iteration.compare_exchange(
1238 usize::MAX,
1239 iteration,
1240 Ordering::SeqCst,
1241 Ordering::SeqCst,
1242 );
1243 }
1244 }
1245 });
1246
1247 assert_eq!(
1248 failed_iteration.load(Ordering::SeqCst),
1249 usize::MAX,
1250 "late subscribe confirmation restored the unsubscribe intent"
1251 );
1252 }
1253
1254 #[rstest]
1255 #[case::unknown_components(false, false, true)]
1256 #[case::known_channel(true, false, true)]
1257 #[case::known_symbol(false, true, true)]
1258 #[case::channel_only(false, false, false)]
1259 #[case::interned_untracked_pair(true, true, true)]
1260 fn test_unknown_acknowledgments_do_not_intern(
1261 #[values("confirm_subscribe", "confirm_unsubscribe", "mark_failure")] operation: &str,
1262 #[case] channel_known: bool,
1263 #[case] symbol_known: bool,
1264 #[case] has_symbol: bool,
1265 ) {
1266 let state = SubscriptionState::new('.');
1267 state.mark_subscribe("cache_confirmed.active");
1268 state.confirm_subscribe("cache_confirmed.active");
1269 state.mark_subscribe("cache_pending.waiting");
1270 state.mark_unsubscribe("cache_cancelled.leaving");
1271 state.add_reference("cache_confirmed.active");
1272 state.add_reference("cache_confirmed.active");
1273 let confirmed = state.confirmed();
1274 let pending_subscribe = state.pending_subscribe();
1275 let pending_unsubscribe = state.pending_unsubscribe();
1276 let desired = snapshot(&state.desired);
1277 let prefix = format!("unknown_ack_{operation}_{channel_known}_{symbol_known}_{has_symbol}");
1278
1279 let channel = if channel_known {
1280 "cache_confirmed".to_string()
1281 } else {
1282 format!("{prefix}_channel")
1283 };
1284
1285 let symbol = if symbol_known {
1286 "waiting".to_string()
1287 } else {
1288 format!("{prefix}_symbol")
1289 };
1290
1291 let topic = if has_symbol {
1292 format!("{channel}.{symbol}")
1293 } else {
1294 channel.clone()
1295 };
1296
1297 let channel_before = Ustr::from_existing(&channel);
1298 let symbol_before = Ustr::from_existing(&symbol);
1299 assert_eq!(channel_before.is_some(), channel_known);
1300 assert_eq!(symbol_before.is_some(), symbol_known);
1301
1302 apply_op(&state, operation, &topic);
1303
1304 assert_eq!(Ustr::from_existing(&channel), channel_before);
1305 assert_eq!(Ustr::from_existing(&symbol), symbol_before);
1306 assert_eq!(state.confirmed(), confirmed);
1307 assert_eq!(state.pending_subscribe(), pending_subscribe);
1308 assert_eq!(state.pending_unsubscribe(), pending_unsubscribe);
1309 assert_eq!(snapshot(&state.desired), desired);
1310 assert_eq!(state.reference_counts.len(), 1);
1311 assert_eq!(state.get_reference_count("cache_confirmed.active"), 2);
1312 }
1313
1314 #[rstest]
1315 fn test_unknown_reference_operations_do_not_intern(#[values(false, true)] remove: bool) {
1316 let state = SubscriptionState::new('.');
1317 let topic = format!("unknown_reference_lookup_{remove}");
1318 assert_eq!(Ustr::from_existing(&topic), None);
1319
1320 if remove {
1321 assert!(!state.remove_reference(&topic));
1322 } else {
1323 assert_eq!(state.get_reference_count(&topic), 0);
1324 }
1325
1326 assert_eq!(Ustr::from_existing(&topic), None);
1327 assert!(state.is_empty());
1328 assert!(state.reference_counts.is_empty());
1329 }
1330
1331 #[rstest]
1332 fn test_untrack_unknown_topic_does_not_intern(#[values(false, true)] channel_known: bool) {
1333 let state = SubscriptionState::new('.');
1334 state.mark_subscribe("untrack_known.retained");
1335 let pending = state.pending_subscribe();
1336
1337 let channel = if channel_known {
1338 "untrack_known".to_string()
1339 } else {
1340 "untrack_unknown_channel".to_string()
1341 };
1342
1343 let symbol = format!("untrack_unknown_symbol_{channel_known}");
1344 let channel_before = Ustr::from_existing(&channel);
1345 assert_eq!(channel_before.is_some(), channel_known);
1346 assert_eq!(Ustr::from_existing(&symbol), None);
1347
1348 untrack_topic(&state.pending_subscribe, &channel, Some(&symbol));
1349
1350 assert_eq!(Ustr::from_existing(&channel), channel_before);
1351 assert_eq!(Ustr::from_existing(&symbol), None);
1352 assert_eq!(state.pending_subscribe(), pending);
1353 }
1354
1355 #[rstest]
1356 fn test_state_machine_invalid_transitions() {
1357 let state = SubscriptionState::new('.');
1358
1359 state.confirm_subscribe("tickers.BTCUSDT");
1361 assert_eq!(state.len(), 0);
1362
1363 state.confirm_unsubscribe("tickers.ETHUSDT");
1365 assert_eq!(state.len(), 0); state.mark_subscribe("orderbook");
1369 state.confirm_subscribe("orderbook");
1370 state.confirm_subscribe("orderbook"); assert_eq!(state.len(), 1);
1372
1373 state.mark_unsubscribe("nonexistent");
1375 state.confirm_unsubscribe("nonexistent");
1376 assert_eq!(state.len(), 1); }
1378
1379 #[rstest]
1380 fn test_mark_failure() {
1381 let state = SubscriptionState::new('.');
1382 state.mark_subscribe("tickers.BTCUSDT");
1383 state.confirm_subscribe("tickers.BTCUSDT");
1384 state.mark_failure("tickers.BTCUSDT");
1385
1386 assert_eq!(state.len(), 0);
1387 assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]);
1388 }
1389
1390 #[rstest]
1391 fn test_mark_failure_moves_to_pending() {
1392 let state = SubscriptionState::new('.');
1393
1394 state.mark_subscribe("tickers.BTCUSDT");
1396 state.confirm_subscribe("tickers.BTCUSDT");
1397 assert_eq!(state.len(), 1);
1398 assert!(state.pending_subscribe_topics().is_empty());
1399
1400 state.mark_failure("tickers.BTCUSDT");
1402
1403 assert_eq!(state.len(), 0);
1405 assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]);
1406
1407 assert_eq!(state.all_topics(), vec!["tickers.BTCUSDT"]);
1409 }
1410
1411 #[rstest]
1412 fn test_mark_failure_respects_pending_unsubscribe() {
1413 let state = SubscriptionState::new('.');
1414
1415 state.mark_subscribe("tickers.BTCUSDT");
1417 state.confirm_subscribe("tickers.BTCUSDT");
1418 assert_eq!(state.len(), 1);
1419
1420 state.mark_unsubscribe("tickers.BTCUSDT");
1422 assert_eq!(state.len(), 0);
1423 assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1424
1425 state.mark_failure("tickers.BTCUSDT");
1427
1428 assert!(state.pending_subscribe_topics().is_empty());
1430 assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1431
1432 assert!(state.all_topics().is_empty());
1434
1435 state.confirm_unsubscribe("tickers.BTCUSDT");
1437 assert!(state.is_empty());
1438 }
1439
1440 #[rstest]
1441 fn test_reconnection_scenario() {
1442 let state = SubscriptionState::new('.');
1443
1444 state.add_reference("tickers.BTCUSDT");
1446 state.mark_subscribe("tickers.BTCUSDT");
1447 state.confirm_subscribe("tickers.BTCUSDT");
1448
1449 state.add_reference("tickers.ETHUSDT");
1450 state.mark_subscribe("tickers.ETHUSDT");
1451 state.confirm_subscribe("tickers.ETHUSDT");
1452
1453 state.add_reference("orderbook");
1454 state.mark_subscribe("orderbook");
1455 state.confirm_subscribe("orderbook");
1456
1457 assert_eq!(state.len(), 3);
1458
1459 let topics_to_resubscribe = state.all_topics();
1461 assert_eq!(topics_to_resubscribe.len(), 3);
1462 assert!(topics_to_resubscribe.contains(&"tickers.BTCUSDT".to_string()));
1463 assert!(topics_to_resubscribe.contains(&"tickers.ETHUSDT".to_string()));
1464 assert!(topics_to_resubscribe.contains(&"orderbook".to_string()));
1465
1466 for topic in &topics_to_resubscribe {
1468 state.mark_subscribe(topic);
1469 }
1470
1471 for topic in &topics_to_resubscribe {
1473 state.confirm_subscribe(topic);
1474 }
1475
1476 assert_eq!(state.len(), 3);
1478 assert_eq!(state.all_topics().len(), 3);
1479 }
1480
1481 #[rstest]
1482 fn test_reconnection_with_partial_state() {
1483 let state = SubscriptionState::new('.');
1484
1485 state.add_reference("confirmed.BTCUSDT");
1488 state.mark_subscribe("confirmed.BTCUSDT");
1489 state.confirm_subscribe("confirmed.BTCUSDT");
1490
1491 state.add_reference("pending.ETHUSDT");
1493 state.mark_subscribe("pending.ETHUSDT");
1494
1495 state.mark_subscribe("cancelled.XRPUSDT");
1497 state.confirm_subscribe("cancelled.XRPUSDT");
1498 state.mark_unsubscribe("cancelled.XRPUSDT");
1499
1500 assert_eq!(state.len(), 1); let all = state.all_topics();
1503 assert_eq!(all.len(), 2); assert!(all.contains(&"confirmed.BTCUSDT".to_string()));
1505 assert!(all.contains(&"pending.ETHUSDT".to_string()));
1506 assert!(!all.contains(&"cancelled.XRPUSDT".to_string())); let topics_to_resubscribe = state.reset_after_reconnect();
1510 assert_eq!(
1511 topics_to_resubscribe,
1512 [
1513 "confirmed.BTCUSDT".to_string(),
1514 "pending.ETHUSDT".to_string()
1515 ]
1516 );
1517 assert_eq!(state.reset_after_reconnect(), topics_to_resubscribe);
1518 assert_eq!(state.len(), 0);
1519 assert_eq!(
1520 state.pending_subscribe_topics(),
1521 [
1522 "confirmed.BTCUSDT".to_string(),
1523 "pending.ETHUSDT".to_string()
1524 ]
1525 );
1526 assert!(state.pending_unsubscribe_topics().is_empty());
1527 assert_eq!(state.get_reference_count("confirmed.BTCUSDT"), 1);
1528 assert_eq!(state.get_reference_count("pending.ETHUSDT"), 1);
1529
1530 for topic in &topics_to_resubscribe {
1532 state.confirm_subscribe(topic);
1533 }
1534
1535 assert_eq!(state.len(), 2); let final_topics = state.all_topics();
1538 assert_eq!(final_topics.len(), 2);
1539 assert!(final_topics.contains(&"confirmed.BTCUSDT".to_string()));
1540 assert!(final_topics.contains(&"pending.ETHUSDT".to_string()));
1541 assert!(!final_topics.contains(&"cancelled.XRPUSDT".to_string()));
1542 }
1543
1544 #[rstest]
1545 fn test_reference_counting_single_topic() {
1546 let state = SubscriptionState::new('.');
1547
1548 assert!(state.add_reference("tickers.BTCUSDT"));
1549 assert_eq!(state.get_reference_count("tickers.BTCUSDT"), 1);
1550
1551 assert!(!state.add_reference("tickers.BTCUSDT"));
1552 assert_eq!(state.get_reference_count("tickers.BTCUSDT"), 2);
1553
1554 assert!(!state.remove_reference("tickers.BTCUSDT"));
1555 assert_eq!(state.get_reference_count("tickers.BTCUSDT"), 1);
1556
1557 assert!(state.remove_reference("tickers.BTCUSDT"));
1558 assert_eq!(state.get_reference_count("tickers.BTCUSDT"), 0);
1559 }
1560
1561 #[rstest]
1562 fn test_reference_counting_multiple_topics() {
1563 let state = SubscriptionState::new('.');
1564
1565 assert!(state.add_reference("tickers.BTCUSDT"));
1566 assert!(state.add_reference("tickers.ETHUSDT"));
1567
1568 assert!(!state.add_reference("tickers.BTCUSDT"));
1569 assert_eq!(state.get_reference_count("tickers.BTCUSDT"), 2);
1570 assert_eq!(state.get_reference_count("tickers.ETHUSDT"), 1);
1571
1572 assert!(!state.remove_reference("tickers.BTCUSDT"));
1573 assert!(state.remove_reference("tickers.ETHUSDT"));
1574 }
1575
1576 #[rstest]
1577 fn test_remove_reference_nonexistent_topic() {
1578 let state = SubscriptionState::new('.');
1579
1580 let should_unsubscribe = state.remove_reference("nonexistent");
1582
1583 assert!(!should_unsubscribe);
1585 assert_eq!(state.get_reference_count("nonexistent"), 0);
1586 }
1587
1588 #[rstest]
1589 fn test_reference_count_underflow_safety() {
1590 let state = SubscriptionState::new('.');
1591
1592 assert!(!state.remove_reference("never.added"));
1594 assert_eq!(state.get_reference_count("never.added"), 0);
1595
1596 state.add_reference("once.added");
1598 assert_eq!(state.get_reference_count("once.added"), 1);
1599
1600 assert!(state.remove_reference("once.added")); assert_eq!(state.get_reference_count("once.added"), 0);
1602
1603 assert!(!state.remove_reference("once.added")); assert!(!state.remove_reference("once.added")); assert_eq!(state.get_reference_count("once.added"), 0);
1606
1607 assert!(state.add_reference("once.added"));
1609 assert_eq!(state.get_reference_count("once.added"), 1);
1610 }
1611
1612 #[rstest]
1613 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1614 async fn test_concurrent_reference_counting_same_topic() {
1615 let state = Arc::new(SubscriptionState::new('.'));
1616 let topic = "tickers.BTCUSDT";
1617 let mut handles = vec![];
1618
1619 for _ in 0..10 {
1621 let state_clone = Arc::clone(&state);
1622
1623 let handle = tokio::spawn(async move {
1624 for _ in 0..10 {
1625 state_clone.add_reference(topic);
1626 }
1627 });
1628
1629 handles.push(handle);
1630 }
1631
1632 for handle in handles {
1633 handle.await.unwrap();
1634 }
1635
1636 assert_eq!(state.get_reference_count(topic), 100);
1638
1639 for _ in 0..50 {
1641 state.remove_reference(topic);
1642 }
1643
1644 assert_eq!(state.get_reference_count(topic), 50);
1646 }
1647
1648 #[rstest]
1649 fn test_clear() {
1650 let state = SubscriptionState::new('.');
1651 state.mark_subscribe("tickers.BTCUSDT");
1652 state.confirm_subscribe("tickers.BTCUSDT");
1653 state.add_reference("tickers.BTCUSDT");
1654
1655 state.clear();
1656
1657 assert!(state.is_empty());
1658 assert_eq!(state.get_reference_count("tickers.BTCUSDT"), 0);
1659 }
1660
1661 #[rstest]
1662 fn test_clear_resets_all_state() {
1663 let state = SubscriptionState::new('.');
1664
1665 for i in 0..10 {
1667 let topic = format!("channel{i}.SYMBOL");
1668 state.add_reference(&topic);
1669 state.add_reference(&topic); state.mark_subscribe(&topic);
1671 state.confirm_subscribe(&topic);
1672 }
1673
1674 assert_eq!(state.len(), 10);
1675 assert!(!state.is_empty());
1676
1677 state.clear();
1679
1680 assert_eq!(state.len(), 0);
1682 assert!(state.is_empty());
1683 assert!(state.all_topics().is_empty());
1684 assert!(state.pending_subscribe_topics().is_empty());
1685 assert!(state.pending_unsubscribe_topics().is_empty());
1686
1687 for i in 0..10 {
1689 let topic = format!("channel{i}.SYMBOL");
1690 assert_eq!(state.get_reference_count(&topic), 0);
1691 }
1692 }
1693
1694 #[rstest]
1695 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1696 async fn test_concurrent_subscribe_same_topic() {
1697 let state = Arc::new(SubscriptionState::new('.'));
1698 let mut handles = vec![];
1699
1700 for _ in 0..10 {
1702 let state_clone = Arc::clone(&state);
1703 let handle = tokio::spawn(async move {
1704 state_clone.add_reference("tickers.BTCUSDT");
1705 state_clone.mark_subscribe("tickers.BTCUSDT");
1706 state_clone.confirm_subscribe("tickers.BTCUSDT");
1707 });
1708
1709 handles.push(handle);
1710 }
1711
1712 for handle in handles {
1713 handle.await.unwrap();
1714 }
1715
1716 assert_eq!(state.get_reference_count("tickers.BTCUSDT"), 10);
1718 assert_eq!(state.len(), 1);
1719 }
1720
1721 #[rstest]
1722 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1723 async fn test_concurrent_subscribe_unsubscribe() {
1724 let state = Arc::new(SubscriptionState::new('.'));
1725 let mut handles = vec![];
1726
1727 for i in 0..20 {
1730 let state_clone = Arc::clone(&state);
1731
1732 let handle = tokio::spawn(async move {
1733 let topic = format!("tickers.SYMBOL{i}");
1734 state_clone.add_reference(&topic);
1736 state_clone.add_reference(&topic);
1737 state_clone.mark_subscribe(&topic);
1738 state_clone.confirm_subscribe(&topic);
1739
1740 state_clone.remove_reference(&topic);
1742 });
1743
1744 handles.push(handle);
1745 }
1746
1747 for handle in handles {
1748 handle.await.unwrap();
1749 }
1750
1751 for i in 0..20 {
1753 let topic = format!("tickers.SYMBOL{i}");
1754 assert_eq!(state.get_reference_count(&topic), 1);
1755 }
1756
1757 assert_eq!(state.len(), 20);
1759 assert!(!state.is_empty());
1760 }
1761
1762 #[rstest]
1763 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1764 async fn test_concurrent_stress_mixed_operations() {
1765 let state = Arc::new(SubscriptionState::new('.'));
1766 let mut handles = vec![];
1767
1768 for i in 0..50 {
1770 let state_clone = Arc::clone(&state);
1771
1772 let handle = tokio::spawn(async move {
1773 let topic1 = format!("channel.SYMBOL{i}");
1774 let topic2 = format!("channel.SYMBOL{}", i + 100);
1775
1776 state_clone.add_reference(&topic1);
1778 state_clone.add_reference(&topic2);
1779
1780 state_clone.mark_subscribe(&topic1);
1782 state_clone.confirm_subscribe(&topic1);
1783 state_clone.mark_subscribe(&topic2);
1784
1785 if i % 3 == 0 {
1787 state_clone.mark_unsubscribe(&topic1);
1788 state_clone.confirm_unsubscribe(&topic1);
1789 }
1790
1791 state_clone.add_reference(&topic2);
1793 state_clone.remove_reference(&topic2);
1794
1795 state_clone.confirm_subscribe(&topic2);
1797 });
1798
1799 handles.push(handle);
1800 }
1801
1802 for handle in handles {
1803 handle.await.unwrap();
1804 }
1805
1806 let actual = state.all_topics().into_iter().collect::<AHashSet<_>>();
1807
1808 let expected = (0..50)
1809 .flat_map(|i| {
1810 let topic2 = format!("channel.SYMBOL{}", i + 100);
1811 (i % 3 != 0)
1812 .then(|| format!("channel.SYMBOL{i}"))
1813 .into_iter()
1814 .chain(std::iter::once(topic2))
1815 })
1816 .collect::<AHashSet<_>>();
1817
1818 assert_eq!(actual, expected);
1819 assert_eq!(state.len(), 83);
1820 assert!(state.pending_subscribe_topics().is_empty());
1821 assert!(state.pending_unsubscribe_topics().is_empty());
1822 assert_eq!(state.reference_counts.len(), 100);
1823 }
1824
1825 #[rstest]
1826 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1827 async fn test_stress_rapid_resubscribe_pattern() {
1828 let state = Arc::new(SubscriptionState::new('.'));
1830 let mut handles = vec![];
1831
1832 for i in 0..100 {
1833 let state_clone = Arc::clone(&state);
1834
1835 let handle = tokio::spawn(async move {
1836 let topic = format!("rapid.SYMBOL{}", i % 10); state_clone.mark_subscribe(&topic);
1840 state_clone.confirm_subscribe(&topic);
1841
1842 state_clone.mark_unsubscribe(&topic);
1844 state_clone.mark_subscribe(&topic);
1846 state_clone.confirm_unsubscribe(&topic);
1848 state_clone.confirm_subscribe(&topic);
1850 });
1851
1852 handles.push(handle);
1853 }
1854
1855 for handle in handles {
1856 handle.await.unwrap();
1857 }
1858
1859 check_invariants(&state, "After rapid resubscribe stress test");
1860 }
1861
1862 #[rstest]
1863 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1864 async fn test_stress_failure_recovery_loop() {
1865 let state = Arc::new(SubscriptionState::new('.'));
1868 let mut handles = vec![];
1869
1870 for i in 0..30 {
1871 let state_clone = Arc::clone(&state);
1872
1873 let handle = tokio::spawn(async move {
1874 let topic = format!("failure.SYMBOL{i}"); state_clone.mark_subscribe(&topic);
1878 state_clone.confirm_subscribe(&topic);
1879
1880 for _ in 0..5 {
1882 state_clone.mark_failure(&topic);
1883 state_clone.confirm_subscribe(&topic); }
1885 });
1886
1887 handles.push(handle);
1888 }
1889
1890 for handle in handles {
1891 handle.await.unwrap();
1892 }
1893
1894 check_invariants(&state, "After failure recovery loops");
1895
1896 assert_eq!(state.len(), 30);
1898 }
1899
1900 #[rstest]
1901 fn test_exhaustive_two_step_transitions() {
1902 let operations = [
1903 "mark_subscribe",
1904 "confirm_subscribe",
1905 "mark_unsubscribe",
1906 "confirm_unsubscribe",
1907 "mark_failure",
1908 ];
1909
1910 for &op1 in &operations {
1911 for &op2 in &operations {
1912 let state = SubscriptionState::new('.');
1913 let topic = "test.TOPIC";
1914
1915 apply_op(&state, op1, topic);
1917 apply_op(&state, op2, topic);
1918
1919 check_invariants(&state, &format!("{op1} -> {op2}"));
1921 check_topic_exclusivity(&state, topic, &format!("{op1} -> {op2}"));
1922 }
1923 }
1924 }
1925
1926 fn apply_op(state: &SubscriptionState, op: &str, topic: &str) {
1927 match op {
1928 "mark_subscribe" => state.mark_subscribe(topic),
1929 "confirm_subscribe" => state.confirm_subscribe(topic),
1930 "mark_unsubscribe" => state.mark_unsubscribe(topic),
1931 "confirm_unsubscribe" => state.confirm_unsubscribe(topic),
1932 "mark_failure" => state.mark_failure(topic),
1933 _ => panic!("Unknown operation: {op}"),
1934 }
1935 }
1936
1937 fn check_invariants(state: &SubscriptionState, label: &str) {
1948 let confirmed_topics: AHashSet<String> = state
1950 .topics_from_map(&state.confirmed)
1951 .into_iter()
1952 .collect();
1953 let pending_sub_topics: AHashSet<String> =
1954 state.pending_subscribe_topics().into_iter().collect();
1955 let pending_unsub_topics: AHashSet<String> =
1956 state.pending_unsubscribe_topics().into_iter().collect();
1957
1958 let confirmed_and_pending_sub: Vec<_> =
1960 confirmed_topics.intersection(&pending_sub_topics).collect();
1961 assert!(
1962 confirmed_and_pending_sub.is_empty(),
1963 "{label}: Topic in both confirmed and pending_subscribe: {confirmed_and_pending_sub:?}"
1964 );
1965
1966 let confirmed_and_pending_unsub: Vec<_> = confirmed_topics
1967 .intersection(&pending_unsub_topics)
1968 .collect();
1969 assert!(
1970 confirmed_and_pending_unsub.is_empty(),
1971 "{label}: Topic in both confirmed and pending_unsubscribe: {confirmed_and_pending_unsub:?}"
1972 );
1973
1974 let pending_sub_and_unsub: Vec<_> = pending_sub_topics
1975 .intersection(&pending_unsub_topics)
1976 .collect();
1977 assert!(
1978 pending_sub_and_unsub.is_empty(),
1979 "{label}: Topic in both pending_subscribe and pending_unsubscribe: {pending_sub_and_unsub:?}"
1980 );
1981
1982 let all_topics: AHashSet<String> = state.all_topics().into_iter().collect();
1984 let expected_all: AHashSet<String> = confirmed_topics
1985 .union(&pending_sub_topics)
1986 .cloned()
1987 .collect();
1988 assert_eq!(
1989 all_topics, expected_all,
1990 "{label}: all_topics() doesn't match confirmed ∪ pending_subscribe"
1991 );
1992
1993 for topic in &pending_unsub_topics {
1995 assert!(
1996 !all_topics.contains(topic),
1997 "{label}: pending_unsubscribe topic {topic} incorrectly in all_topics()"
1998 );
1999 }
2000
2001 let expected_len: usize = state
2003 .confirmed
2004 .iter()
2005 .map(|entry| entry.value().len())
2006 .sum();
2007 assert_eq!(
2008 state.len(),
2009 expected_len,
2010 "{label}: len() mismatch. Expected {expected_len}, was {}",
2011 state.len()
2012 );
2013
2014 let should_be_empty = state.confirmed.is_empty()
2016 && pending_sub_topics.is_empty()
2017 && pending_unsub_topics.is_empty();
2018 assert_eq!(
2019 state.is_empty(),
2020 should_be_empty,
2021 "{label}: is_empty() inconsistent. Maps empty: {should_be_empty}, is_empty(): {}",
2022 state.is_empty()
2023 );
2024
2025 for entry in state.reference_counts.iter() {
2027 let count = entry.value().get();
2028 assert!(
2029 count > 0,
2030 "{label}: Reference count should be NonZeroUsize (> 0), was {count} for {:?}",
2031 entry.key()
2032 );
2033 }
2034 }
2035
2036 fn check_topic_exclusivity(state: &SubscriptionState, topic: &str, label: &str) {
2038 let (channel, symbol) = split_topic(topic, state.delimiter);
2039
2040 let in_confirmed = is_tracked(&state.confirmed, channel, symbol);
2041 let in_pending_sub = is_tracked(&state.pending_subscribe, channel, symbol);
2042 let in_pending_unsub = is_tracked(&state.pending_unsubscribe, channel, symbol);
2043
2044 let count = [in_confirmed, in_pending_sub, in_pending_unsub]
2045 .iter()
2046 .filter(|&&x| x)
2047 .count();
2048
2049 assert!(
2050 count <= 1,
2051 "{label}: Topic {topic} in {count} states (should be 0 or 1). \
2052 confirmed: {in_confirmed}, pending_sub: {in_pending_sub}, pending_unsub: {in_pending_unsub}"
2053 );
2054 }
2055
2056 #[cfg(test)]
2057 mod property_tests {
2058 use ahash::AHashMap;
2059 use proptest::prelude::*;
2060
2061 use super::*;
2062
2063 #[derive(Debug, Clone)]
2064 enum Operation {
2065 MarkSubscribe(String),
2066 ConfirmSubscribe(String),
2067 MarkUnsubscribe(String),
2068 ConfirmUnsubscribe(String),
2069 MarkFailure(String),
2070 AddReference(String),
2071 RemoveReference(String),
2072 Clear,
2073 }
2074
2075 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2076 enum ModelState {
2077 Confirmed,
2078 PendingSubscribe,
2079 PendingUnsubscribe,
2080 }
2081
2082 fn topic_strategy() -> impl Strategy<Value = String> {
2084 prop_oneof![
2085 (any::<u8>(), any::<u8>())
2087 .prop_map(|(ch, sym)| { format!("channel{}.SYMBOL{}", ch % 5, sym % 10) }),
2088 any::<u8>().prop_map(|ch| format!("channel{}", ch % 5)),
2090 ]
2091 }
2092
2093 fn operation_strategy() -> impl Strategy<Value = Operation> {
2095 topic_strategy().prop_flat_map(|topic| {
2096 prop_oneof![
2097 Just(Operation::MarkSubscribe(topic.clone())),
2098 Just(Operation::ConfirmSubscribe(topic.clone())),
2099 Just(Operation::MarkUnsubscribe(topic.clone())),
2100 Just(Operation::ConfirmUnsubscribe(topic.clone())),
2101 Just(Operation::MarkFailure(topic.clone())),
2102 Just(Operation::AddReference(topic.clone())),
2103 Just(Operation::RemoveReference(topic)),
2104 Just(Operation::Clear),
2105 ]
2106 })
2107 }
2108
2109 fn apply_operation(state: &SubscriptionState, op: &Operation) {
2111 match op {
2112 Operation::MarkSubscribe(topic) => state.mark_subscribe(topic),
2113 Operation::ConfirmSubscribe(topic) => state.confirm_subscribe(topic),
2114 Operation::MarkUnsubscribe(topic) => state.mark_unsubscribe(topic),
2115 Operation::ConfirmUnsubscribe(topic) => state.confirm_unsubscribe(topic),
2116 Operation::MarkFailure(topic) => state.mark_failure(topic),
2117 Operation::AddReference(topic) => {
2118 state.add_reference(topic);
2119 }
2120 Operation::RemoveReference(topic) => {
2121 state.remove_reference(topic);
2122 }
2123 Operation::Clear => state.clear(),
2124 }
2125 }
2126
2127 fn apply_model_operation(model: &mut AHashMap<String, ModelState>, op: &Operation) {
2128 match op {
2129 Operation::MarkSubscribe(topic) => {
2130 if model.get(topic) != Some(&ModelState::Confirmed) {
2131 model.insert(topic.clone(), ModelState::PendingSubscribe);
2132 }
2133 }
2134 Operation::ConfirmSubscribe(topic) => {
2135 if matches!(
2136 model.get(topic),
2137 Some(ModelState::PendingSubscribe | ModelState::Confirmed)
2138 ) {
2139 model.insert(topic.clone(), ModelState::Confirmed);
2140 }
2141 }
2142 Operation::MarkUnsubscribe(topic) => {
2143 model.insert(topic.clone(), ModelState::PendingUnsubscribe);
2144 }
2145 Operation::ConfirmUnsubscribe(topic) => {
2146 if model.get(topic) == Some(&ModelState::PendingUnsubscribe) {
2147 model.remove(topic);
2148 }
2149 }
2150 Operation::MarkFailure(topic) => {
2151 if matches!(
2152 model.get(topic),
2153 Some(ModelState::PendingSubscribe | ModelState::Confirmed)
2154 ) {
2155 model.insert(topic.clone(), ModelState::PendingSubscribe);
2156 }
2157 }
2158 Operation::AddReference(_) | Operation::RemoveReference(_) => {}
2159 Operation::Clear => model.clear(),
2160 }
2161 }
2162
2163 fn assert_state_matches_model(
2164 state: &SubscriptionState,
2165 model: &AHashMap<String, ModelState>,
2166 ) {
2167 let topics_for = |expected_state| {
2168 model
2169 .iter()
2170 .filter(|&(_topic, state)| *state == expected_state)
2171 .map(|(topic, _state)| topic.clone())
2172 .collect::<AHashSet<_>>()
2173 };
2174
2175 let confirmed = state
2176 .topics_from_map(&state.confirmed)
2177 .into_iter()
2178 .collect::<AHashSet<_>>();
2179 let pending_subscribe = state
2180 .pending_subscribe_topics()
2181 .into_iter()
2182 .collect::<AHashSet<_>>();
2183 let pending_unsubscribe = state
2184 .pending_unsubscribe_topics()
2185 .into_iter()
2186 .collect::<AHashSet<_>>();
2187 let expected_confirmed = topics_for(ModelState::Confirmed);
2188 let expected_pending_subscribe = topics_for(ModelState::PendingSubscribe);
2189 let expected_pending_unsubscribe = topics_for(ModelState::PendingUnsubscribe);
2190 let expected_all = expected_confirmed
2191 .union(&expected_pending_subscribe)
2192 .cloned()
2193 .collect::<AHashSet<_>>();
2194 let all = state.all_topics().into_iter().collect::<AHashSet<_>>();
2195
2196 assert_eq!(confirmed, expected_confirmed);
2197 assert_eq!(pending_subscribe, expected_pending_subscribe);
2198 assert_eq!(pending_unsubscribe, expected_pending_unsubscribe);
2199 assert_eq!(all, expected_all);
2200 assert_eq!(state.len(), confirmed.len());
2201 assert_eq!(state.is_empty(), model.is_empty());
2202 }
2203
2204 proptest! {
2205 #![proptest_config(ProptestConfig::with_cases(500))]
2206
2207 #[rstest]
2209 fn prop_invariants_hold_after_operations(
2210 operations in prop::collection::vec(operation_strategy(), 1..50)
2211 ) {
2212 let state = SubscriptionState::new('.');
2213 let mut model = AHashMap::new();
2214
2215 for (i, op) in operations.iter().enumerate() {
2216 apply_operation(&state, op);
2217 apply_model_operation(&mut model, op);
2218
2219 check_invariants(&state, &format!("After op {i}: {op:?}"));
2220 assert_state_matches_model(&state, &model);
2221 }
2222
2223 check_invariants(&state, "Final state");
2224 assert_state_matches_model(&state, &model);
2225 }
2226
2227 #[rstest]
2229 fn prop_reference_counting_matches_reference(
2230 ops in prop::collection::vec(
2231 topic_strategy().prop_flat_map(|t| {
2232 prop_oneof![
2233 Just(Operation::AddReference(t.clone())),
2234 Just(Operation::RemoveReference(t)),
2235 ]
2236 }),
2237 1..100
2238 )
2239 ) {
2240 let state = SubscriptionState::new('.');
2241 let mut expected = AHashMap::new();
2242
2243 for op in &ops {
2244 match op {
2245 Operation::AddReference(topic) => {
2246 let count = expected.entry(topic.clone()).or_insert(0usize);
2247 let should_subscribe = *count == 0;
2248 *count += 1;
2249 prop_assert_eq!(state.add_reference(topic), should_subscribe);
2250 }
2251 Operation::RemoveReference(topic) => {
2252 let count = expected.get(topic).copied().unwrap_or(0);
2253 let should_unsubscribe = count == 1;
2254 if should_unsubscribe {
2255 expected.remove(topic);
2256 } else if count > 1 {
2257 *expected.get_mut(topic).unwrap() -= 1;
2258 }
2259 prop_assert_eq!(state.remove_reference(topic), should_unsubscribe);
2260 }
2261 _ => unreachable!("reference-count strategy only generates reference operations"),
2262 }
2263
2264 prop_assert_eq!(state.reference_counts.len(), expected.len());
2265 for (topic, count) in &expected {
2266 prop_assert_eq!(state.get_reference_count(topic), *count);
2267 }
2268 }
2269 }
2270
2271 #[rstest]
2273 fn prop_all_topics_is_union(
2274 operations in prop::collection::vec(operation_strategy(), 1..50)
2275 ) {
2276 let state = SubscriptionState::new('.');
2277
2278 for op in &operations {
2279 apply_operation(&state, op);
2280
2281 let all_topics: AHashSet<String> = state.all_topics().into_iter().collect();
2283 let confirmed: AHashSet<String> = state.topics_from_map(&state.confirmed).into_iter().collect();
2284 let pending_sub: AHashSet<String> = state.pending_subscribe_topics().into_iter().collect();
2285 let expected: AHashSet<String> = confirmed.union(&pending_sub).cloned().collect();
2286
2287 assert_eq!(all_topics, expected);
2288
2289 let pending_unsub: AHashSet<String> = state.pending_unsubscribe_topics().into_iter().collect();
2291 for topic in pending_unsub {
2292 assert!(!all_topics.contains(&topic));
2293 }
2294 }
2295 }
2296
2297 #[rstest]
2299 fn prop_clear_resets_completely(
2300 operations in prop::collection::vec(operation_strategy(), 1..30)
2301 ) {
2302 let state = SubscriptionState::new('.');
2303
2304 for op in &operations {
2306 apply_operation(&state, op);
2307 }
2308
2309 state.clear();
2311
2312 assert!(state.is_empty());
2313 assert_eq!(state.len(), 0);
2314 assert!(state.all_topics().is_empty());
2315 assert!(state.pending_subscribe_topics().is_empty());
2316 assert!(state.pending_unsubscribe_topics().is_empty());
2317 assert!(state.confirmed.is_empty());
2318 assert!(state.pending_subscribe.is_empty());
2319 assert!(state.pending_unsubscribe.is_empty());
2320 assert!(state.reference_counts.is_empty());
2321 }
2322
2323 #[rstest]
2325 fn prop_topic_mutual_exclusivity(
2326 operations in prop::collection::vec(operation_strategy(), 1..50),
2327 topic in topic_strategy()
2328 ) {
2329 let state = SubscriptionState::new('.');
2330
2331 for (i, op) in operations.iter().enumerate() {
2332 apply_operation(&state, op);
2333 check_topic_exclusivity(&state, &topic, &format!("After op {i}: {op:?}"));
2334 }
2335 }
2336 }
2337 }
2338}