futu_gateway_core/bridge/push_health/
push.rs1use std::collections::HashMap;
31use std::sync::Arc;
32use std::sync::Mutex;
33use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
34
35mod f2;
36
37use super::snapshot::{PushHealthSnapshot, PushSubTypeWatermark};
38use f2::F2AccState;
39#[cfg(test)]
40pub(super) use f2::{F2_COOLDOWN_MS, F2_TRIP_THRESHOLD};
41
42pub const RELOGIN_CIRCUIT_TRIP_THRESHOLD: u32 = 5;
44pub const RELOGIN_CIRCUIT_COOLDOWN_MS: i64 = 600_000;
47
48#[derive(Debug)]
61pub struct PushHealth {
62 last_push_received_at_ms: AtomicI64,
64 last_push_by_sub_type: Mutex<HashMap<i32, i64>>,
70 consecutive_parse_errors: AtomicU32,
72 total_parse_errors: AtomicU64,
74 total_pushes_received: AtomicU64,
76 resubscribe_triggers: AtomicU64,
78 circuit_breaker_tripped_at_ms: AtomicI64,
80 circuit_breaker_trips: AtomicU64,
82 orphan_orders_detected: AtomicU64,
84 last_orphan_scan_at_ms: AtomicI64,
86 last_resubscribe_at_ms: AtomicI64,
88 is_circuit_tripped: AtomicBool,
90
91 f2_circuit_tripped_total: AtomicU64,
94
95 f2_acc_state: Mutex<HashMap<u32, F2AccState>>,
98
99 trade_requery_queue_dropped_total: AtomicU64,
108
109 last_trade_requery_drop_at_ms: AtomicI64,
114
115 last_resubscribe_partial_at_ms: AtomicI64,
119 resubscribe_partial_total: AtomicU64,
123 last_missed_keys_count: AtomicU32,
126}
127
128impl Default for PushHealth {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134impl PushHealth {
135 pub fn new() -> Self {
136 Self {
137 last_push_received_at_ms: AtomicI64::new(0),
138 last_push_by_sub_type: Mutex::new(HashMap::new()),
139 consecutive_parse_errors: AtomicU32::new(0),
140 total_parse_errors: AtomicU64::new(0),
141 total_pushes_received: AtomicU64::new(0),
142 resubscribe_triggers: AtomicU64::new(0),
143 circuit_breaker_tripped_at_ms: AtomicI64::new(0),
144 circuit_breaker_trips: AtomicU64::new(0),
145 orphan_orders_detected: AtomicU64::new(0),
146 last_orphan_scan_at_ms: AtomicI64::new(0),
147 last_resubscribe_at_ms: AtomicI64::new(0),
148 is_circuit_tripped: AtomicBool::new(false),
149 f2_circuit_tripped_total: AtomicU64::new(0),
150 f2_acc_state: Mutex::new(HashMap::new()),
151 trade_requery_queue_dropped_total: AtomicU64::new(0),
152 last_trade_requery_drop_at_ms: AtomicI64::new(0),
153 last_resubscribe_partial_at_ms: AtomicI64::new(0),
154 resubscribe_partial_total: AtomicU64::new(0),
155 last_missed_keys_count: AtomicU32::new(0),
156 }
157 }
158
159 pub fn record_push_success(&self) {
165 let now_ms = unix_now_ms();
166 self.last_push_received_at_ms
167 .store(now_ms, Ordering::Relaxed);
168 self.consecutive_parse_errors.store(0, Ordering::Relaxed);
169 self.total_pushes_received.fetch_add(1, Ordering::Relaxed);
170 if self.is_circuit_tripped.load(Ordering::Relaxed) {
172 let tripped_at = self.circuit_breaker_tripped_at_ms.load(Ordering::Relaxed);
173 if tripped_at > 0 && now_ms.saturating_sub(tripped_at) > 30_000 {
174 self.is_circuit_tripped.store(false, Ordering::Relaxed);
176 }
177 }
178 }
179
180 pub fn record_qot_push_sub_type(&self, sub_type: i32) {
188 let now_ms = unix_now_ms();
189 let mut guard = match self.last_push_by_sub_type.lock() {
190 Ok(guard) => guard,
191 Err(poisoned) => poisoned.into_inner(),
192 };
193 guard.insert(sub_type, now_ms);
194 }
195
196 #[cfg(test)]
197 pub(super) fn set_consecutive_parse_errors_for_test(&self, value: u32) {
198 self.consecutive_parse_errors
199 .store(value, Ordering::Relaxed);
200 }
201
202 #[cfg(test)]
203 pub(super) fn set_circuit_breaker_tripped_at_ms_for_test(&self, value: i64) {
204 self.circuit_breaker_tripped_at_ms
205 .store(value, Ordering::Relaxed);
206 }
207
208 #[cfg(test)]
209 pub(super) fn set_last_resubscribe_partial_at_ms_for_test(&self, value: i64) {
210 self.last_resubscribe_partial_at_ms
211 .store(value, Ordering::Relaxed);
212 }
213
214 #[cfg(test)]
215 pub(super) fn set_last_push_received_at_ms_for_test(&self, value: i64) {
216 self.last_push_received_at_ms
217 .store(value, Ordering::Relaxed);
218 }
219
220 #[cfg(test)]
221 pub(super) fn set_trade_requery_drop_state_for_test(&self, total: u64, last_drop_ms: i64) {
222 self.trade_requery_queue_dropped_total
223 .store(total, Ordering::Relaxed);
224 self.last_trade_requery_drop_at_ms
225 .store(last_drop_ms, Ordering::Relaxed);
226 }
227
228 pub fn record_parse_error(&self) -> u32 {
231 self.total_parse_errors.fetch_add(1, Ordering::Relaxed);
232 self.consecutive_parse_errors
233 .fetch_add(1, Ordering::Relaxed)
234 + 1
235 }
236
237 pub fn record_resubscribe(&self) {
239 let now_ms = unix_now_ms();
240 self.resubscribe_triggers.fetch_add(1, Ordering::Relaxed);
241 self.last_resubscribe_at_ms.store(now_ms, Ordering::Relaxed);
242 self.consecutive_parse_errors.store(0, Ordering::Relaxed);
244 }
245
246 pub fn record_resubscribe_partial(&self, missed_keys: u32) {
250 let now_ms = unix_now_ms();
251 self.last_resubscribe_partial_at_ms
252 .store(now_ms, Ordering::Relaxed);
253 self.resubscribe_partial_total
254 .fetch_add(1, Ordering::Relaxed);
255 self.last_missed_keys_count
256 .store(missed_keys, Ordering::Relaxed);
257 }
258
259 pub fn trip_circuit(&self) {
261 let now_ms = unix_now_ms();
262 self.circuit_breaker_tripped_at_ms
263 .store(now_ms, Ordering::Relaxed);
264 self.circuit_breaker_trips.fetch_add(1, Ordering::Relaxed);
265 self.is_circuit_tripped.store(true, Ordering::Relaxed);
266 }
267
268 pub fn is_circuit_tripped_now(&self) -> bool {
271 self.is_circuit_tripped.load(Ordering::Relaxed)
272 }
273
274 pub fn should_skip_dispatch(&self) -> bool {
278 if !self.is_circuit_tripped.load(Ordering::Relaxed) {
279 return false;
280 }
281 let tripped_at = self.circuit_breaker_tripped_at_ms.load(Ordering::Relaxed);
282 if tripped_at == 0 {
283 self.is_circuit_tripped.store(false, Ordering::Relaxed);
284 return false;
285 }
286 let now_ms = unix_now_ms();
287 if now_ms.saturating_sub(tripped_at) > 30_000 {
288 self.is_circuit_tripped.store(false, Ordering::Relaxed);
290 false
291 } else {
292 true
293 }
294 }
295
296 pub fn record_orphan_scan(&self, orphan_count: u64) {
298 let now_ms = unix_now_ms();
299 self.last_orphan_scan_at_ms.store(now_ms, Ordering::Relaxed);
300 if orphan_count > 0 {
301 self.orphan_orders_detected
302 .fetch_add(orphan_count, Ordering::Relaxed);
303 }
304 }
305
306 pub fn record_trade_requery_dropped(&self, reason: &'static str, notice_type: u32) -> u64 {
316 let now_ms = unix_now_ms();
317 self.last_trade_requery_drop_at_ms
318 .store(now_ms, Ordering::Relaxed);
319 let n = self
320 .trade_requery_queue_dropped_total
321 .fetch_add(1, Ordering::Relaxed)
322 + 1;
323 if n == 1 || n.is_multiple_of(100) {
325 tracing::warn!(
326 reason,
327 notice_type,
328 dropped_total = n,
329 "v1.4.106 audit 0932 F1: TradeReQuery dropped (queue full / closed) — \
330 backend notify lost; downstream cache may stale until next push or \
331 manual re-query"
332 );
333 } else {
334 tracing::debug!(
335 reason,
336 notice_type,
337 dropped_total = n,
338 "v1.4.106 audit 0932 F1: TradeReQuery drop (suppressed warn cadence)"
339 );
340 }
341 n
342 }
343
344 pub fn is_healthy(&self) -> bool {
354 if self.is_circuit_tripped.load(Ordering::Relaxed) {
355 return false;
356 }
357 if self.consecutive_parse_errors.load(Ordering::Relaxed) >= 5 {
358 return false;
359 }
360 let last_drop = self.last_trade_requery_drop_at_ms.load(Ordering::Relaxed);
362 if last_drop != 0 {
363 let now_ms = unix_now_ms();
364 if now_ms.saturating_sub(last_drop) < 60_000 {
365 return false;
366 }
367 }
368 let last_partial = self.last_resubscribe_partial_at_ms.load(Ordering::Relaxed);
371 if last_partial != 0 {
372 let now_ms = unix_now_ms();
373 if now_ms.saturating_sub(last_partial) < 60_000 {
374 return false;
375 }
376 }
377 let last = self.last_push_received_at_ms.load(Ordering::Relaxed);
378 if last == 0 {
379 return true;
381 }
382 let now_ms = unix_now_ms();
383 now_ms.saturating_sub(last) < 60_000
385 }
386
387 pub fn compute_degraded_reason(&self) -> Option<String> {
398 if self.is_circuit_tripped.load(Ordering::Relaxed) {
399 return Some("circuit_breaker tripped".to_string());
400 }
401 let pe = self.consecutive_parse_errors.load(Ordering::Relaxed);
402 if pe >= 5 {
403 return Some(format!("consecutive parse errors >= 5 (current={pe})"));
404 }
405 let last_drop = self.last_trade_requery_drop_at_ms.load(Ordering::Relaxed);
406 if last_drop != 0 {
407 let now_ms = unix_now_ms();
408 if now_ms.saturating_sub(last_drop) < 60_000 {
409 return Some("trade requery queue dropped within 60s (backpressure)".to_string());
410 }
411 }
412 let last_partial = self.last_resubscribe_partial_at_ms.load(Ordering::Relaxed);
413 if last_partial != 0 {
414 let now_ms = unix_now_ms();
415 if now_ms.saturating_sub(last_partial) < 60_000 {
416 let missed = self.last_missed_keys_count.load(Ordering::Relaxed);
417 return Some(format!(
418 "resubscribe partial failure within 60s (missed={missed})"
419 ));
420 }
421 }
422 let last_push = self.last_push_received_at_ms.load(Ordering::Relaxed);
423 if last_push == 0 {
424 return None;
426 }
427 let now_ms = unix_now_ms();
428 if now_ms.saturating_sub(last_push) >= 60_000 {
429 let stale_secs = (now_ms.saturating_sub(last_push)) / 1000;
430 return Some(format!("no push received within 60s (stale={stale_secs}s)"));
431 }
432 None
433 }
434
435 pub fn snapshot(&self) -> PushHealthSnapshot {
437 let mut last_push_by_sub_type: Vec<PushSubTypeWatermark> = {
438 let guard = match self.last_push_by_sub_type.lock() {
439 Ok(guard) => guard,
440 Err(poisoned) => poisoned.into_inner(),
441 };
442 guard
443 .iter()
444 .map(
445 |(&sub_type, &last_push_received_at_ms)| PushSubTypeWatermark {
446 sub_type,
447 last_push_received_at_ms,
448 },
449 )
450 .collect()
451 };
452 last_push_by_sub_type.sort_by_key(|item| item.sub_type);
453 PushHealthSnapshot {
454 push_stream_healthy: self.is_healthy(),
455 last_push_received_at_ms: self.last_push_received_at_ms.load(Ordering::Relaxed),
456 last_push_by_sub_type,
457 consecutive_parse_errors: self.consecutive_parse_errors.load(Ordering::Relaxed),
458 total_parse_errors: self.total_parse_errors.load(Ordering::Relaxed),
459 total_pushes_received: self.total_pushes_received.load(Ordering::Relaxed),
460 resubscribe_triggers: self.resubscribe_triggers.load(Ordering::Relaxed),
461 circuit_breaker_trips: self.circuit_breaker_trips.load(Ordering::Relaxed),
462 circuit_breaker_tripped_at_ms: self
463 .circuit_breaker_tripped_at_ms
464 .load(Ordering::Relaxed),
465 is_circuit_tripped_now: self.is_circuit_tripped.load(Ordering::Relaxed),
466 orphan_orders_detected: self.orphan_orders_detected.load(Ordering::Relaxed),
467 last_orphan_scan_at_ms: self.last_orphan_scan_at_ms.load(Ordering::Relaxed),
468 last_resubscribe_at_ms: self.last_resubscribe_at_ms.load(Ordering::Relaxed),
469 f2_circuit_tripped_total: self.f2_circuit_tripped_total.load(Ordering::Relaxed),
470 trade_requery_queue_dropped_total: self
471 .trade_requery_queue_dropped_total
472 .load(Ordering::Relaxed),
473 last_trade_requery_drop_at_ms: self
474 .last_trade_requery_drop_at_ms
475 .load(Ordering::Relaxed),
476 last_resubscribe_partial_at_ms: self
477 .last_resubscribe_partial_at_ms
478 .load(Ordering::Relaxed),
479 resubscribe_partial_total: self.resubscribe_partial_total.load(Ordering::Relaxed),
480 last_missed_keys_count: self.last_missed_keys_count.load(Ordering::Relaxed),
481 degraded_reason: self.compute_degraded_reason(),
482 }
483 }
484
485 #[must_use]
490 pub fn render_prometheus(&self) -> String {
491 self.snapshot().render_prometheus()
492 }
493}
494
495pub type SharedPushHealth = Arc<PushHealth>;
497
498pub fn unix_now_ms() -> i64 {
499 crate::bridge::clock::wall_clock_since_unix_epoch_or_zero("push health unix_now_ms").as_millis()
500 as i64
501}
502
503pub fn parse_relogin_backoff_env(raw: Option<String>) -> [u64; 4] {
514 const DEFAULT: [u64; 4] = [60_000, 120_000, 240_000, 600_000];
515 let Some(s) = raw else { return DEFAULT };
516 let s_trim = s.trim();
517 if s_trim.is_empty() {
518 return DEFAULT;
519 }
520 let parts: Vec<&str> = s_trim.split(',').map(str::trim).collect();
521 if parts.len() != 4 {
522 tracing::warn!(
523 raw = %s_trim,
524 count = parts.len(),
525 "v1.4.97 P1-D-B: FUTU_QOT_RELOGIN_BACKOFF_MS expects 4 comma-separated u64 ms values, using default [60000, 120000, 240000, 600000]"
526 );
527 return DEFAULT;
528 }
529 let mut out = [0u64; 4];
530 for (i, p) in parts.iter().enumerate() {
531 match p.parse::<u64>() {
532 Ok(v) if v > 0 => out[i] = v,
533 _ => {
534 tracing::warn!(
535 raw = %s_trim,
536 index = i,
537 bad_value = *p,
538 "v1.4.97 P1-D-B: FUTU_QOT_RELOGIN_BACKOFF_MS invalid value at index {i}, using default",
539 );
540 return DEFAULT;
541 }
542 }
543 }
544 out
545}