Skip to main content

nautilus_network/websocket/
subscription.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Adapter-managed subscription intent and acknowledgment tracking.
17//!
18//! [`SubscriptionState`] keeps confirmed, `pending_subscribe`, and `pending_unsubscribe` topics
19//! separate. Server acknowledgments move topics between those states; late subscribe
20//! acknowledgments do not revive cancelled intent, and stale unsubscribe acknowledgments do not
21//! remove a later resubscription. [`SubscriptionState::all_topics`] returns confirmed and pending
22//! subscribe intent for recovery, excluding pending unsubscriptions.
23//!
24//! Reference counts are independent of acknowledgment state. The first reference tells the caller
25//! to send a subscribe request, and removing the last tells it to send an unsubscribe request. The
26//! tracker records state but never sends protocol messages.
27//!
28//! # Topic format
29//!
30//! Topics use `channel{delimiter}symbol`; the first delimiter occurrence separates the channel
31//! from the optional symbol. A topic without a delimiter represents the whole channel.
32
33use 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
44/// Marker for channel-level subscriptions (no specific symbol).
45///
46/// An empty string in the symbol set indicates a channel-level subscription
47/// that applies to all symbols for that channel.
48pub(crate) static CHANNEL_LEVEL_MARKER: LazyLock<Ustr> = LazyLock::new(|| Ustr::from(""));
49
50/// Read-only snapshot of subscription topics grouped by channel.
51///
52/// The snapshot supports immutable map operations through [`Deref`], but cannot mutate the
53/// underlying [`SubscriptionState`]. Use the subscription lifecycle methods to change state.
54///
55/// ```compile_fail
56/// use nautilus_network::websocket::SubscriptionState;
57///
58/// let state = SubscriptionState::new('.');
59/// let mut confirmed = state.confirmed();
60/// confirmed.clear();
61/// ```
62#[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/// Tracks subscription intent and acknowledgment state for WebSocket connections.
74///
75/// # State management
76///
77/// The tracker maintains desired subscription intent and three acknowledgment states:
78///
79/// - **Desired**: Topics that should be active, independent of response ordering or connection.
80/// - **Confirmed**: Subscriptions acknowledged by the server and expected to stream data.
81/// - **Pending subscribe**: Subscribe requests awaiting server acknowledgment.
82/// - **Pending unsubscribe**: Unsubscribe requests awaiting server acknowledgment.
83///
84/// Late subscribe acknowledgments do not revive cancelled intent, and stale unsubscribe
85/// acknowledgments do not remove a later resubscription.
86///
87/// # Reference counting
88///
89/// Reference counts remain independent of acknowledgment state. The first consumer tells the
90/// caller to send a subscribe request, while removing the last tells it to send an unsubscribe
91/// request. The tracker records these transitions but does not send protocol messages.
92///
93/// # Topic format
94///
95/// Topics use `channel{delimiter}symbol`, with delimiters such as `.` or `:`. A topic without the
96/// delimiter represents a channel-level subscription.
97///
98/// # Thread safety
99///
100/// Clones share all state. Operations are thread-safe and can run concurrently from multiple
101/// tasks.
102#[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    /// Creates a new subscription state tracker with the specified topic delimiter.
115    #[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    /// Returns the delimiter character used for topic splitting.
129    #[must_use]
130    pub fn delimiter(&self) -> char {
131        self.delimiter
132    }
133
134    /// Returns a read-only snapshot of confirmed subscriptions.
135    #[must_use]
136    pub fn confirmed(&self) -> SubscriptionSnapshot {
137        let _guard = self.state_lock.read();
138        snapshot(&self.confirmed)
139    }
140
141    /// Returns a read-only snapshot of pending subscriptions.
142    #[must_use]
143    pub fn pending_subscribe(&self) -> SubscriptionSnapshot {
144        let _guard = self.state_lock.read();
145        snapshot(&self.pending_subscribe)
146    }
147
148    /// Returns a read-only snapshot of pending unsubscriptions.
149    #[must_use]
150    pub fn pending_unsubscribe(&self) -> SubscriptionSnapshot {
151        let _guard = self.state_lock.read();
152        snapshot(&self.pending_unsubscribe)
153    }
154
155    /// Returns the number of confirmed subscriptions.
156    ///
157    /// Counts both channel-level and symbol-level subscriptions.
158    #[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    /// Returns true if there are no subscriptions (confirmed or pending).
165    #[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    /// Returns true if a channel:symbol pair is subscribed (confirmed or pending subscribe).
175    #[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    /// Returns all pending subscribe topics as strings.
195    #[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    /// Returns all pending unsubscribe topics as strings.
202    #[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    /// Returns all topics that should be active after reconnect recovery.
209    ///
210    /// The result includes confirmed and pending subscribe topics, but excludes pending
211    /// unsubscribe topics.
212    #[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    /// Marks a topic as pending subscription.
221    ///
222    /// Call this after sending a subscribe request. This operation is idempotent for a confirmed
223    /// topic and cancels any pending unsubscription for the same topic.
224    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 already confirmed, don't re-add to pending (idempotent)
230        if is_tracked(&self.confirmed, channel, symbol) {
231            return;
232        }
233
234        // Remove from pending_unsubscribe if present
235        untrack_topic(&self.pending_unsubscribe, channel, symbol);
236
237        track_topic(&self.pending_subscribe, channel, symbol);
238    }
239
240    /// Atomically tries to mark a topic as pending subscription.
241    ///
242    /// Returns `true` if the topic was newly marked as pending (should send subscribe).
243    /// Returns `false` if the topic was already confirmed or pending (skip sending).
244    ///
245    /// The check and state transition are atomic across concurrent subscribe calls.
246    #[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 already desired, no action needed
252        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    /// Confirms a subscription by moving it from pending to confirmed.
265    ///
266    /// Call this when the server acknowledges a subscribe request. A late confirmation cannot
267    /// restore a topic that is no longer desired.
268    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    /// Marks a topic as pending unsubscription.
283    ///
284    /// Removes the topic from confirmed and `pending_subscribe` state before adding it to
285    /// `pending_unsubscribe`. This also handles unsubscription before initial confirmation.
286    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    /// Confirms an unsubscription by removing it from pending and confirmed state.
296    ///
297    /// Call this when the server acknowledges an unsubscribe request. A stale acknowledgment is
298    /// ignored if the topic is no longer pending unsubscription. `pending_subscribe` remains intact
299    /// so an immediate resubscription survives a late unsubscribe acknowledgment.
300    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        // Only process if topic is actually pending unsubscription
305        // This ignores stale unsubscribe ACKs after user has re-subscribed
306        if !is_tracked(&self.pending_unsubscribe, channel, symbol) {
307            return; // Stale ACK, ignore
308        }
309
310        untrack_topic(&self.pending_unsubscribe, channel, symbol);
311        untrack_topic(&self.confirmed, channel, symbol);
312        // Don't clear pending_subscribe - it's a valid re-subscribe request
313    }
314
315    /// Marks a subscription as failed, moving it from confirmed back to pending.
316    ///
317    /// This keeps failed subscriptions available for retry after reconnect. A topic pending
318    /// unsubscription is unchanged because its subscription was cancelled.
319    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    /// Resets acknowledgment state for a replacement connection.
334    ///
335    /// Returns the desired topics to replay. Confirmed topics become pending subscriptions,
336    /// pending unsubscriptions are completed by the closed connection, and reference counts are
337    /// preserved.
338    #[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    /// Increments the reference count for a topic.
360    ///
361    /// Returns `true` if this is the first subscription (caller should send subscribe
362    /// message to server).
363    ///
364    /// # Panics
365    ///
366    /// Panics if the reference count exceeds `usize::MAX` subscriptions for a single topic.
367    #[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    /// Decrements the reference count for a topic.
389    ///
390    /// Returns `true` if this was the last subscription (caller should send unsubscribe
391    /// message to server).
392    ///
393    /// # Panics
394    ///
395    /// Panics if the internal reference count state becomes inconsistent (should never happen
396    /// if the API is used correctly).
397    #[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        // Use entry API to atomically decrement and remove if zero
407        // This prevents race where another thread adds a reference between the check and remove
408        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    /// Returns the current reference count for a topic.
425    ///
426    /// Returns 0 if the topic has no references.
427    #[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    /// Clears all subscription state.
439    ///
440    /// This resets desired intent, acknowledgment state, and reference counts.
441    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    // Converts a subscription map to sorted topic strings
451    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            // Check for channel-level subscription marker
460            if symbols.contains(&marker) {
461                topics.push(channel.to_string());
462            }
463
464            // Add symbol-level subscriptions (skip marker)
465            for symbol in symbols {
466                if *symbol != marker {
467                    topics.push(format!("{channel}{}{symbol}", self.delimiter));
468                }
469            }
470        }
471
472        // Sort so resubscription after a reconnect replays topics in the same sequence
473        // across runs; both the outer DashMap and the inner symbol sets are unordered.
474        topics.sort();
475        topics
476    }
477}
478
479/// Splits a topic into channel and optional symbol using the specified delimiter.
480#[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
495/// Tracks a topic in the given map by adding it to the channel's symbol set.
496///
497/// Channel-level subscriptions are stored using an empty string marker,
498/// allowing both channel-level and symbol-level subscriptions to coexist.
499fn 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
505/// Removes a topic from the given map by removing it from the channel's symbol set.
506///
507/// Removes the entire channel entry if no subscriptions remain after removal.
508fn 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    // Use entry API to atomically remove symbol and check if empty
514    // This prevents race conditions where another thread adds a symbol between operations
515    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
523/// Checks if a topic exists in the given map.
524fn 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        // Verify delimiter is only used for parsing, not storage
607        let state_dot = SubscriptionState::new('.');
608        let state_colon = SubscriptionState::new(':');
609
610        // Add same logical subscription with different delimiters
611        state_dot.mark_subscribe("channel.SYMBOL");
612        state_colon.mark_subscribe("channel:SYMBOL");
613
614        // Both should work correctly
615        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        // Subscribe to channel-level first
642        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        // Add symbol-level subscription to same channel
648        state.mark_subscribe("tickers.BTCUSDT");
649        state.confirm_subscribe("tickers.BTCUSDT");
650        assert_eq!(state.len(), 2);
651
652        // Both should be present
653        assert_eq!(state.all_topics(), vec!["tickers", "tickers.BTCUSDT"]);
654
655        // Add another symbol
656        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        // Unsubscribe from channel-level only
666        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        // Subscribe to symbol first
681        state.mark_subscribe("tickers.BTCUSDT");
682        state.confirm_subscribe("tickers.BTCUSDT");
683        assert_eq!(state.len(), 1);
684
685        // Then add channel-level
686        state.mark_subscribe("tickers");
687        state.confirm_subscribe("tickers");
688        assert_eq!(state.len(), 2);
689
690        // Both should be present after reconnect
691        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        // Edge case: empty string as topic
699        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        // Topics with special characters
711        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        // Topics with multiple delimiters (splits on first delimiter)
740        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        // Topic with leading delimiter (empty channel, symbol is "channel")
746        state.mark_subscribe(".channel");
747        state.confirm_subscribe(".channel");
748        assert_eq!(state.len(), 2);
749
750        // Topic with trailing delimiter - treated as channel-level (empty symbol = marker)
751        // "channel." splits to ("channel", Some("")), and empty string is the channel marker
752        state.mark_subscribe("channel.");
753        state.confirm_subscribe("channel.");
754        assert_eq!(state.len(), 3);
755
756        // Topic without delimiter - explicitly channel-level
757        state.mark_subscribe("tickers");
758        state.confirm_subscribe("tickers");
759        assert_eq!(state.len(), 4);
760
761        // Verify all are retrievable (note: "channel." becomes "channel")
762        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())); // "channel." treated as channel-level
767        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        // Pending unsubscribe should not count as subscribed
845        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        // Insert scrambled across channels and symbols so hash order cannot pass.
891        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        // Confirmed topics sort among themselves, then pending ones do the same.
905        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        // Subscribe and confirm
923        state.mark_subscribe("tickers.BTCUSDT");
924        state.confirm_subscribe("tickers.BTCUSDT");
925
926        // Mark for unsubscribe
927        state.mark_unsubscribe("tickers.BTCUSDT");
928
929        // Should be in pending_unsubscribe but NOT in all_topics
930        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); // Not confirmed yet
942    }
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); // Removed from confirmed
1008        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        // User subscribes
1027        state.mark_subscribe("tickers.BTCUSDT");
1028        assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]);
1029
1030        // User immediately changes mind before server confirms
1031        state.mark_unsubscribe("tickers.BTCUSDT");
1032
1033        // Should be removed from pending_subscribe and added to pending_unsubscribe
1034        assert!(state.pending_subscribe_topics().is_empty());
1035        assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1036
1037        // Confirm the unsubscribe
1038        state.confirm_unsubscribe("tickers.BTCUSDT");
1039
1040        // Should be completely gone
1041        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        // User subscribes
1051        state.mark_subscribe("tickers.BTCUSDT");
1052
1053        // User immediately unsubscribes
1054        state.mark_unsubscribe("tickers.BTCUSDT");
1055
1056        // Late subscribe confirmation arrives from server
1057        state.confirm_subscribe("tickers.BTCUSDT");
1058
1059        // Should NOT be added to confirmed (unsubscribe takes precedence)
1060        assert_eq!(state.len(), 0);
1061        assert!(state.pending_subscribe_topics().is_empty());
1062
1063        // Confirm the unsubscribe
1064        state.confirm_unsubscribe("tickers.BTCUSDT");
1065
1066        // Should still be empty
1067        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        // Regression test for race condition:
1089        // User unsubscribes, then immediately resubscribes before the unsubscribe ACK arrives.
1090        // The unsubscribe ACK should NOT clear the pending_subscribe entry.
1091        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        // User immediately resubscribes (before unsubscribe ACK)
1102        state.mark_subscribe("tickers.BTCUSDT");
1103        assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]);
1104
1105        // Stale unsubscribe ACK arrives - should be ignored (pending_unsubscribe already cleared)
1106        state.confirm_unsubscribe("tickers.BTCUSDT");
1107        assert!(state.pending_unsubscribe_topics().is_empty());
1108        assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]); // Must still be pending
1109
1110        // Subscribe ACK confirms successfully
1111        state.confirm_subscribe("tickers.BTCUSDT");
1112        assert_eq!(state.len(), 1);
1113        assert!(state.pending_subscribe_topics().is_empty());
1114
1115        // Topic available for reconnect
1116        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        // Regression test for P1 bug: Stale unsubscribe ACK removing confirmed topic.
1124        // Scenario: User unsubscribes, immediately resubscribes, subscribe ACK arrives
1125        // FIRST (out of order), then stale unsubscribe ACK arrives.
1126        // The stale ACK must NOT remove the topic from confirmed state.
1127        let state = SubscriptionState::new('.');
1128
1129        // Initial subscription
1130        state.mark_subscribe("tickers.BTCUSDT");
1131        state.confirm_subscribe("tickers.BTCUSDT");
1132        assert_eq!(state.len(), 1);
1133
1134        // User unsubscribes
1135        state.mark_unsubscribe("tickers.BTCUSDT");
1136        assert_eq!(state.len(), 0);
1137        assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1138
1139        // User immediately resubscribes (before unsubscribe ACK)
1140        state.mark_subscribe("tickers.BTCUSDT");
1141        assert!(state.pending_unsubscribe_topics().is_empty()); // Cleared by mark_subscribe
1142        assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]);
1143
1144        // Subscribe ACK arrives FIRST (out of order!)
1145        state.confirm_subscribe("tickers.BTCUSDT");
1146        assert_eq!(state.len(), 1); // Back in confirmed
1147        assert!(state.pending_subscribe_topics().is_empty());
1148
1149        // NOW the stale unsubscribe ACK arrives
1150        // This must be ignored because topic is no longer in pending_unsubscribe
1151        state.confirm_unsubscribe("tickers.BTCUSDT");
1152
1153        // Topic should STILL be confirmed (not removed by stale ACK)
1154        assert_eq!(state.len(), 1); // Must remain confirmed
1155        assert!(state.pending_unsubscribe_topics().is_empty());
1156        assert!(state.pending_subscribe_topics().is_empty());
1157
1158        // Topic should be in all_topics (for reconnect)
1159        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        // Subscribe and confirm
1169        state.mark_subscribe("tickers.BTCUSDT");
1170        state.confirm_subscribe("tickers.BTCUSDT");
1171        assert_eq!(state.len(), 1);
1172
1173        // Unsubscribe
1174        state.mark_unsubscribe("tickers.BTCUSDT");
1175
1176        // Should be removed from confirmed
1177        assert_eq!(state.len(), 0);
1178        assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1179
1180        // Late subscribe confirmation somehow arrives (race condition)
1181        state.confirm_subscribe("tickers.BTCUSDT");
1182
1183        // confirm_unsubscribe should clean everything
1184        state.confirm_unsubscribe("tickers.BTCUSDT");
1185
1186        // Completely empty
1187        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        // Confirm subscribe without matching intent - should be ignored
1360        state.confirm_subscribe("tickers.BTCUSDT");
1361        assert_eq!(state.len(), 0);
1362
1363        // Confirm unsubscribe without marking first - should not crash
1364        state.confirm_unsubscribe("tickers.ETHUSDT");
1365        assert_eq!(state.len(), 0); // Nothing changes
1366
1367        // Double confirm subscribe
1368        state.mark_subscribe("orderbook");
1369        state.confirm_subscribe("orderbook");
1370        state.confirm_subscribe("orderbook"); // Second confirm is idempotent
1371        assert_eq!(state.len(), 1);
1372
1373        // Unsubscribe something that was never subscribed
1374        state.mark_unsubscribe("nonexistent");
1375        state.confirm_unsubscribe("nonexistent");
1376        assert_eq!(state.len(), 1); // Still 1
1377    }
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        // Subscribe and confirm
1395        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        // Mark as failed
1401        state.mark_failure("tickers.BTCUSDT");
1402
1403        // Should be removed from confirmed and back in pending
1404        assert_eq!(state.len(), 0);
1405        assert_eq!(state.pending_subscribe_topics(), vec!["tickers.BTCUSDT"]);
1406
1407        // all_topics should still include it for reconnection
1408        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        // Subscribe and confirm
1416        state.mark_subscribe("tickers.BTCUSDT");
1417        state.confirm_subscribe("tickers.BTCUSDT");
1418        assert_eq!(state.len(), 1);
1419
1420        // User unsubscribes
1421        state.mark_unsubscribe("tickers.BTCUSDT");
1422        assert_eq!(state.len(), 0);
1423        assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1424
1425        // Meanwhile, a network error triggers mark_failure
1426        state.mark_failure("tickers.BTCUSDT");
1427
1428        // Should NOT be added to pending_subscribe (user wanted to unsubscribe)
1429        assert!(state.pending_subscribe_topics().is_empty());
1430        assert_eq!(state.pending_unsubscribe_topics(), vec!["tickers.BTCUSDT"]);
1431
1432        // all_topics should NOT include it
1433        assert!(state.all_topics().is_empty());
1434
1435        // Confirm unsubscribe
1436        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        // Initial subscriptions
1445        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        // Simulate disconnect - topics should be available for resubscription
1460        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        // On reconnect, mark all as pending again
1467        for topic in &topics_to_resubscribe {
1468            state.mark_subscribe(topic);
1469        }
1470
1471        // Simulate server confirmations
1472        for topic in &topics_to_resubscribe {
1473            state.confirm_subscribe(topic);
1474        }
1475
1476        // Should still have all 3 subscriptions
1477        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        // Setup: Some confirmed, some pending subscribe, some pending unsubscribe
1486        // Confirmed
1487        state.add_reference("confirmed.BTCUSDT");
1488        state.mark_subscribe("confirmed.BTCUSDT");
1489        state.confirm_subscribe("confirmed.BTCUSDT");
1490
1491        // Pending subscribe (not yet confirmed)
1492        state.add_reference("pending.ETHUSDT");
1493        state.mark_subscribe("pending.ETHUSDT");
1494
1495        // Pending unsubscribe (user cancelled)
1496        state.mark_subscribe("cancelled.XRPUSDT");
1497        state.confirm_subscribe("cancelled.XRPUSDT");
1498        state.mark_unsubscribe("cancelled.XRPUSDT");
1499
1500        // Verify state before reconnect
1501        assert_eq!(state.len(), 1); // Only confirmed.BTCUSDT
1502        let all = state.all_topics();
1503        assert_eq!(all.len(), 2); // confirmed + pending_subscribe (not pending_unsubscribe)
1504        assert!(all.contains(&"confirmed.BTCUSDT".to_string()));
1505        assert!(all.contains(&"pending.ETHUSDT".to_string()));
1506        assert!(!all.contains(&"cancelled.XRPUSDT".to_string())); // Should NOT be included
1507
1508        // Simulate disconnect and reconnect
1509        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        // Server confirms both
1531        for topic in &topics_to_resubscribe {
1532            state.confirm_subscribe(topic);
1533        }
1534
1535        // Verify final state
1536        assert_eq!(state.len(), 2); // Both confirmed
1537        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        // Removing reference to topic that was never added
1581        let should_unsubscribe = state.remove_reference("nonexistent");
1582
1583        // Should return false and not crash
1584        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        // Remove without ever adding
1593        assert!(!state.remove_reference("never.added"));
1594        assert_eq!(state.get_reference_count("never.added"), 0);
1595
1596        // Add one, remove multiple times
1597        state.add_reference("once.added");
1598        assert_eq!(state.get_reference_count("once.added"), 1);
1599
1600        assert!(state.remove_reference("once.added")); // Should return true (last ref)
1601        assert_eq!(state.get_reference_count("once.added"), 0);
1602
1603        assert!(!state.remove_reference("once.added")); // Should not crash, returns false
1604        assert!(!state.remove_reference("once.added")); // Multiple times
1605        assert_eq!(state.get_reference_count("once.added"), 0);
1606
1607        // Verify we can add again after underflow attempts
1608        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        // Spawn 10 tasks all adding 10 references to the same topic
1620        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        // Should have exactly 100 references (10 tasks * 10 refs each)
1637        assert_eq!(state.get_reference_count(topic), 100);
1638
1639        // Now remove 50 references sequentially
1640        for _ in 0..50 {
1641            state.remove_reference(topic);
1642        }
1643
1644        // Should have exactly 50 references remaining
1645        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        // Add multiple subscriptions and references
1666        for i in 0..10 {
1667            let topic = format!("channel{i}.SYMBOL");
1668            state.add_reference(&topic);
1669            state.add_reference(&topic); // Add twice
1670            state.mark_subscribe(&topic);
1671            state.confirm_subscribe(&topic);
1672        }
1673
1674        assert_eq!(state.len(), 10);
1675        assert!(!state.is_empty());
1676
1677        // Clear everything
1678        state.clear();
1679
1680        // Verify complete reset
1681        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        // Verify reference counts are cleared
1688        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        // Spawn 10 tasks all subscribing to the same topic
1701        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        // Reference count should be exactly 10
1717        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        // Spawn 20 tasks, each adding 2 references to their own unique topic
1728        // This ensures deterministic behavior - we know exactly what the final state should be
1729        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                // Add 2 references
1735                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                // Remove 1 reference (should still have 1 remaining)
1741                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        // Each of the 20 topics should still have 1 reference
1752        for i in 0..20 {
1753            let topic = format!("tickers.SYMBOL{i}");
1754            assert_eq!(state.get_reference_count(&topic), 1);
1755        }
1756
1757        // Should have exactly 20 confirmed subscriptions
1758        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        // Spawn 50 tasks doing random interleaved operations
1769        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                // Add references
1777                state_clone.add_reference(&topic1);
1778                state_clone.add_reference(&topic2);
1779
1780                // Mark and confirm subscriptions
1781                state_clone.mark_subscribe(&topic1);
1782                state_clone.confirm_subscribe(&topic1);
1783                state_clone.mark_subscribe(&topic2);
1784
1785                // Interleave some unsubscribes
1786                if i % 3 == 0 {
1787                    state_clone.mark_unsubscribe(&topic1);
1788                    state_clone.confirm_unsubscribe(&topic1);
1789                }
1790
1791                // More reference operations
1792                state_clone.add_reference(&topic2);
1793                state_clone.remove_reference(&topic2);
1794
1795                // Confirm topic2
1796                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        // Stress test the race condition we fixed: rapid unsubscribe -> resubscribe
1829        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); // 10 unique topics, lots of contention
1837
1838                // Initial subscribe
1839                state_clone.mark_subscribe(&topic);
1840                state_clone.confirm_subscribe(&topic);
1841
1842                // Rapid unsubscribe -> resubscribe (race condition scenario)
1843                state_clone.mark_unsubscribe(&topic);
1844                // Immediately resubscribe before unsubscribe ACK
1845                state_clone.mark_subscribe(&topic);
1846                // Now unsubscribe ACK arrives
1847                state_clone.confirm_unsubscribe(&topic);
1848                // Subscribe ACK arrives
1849                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        // Stress test failure -> recovery loops
1866        // Each task gets its own unique topic to avoid race conditions in the test itself
1867        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}"); // Unique topic per task
1875
1876                // Subscribe and confirm
1877                state_clone.mark_subscribe(&topic);
1878                state_clone.confirm_subscribe(&topic);
1879
1880                // Simulate multiple failures and recoveries
1881                for _ in 0..5 {
1882                    state_clone.mark_failure(&topic);
1883                    state_clone.confirm_subscribe(&topic); // Re-confirm after retry
1884                }
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        // All should eventually be confirmed (30 unique topics)
1897        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 two operations
1916                apply_op(&state, op1, topic);
1917                apply_op(&state, op2, topic);
1918
1919                // Verify invariants hold
1920                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    /// Verifies all invariants of the subscription state.
1938    ///
1939    /// # Invariants
1940    ///
1941    /// 1. **Mutual exclusivity**: A topic cannot exist in multiple states simultaneously
1942    ///    (one of: confirmed, `pending_subscribe`, `pending_unsubscribe`, or none).
1943    /// 2. **`all_topics` consistency**: `all_topics()` must equal `confirmed ∪ pending_subscribe`
1944    /// 3. **len consistency**: `len()` must equal total count of symbols in confirmed map
1945    /// 4. **`is_empty` consistency**: `is_empty()` true iff all maps are empty
1946    /// 5. **Reference count non-negative**: All reference counts >= 0
1947    fn check_invariants(state: &SubscriptionState, label: &str) {
1948        // Collect all topics from each state
1949        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        // INVARIANT 1: Mutual exclusivity - no topic in multiple states
1959        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        // INVARIANT 2: all_topics() == confirmed ∪ pending_subscribe
1983        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        // Ensure pending_unsubscribe is NOT in all_topics
1994        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        // INVARIANT 3: len() == sum of confirmed symbol counts
2002        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        // INVARIANT 4: is_empty() consistency
2015        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        // INVARIANT 5: Reference counts non-negative (NonZeroUsize enforces > 0, absence = 0)
2026        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    /// Checks that a topic exists in exactly one of the three states or none.
2037    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        // Strategy for generating valid topics
2083        fn topic_strategy() -> impl Strategy<Value = String> {
2084            prop_oneof![
2085                // Symbol-level topics
2086                (any::<u8>(), any::<u8>())
2087                    .prop_map(|(ch, sym)| { format!("channel{}.SYMBOL{}", ch % 5, sym % 10) }),
2088                // Channel-level topics (no symbol)
2089                any::<u8>().prop_map(|ch| format!("channel{}", ch % 5)),
2090            ]
2091        }
2092
2093        // Strategy for generating random operations
2094        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        // Apply an operation to the state
2110        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            /// Property: Invariants hold after any sequence of operations.
2208            #[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            /// Reference-count operations match an independent count model.
2228            #[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            /// Property: all_topics() always equals confirmed ∪ pending_subscribe.
2272            #[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                    // Verify all_topics() == confirmed ∪ pending_subscribe
2282                    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                    // Ensure pending_unsubscribe topics are NOT in all_topics
2290                    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            /// Property: clear() resets to empty state.
2298            #[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                // Apply random operations
2305                for op in &operations {
2306                    apply_operation(&state, op);
2307                }
2308
2309                // Clear and verify complete reset
2310                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            /// Property: Topics are mutually exclusive across states.
2324            #[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}