Coverage for python/lsst/ctrl/bps/htcondor/report_utils.py: 63%

295 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-31 01:53 -0700

1# This file is part of ctrl_bps_htcondor. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <https://www.gnu.org/licenses/>. 

27 

28"""Utility functions used for reporting.""" 

29 

30import logging 

31import os 

32import re 

33from pathlib import Path 

34from typing import Any 

35 

36from htcondor2 import JobStatus, Schedd 

37 

38from lsst.ctrl.bps import ( 

39 WmsJobReport, 

40 WmsRunReport, 

41 WmsSpecificInfo, 

42 WmsStates, 

43) 

44 

45from .common_utils import _htc_status_to_wms_state 

46from .lssthtc import ( 

47 MISSING_ID, 

48 WmsNodeType, 

49 condor_search, 

50 htc_check_dagman_output, 

51 htc_tweak_log_info, 

52 pegasus_name_to_label, 

53 read_dag_info, 

54 read_dag_log, 

55 read_dag_status, 

56 read_node_status, 

57 summarize_dag, 

58) 

59 

60_LOG = logging.getLogger(__name__) 

61 

62 

63def _get_status_from_id( 

64 wms_workflow_id: str, hist: float, schedds: dict[str, Schedd] 

65) -> tuple[WmsStates, str]: 

66 """Gather run information using workflow id. 

67 

68 Parameters 

69 ---------- 

70 wms_workflow_id : `str` 

71 Limit to specific run based on id. 

72 hist : `float` 

73 Limit history search to this many days. 

74 schedds : `dict` [ `str`, `htcondor.Schedd` ] 

75 HTCondor schedulers which to query for job information. If empty 

76 dictionary, all queries will be run against the local scheduler only. 

77 

78 Returns 

79 ------- 

80 state : `lsst.ctrl.bps.WmsStates` 

81 Status for the corresponding run. 

82 message : `str` 

83 Message with extra error information. 

84 """ 

85 _LOG.debug("_get_status_from_id: id=%s, hist=%s, schedds=%s", wms_workflow_id, hist, schedds) 

86 

87 message = "" 

88 

89 # Collect information about the job by querying HTCondor schedd and 

90 # HTCondor history. 

91 schedd_dag_info = _get_info_from_schedd(wms_workflow_id, hist, schedds, skip_history=True) 

92 if len(schedd_dag_info) == 1: 

93 schedd_name = next(iter(schedd_dag_info)) 

94 dag_id = next(iter(schedd_dag_info[schedd_name])) 

95 dag_ad = schedd_dag_info[schedd_name][dag_id] 

96 state = _htc_status_to_wms_state(dag_ad) 

97 else: 

98 state = WmsStates.UNKNOWN 

99 message = f"DAGMan job {wms_workflow_id} not found in queue or history. Check id or try path." 

100 return state, message 

101 

102 

103def _get_status_from_path(wms_path: str | os.PathLike) -> tuple[WmsStates, str]: 

104 """Gather run status from a given run directory. 

105 

106 Parameters 

107 ---------- 

108 wms_path : `str` | `os.PathLike` 

109 The directory containing the submit side files (e.g., HTCondor files). 

110 

111 Returns 

112 ------- 

113 state : `lsst.ctrl.bps.WmsStates` 

114 Status for the run. 

115 message : `str` 

116 Message to be printed. 

117 """ 

118 wms_path = Path(wms_path).resolve() 

119 message = "" 

120 dag_ad: dict[str, Any] = {} 

121 try: 

122 wms_workflow_id, dag_ad = read_dag_log(wms_path) 

123 except FileNotFoundError: 

124 wms_workflow_id = MISSING_ID 

125 message = f"DAGMan log not found in {wms_path}. Check path." 

126 

127 if wms_workflow_id == MISSING_ID: 

128 state = WmsStates.UNKNOWN 

129 else: 

130 htc_tweak_log_info(wms_path, dag_ad[wms_workflow_id]) 

131 state = _htc_status_to_wms_state(dag_ad[wms_workflow_id]) 

132 

133 return state, message 

134 

135 

136def _report_from_path(wms_path): 

137 """Gather run information from a given run directory. 

138 

139 Parameters 

140 ---------- 

141 wms_path : `str` 

142 The directory containing the submit side files (e.g., HTCondor files). 

143 

144 Returns 

145 ------- 

146 run_reports : `dict` [`str`, `lsst.ctrl.bps.WmsRunReport`] 

147 Run information for the detailed report. The key is the HTCondor id 

148 and the value is a collection of report information for that run. 

149 message : `str` 

150 Message to be printed with the summary report. 

151 """ 

152 wms_workflow_id, jobs, message = _get_info_from_path(wms_path) 

153 if wms_workflow_id == MISSING_ID: 

154 run_reports = {} 

155 else: 

156 run_reports = _create_detailed_report_from_jobs(wms_workflow_id, jobs) 

157 return run_reports, message 

158 

159 

160def _report_from_id(wms_workflow_id, hist, schedds=None): 

161 """Gather run information using workflow id. 

162 

163 Parameters 

164 ---------- 

165 wms_workflow_id : `str` 

166 Limit to specific run based on id. 

167 hist : `float` 

168 Limit history search to this many days. 

169 schedds : `dict` [ `str`, `htcondor.Schedd` ], optional 

170 HTCondor schedulers which to query for job information. If None 

171 (default), all queries will be run against the local scheduler only. 

172 

173 Returns 

174 ------- 

175 run_reports : `dict` [`str`, `lsst.ctrl.bps.WmsRunReport`] 

176 Run information for the detailed report. The key is the HTCondor id 

177 and the value is a collection of report information for that run. 

178 message : `str` 

179 Message to be printed with the summary report. 

180 """ 

181 messages = [] 

182 

183 # Collect information about the job by querying HTCondor schedd and 

184 # HTCondor history. 

185 schedd_dag_info = _get_info_from_schedd(wms_workflow_id, hist, schedds) 

186 if len(schedd_dag_info) == 1: 

187 # Extract the DAG info without altering the results of the query. 

188 schedd_name = next(iter(schedd_dag_info)) 

189 dag_id = next(iter(schedd_dag_info[schedd_name])) 

190 dag_ad = schedd_dag_info[schedd_name][dag_id] 

191 

192 # If the provided workflow id does not correspond to the one extracted 

193 # from the DAGMan log file in the submit directory, rerun the query 

194 # with the id found in the file. 

195 # 

196 # This is to cover the situation in which the user provided the old job 

197 # id of a restarted run. 

198 try: 

199 path_dag_id, _ = read_dag_log(dag_ad["Iwd"]) 

200 except FileNotFoundError as exc: 

201 # At the moment missing DAGMan log is pretty much a fatal error. 

202 # So empty the DAG info to finish early (see the if statement 

203 # below). 

204 schedd_dag_info.clear() 

205 messages.append(f"Cannot create the report for '{dag_id}': {exc}") 

206 else: 

207 if path_dag_id != dag_id: 

208 schedd_dag_info = _get_info_from_schedd(path_dag_id, hist, schedds) 

209 messages.append( 

210 f"WARNING: Found newer workflow executions in same submit directory as id '{dag_id}'. " 

211 "This normally occurs when a run is restarted. The report shown is for the most " 

212 f"recent status with run id '{path_dag_id}'" 

213 ) 

214 

215 if len(schedd_dag_info) == 0: 

216 run_reports = {} 

217 elif len(schedd_dag_info) == 1: 

218 _, dag_info = schedd_dag_info.popitem() 

219 dag_id, dag_ad = dag_info.popitem() 

220 

221 # Create a mapping between jobs and their classads. The keys will 

222 # be of format 'ClusterId.ProcId'. 

223 job_info = {dag_id: dag_ad} 

224 

225 # Find jobs (nodes) belonging to that DAGMan job. 

226 job_constraint = f"DAGManJobId == {int(float(dag_id))}" 

227 schedd_job_info = condor_search(constraint=job_constraint, hist=hist, schedds=schedds) 

228 if schedd_job_info: 

229 _, node_info = schedd_job_info.popitem() 

230 job_info.update(node_info) 

231 

232 # Collect additional pieces of information about jobs using HTCondor 

233 # files in the submission directory. 

234 _, path_jobs, message = _get_info_from_path(dag_ad["Iwd"]) 

235 _update_jobs(job_info, path_jobs) 

236 if message: 

237 messages.append(message) 

238 run_reports = _create_detailed_report_from_jobs(dag_id, job_info) 

239 else: 

240 ids = [ad["GlobalJobId"] for dag_info in schedd_dag_info.values() for ad in dag_info.values()] 

241 message = ( 

242 f"More than one job matches id '{wms_workflow_id}', " 

243 f"their global ids are: {', '.join(ids)}. Rerun with one of the global ids" 

244 ) 

245 messages.append(message) 

246 run_reports = {} 

247 

248 message = "\n".join(messages) 

249 return run_reports, message 

250 

251 

252def _get_info_from_schedd( 

253 wms_workflow_id: str, hist: float, schedds: dict[str, Schedd], skip_history: bool = False 

254) -> dict[str, dict[str, dict[str, Any]]]: 

255 """Gather run information from HTCondor. 

256 

257 Parameters 

258 ---------- 

259 wms_workflow_id : `str` 

260 Limit to specific run based on id. 

261 hist : `float` 

262 Limit history search to this many days. 

263 schedds : `dict` [ `str`, `htcondor.Schedd` ] 

264 HTCondor schedulers which to query for job information. If empty 

265 dictionary, all queries will be run against the local scheduler only. 

266 skip_history : `bool`, optional 

267 If True, skip searching history if searching the queue returns any 

268 information. Defaults to False. 

269 

270 Returns 

271 ------- 

272 schedd_dag_info : `dict` [`str`, `dict` [`str`, `dict` [`str` Any]]] 

273 Information about jobs satisfying the search criteria where for each 

274 Scheduler, local HTCondor job ids are mapped to their respective 

275 classads. 

276 """ 

277 _LOG.debug( 

278 "_get_info_from_schedd: id=%s, hist=%s, schedds=%s, skip_history=%s", 

279 wms_workflow_id, 

280 hist, 

281 schedds, 

282 skip_history, 

283 ) 

284 

285 dag_constraint = 'regexp("dagman$", Cmd)' 

286 try: 

287 cluster_id = int(float(wms_workflow_id)) 

288 except ValueError: 

289 dag_constraint += f' && GlobalJobId == "{wms_workflow_id}"' 

290 else: 

291 dag_constraint += f" && ClusterId == {cluster_id}" 

292 

293 # With the current implementation of the condor_* functions the query 

294 # will always return only one match per Scheduler. 

295 # 

296 # Even in the highly unlikely situation where HTCondor history (which 

297 # condor_search queries too) is long enough to have jobs from before 

298 # the cluster ids were rolled over (and as a result there is more then 

299 # one job with the same cluster id) they will not show up in 

300 # the results. 

301 schedd_dag_info = condor_search( 

302 constraint=dag_constraint, hist=hist, schedds=schedds, skip_history=skip_history 

303 ) 

304 return schedd_dag_info 

305 

306 

307def _get_info_from_path(wms_path: str | os.PathLike) -> tuple[str, dict[str, dict[str, Any]], str]: 

308 """Gather run information from a given run directory. 

309 

310 Parameters 

311 ---------- 

312 wms_path : `str` or `os.PathLike` 

313 Directory containing HTCondor files. 

314 

315 Returns 

316 ------- 

317 wms_workflow_id : `str` 

318 The run id which is a DAGman job id. 

319 jobs : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

320 Information about jobs read from files in the given directory. 

321 The key is the HTCondor id and the value is a dictionary of HTCondor 

322 keys and values. 

323 message : `str` 

324 Message to be printed with the summary report. 

325 """ 

326 # Ensure path is absolute, in particular for folks helping 

327 # debug failures that need to dig around submit files. 

328 wms_path = Path(wms_path).resolve() 

329 

330 messages = [] 

331 try: 

332 wms_workflow_id, jobs = read_dag_log(wms_path) 

333 _LOG.debug("_get_info_from_path: from dag log %s = %s", wms_workflow_id, jobs) 

334 _update_jobs(jobs, read_node_status(wms_path)) 

335 _LOG.debug("_get_info_from_path: after node status %s = %s", wms_workflow_id, jobs) 

336 

337 # Add more info for DAGman job 

338 job = jobs[wms_workflow_id] 

339 job.update(read_dag_status(wms_path)) 

340 

341 job["total_jobs"], job["state_counts"] = _get_state_counts_from_jobs(wms_workflow_id, jobs) 

342 if "bps_run" not in job: 342 ↛ 345line 342 didn't jump to line 345 because the condition on line 342 was always true

343 _add_run_info(wms_path, job) 

344 

345 message = htc_check_dagman_output(wms_path) 

346 if message: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true

347 messages.append(message) 

348 _LOG.debug( 

349 "_get_info: id = %s, total_jobs = %s", wms_workflow_id, jobs[wms_workflow_id]["total_jobs"] 

350 ) 

351 

352 # Add extra pieces of information which cannot be found in HTCondor 

353 # generated files like 'GlobalJobId'. 

354 # 

355 # Do not treat absence of this file as a serious error. Neither runs 

356 # submitted with earlier versions of the plugin nor the runs submitted 

357 # with Pegasus plugin will have it at the moment. However, once enough 

358 # time passes and Pegasus plugin will have its own report() method 

359 # (instead of sneakily using HTCondor's one), the lack of that file 

360 # should be treated as seriously as lack of any other file. 

361 try: 

362 _, job_info = read_dag_info(wms_path) 

363 except FileNotFoundError as exc: 

364 message = f"Warn: Some information may not be available: {exc}" 

365 messages.append(message) 

366 else: 

367 schedd_name = next(iter(job_info)) 

368 job_ad = next(iter(job_info[schedd_name].values())) 

369 job.update(job_ad) 

370 except FileNotFoundError as err: 

371 message = f"Could not find HTCondor files in '{wms_path}' ({err})" 

372 _LOG.debug(message) 

373 messages.append(message) 

374 try: 

375 message = htc_check_dagman_output(wms_path) 

376 except FileNotFoundError as err: 

377 message = ( 

378 f"Could not find DAGMan standard output file in '{wms_path}'.\n" 

379 "Check that path is a valid HTCondor submission directory.\n" 

380 "Check that the condor_dagman executable path is accessible from the AP machine." 

381 ) 

382 if message: 

383 messages.append(message) 

384 wms_workflow_id = MISSING_ID 

385 jobs = {} 

386 

387 # Add more condor_q-like info. 

388 for job in jobs.values(): 

389 htc_tweak_log_info(wms_path, job) 

390 

391 message = "\n".join([msg for msg in messages if msg]) 

392 _LOG.debug("wms_workflow_id = %s, jobs = %s", wms_workflow_id, jobs.keys()) 

393 _LOG.debug("message = %s", message) 

394 return wms_workflow_id, jobs, message 

395 

396 

397def _create_detailed_report_from_jobs( 

398 wms_workflow_id: str, jobs: dict[str, dict[str, Any]] 

399) -> dict[str, WmsRunReport]: 

400 """Gather run information to be used in generating summary reports. 

401 

402 Parameters 

403 ---------- 

404 wms_workflow_id : `str` 

405 The run id to create the report for. 

406 jobs : `dict` [`str`, `dict` [`str`, Any]] 

407 Mapping HTCondor job id to job information. 

408 

409 Returns 

410 ------- 

411 run_reports : `dict` [`str`, `lsst.ctrl.bps.WmsRunReport`] 

412 Run information for the detailed report. The key is the given HTCondor 

413 id and the value is a collection of report information for that run. 

414 """ 

415 _LOG.debug("_create_detailed_report: id = %s, job = %s", wms_workflow_id, jobs[wms_workflow_id]) 

416 

417 dag_ad = jobs[wms_workflow_id] 

418 

419 report = WmsRunReport( 

420 wms_id=f"{dag_ad['ClusterId']}.{dag_ad['ProcId']}", 

421 global_wms_id=dag_ad.get("GlobalJobId", "MISS"), 

422 path=dag_ad["Iwd"], 

423 label=dag_ad.get("bps_job_label", "MISS"), 

424 run=dag_ad.get("bps_run", "MISS"), 

425 site=dag_ad.get("bps_runsite", ""), 

426 project=dag_ad.get("bps_project", "MISS"), 

427 campaign=dag_ad.get("bps_campaign", "MISS"), 

428 payload=dag_ad.get("bps_payload", "MISS"), 

429 operator=_get_owner(dag_ad), 

430 run_summary=_get_run_summary(dag_ad), 

431 state=_htc_status_to_wms_state(dag_ad), 

432 total_number_jobs=0, 

433 jobs=[], 

434 job_state_counts=dict.fromkeys(WmsStates, 0), 

435 exit_code_summary={}, 

436 ) 

437 

438 payload_jobs = {} # keep track for later processing 

439 specific_info = WmsSpecificInfo() 

440 for job_id, job_ad in jobs.items(): 

441 if job_ad.get("wms_node_type", WmsNodeType.UNKNOWN) in [WmsNodeType.PAYLOAD, WmsNodeType.FINAL]: 

442 try: 

443 name = job_ad.get("DAGNodeName", job_id) 

444 wms_state = _htc_status_to_wms_state(job_ad) 

445 job_report = WmsJobReport( 

446 wms_id=job_id, 

447 name=name, 

448 label=job_ad.get("bps_job_label", pegasus_name_to_label(name)), 

449 state=wms_state, 

450 ) 

451 if job_report.label == "init": 451 ↛ 452line 451 didn't jump to line 452 because the condition on line 451 was never true

452 job_report.label = "pipetaskInit" 

453 report.job_state_counts[wms_state] += 1 

454 report.jobs.append(job_report) 

455 payload_jobs[job_id] = job_ad 

456 except KeyError as ex: 

457 _LOG.error("Job missing key '%s': %s", str(ex), job_ad) 

458 raise 

459 elif is_service_job(job_ad): 

460 _LOG.debug( 

461 "Found service job: id='%s', name='%s', label='%s', NodeStatus='%s', JobStatus='%s'", 

462 job_id, 

463 job_ad["DAGNodeName"], 

464 job_ad.get("bps_job_label", "MISS"), 

465 job_ad.get("NodeStatus", "MISS"), 

466 job_ad.get("JobStatus", "MISS"), 

467 ) 

468 _add_service_job_specific_info(job_ad, specific_info) 

469 

470 report.total_number_jobs = len(payload_jobs) 

471 report.exit_code_summary = _get_exit_code_summary(payload_jobs) 

472 if specific_info: 

473 report.specific_info = specific_info 

474 

475 # Workflow will exit with non-zero DAG_STATUS if problem with 

476 # any of the wms jobs. So change FAILED to SUCCEEDED if all 

477 # payload jobs SUCCEEDED. 

478 if report.total_number_jobs == report.job_state_counts[WmsStates.SUCCEEDED]: 

479 report.state = WmsStates.SUCCEEDED 

480 

481 run_reports = {report.wms_id: report} 

482 _LOG.debug("_create_detailed_report: run_reports = %s", run_reports) 

483 return run_reports 

484 

485 

486def _add_service_job_specific_info(job_ad: dict[str, Any], specific_info: WmsSpecificInfo) -> None: 

487 """Generate report information for service job. 

488 

489 Parameters 

490 ---------- 

491 job_ad : `dict` [`str`, `~typing.Any`] 

492 Provisioning job information. 

493 specific_info : `lsst.ctrl.bps.WmsSpecificInfo` 

494 Where to add message. 

495 """ 

496 status_details = "" 

497 job_status = _htc_status_to_wms_state(job_ad) 

498 

499 # Service jobs in queue are deleted when DAG is done. 

500 # To get accurate status, need to check other info. 

501 if ( 

502 job_status == WmsStates.DELETED 

503 and "Reason" in job_ad 

504 and ( 

505 "Removed by DAGMan" in job_ad["Reason"] 

506 or "removed because <OtherJobRemoveRequirements = DAGManJobId =?=" in job_ad["Reason"] 

507 or "DAG is exiting and writing rescue file." in job_ad["Reason"] 

508 ) 

509 ): 

510 if "HoldReason" in job_ad: 

511 # HoldReason exists even if released, so check. 

512 if "job_released_time" in job_ad and job_ad["job_held_time"] < job_ad["job_released_time"]: 

513 # If released, assume running until deleted. 

514 job_status = WmsStates.SUCCEEDED 

515 status_details = "" 

516 else: 

517 # If job held when deleted by DAGMan, still want to 

518 # report hold reason 

519 status_details = f"(Job was held for the following reason: {job_ad['HoldReason']})" 

520 

521 else: 

522 job_status = WmsStates.SUCCEEDED 

523 elif job_status == WmsStates.SUCCEEDED: 

524 status_details = "(Note: Finished before workflow.)" 

525 elif job_status == WmsStates.HELD: 

526 status_details = f"({job_ad['HoldReason']})" 

527 

528 template = "Status of {job_name}: {status} {status_details}" 

529 context = { 

530 "job_name": job_ad["DAGNodeName"], 

531 "status": job_status.name, 

532 "status_details": status_details, 

533 } 

534 specific_info.add_message(template=template, context=context) 

535 

536 

537def _summary_report(user, hist, pass_thru, schedds=None): 

538 """Gather run information to be used in generating summary reports. 

539 

540 Parameters 

541 ---------- 

542 user : `str` 

543 Run lookup restricted to given user. 

544 hist : `float` 

545 How many previous days to search for run information. 

546 pass_thru : `str` 

547 Advanced users can define the HTCondor constraint to be used 

548 when searching queue and history. 

549 

550 Returns 

551 ------- 

552 run_reports : `dict` [`str`, `lsst.ctrl.bps.WmsRunReport`] 

553 Run information for the summary report. The keys are HTCondor ids and 

554 the values are collections of report information for each run. 

555 message : `str` 

556 Message to be printed with the summary report. 

557 """ 

558 # only doing summary report so only look for dagman jobs 

559 if pass_thru: 

560 constraint = pass_thru 

561 else: 

562 # Notes: 

563 # * bps_isjob == 'True' isn't getting set for DAG jobs that are 

564 # manually restarted. 

565 # * Any job with DAGManJobID isn't a DAG job 

566 constraint = 'bps_isjob == "True" && JobUniverse == 7 && DAGManJobID =?= Undefined' 

567 if user: 

568 constraint += f' && (Owner == "{user}" || bps_operator == "{user}")' 

569 

570 job_info = condor_search(constraint=constraint, hist=hist, schedds=schedds) 

571 

572 # Have list of DAGMan jobs, need to get run_report info. 

573 run_reports = {} 

574 msg = "" 

575 for jobs in job_info.values(): 

576 for job_id, job in jobs.items(): 

577 total_jobs, state_counts = _get_state_counts_from_dag_job(job) 

578 # If didn't get from queue information (e.g., Kerberos bug), 

579 # try reading from file. 

580 if total_jobs == 0: 

581 try: 

582 job.update(read_dag_status(job["Iwd"])) 

583 total_jobs, state_counts = _get_state_counts_from_dag_job(job) 

584 except (StopIteration, FileNotFoundError): 

585 pass # don't kill report can't find htcondor files 

586 

587 if "bps_run" not in job: 

588 _add_run_info(job["Iwd"], job) 

589 report = WmsRunReport( 

590 wms_id=job_id, 

591 global_wms_id=job["GlobalJobId"], 

592 path=job["Iwd"], 

593 label=job.get("bps_job_label", "MISS"), 

594 run=job.get("bps_run", "MISS"), 

595 project=job.get("bps_project", "MISS"), 

596 campaign=job.get("bps_campaign", "MISS"), 

597 payload=job.get("bps_payload", "MISS"), 

598 site=job.get("bps_runsite", ""), 

599 operator=_get_owner(job), 

600 run_summary=_get_run_summary(job), 

601 state=_htc_status_to_wms_state(job), 

602 jobs=[], 

603 total_number_jobs=total_jobs, 

604 job_state_counts=state_counts, 

605 ) 

606 run_reports[report.global_wms_id] = report 

607 

608 return run_reports, msg 

609 

610 

611def _add_run_info(wms_path, job): 

612 """Find BPS run information elsewhere for runs without bps attributes. 

613 

614 Parameters 

615 ---------- 

616 wms_path : `str` 

617 Path to submit files for the run. 

618 job : `dict` [`str`, `~typing.Any`] 

619 HTCondor dag job information. 

620 

621 Raises 

622 ------ 

623 StopIteration 

624 If cannot find file it is looking for. Permission errors are 

625 caught and job's run is marked with error. 

626 """ 

627 path = Path(wms_path) / "jobs" 

628 try: 

629 subfile = next(path.glob("**/*.sub")) 

630 except (StopIteration, PermissionError): 

631 job["bps_run"] = "Unavailable" 

632 else: 

633 _LOG.debug("_add_run_info: subfile = %s", subfile) 

634 try: 

635 with open(subfile, encoding="utf-8") as fh: 

636 for line in fh: 

637 if line.startswith("+bps_"): 

638 m = re.match(r"\+(bps_[^\s]+)\s*=\s*(.+)$", line) 

639 if m: 

640 _LOG.debug("Matching line: %s", line) 

641 job[m.group(1)] = m.group(2).replace('"', "") 

642 else: 

643 _LOG.debug("Could not parse attribute: %s", line) 

644 except PermissionError: 

645 job["bps_run"] = "PermissionError" 

646 _LOG.debug("After adding job = %s", job) 

647 

648 

649def _get_owner(job): 

650 """Get the owner of a dag job. 

651 

652 Parameters 

653 ---------- 

654 job : `dict` [`str`, `~typing.Any`] 

655 HTCondor dag job information. 

656 

657 Returns 

658 ------- 

659 owner : `str` 

660 Owner of the dag job. 

661 """ 

662 owner = job.get("bps_operator", None) 

663 if not owner: 663 ↛ 664line 663 didn't jump to line 664 because the condition on line 663 was never true

664 owner = job.get("Owner", None) 

665 if not owner: 

666 _LOG.warning("Could not get Owner from htcondor job: %s", job) 

667 owner = "MISS" 

668 return owner 

669 

670 

671def _get_run_summary(job): 

672 """Get the run summary for a job. 

673 

674 Parameters 

675 ---------- 

676 job : `dict` [`str`, `~typing.Any`] 

677 HTCondor dag job information. 

678 

679 Returns 

680 ------- 

681 summary : `str` 

682 Number of jobs per PipelineTask label in approximate pipeline order. 

683 Format: <label>:<count>[;<label>:<count>]+ 

684 """ 

685 summary = job.get("bps_job_summary", job.get("bps_run_summary", None)) 

686 if not summary: 

687 summary, _, _ = summarize_dag(job["Iwd"]) 

688 if not summary: 

689 _LOG.warning("Could not get run summary for htcondor job: %s", job) 

690 _LOG.debug("_get_run_summary: summary=%s", summary) 

691 

692 # Workaround sometimes using init vs pipetaskInit 

693 summary = summary.replace("init:", "pipetaskInit:") 

694 

695 if "pegasus_version" in job and "pegasus" not in summary: 695 ↛ 696line 695 didn't jump to line 696 because the condition on line 695 was never true

696 summary += ";pegasus:0" 

697 

698 return summary 

699 

700 

701def _get_exit_code_summary(jobs): 

702 """Get the exit code summary for a run. 

703 

704 Parameters 

705 ---------- 

706 jobs : `dict` [`str`, `dict` [`str`, Any]] 

707 Mapping HTCondor job id to job information. 

708 

709 Returns 

710 ------- 

711 summary : `dict` [`str`, `list` [`int`]] 

712 Jobs' exit codes per job label. 

713 """ 

714 summary = {} 

715 for job_id, job_ad in jobs.items(): 

716 job_label = job_ad["bps_job_label"] 

717 summary.setdefault(job_label, []) 

718 try: 

719 exit_code = 0 

720 job_status = job_ad["JobStatus"] 

721 match job_status: 

722 case JobStatus.COMPLETED | JobStatus.HELD | JobStatus.REMOVED: 

723 exit_code = job_ad["ExitSignal"] if job_ad["ExitBySignal"] else job_ad["ExitCode"] 

724 case JobStatus.IDLE | JobStatus.RUNNING | JobStatus.TRANSFERRING_OUTPUT | JobStatus.SUSPENDED: 

725 pass 

726 case _: 

727 _LOG.debug("Unknown 'JobStatus' value ('%d') in classad for job '%s'", job_status, job_id) 

728 if exit_code != 0: 

729 summary[job_label].append(exit_code) 

730 except KeyError as ex: 

731 _LOG.debug("Attribute '%s' not found in the classad for job '%s'", ex, job_id) 

732 return summary 

733 

734 

735def _get_state_counts_from_jobs( 

736 wms_workflow_id: str, jobs: dict[str, dict[str, Any]] 

737) -> tuple[int, dict[WmsStates, int]]: 

738 """Count number of jobs per WMS state. 

739 

740 The workflow job and the service jobs are excluded from the count. 

741 

742 Parameters 

743 ---------- 

744 wms_workflow_id : `str` 

745 HTCondor job id. 

746 jobs : `dict [`dict` [`str`, `~typing.Any`]] 

747 HTCondor dag job information. 

748 

749 Returns 

750 ------- 

751 total_count : `int` 

752 Total number of dag nodes. 

753 state_counts : `dict` [`lsst.ctrl.bps.WmsStates`, `int`] 

754 Keys are the different WMS states and values are counts of jobs 

755 that are in that WMS state. 

756 """ 

757 state_counts = dict.fromkeys(WmsStates, 0) 

758 for job_id, job_ad in jobs.items(): 

759 if job_id != wms_workflow_id and job_ad.get("wms_node_type", WmsNodeType.UNKNOWN) in [ 

760 WmsNodeType.PAYLOAD, 

761 WmsNodeType.FINAL, 

762 ]: 

763 state_counts[_htc_status_to_wms_state(job_ad)] += 1 

764 total_count = sum(state_counts.values()) 

765 

766 return total_count, state_counts 

767 

768 

769def _get_state_counts_from_dag_job(job): 

770 """Count number of jobs per WMS state. 

771 

772 Parameters 

773 ---------- 

774 job : `dict` [`str`, `~typing.Any`] 

775 HTCondor dag job information. 

776 

777 Returns 

778 ------- 

779 total_count : `int` 

780 Total number of dag nodes. 

781 state_counts : `dict` [`lsst.ctrl.bps.WmsStates`, `int`] 

782 Keys are the different WMS states and values are counts of jobs 

783 that are in that WMS state. 

784 """ 

785 _LOG.debug("_get_state_counts_from_dag_job: job = %s %s", type(job), len(job)) 

786 state_counts = dict.fromkeys(WmsStates, 0) 

787 if "DAG_NodesReady" in job: 787 ↛ 799line 787 didn't jump to line 799 because the condition on line 787 was always true

788 state_counts = { 

789 WmsStates.UNREADY: job.get("DAG_NodesUnready", 0), 

790 WmsStates.READY: job.get("DAG_NodesReady", 0), 

791 WmsStates.HELD: job.get("DAG_JobsHeld", 0), 

792 WmsStates.SUCCEEDED: job.get("DAG_NodesDone", 0), 

793 WmsStates.FAILED: job.get("DAG_NodesFailed", 0), 

794 WmsStates.PRUNED: job.get("DAG_NodesFutile", 0), 

795 WmsStates.MISFIT: job.get("DAG_NodesPre", 0) + job.get("DAG_NodesPost", 0), 

796 } 

797 total_jobs = job.get("DAG_NodesTotal") 

798 _LOG.debug("_get_state_counts_from_dag_job: from DAG_* keys, total_jobs = %s", total_jobs) 

799 elif "NodesFailed" in job: 

800 state_counts = { 

801 WmsStates.UNREADY: job.get("NodesUnready", 0), 

802 WmsStates.READY: job.get("NodesReady", 0), 

803 WmsStates.HELD: job.get("JobProcsHeld", 0), 

804 WmsStates.SUCCEEDED: job.get("NodesDone", 0), 

805 WmsStates.FAILED: job.get("NodesFailed", 0), 

806 WmsStates.PRUNED: job.get("NodesFutile", 0), 

807 WmsStates.MISFIT: job.get("NodesPre", 0) + job.get("NodesPost", 0), 

808 } 

809 try: 

810 total_jobs = job.get("NodesTotal") 

811 except KeyError as ex: 

812 _LOG.error("Job missing %s. job = %s", str(ex), job) 

813 raise 

814 _LOG.debug("_get_state_counts_from_dag_job: from NODES* keys, total_jobs = %s", total_jobs) 

815 else: 

816 # With Kerberos job auth and Kerberos bug, if warning would be printed 

817 # for every DAG. 

818 _LOG.debug("Can't get job state counts %s", job["Iwd"]) 

819 total_jobs = 0 

820 

821 _LOG.debug("total_jobs = %s, state_counts: %s", total_jobs, state_counts) 

822 return total_jobs, state_counts 

823 

824 

825def _update_jobs(jobs1, jobs2): 

826 """Update jobs1 with info in jobs2. 

827 

828 (Basically an update for nested dictionaries.) 

829 

830 Parameters 

831 ---------- 

832 jobs1 : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

833 HTCondor job information to be updated. 

834 jobs2 : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

835 Additional HTCondor job information. 

836 """ 

837 for job_id, job_ad in jobs2.items(): 

838 if job_id in jobs1: 838 ↛ 839line 838 didn't jump to line 839 because the condition on line 838 was never true

839 jobs1[job_id].update(job_ad) 

840 else: 

841 jobs1[job_id] = job_ad 

842 

843 

844def is_service_job(job_ad: dict[str, Any]) -> bool: 

845 """Determine if a job is a service one. 

846 

847 Parameters 

848 ---------- 

849 job_ad : `dict` [`str`, Any] 

850 Information about an HTCondor job. 

851 

852 Returns 

853 ------- 

854 is_service_job : `bool` 

855 True if the job is a service one, false otherwise. 

856 

857 Notes 

858 ----- 

859 At the moment, HTCondor does not provide a native way to distinguish 

860 between payload and service jobs in the workflow. This code depends 

861 on read_node_status adding wms_node_type. 

862 """ 

863 return job_ad.get("wms_node_type", WmsNodeType.UNKNOWN) == WmsNodeType.SERVICE