Skip to main content

futu_gateway_core/bridge/push_health/
qot.rs

1//! bridge/push_health/qot — QotLoginHealth + SharedQotLoginHealth
2//! (v1.4.110 CC Batch P: 拆自 push_health.rs L655-990)
3//!
4//! v1.4.90 P1-D / v1.4.118 Jackie: qot-login self-heal state holder.
5//!
6//! Under extreme network pressure (push storm, partial network failure during
7//! heartbeat windows, backend session expiration), the daemon can enter
8//! `LoginCache.is_logged_in=false` while TCP is still up. v1.4.83 §9 F2/F3/F4
9//! self-heal pipelines cover the push channel (parse errors, dispatcher
10//! backpressure, orphan orders), while this module tracks the coarser
11//! auth/query-layer signal. Backend disconnect recovery is owned by Phase4
12//! reconnect, so this loop does not empty-fire auth refresh while TCP is down.
13
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
16
17use super::push::{
18    RELOGIN_CIRCUIT_COOLDOWN_MS, RELOGIN_CIRCUIT_TRIP_THRESHOLD, parse_relogin_backoff_env,
19    unix_now_ms,
20};
21
22pub struct QotLoginHealth {
23    /// Most recent observation of `qot_logined=false` (Unix ms; 0 = never observed).
24    last_qot_logined_false_at_ms: AtomicI64,
25    /// Most recent attempt to trigger relogin (Unix ms; 0 = never).
26    last_relogin_attempt_at_ms: AtomicI64,
27    /// Most recent successful relogin completion (Unix ms; 0 = never).
28    last_relogin_success_at_ms: AtomicI64,
29    /// Consecutive failed relogin attempts (cleared on success).
30    consecutive_relogin_failures: AtomicU32,
31    /// Total relogin attempts (monotonic, for metrics rate).
32    total_relogin_attempts: AtomicU64,
33    /// Total successful relogin completions (monotonic).
34    total_relogin_successes: AtomicU64,
35    /// Total observations of `qot_logined=false` (monotonic, for metrics).
36    total_qot_logined_false_observed: AtomicU64,
37
38    // ========================================================================
39    // v1.4.92 P1-D Tier A2: state machine — single in-flight + circuit-open
40    // ========================================================================
41    /// v1.4.92: 单 in-flight relogin attempt 标志.
42    ///
43    /// `try_begin_attempt()` 用 `compare_exchange(false, true, AcqRel, Relaxed)`
44    /// 在 attempt 之前; `end_attempt()` 用 `store(false, Release)` 之后. 防止 30s
45    /// tick 与 attempt 重叠导致两个并发 refresher 同时 POST /authority/.
46    ///
47    /// 单 in-flight 而不是 N=2/3 并发, 因为 backend 同 uid 短时间内重 POST
48    /// /authority/ → 反刷 ret_type=15 (CLAUDE.md auth 坑 #15 第 2 类来源).
49    relogin_attempt_in_flight: AtomicBool,
50
51    /// v1.4.92: 5 连续失败后 trip circuit, 跳后续 attempt 10 min.
52    ///
53    /// 与 F4 push circuit-breaker 思路对齐 (v1.4.83 §9 Phase 2):
54    /// - 连续 N=5 次失败 → `open_circuit()` 把 flag 置 true + 记 timestamp
55    /// - `should_attempt_relogin_v2` 看到 flag=true + 未超 10 min cooldown → return false
56    /// - 10 min 后 `maybe_close_circuit()` 自动重置 flag + 清 failure counter
57    ///   (给重试 fresh budget)
58    relogin_circuit_open: AtomicBool,
59
60    /// v1.4.92: circuit_open 起始时间 (Unix ms; 0 = 未 open).
61    /// 用于判断 cooldown 是否过期.
62    relogin_circuit_opened_at_ms: AtomicI64,
63
64    // ========================================================================
65    // v1.4.97 P1-D-B: env-tunable backoff ladder
66    // ========================================================================
67    /// v1.4.97: backoff ladder (ms) per `consecutive_relogin_failures` count
68    /// `[fail_0, fail_1, fail_2, fail_>=3]`. Default `[60_000, 120_000,
69    /// 240_000, 600_000]` (60s/2min/4min/10min cap, unchanged from v1.4.94).
70    ///
71    /// Override via env `FUTU_QOT_RELOGIN_BACKOFF_MS=60000,120000,240000,600000`
72    /// (4 comma-separated u64 ms). Parse-fail → keep default + WARN log.
73    ///
74    /// Tester real-machine verify: shorten via env (e.g. 5000,10000,20000,40000)
75    /// to cover full ladder in ~1 min instead of waiting 7+ min for default.
76    backoff_ms_ladder: [u64; 4],
77}
78
79impl Default for QotLoginHealth {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl QotLoginHealth {
86    pub fn new() -> Self {
87        // v1.4.97 P1-D-B: parse env once at construction time.
88        let ladder = parse_relogin_backoff_env(std::env::var("FUTU_QOT_RELOGIN_BACKOFF_MS").ok());
89        Self {
90            last_qot_logined_false_at_ms: AtomicI64::new(0),
91            last_relogin_attempt_at_ms: AtomicI64::new(0),
92            last_relogin_success_at_ms: AtomicI64::new(0),
93            consecutive_relogin_failures: AtomicU32::new(0),
94            total_relogin_attempts: AtomicU64::new(0),
95            total_relogin_successes: AtomicU64::new(0),
96            total_qot_logined_false_observed: AtomicU64::new(0),
97            // v1.4.92 P1-D Tier A2: state machine init
98            relogin_attempt_in_flight: AtomicBool::new(false),
99            relogin_circuit_open: AtomicBool::new(false),
100            relogin_circuit_opened_at_ms: AtomicI64::new(0),
101            // v1.4.97 P1-D-B: env-tunable ladder
102            backoff_ms_ladder: ladder,
103        }
104    }
105
106    /// v1.4.97 P1-D-B: test-friendly construction with explicit ladder.
107    pub fn with_ladder(ladder: [u64; 4]) -> Self {
108        let mut h = Self::new();
109        h.backoff_ms_ladder = ladder;
110        h
111    }
112
113    #[cfg(test)]
114    pub(super) fn set_last_relogin_attempt_at_ms_for_test(&self, value: i64) {
115        self.last_relogin_attempt_at_ms
116            .store(value, Ordering::Relaxed);
117    }
118
119    #[cfg(test)]
120    pub(super) fn set_relogin_circuit_opened_at_ms_for_test(&self, value: i64) {
121        self.relogin_circuit_opened_at_ms
122            .store(value, Ordering::Relaxed);
123    }
124
125    pub fn backoff_ms_ladder(&self) -> [u64; 4] {
126        self.backoff_ms_ladder
127    }
128
129    /// Call from query handler / health-poll task whenever
130    /// `LoginCache::is_logged_in()` returns `false` while backend is connected.
131    /// Updates last-observed timestamp + monotonic counter.
132    pub fn record_qot_logined_false(&self) {
133        let now_ms = unix_now_ms();
134        self.last_qot_logined_false_at_ms
135            .store(now_ms, Ordering::Relaxed);
136        self.total_qot_logined_false_observed
137            .fetch_add(1, Ordering::Relaxed);
138    }
139
140    /// Call right before invoking the relogin code path. Records timestamp +
141    /// bumps `total_relogin_attempts`.
142    pub fn record_relogin_attempt(&self) {
143        let now_ms = unix_now_ms();
144        self.last_relogin_attempt_at_ms
145            .store(now_ms, Ordering::Relaxed);
146        self.total_relogin_attempts.fetch_add(1, Ordering::Relaxed);
147    }
148
149    /// Call after relogin completes successfully. Clears failure counter,
150    /// records timestamp, bumps success monotonic.
151    pub fn record_relogin_success(&self) {
152        let now_ms = unix_now_ms();
153        self.last_relogin_success_at_ms
154            .store(now_ms, Ordering::Relaxed);
155        self.consecutive_relogin_failures
156            .store(0, Ordering::Relaxed);
157        self.total_relogin_successes.fetch_add(1, Ordering::Relaxed);
158    }
159
160    /// Call after relogin attempt fails. Bumps consecutive failure counter
161    /// (drives exponential backoff in `should_attempt_relogin`).
162    pub fn record_relogin_failure(&self) {
163        self.consecutive_relogin_failures
164            .fetch_add(1, Ordering::Relaxed);
165    }
166
167    /// **Decision API** for the 30s polling task.
168    ///
169    /// Returns `true` iff:
170    /// 1. caller observed a stale QOT login while the backend TCP is connected:
171    ///    `qot_logined_now=false` AND `backend_connected=true`; AND
172    /// 2. enough time has passed since `last_relogin_attempt_at_ms` per
173    ///    exponential-backoff schedule:
174    ///    - 0 failures → 60s
175    ///    - 1 failure → 120s
176    ///    - 2 failures → 240s
177    ///    - 3+ failures → 600s cap
178    ///
179    /// Caller is responsible for separately calling
180    /// `record_relogin_attempt()` then `record_relogin_success()` /
181    /// `record_relogin_failure()` after the relogin runs.
182    pub fn should_attempt_relogin(&self, qot_logined_now: bool, backend_connected: bool) -> bool {
183        if !backend_connected {
184            return false;
185        }
186        if qot_logined_now {
187            return false;
188        }
189
190        let last_attempt = self.last_relogin_attempt_at_ms.load(Ordering::Relaxed);
191        if last_attempt == 0 {
192            // Never attempted — fire immediately.
193            return true;
194        }
195        let backoff_ms = self.relogin_backoff_ms();
196        let now_ms = unix_now_ms();
197        now_ms.saturating_sub(last_attempt) >= backoff_ms
198    }
199
200    /// Current backoff window in ms based on `consecutive_relogin_failures`.
201    /// Public for tests / metrics; not intended for caller decision (use
202    /// `should_attempt_relogin`).
203    ///
204    /// v1.4.97 P1-D-B: ladder is now env-tunable via
205    /// `FUTU_QOT_RELOGIN_BACKOFF_MS=60000,120000,240000,600000`. Default
206    /// values unchanged from v1.4.94 baseline.
207    pub fn relogin_backoff_ms(&self) -> i64 {
208        let failures = self.consecutive_relogin_failures.load(Ordering::Relaxed);
209        let idx = (failures.min(3)) as usize;
210        self.backoff_ms_ladder[idx] as i64
211    }
212
213    // ========================================================================
214    // v1.4.92 P1-D Tier A2: state machine API
215    // ========================================================================
216
217    /// v1.4.92: 尝试 acquire in-flight slot. 返 `true` 表示获取成功 (caller
218    /// 现在可调 refresher), 返 `false` 表示已有别的 attempt 在跑 (caller skip).
219    ///
220    /// 用 `compare_exchange(false, true, AcqRel, Relaxed)` 而非 `load + store`
221    /// 防止两个并发 tick 都看到 false → 都进 attempt.
222    pub fn try_begin_attempt(&self) -> bool {
223        self.relogin_attempt_in_flight
224            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
225            .is_ok()
226    }
227
228    /// v1.4.92: release in-flight slot (无论 attempt 成功或失败都该调).
229    pub fn end_attempt(&self) {
230        self.relogin_attempt_in_flight
231            .store(false, Ordering::Release);
232    }
233
234    /// v1.4.92: 触发 circuit_open (5 连续失败后调).
235    /// 记 timestamp + 置 flag.
236    pub fn open_circuit(&self) {
237        let now = unix_now_ms();
238        self.relogin_circuit_opened_at_ms
239            .store(now, Ordering::Relaxed);
240        self.relogin_circuit_open.store(true, Ordering::Release);
241    }
242
243    /// v1.4.92: 自动检查 + 重置 circuit (cooldown 过期则关闭 + 清 failure
244    /// counter 给重试 fresh budget).
245    ///
246    /// 每次 polling tick 调一次, 不会引入额外开销 (atomic load + 比较).
247    pub fn maybe_close_circuit(&self) {
248        if !self.relogin_circuit_open.load(Ordering::Acquire) {
249            return;
250        }
251        let opened_at = self.relogin_circuit_opened_at_ms.load(Ordering::Relaxed);
252        if opened_at == 0 {
253            // open_circuit 未正确设 timestamp — 直接关闭 (defensive).
254            self.relogin_circuit_open.store(false, Ordering::Release);
255            return;
256        }
257        let now = unix_now_ms();
258        if now.saturating_sub(opened_at) >= RELOGIN_CIRCUIT_COOLDOWN_MS {
259            self.relogin_circuit_open.store(false, Ordering::Release);
260            self.relogin_circuit_opened_at_ms
261                .store(0, Ordering::Relaxed);
262            // 清 failure counter, 让下次 attempt 有 fresh 60s backoff.
263            self.consecutive_relogin_failures
264                .store(0, Ordering::Relaxed);
265            tracing::info!(
266                "v1.4.92 P1-D: relogin circuit closed after {}ms cooldown, failure \
267                 counter reset to 0",
268                RELOGIN_CIRCUIT_COOLDOWN_MS
269            );
270        }
271    }
272
273    /// v1.4.92: 扩展 decision API — 包含 circuit_open 检查.
274    ///
275    /// 与 [`Self::should_attempt_relogin`] 关系:
276    /// - 先 `maybe_close_circuit()` 检查 cooldown 是否过期 → 自动重置
277    /// - 若 circuit 仍 open → return false (跳所有 attempt)
278    /// - 否则 delegate 到 `should_attempt_relogin` (原 v1.4.90 backoff 逻辑)
279    ///
280    /// 调用方 (`run_qot_login_health_loop`) 应该用这个 v2 API 而不是 v1
281    /// 的 `should_attempt_relogin`. v1 保留作 backward-compat (单测仍在用).
282    pub fn should_attempt_relogin_v2(
283        &self,
284        qot_logined_now: bool,
285        backend_connected: bool,
286    ) -> bool {
287        self.maybe_close_circuit();
288        if self.relogin_circuit_open.load(Ordering::Acquire) {
289            return false;
290        }
291        self.should_attempt_relogin(qot_logined_now, backend_connected)
292    }
293
294    /// v1.4.92: 扩展版 record_relogin_failure — 失败后检查是否达 trip 阈值.
295    ///
296    /// 与 [`Self::record_relogin_failure`] 关系:
297    /// - 先调 record_relogin_failure (++ counter)
298    /// - 若 counter >= [`RELOGIN_CIRCUIT_TRIP_THRESHOLD`] → 自动 open_circuit
299    ///
300    /// caller 可选用 v1 (只 ++counter) 或 v2 (++ + 触发 trip). polling loop
301    /// 实装应该用 v2.
302    pub fn record_relogin_failure_with_circuit_check(&self) {
303        self.record_relogin_failure();
304        let failures = self.consecutive_relogin_failures.load(Ordering::Relaxed);
305        if failures >= RELOGIN_CIRCUIT_TRIP_THRESHOLD
306            && !self.relogin_circuit_open.load(Ordering::Acquire)
307        {
308            self.open_circuit();
309            tracing::warn!(
310                consecutive_failures = failures,
311                cooldown_ms = RELOGIN_CIRCUIT_COOLDOWN_MS,
312                "v1.4.92 P1-D: relogin circuit OPEN ({} consecutive failures), \
313                 skipping all attempts for cooldown window",
314                failures
315            );
316        }
317    }
318
319    /// Snapshot for `/api/push-subscriber-info` extension or future
320    /// `/api/admin/qot-login-health` endpoint.
321    pub fn snapshot(&self) -> QotLoginHealthSnapshot {
322        QotLoginHealthSnapshot {
323            last_qot_logined_false_at_ms: self.last_qot_logined_false_at_ms.load(Ordering::Relaxed),
324            last_relogin_attempt_at_ms: self.last_relogin_attempt_at_ms.load(Ordering::Relaxed),
325            last_relogin_success_at_ms: self.last_relogin_success_at_ms.load(Ordering::Relaxed),
326            consecutive_relogin_failures: self.consecutive_relogin_failures.load(Ordering::Relaxed),
327            total_relogin_attempts: self.total_relogin_attempts.load(Ordering::Relaxed),
328            total_relogin_successes: self.total_relogin_successes.load(Ordering::Relaxed),
329            total_qot_logined_false_observed: self
330                .total_qot_logined_false_observed
331                .load(Ordering::Relaxed),
332            current_backoff_ms: self.relogin_backoff_ms(),
333            // v1.4.92 P1-D Tier A2: state machine snapshot fields
334            relogin_attempt_in_flight: self.relogin_attempt_in_flight.load(Ordering::Relaxed),
335            relogin_circuit_open: self.relogin_circuit_open.load(Ordering::Relaxed),
336            relogin_circuit_opened_at_ms: self.relogin_circuit_opened_at_ms.load(Ordering::Relaxed),
337        }
338    }
339}
340
341/// v1.4.90 P1-D structured snapshot for REST exposure.
342///
343/// **v1.4.91 P1-D wiring**: REST handler `/api/push-subscriber-info` 已暴露
344/// 此 snapshot 在 `qot_login_health` 字段下 (合并入 push_health snapshot).
345#[derive(Debug, Clone, serde::Serialize)]
346pub struct QotLoginHealthSnapshot {
347    pub last_qot_logined_false_at_ms: i64,
348    pub last_relogin_attempt_at_ms: i64,
349    pub last_relogin_success_at_ms: i64,
350    pub consecutive_relogin_failures: u32,
351    pub total_relogin_attempts: u64,
352    pub total_relogin_successes: u64,
353    pub total_qot_logined_false_observed: u64,
354    pub current_backoff_ms: i64,
355    /// v1.4.92 P1-D Tier A2: 当前是否有 in-flight relogin attempt.
356    pub relogin_attempt_in_flight: bool,
357    /// v1.4.92 P1-D Tier A2: circuit breaker 是否 open (跳所有 attempt).
358    pub relogin_circuit_open: bool,
359    /// v1.4.92 P1-D Tier A2: circuit open 起始时间 (Unix ms; 0 = 未 open).
360    pub relogin_circuit_opened_at_ms: i64,
361}
362
363/// 共享指针 — bridge spawn 30s polling task + REST handler 共享.
364///
365/// **v1.4.91 P1-D wiring**: bridge `tokio::spawn run_qot_login_health_loop`
366/// + REST `/api/push-subscriber-info` provider closure 都已使用此别名.
367pub type SharedQotLoginHealth = Arc<QotLoginHealth>;