Skip to main content

futu_server/
subscription.rs

1// 订阅管理:行情订阅 + 交易账户推送订阅 + 通知订阅
2//
3// v1.4.106 codex 1131 F3 [P1] (BLOCKER fix): C++ 把"backend 订阅状态"
4// (`m_setSub`) 与"push 注册状态" (`m_mapRegPush`) 分开维护. Rust 之前
5// 把两者塞同一 `qot_subs` map → `Qot_RegQotPush(register=true)` 制造
6// 假订阅, `Qot_RegQotPush(register=false)` 误删真订阅, GetSubInfo 把
7// push 注册当订阅项报告. 本版彻底拆开:
8//   - `qot_subs: HashMap<(SecurityKey, SubType), HashSet<ConnId>>`
9//     = desired sub state, 决定 backend CMD 6211 desired set + GetSubInfo
10//   - `qot_push_regs: HashMap<(SecurityKey, SubType, RehabType), HashSet<ConnId>>`
11//     = push 注册 state, 决定 PushDispatcher 路由对象, 不影响订阅
12// C++ 对照:
13//   - QotSubscribe.cpp:107-108 m_setSub[(stockID, subType)].insert(connID)
14//   - QotSubscribe.cpp:489-490 m_mapRegPush[(stockID, subType, rehabType)].insert(connID)
15//   - QotSubscribe.cpp:639-649 GetPushConn(stockID, subType, rehabType)
16//
17// v1.4.110 codex Phase 3 closeout (broker-aware key model): C++ QOT 模块
18// (subscription / cache / push / quota) 以 `StockKey` (=stockID + 可选
19// brokerID) 为 first-class identity. Rust 旧 String facade 已从生产
20// `SubscriptionManager` 删除;内部状态直接以 `QotSecurityKey` keyed.
21//
22// C++ 对照 (QotSubscribe.h):
23//   - Map_t<SubType, Set_t<StockKey>> m_mapSub  (per-conn)
24//   - Set_t<(StockKey, SubType)>      m_setSub  (global)
25//   - Map_t<(StockKey, SubType, RehabType), Set<ConnID>> m_mapRegPush
26//   - Map_t<(ConnID, StockKey), bool> m_mapConnOrderBookDetail / BrokerDetail
27
28mod connection_lifecycle;
29mod disconnected_cleanup;
30mod push_regs;
31mod qot_commit;
32mod session_detail;
33mod unsubscribe_all_commit;
34mod views;
35
36use std::collections::{HashMap, HashSet};
37use std::sync::Arc;
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::time::{Duration, Instant};
40
41use dashmap::DashMap;
42use futu_core::qot_stock_key::QotSecurityKey;
43pub use futu_domain_qot_subscription::QOT_MIN_UNSUB_ELAPSED_SECS;
44use futu_domain_qot_subscription::{
45    CryptoSubscriptionProbe, UnsubscribeAllGlobalEmptyProbe,
46    is_crypto_stock_broker_globally_unsubscribed, is_crypto_stock_globally_unsubscribed,
47    plan_unsubscribe_all_global_empty_keys, qot_min_unsub_freshness_from_elapsed_secs,
48};
49use parking_lot::RwLock;
50
51use crate::conn::ClientCloseControl;
52
53pub type ConnectionDisconnectObserver = Arc<dyn Fn(u64) + Send + Sync>;
54pub type ConnectionOpenObserver = Arc<dyn Fn(u64, u64) + Send + Sync>;
55
56/// 订阅管理器
57pub struct SubscriptionManager {
58    connection_open_observers: RwLock<Vec<ConnectionOpenObserver>>,
59    /// Runtime owners that must cancel connection-scoped work before a dead
60    /// TCP/WS sink can receive any late result. Observers receive only the
61    /// opaque connection generation id and must be idempotent.
62    disconnect_observers: RwLock<Vec<ConnectionDisconnectObserver>>,
63
64    /// Per-connection close controls shared by listeners and push fanout.
65    ///
66    /// This lives beside connection lifecycle state instead of inside the
67    /// public `ClientConn`, preserving the latter's external struct-literal
68    /// construction contract.
69    client_close_controls: DashMap<u64, ClientCloseControl>,
70    /// Exact physical connection generation used by async QOT work leases.
71    connection_generations: DashMap<u64, u64>,
72
73    /// 通知订阅:哪些连接订阅了系统通知
74    notify_subs: RwLock<HashSet<u64>>,
75
76    /// 交易账户推送订阅:acc_id → Set<conn_id>
77    trd_acc_subs: RwLock<HashMap<u64, HashSet<u64>>>,
78
79    /// C++ `APIServerCS_PageReq` equivalent: opaque 16-byte page keys are
80    /// owned by the client connection that received them.
81    api_page_req_keys: RwLock<HashMap<u64, HashSet<[u8; 16]>>>,
82
83    /// **行情订阅** (desired sub state, 对齐 C++ `m_setSub`):
84    ///   key = (QotSecurityKey, sub_type), val = 订阅 conn 集合.
85    qot_subs: RwLock<HashMap<(QotSecurityKey, i32), HashSet<u64>>>,
86
87    /// **行情 push 注册** (对齐 C++ `m_mapRegPush`):
88    ///   key = (QotSecurityKey, sub_type, rehab_type), val = 注册接收 push 的 conn 集合.
89    /// rehab_type 仅 KL 类有效 (None=0 / Forward=1 / Backword=2 / N/A=0 for non-KL).
90    qot_push_regs: RwLock<QotPushRegistrations>,
91
92    /// **每 (security, sub_type) 的 desired session** (对齐 C++
93    /// `m_mapConnTickerSession` / `m_mapConnKLRTSession` global view):
94    /// max(per-conn session) 决定 backend desired session.
95    /// session: 0=Unknown / 1=RTH / 2=ETH / 3=ALL / 4=OVERNIGHT (rejected).
96    qot_sub_sessions: RwLock<QotSessionState>,
97
98    /// **每 (security) 的 OrderBook detail flag** (对齐 C++
99    /// `m_mapConnOrderBookDetail`): 一旦有 conn 要 detail, 全局走 detail.
100    qot_orderbook_detail: RwLock<HashMap<QotSecurityKey, HashMap<u64, bool>>>,
101
102    /// **每 (security) 的 Broker detail flag** (对齐 C++
103    /// `m_mapConnBrokerDetail`).
104    qot_broker_detail: RwLock<HashMap<QotSecurityKey, HashMap<u64, bool>>>,
105
106    /// 全局订阅时间 (key = (QotSecurityKey, sub_type)).
107    /// 对齐 C++ `m_mapSubTime`: 只有全局第一次订阅该 SubKey 或 backend
108    /// 属性升级重新拉取时才刷新;退订前至少等待 `QOT_MIN_UNSUB_ELAPSED_SECS`.
109    qot_sub_times: RwLock<HashMap<(QotSecurityKey, i32), Instant>>,
110
111    /// 已断开的 conn_id,但其 QOT 订阅还没达到 C++ 最短退订窗口。
112    ///
113    /// 对齐 C++ `QotSubscribe::ClearConnSubInfo`: 断线时 push 注册立即清,
114    /// 但 `m_setSub` 只有在 `IsSubTimeEnoughToUnSub` 后才移除;没到窗口的
115    /// 连接由后续定时清理再次尝试。
116    qot_disconnected_conns: RwLock<HashSet<u64>>,
117
118    /// 断线延迟清理导致 global desired set 变化的 generation。
119    ///
120    /// server 层没有 backend 句柄,不能在 `on_disconnect` 里直接发 CMD6211。
121    /// 这里仅记录“需要 gateway 同步”的单调计数,gateway 后台任务看到变化后
122    /// 发当前 desired set。
123    qot_disconnect_sync_generation: AtomicU64,
124    qot_owner_token_high_water: AtomicU64,
125    qot_owner_tokens: RwLock<HashMap<(QotSecurityKey, i32, u64), u64>>,
126    qot_push_intent_high_water: AtomicU64,
127}
128
129#[derive(Default)]
130struct QotPushRegistrations {
131    by_tuple: HashMap<(QotSecurityKey, i32, i32), HashSet<u64>>,
132    qot_push_regs_by_cache_key: QotPushIntentIndex,
133    intent_epochs: HashMap<(QotSecurityKey, i32, i32, u64), u64>,
134}
135
136type QotPushIntentOwners = HashMap<u64, u64>;
137type QotPushIntentRoutes = HashMap<(i32, i32), QotPushIntentOwners>;
138type QotPushIntentIndex = HashMap<String, QotPushIntentRoutes>;
139
140/// Exact push-registration intent captured for asynchronous first-push work.
141///
142/// The opaque epoch distinguishes unregister -> re-register ABA transitions;
143/// the connection generation and session fence late results for a replaced
144/// client sink or a changed KLine aggregate.
145#[derive(Clone, Debug, PartialEq, Eq)]
146pub struct QotPushRegistrationLease {
147    key: QotSecurityKey,
148    sub_type: i32,
149    rehab_type: i32,
150    conn_id: u64,
151    intent_epoch: u64,
152    connection_generation: u64,
153    conn_session: i32,
154}
155
156impl QotPushRegistrationLease {
157    #[must_use]
158    pub fn connection_generation(&self) -> u64 {
159        self.connection_generation
160    }
161
162    #[must_use]
163    pub(crate) fn intent_epoch(&self) -> u64 {
164        self.intent_epoch
165    }
166
167    #[must_use]
168    pub fn matches_delivery(
169        &self,
170        conn_id: u64,
171        connection_generation: u64,
172        sec_key: &str,
173        sub_type: i32,
174        rehab_type: i32,
175    ) -> bool {
176        self.conn_id == conn_id
177            && self.connection_generation == connection_generation
178            && self.key.cache_key() == sec_key
179            && self.sub_type == sub_type
180            && self.rehab_type == rehab_type
181    }
182}
183
184#[derive(Default)]
185struct QotSessionState {
186    by_key: HashMap<(QotSecurityKey, i32), HashMap<u64, i32>>,
187    /// Hot push-route mirror keyed by the exact cache-key display carried by
188    /// `PushEvent`. Both views are mutated under this one owner.
189    by_cache_key: HashMap<(String, i32), HashMap<u64, i32>>,
190}
191
192/// **subscribe_qot 返回的 commit 结果** (用于 quota 维度精确计算 — 对齐 C++
193/// `m_setSub` 全局唯一计 quota).
194///
195/// C++ 对照: QotSubscribe.cpp:84-111. `bNoSub = m_setSub.count(pairSubKey) == 0`,
196/// 仅当全局 set 不存在该 key 时才 `UseQuota()`.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum SubResult {
199    /// 全局首次订阅该 (security, sub_type) — quota 应 +1.
200    NewGlobal,
201    /// 全局已有订阅, 本 conn 是新加入 — quota 不变.
202    AlreadyGlobal,
203    /// (conn_id, security, sub_type) 已在 set 中, 重复订阅 — quota 不变.
204    NoChange,
205}
206
207/// **unsubscribe_qot 返回的 commit 结果**.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum UnsubResult {
210    /// 最后一个 conn 退订 → 全局 set 删除该 key, **caller 必须发 backend
211    /// fresh CMD 6211 with new desired set** (drop 该 (stock_id, sub_type)).
212    LastSubscriber,
213    /// 还有其他 conn 订阅该 (security, sub_type), backend 不需退订.
214    StillSubscribed,
215    /// (conn_id, security, sub_type) 之前未订阅, silent no-op (caller 决定
216    /// 是否 loud reject).
217    NotSubscribed,
218}
219
220impl SubscriptionManager {
221    pub fn new() -> Self {
222        Self {
223            connection_open_observers: RwLock::new(Vec::new()),
224            disconnect_observers: RwLock::new(Vec::new()),
225            client_close_controls: DashMap::new(),
226            connection_generations: DashMap::new(),
227            notify_subs: RwLock::new(HashSet::new()),
228            trd_acc_subs: RwLock::new(HashMap::new()),
229            api_page_req_keys: RwLock::new(HashMap::new()),
230            qot_subs: RwLock::new(HashMap::new()),
231            qot_push_regs: RwLock::new(QotPushRegistrations::default()),
232            qot_sub_sessions: RwLock::new(QotSessionState::default()),
233            qot_orderbook_detail: RwLock::new(HashMap::new()),
234            qot_broker_detail: RwLock::new(HashMap::new()),
235            qot_sub_times: RwLock::new(HashMap::new()),
236            qot_disconnected_conns: RwLock::new(HashSet::new()),
237            qot_disconnect_sync_generation: AtomicU64::new(0),
238            qot_owner_token_high_water: AtomicU64::new(0),
239            qot_owner_tokens: RwLock::new(HashMap::new()),
240            qot_push_intent_high_water: AtomicU64::new(0),
241        }
242    }
243
244    // ===== 通知订阅 =====
245
246    pub fn subscribe_notify(&self, conn_id: u64) {
247        self.notify_subs.write().insert(conn_id);
248    }
249
250    pub fn unsubscribe_notify(&self, conn_id: u64) {
251        self.notify_subs.write().remove(&conn_id);
252    }
253
254    pub fn is_subscribed_notify(&self, conn_id: u64) -> bool {
255        self.notify_subs.read().contains(&conn_id)
256    }
257
258    pub fn register_api_page_req_key(&self, conn_id: u64, key: &[u8]) -> bool {
259        let Ok(key) = <[u8; 16]>::try_from(key) else {
260            return false;
261        };
262        self.api_page_req_keys
263            .write()
264            .entry(conn_id)
265            .or_default()
266            .insert(key);
267        true
268    }
269
270    #[must_use]
271    pub fn is_api_page_req_key_registered(&self, conn_id: u64, key: &[u8]) -> bool {
272        let Ok(key) = <[u8; 16]>::try_from(key) else {
273            return false;
274        };
275        self.api_page_req_keys
276            .read()
277            .get(&conn_id)
278            .is_some_and(|keys| keys.contains(&key))
279    }
280
281    // ===== 交易账户推送 =====
282
283    pub fn subscribe_trd_acc(&self, conn_id: u64, acc_id: u64) {
284        self.trd_acc_subs
285            .write()
286            .entry(acc_id)
287            .or_default()
288            .insert(conn_id);
289    }
290
291    pub fn unsubscribe_trd_acc(&self, conn_id: u64, acc_id: u64) {
292        if let Some(subs) = self.trd_acc_subs.write().get_mut(&acc_id) {
293            subs.remove(&conn_id);
294        }
295    }
296
297    pub fn get_acc_subscribers(&self, acc_id: u64) -> Vec<u64> {
298        match self.trd_acc_subs.read().get(&acc_id) {
299            Some(subscribers) => subscribers.iter().copied().collect(),
300            None => Vec::new(),
301        }
302    }
303
304    // ===== 行情订阅 (subscribers, F3 split-state) =====
305
306    /// 生成行情订阅 key.
307    pub fn make_qot_key(market: i32, code: &str, sub_type: i32) -> String {
308        format!("{market}_{code}:{sub_type}")
309    }
310
311    #[inline]
312    fn broker_key(sec_key: &QotSecurityKey) -> QotSecurityKey {
313        sec_key.clone()
314    }
315
316    /// **v1.4.106 codex 1131 F1+F5 [P1+P2]**: 订阅行情. 返 [`SubResult`]
317    /// 表示是否新加全局订阅 (caller 据此累 quota).
318    /// 重复订阅 (同 conn_id 同 key) 不影响 set, 不影响 quota.
319    /// **NOTE**: caller 必须先 backend ack-then-commit (F1) — 本方法仅写
320    /// local state. 失败 caller 应调 `unsubscribe_qot_broker` 回滚.
321    pub fn subscribe_qot_broker(
322        &self,
323        conn_id: u64,
324        sec_key: &QotSecurityKey,
325        sub_type: i32,
326    ) -> SubResult {
327        qot_commit::subscribe_broker(self, conn_id, Self::broker_key(sec_key), sub_type)
328    }
329
330    /// 退订并返结构化结果. caller 据 `LastSubscriber` 决定是否发 backend
331    /// fresh CMD 6211 with new desired set.
332    pub fn unsubscribe_qot_broker(
333        &self,
334        conn_id: u64,
335        sec_key: &QotSecurityKey,
336        sub_type: i32,
337    ) -> UnsubResult {
338        qot_commit::unsubscribe_broker(self, conn_id, Self::broker_key(sec_key), sub_type)
339    }
340
341    /// 是否 (conn_id, key, sub_type) 已订阅.
342    pub fn is_qot_subscribed_broker(
343        &self,
344        conn_id: u64,
345        sec_key: &QotSecurityKey,
346        sub_type: i32,
347    ) -> bool {
348        self.qot_subs
349            .read()
350            .get(&(Self::broker_key(sec_key), sub_type))
351            .is_some_and(|subs| subs.contains(&conn_id))
352    }
353
354    /// v1.4.106 codex 1131 F3 [P1]: 全局 (ignore conn) 是否有订阅 — RegQotPush
355    /// 的 precondition. 对齐 C++ `QotSubscribe::IsSub(stockID, subType)`.
356    pub fn is_globally_subscribed_broker(&self, sec_key: &QotSecurityKey, sub_type: i32) -> bool {
357        self.qot_subs
358            .read()
359            .get(&(Self::broker_key(sec_key), sub_type))
360            .is_some_and(|subs| !subs.is_empty())
361    }
362
363    /// min-unsub window for broker-aware subscription keys.
364    pub fn qot_min_unsub_elapsed_broker(&self, sec_key: &QotSecurityKey, sub_type: i32) -> bool {
365        self.qot_sub_times
366            .read()
367            .get(&(Self::broker_key(sec_key), sub_type))
368            .map(|instant| {
369                qot_min_unsub_freshness_from_elapsed_secs(instant.elapsed().as_secs()).min_elapsed
370            })
371            .unwrap_or(true)
372    }
373
374    /// remaining min-unsub window for broker-aware keys.
375    pub fn qot_min_unsub_remaining_secs_broker(
376        &self,
377        sec_key: &QotSecurityKey,
378        sub_type: i32,
379    ) -> u64 {
380        self.qot_sub_times
381            .read()
382            .get(&(Self::broker_key(sec_key), sub_type))
383            .map(|instant| {
384                qot_min_unsub_freshness_from_elapsed_secs(instant.elapsed().as_secs())
385                    .remaining_secs
386            })
387            .unwrap_or(0)
388    }
389
390    /// 断线延迟清理后需要 gateway 同步 CMD6211 的 generation。
391    pub fn qot_disconnect_sync_generation(&self) -> u64 {
392        self.qot_disconnect_sync_generation.load(Ordering::SeqCst)
393    }
394
395    #[doc(hidden)]
396    pub fn backdate_qot_sub_time_broker_for_test(
397        &self,
398        sec_key: &QotSecurityKey,
399        sub_type: i32,
400        elapsed: Duration,
401    ) {
402        let map_key = (Self::broker_key(sec_key), sub_type);
403        let instant = Instant::now()
404            .checked_sub(elapsed)
405            .unwrap_or_else(Instant::now);
406        self.qot_sub_times.write().insert(map_key, instant);
407    }
408
409    /// v1.4.106 codex 1131 F2: clear all qot subs for a single conn_id.
410    /// 返 (sec_key, sub_type) 列表 of "本 conn 退订后变成全局空的" — caller
411    /// 据此构 backend new desired set. 返的 sec_key 是 cache_key display string
412    /// (`"market_code"` or `"market_code@b{id}"`).
413    pub fn unsubscribe_all_qot_collect_global_empty(&self, conn_id: u64) -> Vec<(String, i32)> {
414        unsubscribe_all_commit::collect_global_empty(self, conn_id)
415    }
416
417    /// 清理已断开且已满足 C++ 最短退订窗口的 QOT conn。
418    ///
419    /// 返回本次清理后 global desired set 变空的 `(sec_key, sub_type)` 列表
420    /// (sec_key 是 cache_key display string: `"market_code"` or
421    /// `"market_code@b{id}"`).
422    /// 若列表非空,会 bump `qot_disconnect_sync_generation`,由 gateway 后台
423    /// 任务负责把新的全局 desired set 发到 backend。
424    pub fn cleanup_due_disconnected_qot(&self) -> Vec<(String, i32)> {
425        disconnected_cleanup::cleanup_due(self)
426    }
427
428    /// **v1.4.106 codex 0631 F1 [P1]**: ack-then-commit `unsub_all` 的"干跑"半段.
429    ///
430    /// 计算: **若**本 conn 退订全部, 哪些 `(sec_key, sub_type)` 在 global
431    /// desired set 中**变空** (= backend 该真退). **不修 state, 不动 detail
432    /// flag, 不动 push_regs**. 用在 ack-then-commit pipeline:
433    ///
434    /// `dry_run -> submit_global_desired_set (backend ack) -> commit (清 state)`
435    ///
436    /// backend reject → caller 不调 `commit`, state 保留 → 客户端可重试幂等.
437    /// 老 `unsubscribe_all_qot_collect_global_empty` 是先清后算 — 失败时
438    /// state 已 mutate, 不能 rollback (split-brain 风险). 本 helper 替代.
439    pub fn unsubscribe_all_qot_dry_run(&self, conn_id: u64) -> Vec<(String, i32)> {
440        let qot = self.qot_subs.read();
441        let probes = qot
442            .iter()
443            .map(|((key, sub_type), set)| UnsubscribeAllGlobalEmptyProbe {
444                key: key.cache_key(),
445                sub_type: *sub_type,
446                conn_is_subscribed: set.contains(&conn_id),
447                subscriber_count: set.len(),
448            });
449        plan_unsubscribe_all_global_empty_keys(probes)
450    }
451
452    /// **v1.4.106 codex 0631 F1 [P1]**: ack-then-commit `unsub_all` 的"提交"半段.
453    /// 等价于老 `unsubscribe_all_qot_collect_global_empty` (语义不变, 仅在
454    /// backend ack OK 后才调). 同时清 session / detail / push_regs.
455    pub fn unsubscribe_all_qot_commit(&self, conn_id: u64) -> Vec<(String, i32)> {
456        self.unsubscribe_all_qot_collect_global_empty(conn_id)
457    }
458
459    /// 获取订阅了指定行情的连接列表 (subscribers, **不**用作 push 路由).
460    /// 用于 `apply_unsubscribe_delta` 判断 broker-aware key 上是否还有其他
461    /// conn 订阅 (last-subscriber gate for desired-set remove).
462    pub fn get_qot_subscribers_broker(&self, sec_key: &QotSecurityKey, sub_type: i32) -> Vec<u64> {
463        match self
464            .qot_subs
465            .read()
466            .get(&(Self::broker_key(sec_key), sub_type))
467        {
468            Some(subscribers) => subscribers.iter().copied().collect(),
469            None => Vec::new(),
470        }
471    }
472
473    /// Snapshot active owners for generation-fenced async quote work.
474    pub fn qot_owner_lease_broker(
475        &self,
476        sec_key: &QotSecurityKey,
477        sub_type: i32,
478    ) -> Vec<(u64, u64, i32, u64)> {
479        let disconnected = self.qot_disconnected_conns.read();
480        let mut owners = self
481            .get_qot_subscribers_broker(sec_key, sub_type)
482            .into_iter()
483            .filter(|conn_id| !disconnected.contains(conn_id))
484            .filter_map(|conn_id| {
485                let connection_generation = self
486                    .connection_generations
487                    .get(&conn_id)
488                    .map(|generation| *generation)
489                    .unwrap_or(0);
490                let owner_token = self
491                    .qot_owner_tokens
492                    .read()
493                    .get(&(Self::broker_key(sec_key), sub_type, conn_id))
494                    .copied()?;
495                Some((
496                    conn_id,
497                    connection_generation,
498                    self.get_conn_session_broker(conn_id, sec_key, sub_type),
499                    owner_token,
500                ))
501            })
502            .collect::<Vec<_>>();
503        owners.sort_unstable();
504        owners
505    }
506
507    pub fn qot_owner_lease_is_current(
508        &self,
509        sec_key: &QotSecurityKey,
510        sub_type: i32,
511        captured: &[(u64, u64, i32, u64)],
512        request_section: Option<i32>,
513    ) -> bool {
514        self.with_current_qot_owner_lease(sec_key, sub_type, captured, request_section, || ())
515            .is_some()
516    }
517
518    pub fn with_current_qot_owner_lease<R>(
519        &self,
520        sec_key: &QotSecurityKey,
521        sub_type: i32,
522        captured: &[(u64, u64, i32, u64)],
523        request_section: Option<i32>,
524        publish: impl FnOnce() -> R,
525    ) -> Option<R> {
526        let key = Self::broker_key(sec_key);
527        let qot = self.qot_subs.read();
528        let subscribers = qot.get(&(key.clone(), sub_type))?;
529        let disconnected = self.qot_disconnected_conns.read();
530        let tokens = self.qot_owner_tokens.read();
531        let sessions = self.qot_sub_sessions.read();
532        let session_map = sessions.by_key.get(&(key.clone(), sub_type));
533        let current = captured.iter().any(
534            |(conn_id, connection_generation, captured_session, owner_token)| {
535                if !subscribers.contains(conn_id) || disconnected.contains(conn_id) {
536                    return false;
537                }
538                let current_connection_generation = self
539                    .connection_generations
540                    .get(conn_id)
541                    .map(|generation| *generation)
542                    .unwrap_or(0);
543                let same_connection = current_connection_generation == *connection_generation;
544                let same_owner = tokens
545                    .get(&(key.clone(), sub_type, *conn_id))
546                    .is_some_and(|token| *token == *owner_token);
547                let session = session_map
548                    .and_then(|map| map.get(conn_id))
549                    .copied()
550                    .unwrap_or(1);
551                same_connection
552                    && same_owner
553                    && session == *captured_session
554                    && request_section.is_none_or(|section| match section {
555                        2 | 3 => matches!(session, 2 | 3),
556                        5 => session == 3,
557                        _ => matches!(session, 0..=3),
558                    })
559            },
560        );
561        current.then(publish)
562    }
563
564    fn next_qot_owner_token(&self) -> u64 {
565        let mut current = self.qot_owner_token_high_water.load(Ordering::SeqCst);
566        loop {
567            let next = if current == u64::MAX { 1 } else { current + 1 };
568            match self.qot_owner_token_high_water.compare_exchange(
569                current,
570                next,
571                Ordering::SeqCst,
572                Ordering::SeqCst,
573            ) {
574                Ok(_) => return next,
575                Err(observed) => current = observed,
576            }
577        }
578    }
579
580    pub(super) fn next_qot_push_intent_epoch(&self) -> u64 {
581        let mut current = self.qot_push_intent_high_water.load(Ordering::SeqCst);
582        loop {
583            let next = if current == u64::MAX { 1 } else { current + 1 };
584            match self.qot_push_intent_high_water.compare_exchange(
585                current,
586                next,
587                Ordering::SeqCst,
588                Ordering::SeqCst,
589            ) {
590                Ok(_) => return next,
591                Err(observed) => current = observed,
592            }
593        }
594    }
595
596    pub(super) fn assign_qot_owner_token(&self, key: &QotSecurityKey, sub_type: i32, conn_id: u64) {
597        let token = self.next_qot_owner_token();
598        self.qot_owner_tokens
599            .write()
600            .insert((Self::broker_key(key), sub_type, conn_id), token);
601    }
602
603    pub(super) fn remove_qot_owner_token(&self, key: &QotSecurityKey, sub_type: i32, conn_id: u64) {
604        self.qot_owner_tokens
605            .write()
606            .remove(&(Self::broker_key(key), sub_type, conn_id));
607    }
608
609    pub(super) fn remove_all_qot_owner_tokens(&self, conn_id: u64) {
610        self.qot_owner_tokens
611            .write()
612            .retain(|(_, _, owner), _| *owner != conn_id);
613    }
614
615    /// **v1.4.110 codex audit Round3 P2 #21**: 给定 `stock_id`, 判断该 stock 是否
616    /// **全局**已无任何 conn 订阅 (跨所有 broker_id + 所有 sub_type).
617    ///
618    /// 用途: `Qot_Sub` 退订路径在 commit 之后判断 crypto symbol 是否真正全空,
619    /// 决定是否调 `CryptoExchangeCache::clear_stock(stock_id)` 清 stale entry —
620    /// 因为 `crypto_exchange_cache` 按 `stock_id` keyed (broker 无关), 只有该
621    /// stock 全 broker 全 sub_type 都退掉才能安全清.
622    ///
623    /// 返 `true` ⟺ 该 stock_id 在 `qot_subs` 中无任何带 subscriber 的 entry.
624    pub fn crypto_stock_globally_unsubscribed(&self, stock_id: u64) -> bool {
625        let qot = self.qot_subs.read();
626        let probes = qot.iter().map(|((key, _sub_type), subs)| {
627            CryptoSubscriptionProbe::from_runtime_facts(
628                key.stock_key.stock_id,
629                key.stock_key.broker_id,
630                subs.len(),
631            )
632        });
633        is_crypto_stock_globally_unsubscribed(stock_id, probes)
634    }
635
636    /// v1.4.110 R6-8: `(stock_id, broker_id)`-级版 `crypto_stock_globally_unsubscribed`.
637    ///
638    /// 用途: 部分 broker 退订时, 判断某具体 `(stock_id, broker_id)` 是否已无任何
639    /// conn 订阅 → 决定是否调 `CryptoExchangeCache::clear_stock_broker` 清该
640    /// broker 的 stale `by_broker` entry (整 stock 仍有别的 broker 在订时
641    /// `clear_stock` 不适用).
642    ///
643    /// 返 `true` ⟺ 该 `(stock_id, broker_id)` 在 `qot_subs` 无任何带 subscriber 的 entry.
644    pub fn crypto_stock_broker_globally_unsubscribed(&self, stock_id: u64, broker_id: u32) -> bool {
645        let target_broker = std::num::NonZeroU32::new(broker_id);
646        let qot = self.qot_subs.read();
647        let probes = qot.iter().map(|((key, _sub_type), subs)| {
648            CryptoSubscriptionProbe::from_runtime_facts(
649                key.stock_key.stock_id,
650                key.stock_key.broker_id,
651                subs.len(),
652            )
653        });
654        is_crypto_stock_broker_globally_unsubscribed(stock_id, target_broker, probes)
655    }
656
657    // ===== Session / Detail (per-(security, sub_type) global aggregator) =====
658
659    pub fn set_conn_session_broker(
660        &self,
661        conn_id: u64,
662        sec_key: &QotSecurityKey,
663        sub_type: i32,
664        session: i32,
665    ) {
666        let previous = self.get_conn_session_broker(conn_id, sec_key, sub_type);
667        session_detail::set_conn_session(
668            self,
669            conn_id,
670            Self::broker_key(sec_key),
671            sub_type,
672            session,
673        );
674        if previous != session && self.is_qot_subscribed_broker(conn_id, sec_key, sub_type) {
675            self.assign_qot_owner_token(sec_key, sub_type, conn_id);
676        }
677    }
678
679    pub fn get_global_session_broker(&self, sec_key: &QotSecurityKey, sub_type: i32) -> i32 {
680        session_detail::global_session(self, Self::broker_key(sec_key), sub_type)
681    }
682
683    /// 获取单连接订阅 session(没有显式记录时按 C++ 默认 RTH)。
684    pub fn get_conn_session_broker(
685        &self,
686        conn_id: u64,
687        sec_key: &QotSecurityKey,
688        sub_type: i32,
689    ) -> i32 {
690        session_detail::conn_session(self, conn_id, Self::broker_key(sec_key), sub_type)
691    }
692
693    /// Lookup the FTAPI connection session from the internal cache-key carried
694    /// by a live push. Missing entries retain the C++ default RTH session.
695    pub fn get_conn_session_by_cache_key(
696        &self,
697        conn_id: u64,
698        cache_key: &str,
699        sub_type: i32,
700    ) -> i32 {
701        self.qot_sub_sessions
702            .read()
703            .by_cache_key
704            .get(&(cache_key.to_owned(), sub_type))
705            .and_then(|sessions| sessions.get(&conn_id))
706            .copied()
707            .unwrap_or(1)
708    }
709
710    pub fn set_conn_orderbook_detail_broker(
711        &self,
712        conn_id: u64,
713        sec_key: &QotSecurityKey,
714        detail: bool,
715    ) {
716        session_detail::set_conn_orderbook_detail(self, conn_id, Self::broker_key(sec_key), detail);
717    }
718
719    pub fn is_global_orderbook_detail_broker(&self, sec_key: &QotSecurityKey) -> bool {
720        session_detail::global_orderbook_detail(self, Self::broker_key(sec_key))
721    }
722
723    pub fn set_conn_broker_detail_broker(
724        &self,
725        conn_id: u64,
726        sec_key: &QotSecurityKey,
727        detail: bool,
728    ) {
729        session_detail::set_conn_broker_detail(self, conn_id, Self::broker_key(sec_key), detail);
730    }
731
732    pub fn is_global_broker_detail_broker(&self, sec_key: &QotSecurityKey) -> bool {
733        session_detail::global_broker_detail(self, Self::broker_key(sec_key))
734    }
735
736    // ===== 连接断开清理 =====
737
738    pub fn register_connection_open_observer(&self, observer: ConnectionOpenObserver) {
739        self.connection_open_observers.write().push(observer);
740    }
741
742    pub(crate) fn on_connect(&self, conn_id: u64, session_generation: u64) {
743        self.connection_generations
744            .insert(conn_id, session_generation);
745        let observers = self.connection_open_observers.read().clone();
746        for observer in observers {
747            observer(conn_id, session_generation);
748        }
749    }
750
751    pub fn register_disconnect_observer(&self, observer: ConnectionDisconnectObserver) {
752        self.disconnect_observers.write().push(observer);
753    }
754
755    pub(crate) fn register_client_close_control(
756        &self,
757        conn_id: u64,
758        close_control: ClientCloseControl,
759    ) {
760        self.client_close_controls.insert(conn_id, close_control);
761    }
762
763    /// Returns `None` when the listener never registered a close control,
764    /// otherwise whether this call performed the first open-to-closing
765    /// transition.
766    pub(crate) fn request_client_close(&self, conn_id: u64) -> Option<bool> {
767        self.client_close_controls
768            .get(&conn_id)
769            .map(|control| control.request_close())
770    }
771
772    pub(crate) fn remove_client_close_control(&self, conn_id: u64) {
773        self.client_close_controls.remove(&conn_id);
774    }
775
776    pub fn on_disconnect(&self, conn_id: u64) -> Vec<(String, i32)> {
777        connection_lifecycle::on_disconnect(self, conn_id)
778    }
779}
780
781impl Default for SubscriptionManager {
782    fn default() -> Self {
783        Self::new()
784    }
785}
786
787#[inline]
788fn sub_type_orderbook() -> i32 {
789    2
790}
791
792#[inline]
793fn sub_type_broker() -> i32 {
794    14
795}
796
797#[cfg(test)]
798mod tests;