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>;