@@ -43,12 +43,14 @@ class HardwareHealthIndex:
4343 now_unix_ns : int
4444 stale_after_s : float
4545 snapshots : tuple [dict [str , Any ], ...] = ()
46+ missing_node_ranks : tuple [int , ...] = ()
4647
4748 @property
4849 def overall_status (self ) -> str :
4950 if not self .enabled :
5051 return "not_collected"
5152 statuses = [self ._snapshot_status (snapshot ) for snapshot in self .snapshots ]
53+ statuses .extend ("unavailable" for _ in self .missing_node_ranks )
5254 return _worst_status (statuses , "unavailable" )
5355
5456 def _snapshot_age_s (self , snapshot : dict [str , Any ]) -> float | None :
@@ -80,6 +82,18 @@ def summary(self) -> dict[str, Any]:
8082 "gpus" : snapshot .get ("gpus" , []),
8183 }
8284 )
85+ for node_rank in self .missing_node_ranks :
86+ nodes .append (
87+ {
88+ "node_rank" : node_rank ,
89+ "hostname" : None ,
90+ "status" : "unavailable" ,
91+ "sample_age_s" : None ,
92+ "source" : None ,
93+ "error" : "gpu_health_snapshot_not_generated" ,
94+ "gpus" : [],
95+ }
96+ )
8397 return {"enabled" : True , "status" : self .overall_status , "nodes" : nodes }
8498
8599 def for_rank (self , rank_event : dict [str , Any ]) -> dict [str , Any ]:
@@ -145,13 +159,22 @@ def _match_rank_gpu(
145159
146160class HardwareHealthReader :
147161 def __init__ (
148- self , heartbeat_dir : Path , run_id : str , enabled : bool , stale_after_s : float
162+ self ,
163+ heartbeat_dir : Path ,
164+ run_id : str ,
165+ enabled : bool ,
166+ stale_after_s : float ,
167+ expected_node_count : int = 0 ,
168+ monitor_started_unix_ns : int = 0 ,
149169 ) -> None :
150170 self .heartbeat_dir = heartbeat_dir
151171 self .run_id = run_id
152172 self .enabled = enabled
153173 self .stale_after_s = stale_after_s
174+ self .expected_node_count = expected_node_count
175+ self .monitor_started_unix_ns = monitor_started_unix_ns
154176 self ._reported : set [tuple [str , str , tuple [str , ...]]] = set ()
177+ self ._reported_missing_nodes : set [int ] = set ()
155178
156179 def poll (self , now_unix_ns : int ) -> tuple [HardwareHealthIndex , list [dict [str , Any ]]]:
157180 if not self .enabled :
@@ -164,8 +187,67 @@ def poll(self, now_unix_ns: int) -> tuple[HardwareHealthIndex, list[dict[str, An
164187 continue
165188 if isinstance (payload , dict ) and payload .get ("run_id" ) == self .run_id :
166189 snapshots .append (payload )
167- index = HardwareHealthIndex (True , now_unix_ns , self .stale_after_s , tuple (snapshots ))
168- return index , self ._new_findings (index , now_unix_ns )
190+ missing_node_ranks = self ._missing_node_ranks (snapshots , now_unix_ns )
191+ index = HardwareHealthIndex (
192+ True ,
193+ now_unix_ns ,
194+ self .stale_after_s ,
195+ tuple (snapshots ),
196+ missing_node_ranks ,
197+ )
198+ findings = self ._new_findings (index , now_unix_ns )
199+ findings .extend (self ._new_missing_findings (index , now_unix_ns ))
200+ return index , findings
201+
202+ def _missing_node_ranks (
203+ self , snapshots : list [dict [str , Any ]], now_unix_ns : int
204+ ) -> tuple [int , ...]:
205+ if self .expected_node_count <= 0 :
206+ return ()
207+ monitor_age_s = max (
208+ 0.0 ,
209+ (now_unix_ns - self .monitor_started_unix_ns ) / 1_000_000_000 ,
210+ )
211+ if monitor_age_s <= self .stale_after_s :
212+ return ()
213+ observed_node_ranks : set [int ] = set ()
214+ for snapshot in snapshots :
215+ try :
216+ node_rank = int (snapshot .get ("node_rank" ))
217+ except (TypeError , ValueError ):
218+ continue
219+ if 0 <= node_rank < self .expected_node_count :
220+ observed_node_ranks .add (node_rank )
221+ return tuple (
222+ node_rank
223+ for node_rank in range (self .expected_node_count )
224+ if node_rank not in observed_node_ranks
225+ )
226+
227+ def _new_missing_findings (
228+ self , index : HardwareHealthIndex , now_unix_ns : int
229+ ) -> list [dict [str , Any ]]:
230+ missing_node_ranks = set (index .missing_node_ranks )
231+ self ._reported_missing_nodes .intersection_update (missing_node_ranks )
232+ findings : list [dict [str , Any ]] = []
233+ for node_rank in index .missing_node_ranks :
234+ if node_rank in self ._reported_missing_nodes :
235+ continue
236+ self ._reported_missing_nodes .add (node_rank )
237+ findings .append (
238+ {
239+ "finding_type" : "gpu_health_snapshot_missing" ,
240+ "run_id" : self .run_id ,
241+ "detected_at_unix_ns" : now_unix_ns ,
242+ "node_rank" : node_rank ,
243+ "hostname" : None ,
244+ "gpu_device_health" : "unavailable" ,
245+ "expected_file" : f"gpu_health_node_{ node_rank } .json" ,
246+ "reason" : "gpu_health_snapshot_not_generated" ,
247+ "confidence" : "observed" ,
248+ }
249+ )
250+ return findings
169251
170252 def _new_findings (self , index : HardwareHealthIndex , now_unix_ns : int ) -> list [dict [str , Any ]]:
171253 findings : list [dict [str , Any ]] = []
0 commit comments