@@ -17,12 +17,70 @@ def _matches(name: str | None, needle: str) -> bool:
1717 return needle .lower () in name .lower ()
1818
1919
20+ def _burst_update_interval_s (schedule_min : list [int ], update_count : int ) -> int :
21+ """Pick the next update interval (seconds) for a burst.
22+
23+ The schedule is consumed in order; once past the end, the final value
24+ is reused indefinitely (the cap). Defaults give 15min → 30min → 1h → 2h
25+ and then 2h forever.
26+ """
27+ if not schedule_min :
28+ return 120 * 60
29+ idx = min (update_count , len (schedule_min ) - 1 )
30+ return max (int (schedule_min [idx ]), 1 ) * 60
31+
32+
2033def check_missed_blocks (
2134 dora : DoraClient ,
2235 notifier : Notifier ,
2336 cfg : Config ,
2437 state : State ,
2538) -> None :
39+ now = time .time ()
40+ window_s = max (cfg .missed_burst_window_minutes , 1 ) * 60
41+ threshold = max (cfg .missed_burst_threshold , 1 )
42+
43+ # 1. Age out the recent-misses window for every known proposer; resolve
44+ # any burst whose window is now empty; fire backoff updates for the rest.
45+ for proposer in list (state .missed_recent .keys ()):
46+ cutoff = now - window_s
47+ kept = [pair for pair in state .missed_recent [proposer ] if pair [0 ] >= cutoff ]
48+ if kept :
49+ state .missed_recent [proposer ] = kept
50+ else :
51+ del state .missed_recent [proposer ]
52+
53+ for proposer in list (state .burst_state .keys ()):
54+ burst = state .burst_state [proposer ]
55+ if proposer not in state .missed_recent :
56+ duration_min = max (int ((now - float (burst .get ("started_ts" , now ))) / 60 ), 0 )
57+ notifier .send (
58+ f":white_check_mark: *Missed-block burst resolved* — `{ proposer } `: "
59+ f"*{ int (burst .get ('total_misses' , 0 ))} * missed blocks over { duration_min } min "
60+ f"(first `{ int (burst .get ('first_slot' , 0 ))} `, last `{ int (burst .get ('last_slot' , 0 ))} `)"
61+ )
62+ del state .burst_state [proposer ]
63+ continue
64+ interval = _burst_update_interval_s (
65+ cfg .missed_burst_update_schedule_minutes ,
66+ int (burst .get ("update_count" , 0 )),
67+ )
68+ if now - float (burst .get ("last_update_ts" , 0.0 )) >= interval :
69+ burst ["last_update_ts" ] = now
70+ burst ["update_count" ] = int (burst .get ("update_count" , 0 )) + 1
71+ next_interval = _burst_update_interval_s (
72+ cfg .missed_burst_update_schedule_minutes ,
73+ int (burst ["update_count" ]),
74+ )
75+ next_min = next_interval // 60
76+ notifier .send (
77+ f":fire: *Missed-block burst continues* — `{ proposer } `: "
78+ f"*{ int (burst .get ('total_misses' , 0 ))} * missed blocks since start "
79+ f"(latest slot `{ int (burst .get ('last_slot' , 0 ))} `). "
80+ f"Next update in ~{ next_min } min."
81+ )
82+
83+ # 2. Process new missed / orphaned slots from Dora.
2684 slots = dora .slots (limit = cfg .slot_scan_limit , with_orphaned = 1 , with_missing = 1 )
2785 for s in slots :
2886 proposer_name = s .get ("proposer_name" ) or ""
@@ -32,9 +90,8 @@ def check_missed_blocks(
3290 status = (s .get ("status" ) or "" ).lower ()
3391 if status == "missing" and slot_num not in state .reported_missed_slots :
3492 state .reported_missed_slots .add (slot_num )
35- notifier .send (
36- f":warning: *Missed block* — slot `{ slot_num } ` "
37- f"(epoch { s .get ('epoch' )} ) proposer `{ proposer_name } ` (idx { s .get ('proposer' )} )"
93+ _handle_missed_slot (
94+ notifier , state , cfg , proposer_name , slot_num , s , now , window_s , threshold
3895 )
3996 elif status == "orphaned" and slot_num not in state .reported_orphan_slots :
4097 state .reported_orphan_slots .add (slot_num )
@@ -44,6 +101,57 @@ def check_missed_blocks(
44101 )
45102
46103
104+ def _handle_missed_slot (
105+ notifier : Notifier ,
106+ state : State ,
107+ cfg : Config ,
108+ proposer : str ,
109+ slot : int ,
110+ raw : dict ,
111+ now : float ,
112+ window_s : int ,
113+ threshold : int ,
114+ ) -> None :
115+ recent = state .missed_recent .setdefault (proposer , [])
116+ recent .append ([now , slot ])
117+ cutoff = now - window_s
118+ state .missed_recent [proposer ] = [pair for pair in recent if pair [0 ] >= cutoff ]
119+ recent = state .missed_recent [proposer ]
120+
121+ burst = state .burst_state .get (proposer )
122+ if burst :
123+ # Already in storm mode: accumulate counters, suppress per-slot alert.
124+ burst ["total_misses" ] = int (burst .get ("total_misses" , 0 )) + 1
125+ burst ["last_slot" ] = slot
126+ return
127+
128+ if len (recent ) >= threshold :
129+ slots_csv = ", " .join (f"`{ int (s )} `" for _ , s in sorted (recent , key = lambda p : p [1 ]))
130+ first_slot = min (int (s ) for _ , s in recent )
131+ first_interval_min = _burst_update_interval_s (
132+ cfg .missed_burst_update_schedule_minutes , 0
133+ ) // 60
134+ state .burst_state [proposer ] = {
135+ "started_ts" : now ,
136+ "last_update_ts" : now ,
137+ "first_slot" : first_slot ,
138+ "last_slot" : slot ,
139+ "total_misses" : len (recent ),
140+ "update_count" : 0 ,
141+ }
142+ notifier .send (
143+ f":fire: *Missed-block burst* — `{ proposer } `: *{ len (recent )} * missed blocks "
144+ f"in last { cfg .missed_burst_window_minutes } min — slots { slots_csv } . "
145+ f"Further per-miss alerts suppressed; next update in ~{ first_interval_min } min."
146+ )
147+ return
148+
149+ notifier .send (
150+ f":warning: *Missed block* — slot `{ slot } ` "
151+ f"(epoch { raw .get ('epoch' )} ) proposer `{ proposer } ` (idx { raw .get ('proposer' )} )"
152+ )
153+
154+
47155def check_client_head_forks (
48156 dora : DoraClient ,
49157 notifier : Notifier ,
0 commit comments