Coverage for python/lsst/ctrl/bps/htcondor/lssthtc.py: 81%

937 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 17:01 -0800

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"""Placeholder HTCondor DAGMan API. 

29 

30There is new work on a python DAGMan API from HTCondor. However, at this 

31time, it tries to make things easier by assuming DAG is easily broken into 

32levels where there are 1-1 or all-to-all relationships to nodes in next 

33level. LSST workflows are more complicated. 

34""" 

35 

36__all__ = [ 

37 "MISSING_ID", 

38 "DagStatus", 

39 "HTCDag", 

40 "HTCJob", 

41 "NodeStatus", 

42 "RestrictedDict", 

43 "WmsNodeType", 

44 "condor_history", 

45 "condor_q", 

46 "condor_search", 

47 "condor_status", 

48 "htc_backup_files", 

49 "htc_check_dagman_output", 

50 "htc_create_submit_from_cmd", 

51 "htc_create_submit_from_dag", 

52 "htc_create_submit_from_file", 

53 "htc_escape", 

54 "htc_query_history", 

55 "htc_query_present", 

56 "htc_submit_dag", 

57 "htc_tweak_log_info", 

58 "htc_write_attribs", 

59 "htc_write_condor_file", 

60 "pegasus_name_to_label", 

61 "read_dag_info", 

62 "read_dag_log", 

63 "read_dag_nodes_log", 

64 "read_dag_status", 

65 "read_node_status", 

66 "summarize_dag", 

67 "update_job_info", 

68 "write_dag_info", 

69] 

70 

71 

72import contextlib 

73import itertools 

74import json 

75import logging 

76import os 

77import pprint 

78import re 

79import subprocess 

80from collections import Counter, defaultdict 

81from collections.abc import MutableMapping 

82from datetime import datetime, timedelta 

83from enum import IntEnum, auto 

84from pathlib import Path 

85from typing import Any, TextIO 

86 

87import networkx 

88from classad2 import parseAds, parseNext 

89from htcondor2 import ( 

90 Collector, 

91 DaemonTypes, 

92 JobEventLog, 

93 JobEventType, 

94 JobStatus, 

95 Schedd, 

96 Submit, 

97 param, 

98) 

99 

100from .handlers import HTC_JOB_AD_HANDLERS 

101from .htcondor_compat import HTCondorException, compat_submit_ad 

102 

103_LOG = logging.getLogger(__name__) 

104 

105MISSING_ID = "-99999" 

106 

107 

108class DagStatus(IntEnum): 

109 """HTCondor DAGMan's statuses for a DAG.""" 

110 

111 OK = 0 

112 ERROR = 1 # an error condition different than those listed here 

113 FAILED = 2 # one or more nodes in the DAG have failed 

114 ABORTED = 3 # the DAG has been aborted by an ABORT-DAG-ON specification 

115 REMOVED = 4 # the DAG has been removed by condor_rm 

116 CYCLE = 5 # a cycle was found in the DAG 

117 SUSPENDED = 6 # the DAG has been suspended (see section 2.10.8) 

118 

119 

120class NodeStatus(IntEnum): 

121 """HTCondor's statuses for DAGman nodes.""" 

122 

123 # (STATUS_NOT_READY): At least one parent has not yet finished or the node 

124 # is a FINAL node. 

125 NOT_READY = 0 

126 

127 # (STATUS_READY): All parents have finished, but the node is not yet 

128 # running. 

129 READY = 1 

130 

131 # (STATUS_PRERUN): The node’s PRE script is running. 

132 PRERUN = 2 

133 

134 # (STATUS_SUBMITTED): The node’s HTCondor job(s) are in the queue. 

135 # StatusDetails = "not_idle" -> running. 

136 # JobProcsHeld = 1-> hold. 

137 # JobProcsQueued = 1 -> idle. 

138 SUBMITTED = 3 

139 

140 # (STATUS_POSTRUN): The node’s POST script is running. 

141 POSTRUN = 4 

142 

143 # (STATUS_DONE): The node has completed successfully. 

144 DONE = 5 

145 

146 # (STATUS_ERROR): The node has failed. StatusDetails has info (e.g., 

147 # ULOG_JOB_ABORTED for deleted job). 

148 ERROR = 6 

149 

150 # (STATUS_FUTILE): The node will never run because ancestor node failed. 

151 FUTILE = 7 

152 

153 

154class WmsNodeType(IntEnum): 

155 """HTCondor plugin node types to help with payload reporting.""" 

156 

157 UNKNOWN = auto() 

158 """Dummy value when missing.""" 

159 

160 PAYLOAD = auto() 

161 """Payload job.""" 

162 

163 FINAL = auto() 

164 """Final job.""" 

165 

166 SERVICE = auto() 

167 """Service job.""" 

168 

169 NOOP = auto() 

170 """NOOP job used for ordering jobs.""" 

171 

172 SUBDAG = auto() 

173 """SUBDAG job used for ordering jobs.""" 

174 

175 SUBDAG_CHECK = auto() 

176 """Job used to correctly prune jobs after a subdag.""" 

177 

178 

179HTC_QUOTE_KEYS = {"environment", "arguments"} 

180HTC_VALID_JOB_KEYS = { 

181 "universe", 

182 "executable", 

183 "arguments", 

184 "environment", 

185 "log", 

186 "error", 

187 "output", 

188 "should_transfer_files", 

189 "when_to_transfer_output", 

190 "getenv", 

191 "notification", 

192 "notify_user", 

193 "concurrency_limit", 

194 "transfer_executable", 

195 "transfer_input_files", 

196 "transfer_output_files", 

197 "transfer_output_remaps", 

198 "request_cpus", 

199 "request_memory", 

200 "request_disk", 

201 "priority", 

202 "category", 

203 "requirements", 

204 "on_exit_hold", 

205 "on_exit_hold_reason", 

206 "on_exit_hold_subcode", 

207 "max_retries", 

208 "retry_until", 

209 "periodic_release", 

210 "periodic_remove", 

211 "accounting_group", 

212 "accounting_group_user", 

213 "kill_sig", 

214 "want_graceful_removal", 

215 "job_max_vacate_time", 

216} 

217HTC_VALID_JOB_DAG_KEYS = { 

218 "dir", 

219 "noop", 

220 "done", 

221 "vars", 

222 "pre", 

223 "post", 

224 "retry", 

225 "retry_unless_exit", 

226 "abort_dag_on", 

227 "abort_exit", 

228 "priority", 

229} 

230 

231 

232class RestrictedDict(MutableMapping): 

233 """A dictionary that only allows certain keys. 

234 

235 Parameters 

236 ---------- 

237 valid_keys : `~collections.abc.Container` 

238 Strings that are valid keys. 

239 init_data : `dict` or `RestrictedDict`, optional 

240 Initial data. 

241 

242 Raises 

243 ------ 

244 KeyError 

245 If invalid key(s) in init_data. 

246 """ 

247 

248 def __init__(self, valid_keys, init_data=()): 

249 self.valid_keys = valid_keys 

250 self.data = {} 

251 self.update(init_data) 

252 

253 def __getitem__(self, key): 

254 """Return value for given key if exists. 

255 

256 Parameters 

257 ---------- 

258 key : `str` 

259 Identifier for value to return. 

260 

261 Returns 

262 ------- 

263 value : `~typing.Any` 

264 Value associated with given key. 

265 

266 Raises 

267 ------ 

268 KeyError 

269 If key doesn't exist. 

270 """ 

271 return self.data[key] 

272 

273 def __delitem__(self, key): 

274 """Delete value for given key if exists. 

275 

276 Parameters 

277 ---------- 

278 key : `str` 

279 Identifier for value to delete. 

280 

281 Raises 

282 ------ 

283 KeyError 

284 If key doesn't exist. 

285 """ 

286 del self.data[key] 

287 

288 def __setitem__(self, key, value): 

289 """Store key,value in internal dict only if key is valid. 

290 

291 Parameters 

292 ---------- 

293 key : `str` 

294 Identifier to associate with given value. 

295 value : `~typing.Any` 

296 Value to store. 

297 

298 Raises 

299 ------ 

300 KeyError 

301 If key is invalid. 

302 """ 

303 if key not in self.valid_keys: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true

304 raise KeyError(f"Invalid key {key}") 

305 self.data[key] = value 

306 

307 def __iter__(self): 

308 return self.data.__iter__() 

309 

310 def __len__(self): 

311 return len(self.data) 

312 

313 def __str__(self): 

314 return str(self.data) 

315 

316 

317def htc_backup_files( 

318 wms_path: str | os.PathLike, 

319 subdir: str | os.PathLike | None = None, 

320 limit: int = 100, 

321 failed_subdags: set[str] | None = None, 

322) -> None: 

323 """Backup select HTCondor files in the submit directory. 

324 

325 Files will be saved in separate subdirectories which will be created in 

326 the submit directory where the files are located. These subdirectories 

327 will be consecutive, zero-padded integers. Their values will correspond to 

328 the number of HTCondor rescue DAGs in the submit directory. 

329 

330 Hence, with the default settings, copies after the initial failed run will 

331 be placed in '001' subdirectory, '002' after the first restart, and so on 

332 until the limit of backups is reached. If there's no rescue DAG yet, files 

333 will be copied to '000' subdirectory. 

334 

335 This is not a generic function for making backups. It is intended to be 

336 used once, just before a restart, to make snapshots of files which will be 

337 overwritten by HTCondor after during the next run. 

338 

339 Parameters 

340 ---------- 

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

342 Path to the submit directory either absolute or relative. 

343 subdir : `str` or `os.PathLike`, optional 

344 A path, relative to the submit directory, where all subdirectories with 

345 backup files will be kept. Defaults to None which means that the backup 

346 subdirectories will be placed directly in the submit directory. 

347 limit : `int`, optional 

348 Maximal number of backups. If the number of backups reaches the limit, 

349 the last backup files will be overwritten. The default value is 100 

350 to match the default value of HTCondor's DAGMAN_MAX_RESCUE_NUM in 

351 version 8.8+. 

352 failed_subdags : `set` [`str`], optional 

353 Names of subdag jobs that failed. Only files of these subdags will be 

354 backed up. If None (the default), files of all subdags with rescue 

355 files are backed up. 

356 

357 Raises 

358 ------ 

359 FileNotFoundError 

360 If the submit directory or the file that needs to be backed up does not 

361 exist. 

362 OSError 

363 If the submit directory cannot be accessed or backing up a file failed 

364 either due to permission or filesystem related issues. 

365 """ 

366 width = len(str(limit)) 

367 

368 path = Path(wms_path).resolve() 

369 if not path.is_dir(): 

370 raise FileNotFoundError(f"Directory {path} not found") 

371 

372 # Initialize the backup counter. 

373 # If using control DAG, don't want to include nested DAGs. 

374 rescue_dags = list(path.glob("*_ctrl.dag.rescue[0-9][0-9][0-9]")) 

375 if not rescue_dags: 375 ↛ 377line 375 didn't jump to line 377 because the condition on line 375 was always true

376 rescue_dags = list(path.glob("*.rescue[0-9][0-9][0-9]")) 

377 counter = min(len(rescue_dags), limit) 

378 

379 # Create the backup directory and move select files there. 

380 dest = path 

381 if subdir: 

382 # PurePath.is_relative_to() is not available before Python 3.9. Hence 

383 # we need to check is 'subdir' is in the submit directory in some other 

384 # way if it is an absolute path. 

385 subdir = Path(subdir) 

386 if subdir.is_absolute(): 

387 subdir = subdir.resolve() # Since resolve was run on path, must run it here 

388 if dest not in subdir.parents: 

389 _LOG.warning( 

390 "Invalid backup location: '%s' not in the submit directory, will use '%s' instead.", 

391 subdir, 

392 wms_path, 

393 ) 

394 else: 

395 dest /= subdir 

396 else: 

397 dest /= subdir 

398 dest /= f"{counter:0{width}}" 

399 _LOG.debug("dest = %s", dest) 

400 try: 

401 dest.mkdir(parents=True, exist_ok=False if counter < limit else True) 

402 except FileExistsError: 

403 _LOG.warning("Refusing to do backups: target directory '%s' already exists", dest) 

404 else: 

405 htc_backup_files_single_path(path, dest) 

406 

407 # Back up selected files for failed subdags as well. 

408 # 

409 # Do NOT back up files of subdags that succeeded! These files need to stay 

410 # in their respective directories as they will not be recreated by HTCondor 

411 # after the run is restarted. As HTCondorService.report() uses information 

412 # in these files to determine job statuses, their absence may lead to 

413 # reporting incorrect job status counts. 

414 for subdag_dir in {file.parent for file in path.glob("subdags/*/*.rescue*")}: 

415 if failed_subdags is not None and subdag_dir.name not in failed_subdags: 415 ↛ 416line 415 didn't jump to line 416 because the condition on line 415 was never true

416 continue 

417 subdag_dest = dest / subdag_dir.relative_to(path) 

418 subdag_dest.mkdir(parents=True, exist_ok=False) 

419 htc_backup_files_single_path(subdag_dir, subdag_dest) 

420 

421 

422def htc_backup_files_single_path(src: str | os.PathLike, dest: str | os.PathLike) -> None: 

423 """Move particular htc files to a different directory for later debugging. 

424 

425 Parameters 

426 ---------- 

427 src : `str` or `os.PathLike` 

428 Directory from which to back up particular files. 

429 dest : `str` or `os.PathLike` 

430 Directory to which particular files are moved. 

431 

432 Raises 

433 ------ 

434 RuntimeError 

435 If given dest directory matches given src directory. 

436 OSError 

437 If problems moving file. 

438 FileNotFoundError 

439 Item matching pattern in src directory isn't a file. 

440 """ 

441 src = Path(src) 

442 dest = Path(dest) 

443 if dest.samefile(src): 

444 raise RuntimeError(f"Destination directory is same as the source directory ({src})") 

445 

446 for patt in [ 

447 "*.info.*", 

448 "*.dag.metrics", 

449 "*.dag.nodes.log", 

450 "*.node_status", 

451 "wms_*.dag.post.out", 

452 "wms_*.status.txt", 

453 ]: 

454 for source in src.glob(patt): 

455 if source.is_file(): 455 ↛ 462line 455 didn't jump to line 462 because the condition on line 455 was always true

456 target = dest / source.relative_to(src) 

457 try: 

458 source.rename(target) 

459 except OSError as exc: 

460 raise type(exc)(f"Backing up '{source}' failed: {exc.strerror}") from None 

461 else: 

462 raise FileNotFoundError(f"Backing up '{source}' failed: not a file") 

463 

464 

465def htc_escape(value): 

466 """Escape characters in given value based upon HTCondor syntax. 

467 

468 Parameters 

469 ---------- 

470 value : `~typing.Any` 

471 Value that needs to have characters escaped if string. 

472 

473 Returns 

474 ------- 

475 new_value : `~typing.Any` 

476 Given value with characters escaped appropriate for HTCondor if string. 

477 """ 

478 if isinstance(value, str): 

479 newval = value.replace('"', '""').replace("'", "''").replace("&quot;", '"') 

480 else: 

481 newval = value 

482 

483 return newval 

484 

485 

486def htc_write_attribs(stream, attrs): 

487 """Write job attributes in HTCondor format to writeable stream. 

488 

489 Parameters 

490 ---------- 

491 stream : `~typing.TextIO` 

492 Output text stream (typically an open file). 

493 attrs : `dict` 

494 HTCondor job attributes (dictionary of attribute key, value). 

495 """ 

496 for key, value in attrs.items(): 

497 # Make sure strings are syntactically correct for HTCondor. 

498 if isinstance(value, str): 498 ↛ 501line 498 didn't jump to line 501 because the condition on line 498 was always true

499 pval = f'"{htc_escape(value)}"' 

500 else: 

501 pval = value 

502 

503 print(f"+{key} = {pval}", file=stream) 

504 

505 

506def htc_write_condor_file( 

507 filename: str | os.PathLike, job_name: str, job: RestrictedDict, job_attrs: dict[str, Any] 

508) -> None: 

509 """Write an HTCondor submit file. 

510 

511 Parameters 

512 ---------- 

513 filename : `str` or `os.PathLike` 

514 Filename for the HTCondor submit file. 

515 job_name : `str` 

516 Job name to use in submit file. 

517 job : `RestrictedDict` 

518 Submit script information. 

519 job_attrs : `dict` 

520 Job attributes. 

521 """ 

522 os.makedirs(os.path.dirname(filename), exist_ok=True) 

523 with open(filename, "w") as fh: 

524 for key, value in job.items(): 

525 if value is not None: 525 ↛ 524line 525 didn't jump to line 524 because the condition on line 525 was always true

526 if key in HTC_QUOTE_KEYS: # Assumes internal quotes are already escaped correctly 

527 print(f'{key}="{value}"', file=fh) 

528 else: 

529 print(f"{key}={value}", file=fh) 

530 for key in ["output", "error", "log"]: 

531 if key not in job: 

532 filename = f"{job_name}.$(Cluster).{'out' if key != 'log' else key}" 

533 print(f"{key}={filename}", file=fh) 

534 

535 if job_attrs is not None: 

536 htc_write_attribs(fh, job_attrs) 

537 print("queue", file=fh) 

538 

539 

540def htc_tune_schedd_args(**kwargs): 

541 """Ensure that arguments for Schedd are version appropriate. 

542 

543 This is the fallback function if no version specific alteration are 

544 necessary. Effectively, a no-op. 

545 

546 Parameters 

547 ---------- 

548 **kwargs 

549 Any keyword arguments that Schedd.history(), Schedd.query(), and 

550 Schedd.xquery() accepts. 

551 

552 Returns 

553 ------- 

554 kwargs : `dict` [`str`, `~typing.Any`] 

555 Keywords arguments that were passed to the function. 

556 

557 Notes 

558 ----- 

559 Left as a placeholder for any future changes to the keyword arguments 

560 for ``Schedd.(history|query|xquery)``. 

561 """ 

562 return kwargs 

563 

564 

565def htc_query_history(schedds, **kwargs): 

566 """Fetch history records from the condor_schedd daemon. 

567 

568 Parameters 

569 ---------- 

570 schedds : `htcondor.Schedd` 

571 HTCondor schedulers which to query for job information. 

572 **kwargs 

573 Any keyword arguments that Schedd.history() accepts. 

574 

575 Yields 

576 ------ 

577 schedd_name : `str` 

578 Name of the HTCondor scheduler managing the job queue. 

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

580 A dictionary representing HTCondor ClassAd describing a job. It maps 

581 job attributes names to values of the ClassAd expressions they 

582 represent. 

583 """ 

584 # If not set, provide defaults for positional arguments. 

585 kwargs.setdefault("constraint", None) 

586 kwargs.setdefault("projection", []) 

587 kwargs = htc_tune_schedd_args(**kwargs) 

588 for schedd_name, schedd in schedds.items(): 

589 for job_ad in schedd.history(**kwargs): 

590 yield schedd_name, dict(job_ad) 

591 

592 

593def htc_query_present(schedds, **kwargs): 

594 """Query the condor_schedd daemon for job ads. 

595 

596 Parameters 

597 ---------- 

598 schedds : `htcondor.Schedd` 

599 HTCondor schedulers which to query for job information. 

600 **kwargs 

601 Any keyword arguments that Schedd.xquery() accepts. 

602 

603 Yields 

604 ------ 

605 schedd_name : `str` 

606 Name of the HTCondor scheduler managing the job queue. 

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

608 A dictionary representing HTCondor ClassAd describing a job. It maps 

609 job attributes names to values of the ClassAd expressions they 

610 represent. 

611 """ 

612 kwargs = htc_tune_schedd_args(**kwargs) 

613 for schedd_name, schedd in schedds.items(): 

614 for job_ad in schedd.query(**kwargs): 

615 yield schedd_name, dict(job_ad) 

616 

617 

618def htc_submit_dag(sub: Submit): 

619 """Submit job for execution. 

620 

621 Parameters 

622 ---------- 

623 sub : `htcondor.Submit` 

624 An object representing a job submit description. 

625 

626 Returns 

627 ------- 

628 schedd_job_info : `dict` [`str`, `dict` [`str`, \ 

629 `dict` [`str`, `~typing.Any`]]] 

630 Information about jobs satisfying the search criteria where for each 

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

632 classads. 

633 """ 

634 coll = Collector() 

635 schedd_ad = coll.locate(DaemonTypes.Schedd) 

636 schedd = Schedd(schedd_ad) 

637 

638 # Suppress stdout during job submission seen with v24 where the 

639 # dagman submit description is dumped to stdout. 

640 with contextlib.redirect_stdout(open(os.devnull, "w")): # pragma: no cover 

641 # If Schedd.submit() fails, the method will raise an exception. 

642 # Usually, that implies issues with the HTCondor pool which BPS 

643 # can't address. Hence, no effort is made to handle the exception. 

644 submit_result = schedd.submit(sub, itemdata=None) 

645 

646 # Sadly, the ClassAd from Schedd.submit() (see above) does not have 

647 # 'GlobalJobId' so we need to run a regular query to get it anyway. 

648 schedd_name = schedd_ad["Name"] 

649 schedd_dag_info = condor_q( 

650 constraint=f"ClusterId == {submit_result.cluster()}", schedds={schedd_name: schedd} 

651 ) 

652 return schedd_dag_info 

653 

654 

655def htc_create_submit_from_dag( 

656 dag_filename: str, submit_options: dict[str, Any], dagman_conf_filename: str | os.PathLike | None = None 

657) -> Submit: 

658 """Create a DAGMan job submit description. 

659 

660 Parameters 

661 ---------- 

662 dag_filename : `str` 

663 Name of file containing HTCondor DAG commands. 

664 submit_options : `dict` [`str`, `~typing.Any`], optional 

665 Contains extra options for command line (Value of None means flag). 

666 dagman_conf_filename : `str` or `os.PathLike`, optional 

667 Location of DAGMan configuration file, if Any. Defaults to no 

668 file (None). 

669 

670 Returns 

671 ------- 

672 sub : `htcondor.Submit` 

673 An object representing a job submit description. 

674 

675 Notes 

676 ----- 

677 Use with HTCondor versions which support htcondor.Submit.from_dag(), 

678 i.e., 8.9.3 or newer. 

679 """ 

680 # Config and environment variables do not seem to override -MaxIdle 

681 # on the .dag.condor.sub's command line (broken in some 24.0.x versions). 

682 # Explicitly forward them as a submit_option if either exists. 

683 # Note: auto generated subdag submit files are still the -MaxIdle=1000 

684 # in the broken versions. 

685 if "MaxIdle" not in submit_options: 

686 max_jobs_idle: int | None = None 

687 config_var_name = "DAGMAN_MAX_JOBS_IDLE" 

688 

689 if dagman_conf_filename: 

690 _LOG.debug("Checking DAGMan config file = %s", dagman_conf_filename) 

691 with open(dagman_conf_filename) as fh: 

692 for line in fh: 

693 _LOG.debug("DAGMan config file line = %s", line) 

694 parts = line.split("=") 

695 _LOG.debug("DAGMan config file line parts = %s", parts) 

696 if len(parts) == 2 and parts[0].strip() == config_var_name: 

697 max_jobs_idle = int(parts[1].strip()) 

698 _LOG.debug("Found %s = %s", config_var_name, max_jobs_idle) 

699 break 

700 if max_jobs_idle is None: 

701 if f"_CONDOR_{config_var_name}" in os.environ: 

702 max_jobs_idle = int(os.environ[f"_CONDOR_{config_var_name}"]) 

703 elif config_var_name in param: 

704 max_jobs_idle = param[config_var_name] 

705 

706 if max_jobs_idle: 

707 submit_options["MaxIdle"] = max_jobs_idle 

708 else: 

709 _LOG.debug("MaxIdle already in submit_options: %s", submit_options) 

710 

711 _LOG.debug("Using submit_options = %s", submit_options) 

712 submit_ad = Submit.from_dag(dag_filename, submit_options) 

713 submit_ad = compat_submit_ad(submit_ad) 

714 return submit_ad 

715 

716 

717def htc_create_submit_from_cmd(dag_filename, submit_options=None) -> Submit: 

718 """Create a DAGMan job submit description. 

719 

720 Create a DAGMan job submit description by calling ``condor_submit_dag`` 

721 on given DAG description file. 

722 

723 Parameters 

724 ---------- 

725 dag_filename : `str` 

726 Name of file containing HTCondor DAG commands. 

727 submit_options : `dict` [`str`, `~typing.Any`], optional 

728 Contains extra options for command line (Value of None means flag). 

729 

730 Returns 

731 ------- 

732 sub : `htcondor.Submit` 

733 An object representing a job submit description. 

734 

735 Notes 

736 ----- 

737 Use with HTCondor versions which do not support htcondor.Submit.from_dag(), 

738 i.e., older than 8.9.3. 

739 """ 

740 # Run command line condor_submit_dag command. 

741 cmd = "condor_submit_dag -f -no_submit -notification never -autorescue 1 -UseDagDir -no_recurse " 

742 

743 if submit_options is not None: 

744 for opt, val in submit_options.items(): 

745 cmd += f" -{opt} {val or ''}" 

746 cmd += f"{dag_filename}" 

747 

748 process = subprocess.Popen( 

749 cmd.split(), shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8" 

750 ) 

751 process.wait() 

752 

753 if process.returncode != 0: 

754 print(f"Exit code: {process.returncode}") 

755 print(process.communicate()[0]) 

756 raise RuntimeError("Problems running condor_submit_dag") 

757 

758 return htc_create_submit_from_file(f"{dag_filename}.condor.sub") 

759 

760 

761def htc_create_submit_from_file(submit_file): 

762 """Parse a submission file. 

763 

764 Parameters 

765 ---------- 

766 submit_file : `str` 

767 Name of the HTCondor submit file. 

768 

769 Returns 

770 ------- 

771 sub : `htcondor.Submit` 

772 An object representing a job submit description. 

773 """ 

774 descriptors = {} 

775 with open(submit_file) as fh: 

776 for line in fh: 

777 line = line.strip() 

778 if not line.startswith("#") and not line == "queue": 

779 (key, val) = re.split(r"\s*=\s*", line, maxsplit=1) 

780 descriptors[key] = val 

781 

782 # Avoid UserWarning: the line 'copy_to_spool = False' was 

783 # unused by Submit object. Is it a typo? 

784 try: 

785 del descriptors["copy_to_spool"] 

786 except KeyError: 

787 pass 

788 

789 submit_ad = Submit(descriptors) 

790 submit_ad = compat_submit_ad(submit_ad) 

791 return submit_ad 

792 

793 

794def _htc_write_job_commands(stream, name, commands, node_type="JOB"): 

795 """Output the DAGMan job lines for single job in DAG. 

796 

797 Parameters 

798 ---------- 

799 stream : `~typing.TextIO` 

800 Writeable text stream (typically an opened file). 

801 name : `str` 

802 Job name. 

803 commands : `RestrictedDict` 

804 DAG commands for a job. 

805 node_type : `str`, optional 

806 Type of DAGMan node (JOB, FINAL, SERVICE). Defaults to "JOB". 

807 """ 

808 # Note: optional pieces of commands include a space at the beginning. 

809 # also making sure values aren't empty strings as placeholders. 

810 if "pre" in commands and commands["pre"]: 

811 defer = "" 

812 if "defer" in commands["pre"] and commands["pre"]["defer"]: 

813 defer = f" DEFER {commands['pre']['defer']['status']} {commands['pre']['defer']['time']}" 

814 

815 debug = "" 

816 if "debug" in commands["pre"] and commands["pre"]["debug"]: 

817 debug = f" DEBUG {commands['pre']['debug']['filename']} {commands['pre']['debug']['type']}" 

818 

819 arguments = "" 

820 if "arguments" in commands["pre"] and commands["pre"]["arguments"]: 

821 arguments = f" {commands['pre']['arguments']}" 

822 

823 executable = commands["pre"]["executable"] 

824 print(f"SCRIPT{defer}{debug} PRE {name} {executable}{arguments}", file=stream) 

825 

826 if "post" in commands and commands["post"]: 

827 defer = "" 

828 if "defer" in commands["post"] and commands["post"]["defer"]: 

829 defer = f" DEFER {commands['post']['defer']['status']} {commands['post']['defer']['time']}" 

830 

831 debug = "" 

832 if "debug" in commands["post"] and commands["post"]["debug"]: 

833 debug = f" DEBUG {commands['post']['debug']['filename']} {commands['post']['debug']['type']}" 

834 

835 arguments = "" 

836 if "arguments" in commands["post"] and commands["post"]["arguments"]: 

837 arguments = f" {commands['post']['arguments']}" 

838 

839 executable = commands["post"]["executable"] 

840 print(f"SCRIPT{defer}{debug} POST {name} {executable}{arguments}", file=stream) 

841 

842 if "vars" in commands and commands["vars"]: 

843 for key, value in commands["vars"].items(): 

844 print(f'VARS {name} {key}="{htc_escape(value)}"', file=stream) 

845 

846 if "pre_skip" in commands and commands["pre_skip"]: 

847 print(f"PRE_SKIP {name} {commands['pre_skip']}", file=stream) 

848 

849 # FINAL node cannot have a DAGMan retry, abort-dag-on, priority, category 

850 if node_type != "FINAL": 

851 if "retry" in commands and commands["retry"]: 

852 print(f"RETRY {name} {commands['retry']}", end="", file=stream) 

853 if "retry_unless_exit" in commands and commands["retry_unless_exit"]: 

854 print(f" UNLESS-EXIT {commands['retry_unless_exit']}", end="", file=stream) 

855 print("", file=stream) # Since previous prints don't include new line 

856 

857 if "abort_dag_on" in commands and commands["abort_dag_on"]: 

858 print( 

859 f"ABORT-DAG-ON {name} {commands['abort_dag_on']['node_exit']}" 

860 f" RETURN {commands['abort_dag_on']['abort_exit']}", 

861 file=stream, 

862 ) 

863 

864 if "priority" in commands and commands["priority"]: 

865 print( 

866 f"PRIORITY {name} {commands['priority']}", 

867 file=stream, 

868 ) 

869 

870 

871class HTCJob: 

872 """HTCondor job for use in building DAG. 

873 

874 Parameters 

875 ---------- 

876 name : `str` 

877 Name of the job. 

878 label : `str` 

879 Label that can used for grouping or lookup. 

880 initcmds : `RestrictedDict` 

881 Initial job commands for submit file. 

882 initdagcmds : `RestrictedDict` 

883 Initial commands for job inside DAG. 

884 initattrs : `dict` 

885 Initial dictionary of job attributes. 

886 """ 

887 

888 def __init__(self, name, label=None, initcmds=(), initdagcmds=(), initattrs=None): 

889 self.name = name 

890 self.label = label 

891 self.cmds = RestrictedDict(HTC_VALID_JOB_KEYS, initcmds) 

892 self.dagcmds = RestrictedDict(HTC_VALID_JOB_DAG_KEYS, initdagcmds) 

893 self.attrs = initattrs 

894 self.subfile = None 

895 self.subdir = None 

896 self.subdag = None 

897 

898 def __str__(self): 

899 return self.name 

900 

901 def add_job_cmds(self, new_commands): 

902 """Add commands to Job (overwrite existing). 

903 

904 Parameters 

905 ---------- 

906 new_commands : `dict` 

907 Submit file commands to be added to Job. 

908 """ 

909 self.cmds.update(new_commands) 

910 

911 def add_dag_cmds(self, new_commands): 

912 """Add DAG commands to Job (overwrite existing). 

913 

914 Parameters 

915 ---------- 

916 new_commands : `dict` 

917 DAG file commands to be added to Job. 

918 """ 

919 self.dagcmds.update(new_commands) 

920 

921 def add_job_attrs(self, new_attrs): 

922 """Add attributes to Job (overwrite existing). 

923 

924 Parameters 

925 ---------- 

926 new_attrs : `dict` 

927 Attributes to be added to Job. 

928 """ 

929 if self.attrs is None: 

930 self.attrs = {} 

931 if new_attrs: 

932 self.attrs.update(new_attrs) 

933 

934 def write_submit_file(self, submit_path: str | os.PathLike) -> None: 

935 """Write job description to submit file. 

936 

937 Parameters 

938 ---------- 

939 submit_path : `str` or `os.PathLike` 

940 Prefix path for the submit file. 

941 """ 

942 if not self.subfile: 

943 self.subfile = f"{self.name}.sub" 

944 

945 subfile = self.subfile 

946 if self.subdir: 

947 subfile = Path(self.subdir) / subfile 

948 

949 subfile = Path(os.path.expandvars(subfile)) 

950 if not subfile.is_absolute(): 

951 subfile = Path(submit_path) / subfile 

952 if not subfile.exists(): 

953 _LOG.debug("Writing subfile: %s", subfile) 

954 htc_write_condor_file(subfile, self.name, self.cmds, self.attrs) 

955 else: 

956 _LOG.debug("Using existing subfile: %s", subfile) 

957 

958 def write_dag_commands(self, stream, dag_rel_path, command_name="JOB"): 

959 """Write DAG commands for single job to output stream. 

960 

961 Parameters 

962 ---------- 

963 stream : `~typing.TextIO` 

964 Output Stream. 

965 dag_rel_path : `str` 

966 Relative path of dag to submit directory. 

967 command_name : `str` 

968 Name of the DAG command (e.g., JOB, FINAL). 

969 """ 

970 subfile = os.path.expandvars(self.subfile) 

971 

972 # JOB NodeName SubmitDescription [DIR directory] [NOOP] [DONE] 

973 job_line = f'{command_name} {self.name} "{subfile}"' 

974 if "dir" in self.dagcmds: 

975 dir_val = self.dagcmds["dir"] 

976 if dag_rel_path: 

977 dir_val = os.path.join(dag_rel_path, dir_val) 

978 job_line += f' DIR "{dir_val}"' 

979 if self.dagcmds.get("noop", False): 

980 job_line += " NOOP" 

981 

982 print(job_line, file=stream) 

983 if self.dagcmds: 

984 _htc_write_job_commands(stream, self.name, self.dagcmds, command_name) 

985 

986 def dump(self, fh): 

987 """Dump job information to output stream. 

988 

989 Parameters 

990 ---------- 

991 fh : `~typing.TextIO` 

992 Output stream. 

993 """ 

994 printer = pprint.PrettyPrinter(indent=4, stream=fh) 

995 printer.pprint(self.name) 

996 printer.pprint(self.cmds) 

997 printer.pprint(self.attrs) 

998 

999 

1000class HTCDag(networkx.DiGraph): 

1001 """HTCondor DAG. 

1002 

1003 Parameters 

1004 ---------- 

1005 data : `~typing.Any` 

1006 Initial graph data of any format that is supported 

1007 by the to_network_graph() function. 

1008 name : `str` 

1009 Name for DAG. 

1010 """ 

1011 

1012 def __init__(self, data=None, name=""): 

1013 super().__init__(data=data, name=name) 

1014 

1015 self.graph["attr"] = {} 

1016 self.graph["run_id"] = None 

1017 self.graph["submit_path"] = None 

1018 self.graph["final_job"] = None 

1019 self.graph["service_job"] = None 

1020 self.graph["submit_options"] = {} 

1021 

1022 def __str__(self): 

1023 """Represent basic DAG info as string. 

1024 

1025 Returns 

1026 ------- 

1027 info : `str` 

1028 String containing basic DAG info. 

1029 """ 

1030 return f"{self.graph['name']} {len(self)}" 

1031 

1032 def add_attribs(self, attribs=None): 

1033 """Add attributes to the DAG. 

1034 

1035 Parameters 

1036 ---------- 

1037 attribs : `dict` 

1038 DAG attributes. 

1039 """ 

1040 if attribs is not None: 1040 ↛ exitline 1040 didn't return from function 'add_attribs' because the condition on line 1040 was always true

1041 self.graph["attr"].update(attribs) 

1042 

1043 def add_job(self, job, parent_names=None, child_names=None): 

1044 """Add an HTCJob to the HTCDag. 

1045 

1046 Parameters 

1047 ---------- 

1048 job : `HTCJob` 

1049 HTCJob to add to the HTCDag. 

1050 parent_names : `~collections.abc.Iterable` [`str`], optional 

1051 Names of parent jobs. 

1052 child_names : `~collections.abc.Iterable` [`str`], optional 

1053 Names of child jobs. 

1054 """ 

1055 assert isinstance(job, HTCJob) 

1056 _LOG.debug("Adding job %s to dag", job.name) 

1057 

1058 # Add dag level attributes to each job 

1059 job.add_job_attrs(self.graph["attr"]) 

1060 

1061 self.add_node(job.name, data=job) 

1062 

1063 if parent_names is not None: 1063 ↛ 1064line 1063 didn't jump to line 1064 because the condition on line 1063 was never true

1064 self.add_job_relationships(parent_names, [job.name]) 

1065 

1066 if child_names is not None: 1066 ↛ 1067line 1066 didn't jump to line 1067 because the condition on line 1066 was never true

1067 self.add_job_relationships(child_names, [job.name]) 

1068 

1069 def add_job_relationships(self, parents, children): 

1070 """Add DAG edge between parents and children jobs. 

1071 

1072 Parameters 

1073 ---------- 

1074 parents : `list` [`str`] 

1075 Contains parent job name(s). 

1076 children : `list` [`str`] 

1077 Contains children job name(s). 

1078 """ 

1079 self.add_edges_from(itertools.product(parents, children)) 

1080 

1081 def add_final_job(self, job): 

1082 """Add an HTCJob for the FINAL job in HTCDag. 

1083 

1084 Parameters 

1085 ---------- 

1086 job : `HTCJob` 

1087 HTCJob to add to the HTCDag as a FINAL job. 

1088 """ 

1089 # Add dag level attributes to each job 

1090 job.add_job_attrs(self.graph["attr"]) 

1091 

1092 self.graph["final_job"] = job 

1093 

1094 def add_service_job(self, job): 

1095 """Add an HTCJob for the SERVICE job in HTCDag. 

1096 

1097 Parameters 

1098 ---------- 

1099 job : `HTCJob` 

1100 HTCJob to add to the HTCDag as a SERVICE job. 

1101 """ 

1102 # Add dag level attributes to each job 

1103 job.add_job_attrs(self.graph["attr"]) 

1104 

1105 self.graph["service_job"] = job 

1106 

1107 def del_job(self, job_name): 

1108 """Delete the job from the DAG. 

1109 

1110 Parameters 

1111 ---------- 

1112 job_name : `str` 

1113 Name of job in DAG to delete. 

1114 """ 

1115 # Reconnect edges around node to delete 

1116 parents = self.predecessors(job_name) 

1117 children = self.successors(job_name) 

1118 self.add_edges_from(itertools.product(parents, children)) 

1119 

1120 # Delete job node (which deletes its edges). 

1121 self.remove_node(job_name) 

1122 

1123 def write(self, submit_path, job_subdir="", dag_subdir="", dag_rel_path=""): 

1124 """Write DAG to a file. 

1125 

1126 Parameters 

1127 ---------- 

1128 submit_path : `str` 

1129 Prefix path for all outputs. 

1130 job_subdir : `str`, optional 

1131 Template for job subdir (submit_path + job_subdir). 

1132 dag_subdir : `str`, optional 

1133 DAG subdir (submit_path + dag_subdir). 

1134 dag_rel_path : `str`, optional 

1135 Prefix to job_subdir for jobs inside subdag. 

1136 """ 

1137 self.graph["submit_path"] = submit_path 

1138 self.graph["dag_filename"] = os.path.join(dag_subdir, f"{self.graph['name']}.dag") 

1139 full_filename = os.path.join(submit_path, self.graph["dag_filename"]) 

1140 os.makedirs(os.path.dirname(full_filename), exist_ok=True) 

1141 

1142 try: 

1143 dagman_config_path = Path(self.graph["attr"]["bps_wms_config_path"]) 

1144 except KeyError: 

1145 dagman_config_path = None 

1146 with open(full_filename, "w") as fh: 

1147 if dagman_config_path is not None: 

1148 fh.write(f"CONFIG {dag_rel_path / dagman_config_path}\n") 

1149 

1150 for name, nodeval in self.nodes().items(): 

1151 try: 

1152 job = nodeval["data"] 

1153 except KeyError: 

1154 _LOG.error("Job %s doesn't have data (keys: %s).", name, nodeval.keys()) 

1155 raise 

1156 if job.subdag: 1156 ↛ 1157line 1156 didn't jump to line 1157 because the condition on line 1156 was never true

1157 if job.subfile: 

1158 this_dag_rel_path = "" 

1159 else: 

1160 this_dag_rel_path = "../.." 

1161 dag_subdir = f"subdags/{job.name}" 

1162 if "dir" in job.dagcmds: 

1163 subdir = job.dagcmds["dir"] 

1164 else: 

1165 subdir = job_subdir 

1166 if dagman_config_path is not None: 

1167 job.subdag.add_attribs({"bps_wms_config_path": str(dagman_config_path)}) 

1168 job.subdag.write(submit_path, subdir, dag_subdir, this_dag_rel_path) 

1169 fh.write(f"SUBDAG EXTERNAL {job.name} {Path(job.subdag.graph['dag_filename']).name}") 

1170 if dag_subdir: 

1171 fh.write(f" DIR {dag_subdir}") 

1172 fh.write("\n") 

1173 if job.dagcmds: 

1174 _htc_write_job_commands(fh, job.name, job.dagcmds) 

1175 else: 

1176 job.write_submit_file(submit_path) 

1177 job.write_dag_commands(fh, dag_rel_path) 

1178 

1179 for edge in self.edges(): 

1180 print(f"PARENT {edge[0]} CHILD {edge[1]}", file=fh) 

1181 

1182 if self.graph.get("write_dot", False): 

1183 print(f"DOT {self.name}.dot", file=fh) 

1184 

1185 print(f"NODE_STATUS_FILE {self.name}.node_status", file=fh) 

1186 

1187 # Add bps attributes to dag submission 

1188 for key, value in self.graph["attr"].items(): 

1189 print(f'SET_JOB_ATTR {key}= "{htc_escape(value)}"', file=fh) 

1190 

1191 # Add special nodes if any. 

1192 special_jobs = { 

1193 "FINAL": self.graph["final_job"], 

1194 "SERVICE": self.graph["service_job"], 

1195 } 

1196 for dagcmd, job in special_jobs.items(): 

1197 if job is not None: 

1198 job.write_submit_file(submit_path) 

1199 job.write_dag_commands(fh, dag_rel_path, dagcmd) 

1200 

1201 def dump(self, fh): 

1202 """Dump DAG info to output stream. 

1203 

1204 Parameters 

1205 ---------- 

1206 fh : `typing.IO` 

1207 Where to dump DAG info as text. 

1208 """ 

1209 for key, value in self.graph: 

1210 print(f"{key}={value}", file=fh) 

1211 for name, data in self.nodes().items(): 

1212 print(f"{name}:", file=fh) 

1213 data.dump(fh) 

1214 for edge in self.edges(): 

1215 print(f"PARENT {edge[0]} CHILD {edge[1]}", file=fh) 

1216 if self.graph["final_job"]: 

1217 print(f"FINAL {self.graph['final_job'].name}:", file=fh) 

1218 self.graph["final_job"].dump(fh) 

1219 

1220 def write_dot(self, filename): 

1221 """Write a dot version of the DAG. 

1222 

1223 Parameters 

1224 ---------- 

1225 filename : `str` 

1226 Name of the dot file. 

1227 """ 

1228 pos = networkx.nx_agraph.graphviz_layout(self) 

1229 networkx.draw(self, pos=pos) 

1230 networkx.drawing.nx_pydot.write_dot(self, filename) 

1231 

1232 

1233def condor_q(constraint=None, schedds=None, **kwargs): 

1234 """Get information about the jobs in the HTCondor job queue(s). 

1235 

1236 Parameters 

1237 ---------- 

1238 constraint : `str`, optional 

1239 Constraints to be passed to job query. 

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

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

1242 (default), the query will be run against local scheduler only. 

1243 **kwargs : `~typing.Any` 

1244 Additional keyword arguments that need to be passed to the internal 

1245 query method. 

1246 

1247 Returns 

1248 ------- 

1249 job_info : `dict` [`str`, `dict` [`str`, `dict` [`str`, `~typing.Any`]]] 

1250 Information about jobs satisfying the search criteria where for each 

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

1252 classads. 

1253 """ 

1254 return condor_query(constraint, schedds, htc_query_present, **kwargs) 

1255 

1256 

1257def condor_history(constraint=None, schedds=None, **kwargs): 

1258 """Get information about the jobs from HTCondor history records. 

1259 

1260 Parameters 

1261 ---------- 

1262 constraint : `str`, optional 

1263 Constraints to be passed to job query. 

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

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

1266 (default), the query will be run against the history file of 

1267 the local scheduler only. 

1268 **kwargs : `~typing.Any` 

1269 Additional keyword arguments that need to be passed to the internal 

1270 query method. 

1271 

1272 Returns 

1273 ------- 

1274 job_info : `dict` [`str`, `dict` [`str`, `dict` [`str`, `~typing.Any`]]] 

1275 Information about jobs satisfying the search criteria where for each 

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

1277 classads. 

1278 """ 

1279 return condor_query(constraint, schedds, htc_query_history, **kwargs) 

1280 

1281 

1282def condor_query(constraint=None, schedds=None, query_func=htc_query_present, **kwargs): 

1283 """Get information about HTCondor jobs. 

1284 

1285 Parameters 

1286 ---------- 

1287 constraint : `str`, optional 

1288 Constraints to be passed to job query. 

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

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

1291 (default), the query will be run against the history file of 

1292 the local scheduler only. 

1293 query_func : `~collections.abc.Callable` 

1294 An query function which takes following arguments: 

1295 

1296 - ``schedds``: Schedulers to query (`list` [`htcondor.Schedd`]). 

1297 - ``**kwargs``: Keyword arguments that will be passed to the query 

1298 function. 

1299 **kwargs : `~typing.Any` 

1300 Additional keyword arguments that need to be passed to the query 

1301 method. 

1302 

1303 Returns 

1304 ------- 

1305 job_info : `dict` [`str`, `dict` [`str`, `dict` [`str`, `~typing.Any`]]] 

1306 Information about jobs satisfying the search criteria where for each 

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

1308 classads. 

1309 """ 

1310 if not schedds: 

1311 coll = Collector() 

1312 schedd_ad = coll.locate(DaemonTypes.Schedd) 

1313 schedds = {schedd_ad["Name"]: Schedd(schedd_ad)} 

1314 

1315 # Make sure that 'ClusterId' and 'ProcId' attributes are always included 

1316 # in the job classad. They are needed to construct the job id. 

1317 added_attrs = set() 

1318 if "projection" in kwargs and kwargs["projection"]: 

1319 requested_attrs = set(kwargs["projection"]) 

1320 required_attrs = {"ClusterId", "ProcId"} 

1321 added_attrs = required_attrs - requested_attrs 

1322 for attr in added_attrs: 

1323 kwargs["projection"].append(attr) 

1324 

1325 unwanted_attrs = {"Env", "Environment"} | added_attrs 

1326 job_info = defaultdict(dict) 

1327 for schedd_name, job_ad in query_func(schedds, constraint=constraint, **kwargs): 

1328 id_ = f"{job_ad['ClusterId']}.{job_ad['ProcId']}" 

1329 for attr in set(job_ad) & unwanted_attrs: 

1330 del job_ad[attr] 

1331 job_info[schedd_name][id_] = job_ad 

1332 _LOG.debug("query returned %d jobs", sum(len(val) for val in job_info.values())) 

1333 

1334 # Restore the list of the requested attributes to its original value 

1335 # if needed. 

1336 if added_attrs: 

1337 for attr in added_attrs: 

1338 kwargs["projection"].remove(attr) 

1339 

1340 # When returning the results filter out entries for schedulers with no jobs 

1341 # matching the search criteria. 

1342 return {key: val for key, val in job_info.items() if val} 

1343 

1344 

1345def condor_search( 

1346 constraint: str | None = None, 

1347 hist: float | None = None, 

1348 schedds: dict[str, dict[str, Schedd]] | None = None, 

1349 skip_history: bool = False, 

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

1351 """Search for running and finished jobs satisfying given criteria. 

1352 

1353 Parameters 

1354 ---------- 

1355 constraint : `str`, optional 

1356 Constraints to be passed to job query. 

1357 hist : `float` 

1358 Limit history search to this many days. 

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

1360 The list of the HTCondor schedulers which to query for job information. 

1361 If None (default), only the local scheduler will be queried. 

1362 skip_history : `bool`, optional 

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

1364 information. Defaults to False. 

1365 

1366 Returns 

1367 ------- 

1368 job_info : `dict` [`str`, `dict` [`str`, `dict` [`str` `~typing.Any`]]] 

1369 Information about jobs satisfying the search criteria where for each 

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

1371 classads. 

1372 """ 

1373 if constraint is None: 

1374 constraint = "" 

1375 

1376 if not schedds: 

1377 coll = Collector() 

1378 schedd_ad = coll.locate(DaemonTypes.Schedd) 

1379 schedds = {schedd_ad["Name"]: Schedd(location=schedd_ad)} 

1380 

1381 job_info = condor_q(constraint=constraint, schedds=schedds) 

1382 if hist is not None and (not skip_history or not job_info): 

1383 _LOG.debug("Searching history going back %s days", hist) 

1384 epoch = (datetime.now() - timedelta(days=hist)).timestamp() 

1385 constraint += f" && (CompletionDate >= {epoch} || JobFinishedHookDone >= {epoch})" 

1386 hist_info = condor_history(constraint, schedds=schedds) 

1387 update_job_info(job_info, hist_info) 

1388 return job_info 

1389 

1390 

1391def condor_status(constraint=None, coll=None): 

1392 """Get information about HTCondor pool. 

1393 

1394 Parameters 

1395 ---------- 

1396 constraint : `str`, optional 

1397 Constraints to be passed to the query. 

1398 coll : `htcondor.Collector`, optional 

1399 Object representing HTCondor collector daemon. 

1400 

1401 Returns 

1402 ------- 

1403 pool_info : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

1404 Mapping between HTCondor slot names and slot information (classAds). 

1405 """ 

1406 if coll is None: 

1407 coll = Collector() 

1408 try: 

1409 pool_ads = coll.query(constraint=constraint) 

1410 except HTCondorException as ex: 

1411 raise RuntimeError(f"Problem querying the Collector. (Constraint='{constraint}')") from ex 

1412 

1413 pool_info = {} 

1414 for slot in pool_ads: 

1415 pool_info[slot["name"]] = dict(slot) 

1416 _LOG.debug("condor_status returned %d ads", len(pool_info)) 

1417 return pool_info 

1418 

1419 

1420def update_job_info(job_info, other_info): 

1421 """Update results of a job query with results from another query. 

1422 

1423 Parameters 

1424 ---------- 

1425 job_info : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

1426 Results of the job query that needs to be updated. 

1427 other_info : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

1428 Results of the other job query. 

1429 

1430 Returns 

1431 ------- 

1432 job_info : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

1433 The updated results. 

1434 """ 

1435 for schedd_name, others in other_info.items(): 

1436 try: 

1437 jobs = job_info[schedd_name] 

1438 except KeyError: 

1439 job_info[schedd_name] = others 

1440 else: 

1441 for id_, ad in others.items(): 

1442 jobs.setdefault(id_, {}).update(ad) 

1443 return job_info 

1444 

1445 

1446def count_jobs_in_single_dag( 

1447 filename: str | os.PathLike, 

1448) -> tuple[Counter[str], dict[str, str], dict[str, WmsNodeType]]: 

1449 """Build bps_run_summary string from dag file. 

1450 

1451 Parameters 

1452 ---------- 

1453 filename : `str` 

1454 Path that includes dag file for a run. 

1455 

1456 Returns 

1457 ------- 

1458 counts : `Counter` [`str`] 

1459 Semi-colon separated list of job labels and counts. 

1460 (Same format as saved in dag classad). 

1461 job_name_to_label : `dict` [`str`, `str`] 

1462 Mapping of job names to job labels. 

1463 job_name_to_type : `dict` [`str`, `lsst.ctrl.bps.htcondor.WmsNodeType`] 

1464 Mapping of job names to job types 

1465 (e.g., payload, final, service). 

1466 """ 

1467 # Later code depends upon insertion order 

1468 counts: Counter = Counter() # counts of payload jobs per label 

1469 job_name_to_label: dict[str, str] = {} 

1470 job_name_to_type: dict[str, WmsNodeType] = {} 

1471 with open(filename) as fh: 

1472 for line in fh: 

1473 # Skip any line that contains commands irrelevant to job counting. 

1474 if not line.startswith( 

1475 ( 

1476 "JOB", 

1477 "FINAL", 

1478 "SERVICE", 

1479 "SUBDAG EXTERNAL", 

1480 ) 

1481 ): 

1482 continue 

1483 

1484 m = re.match( 

1485 r"(?P<command>JOB|FINAL|SERVICE|SUBDAG EXTERNAL)\s+" 

1486 r'(?P<jobname>(?P<wms>wms_)?\S+)\s+"?(?P<subfile>\S+)"?\s*' 

1487 r'(DIR "?(?P<dir>[^\s"]+)"?)?\s*(?P<noop>NOOP)?', 

1488 line, 

1489 ) 

1490 if m: 1490 ↛ 1543line 1490 didn't jump to line 1543 because the condition on line 1490 was always true

1491 job_name = m.group("jobname") 

1492 job_type = WmsNodeType.UNKNOWN 

1493 name_parts = job_name.split("_") 

1494 

1495 label = "" 

1496 if m.group("dir"): 

1497 dir_match = re.search(r"jobs/([^\s/]+)", m.group("dir")) 

1498 if dir_match: 

1499 label = dir_match.group(1) 

1500 else: 

1501 _LOG.debug("Parse DAG: unparsed dir = %s", line) 

1502 elif m.group("subfile"): 1502 ↛ 1509line 1502 didn't jump to line 1509 because the condition on line 1502 was always true

1503 subfile_match = re.search(r"jobs/([^\s/]+)", m.group("subfile")) 

1504 if subfile_match: 

1505 label = m.group("subfile").split("/")[1] 

1506 else: 

1507 label = pegasus_name_to_label(job_name) 

1508 

1509 match m.group("command"): 

1510 case "JOB": 

1511 if m.group("noop"): 

1512 job_type = WmsNodeType.NOOP 

1513 # wms_noop_label 

1514 label = name_parts[2] 

1515 elif m.group("wms"): 

1516 if name_parts[1] == "check": 1516 ↛ 1521line 1516 didn't jump to line 1521 because the condition on line 1516 was always true

1517 job_type = WmsNodeType.SUBDAG_CHECK 

1518 # wms_check_status_wms_group_label 

1519 label = name_parts[5] 

1520 else: 

1521 _LOG.warning( 

1522 "Unexpected skipping of dag line due to unknown wms job: %s", line 

1523 ) 

1524 else: 

1525 job_type = WmsNodeType.PAYLOAD 

1526 if label == "init": 1526 ↛ 1527line 1526 didn't jump to line 1527 because the condition on line 1526 was never true

1527 label = "pipetaskInit" 

1528 counts[label] += 1 

1529 case "FINAL": 

1530 job_type = WmsNodeType.FINAL 

1531 counts[label] += 1 # final counts a payload job. 

1532 case "SERVICE": 

1533 job_type = WmsNodeType.SERVICE 

1534 case "SUBDAG EXTERNAL": 1534 ↛ 1538line 1534 didn't jump to line 1538 because the pattern on line 1534 always matched

1535 job_type = WmsNodeType.SUBDAG 

1536 label = name_parts[2] 

1537 

1538 job_name_to_label[job_name] = label 

1539 job_name_to_type[job_name] = job_type 

1540 else: 

1541 # The line should, but didn't match the pattern above. Probably 

1542 # problems with regex. 

1543 _LOG.warning("Unexpected skipping of dag line: %s", line) 

1544 

1545 return counts, job_name_to_label, job_name_to_type 

1546 

1547 

1548def summarize_dag(dir_name: str) -> tuple[str, dict[str, str], dict[str, WmsNodeType]]: 

1549 """Build bps_run_summary string from dag file. 

1550 

1551 Parameters 

1552 ---------- 

1553 dir_name : `str` 

1554 Path that includes dag file for a run. 

1555 

1556 Returns 

1557 ------- 

1558 summary : `str` 

1559 Semi-colon separated list of job labels and counts 

1560 (Same format as saved in dag classad). 

1561 job_name_to_label : `dict` [`str`, `str`] 

1562 Mapping of job names to job labels. 

1563 job_name_to_type : `dict` [`str`, `lsst.ctrl.bps.htcondor.WmsNodeType`] 

1564 Mapping of job names to job types 

1565 (e.g., payload, final, service). 

1566 """ 

1567 # Later code depends upon insertion order 

1568 counts: Counter[str] = Counter() # counts of payload jobs per label 

1569 job_name_to_label: dict[str, str] = {} 

1570 job_name_to_type: dict[str, WmsNodeType] = {} 

1571 for filename in Path(dir_name).glob("*.dag"): 

1572 single_counts, single_job_name_to_label, single_job_name_to_type = count_jobs_in_single_dag(filename) 

1573 counts += single_counts 

1574 _update_dicts(job_name_to_label, single_job_name_to_label) 

1575 _update_dicts(job_name_to_type, single_job_name_to_type) 

1576 

1577 for filename in Path(dir_name).glob("subdags/*/*.dag"): 

1578 single_counts, single_job_name_to_label, single_job_name_to_type = count_jobs_in_single_dag(filename) 

1579 counts += single_counts 

1580 _update_dicts(job_name_to_label, single_job_name_to_label) 

1581 _update_dicts(job_name_to_type, single_job_name_to_type) 

1582 

1583 summary = ";".join([f"{name}:{counts[name]}" for name in counts]) 

1584 _LOG.debug("summarize_dag: %s %s %s", summary, job_name_to_label, job_name_to_type) 

1585 return summary, job_name_to_label, job_name_to_type 

1586 

1587 

1588def pegasus_name_to_label(name): 

1589 """Convert pegasus job name to a label for the report. 

1590 

1591 Parameters 

1592 ---------- 

1593 name : `str` 

1594 Name of job. 

1595 

1596 Returns 

1597 ------- 

1598 label : `str` 

1599 Label for job. 

1600 """ 

1601 label = "UNK" 

1602 if name.startswith("create_dir") or name.startswith("stage_in") or name.startswith("stage_out"): 1602 ↛ 1603line 1602 didn't jump to line 1603 because the condition on line 1602 was never true

1603 label = "pegasus" 

1604 else: 

1605 m = re.match(r"pipetask_(\d+_)?([^_]+)", name) 

1606 if m: 1606 ↛ 1607line 1606 didn't jump to line 1607 because the condition on line 1606 was never true

1607 label = m.group(2) 

1608 if label == "init": 

1609 label = "pipetaskInit" 

1610 

1611 return label 

1612 

1613 

1614def read_single_dag_status(filename: str | os.PathLike) -> dict[str, Any]: 

1615 """Read the node status file for DAG summary information. 

1616 

1617 Parameters 

1618 ---------- 

1619 filename : `str` or `Path.pathlib` 

1620 Node status filename. 

1621 

1622 Returns 

1623 ------- 

1624 dag_ad : `dict` [`str`, `~typing.Any`] 

1625 DAG summary information. 

1626 """ 

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

1628 

1629 # While this is probably more up to date than dag classad, only read from 

1630 # file if need to. 

1631 try: 

1632 node_stat_file = Path(filename) 

1633 _LOG.debug("Reading Node Status File %s", node_stat_file) 

1634 with open(node_stat_file) as infh: 

1635 dag_ad = dict(parseNext(infh)) # pylint: disable=E1101 

1636 

1637 if not dag_ad: 1637 ↛ 1639line 1637 didn't jump to line 1639 because the condition on line 1637 was never true

1638 # Pegasus check here 

1639 metrics_file = node_stat_file.with_suffix(".dag.metrics") 

1640 if metrics_file.exists(): 

1641 with open(metrics_file) as infh: 

1642 metrics = json.load(infh) 

1643 dag_ad["NodesTotal"] = metrics.get("jobs", 0) 

1644 dag_ad["NodesFailed"] = metrics.get("jobs_failed", 0) 

1645 dag_ad["NodesDone"] = metrics.get("jobs_succeeded", 0) 

1646 metrics_file = node_stat_file.with_suffix(".metrics") 

1647 with open(metrics_file) as infh: 

1648 metrics = json.load(infh) 

1649 dag_ad["NodesTotal"] = metrics["wf_metrics"]["total_jobs"] 

1650 except (OSError, PermissionError): 

1651 pass 

1652 

1653 _LOG.debug("read_dag_status: %s", dag_ad) 

1654 return dag_ad 

1655 

1656 

1657def read_dag_status(wms_path: str | os.PathLike) -> dict[str, Any]: 

1658 """Read the node status file for DAG summary information. 

1659 

1660 Parameters 

1661 ---------- 

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

1663 Path that includes node status file for a run. 

1664 

1665 Returns 

1666 ------- 

1667 dag_ad : `dict` [`str`, `~typing.Any`] 

1668 DAG summary information, counts summed across any subdags. 

1669 """ 

1670 dag_ads: dict[str, Any] = {} 

1671 path = Path(wms_path) 

1672 try: 

1673 node_stat_file = next(path.glob("*.node_status")) 

1674 except StopIteration as exc: 

1675 raise FileNotFoundError(f"DAGMan node status not found in {wms_path}") from exc 

1676 

1677 dag_ads = read_single_dag_status(node_stat_file) 

1678 

1679 for node_stat_file in path.glob("subdags/*/*.node_status"): 

1680 dag_ad = read_single_dag_status(node_stat_file) 

1681 dag_ads["JobProcsHeld"] += dag_ad.get("JobProcsHeld", 0) 

1682 dag_ads["NodesPost"] += dag_ad.get("NodesPost", 0) 

1683 dag_ads["JobProcsIdle"] += dag_ad.get("JobProcsIdle", 0) 

1684 dag_ads["NodesTotal"] += dag_ad.get("NodesTotal", 0) 

1685 dag_ads["NodesFailed"] += dag_ad.get("NodesFailed", 0) 

1686 dag_ads["NodesDone"] += dag_ad.get("NodesDone", 0) 

1687 dag_ads["NodesQueued"] += dag_ad.get("NodesQueued", 0) 

1688 dag_ads["NodesPre"] += dag_ad.get("NodesReady", 0) 

1689 dag_ads["NodesFutile"] += dag_ad.get("NodesFutile", 0) 

1690 dag_ads["NodesUnready"] += dag_ad.get("NodesUnready", 0) 

1691 

1692 return dag_ads 

1693 

1694 

1695def read_single_node_status(filename: str | os.PathLike, init_fake_id: int) -> dict[str, Any]: 

1696 """Read entire node status file. 

1697 

1698 Parameters 

1699 ---------- 

1700 filename : `str` or `pathlib.Path` 

1701 Node status filename. 

1702 init_fake_id : `int` 

1703 Initial fake id value. 

1704 

1705 Returns 

1706 ------- 

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

1708 DAG summary information compiled from the node status file combined 

1709 with the information found in the node event log. 

1710 

1711 Currently, if the same job attribute is found in both files, its value 

1712 from the event log takes precedence over the value from the node status 

1713 file. 

1714 """ 

1715 filename = Path(filename) 

1716 

1717 # Get jobid info from other places to fill in gaps in info from node_status 

1718 _, job_name_to_label, job_name_to_type = count_jobs_in_single_dag(filename.with_suffix(".dag")) 

1719 loginfo: dict[str, dict[str, Any]] = {} 

1720 wms_workflow_id = MISSING_ID 

1721 try: 

1722 wms_workflow_id, _ = read_single_dag_log(filename.with_suffix(".dag.dagman.log")) 

1723 loginfo = read_single_dag_nodes_log(filename.with_suffix(".dag.nodes.log")) 

1724 except (HTCondorException, OSError, PermissionError): 

1725 pass 

1726 

1727 job_name_to_id: dict[str, str] = {} 

1728 _LOG.debug("loginfo = %s", loginfo) 

1729 log_job_name_to_id: dict[str, str] = {} 

1730 for job_id, job_info in loginfo.items(): 

1731 if "LogNotes" in job_info: 1731 ↛ 1730line 1731 didn't jump to line 1730 because the condition on line 1731 was always true

1732 m = re.match(r"DAG Node: (\S+)", job_info["LogNotes"]) 

1733 if m: 1733 ↛ 1730line 1733 didn't jump to line 1730 because the condition on line 1733 was always true

1734 job_name = m.group(1) 

1735 log_job_name_to_id[job_name] = job_id 

1736 job_info["DAGNodeName"] = job_name 

1737 job_info["wms_node_type"] = job_name_to_type[job_name] 

1738 job_info["bps_job_label"] = job_name_to_label[job_name] 

1739 

1740 jobs = {} 

1741 fake_id = init_fake_id # For nodes that do not yet have a job id, give fake one 

1742 try: 

1743 with open(filename) as fh: 

1744 for ad in parseAds(fh): 

1745 match ad["Type"]: 

1746 case "DagStatus": 

1747 # Skip DAG summary. 

1748 pass 

1749 case "NodeStatus": 

1750 job_name = ad["Node"] 

1751 if job_name in job_name_to_label: 1751 ↛ 1753line 1751 didn't jump to line 1753 because the condition on line 1751 was always true

1752 job_label = job_name_to_label[job_name] 

1753 elif "_" in job_name: 

1754 job_label = job_name.split("_")[1] 

1755 else: 

1756 job_label = job_name 

1757 

1758 job = dict(ad) 

1759 if job_name in log_job_name_to_id: 

1760 job_id = str(log_job_name_to_id[job_name]) 

1761 _update_dicts(job, loginfo[job_id]) 

1762 else: 

1763 job_id = str(fake_id) 

1764 job = dict(ad) 

1765 fake_id -= 1 

1766 jobs[job_id] = job 

1767 job_name_to_id[job_name] = job_id 

1768 

1769 # Make job info as if came from condor_q. 

1770 job["ClusterId"] = int(float(job_id)) 

1771 job["DAGManJobID"] = wms_workflow_id 

1772 job["DAGNodeName"] = job_name 

1773 job["bps_job_label"] = job_label 

1774 job["wms_node_type"] = job_name_to_type[job_name] 

1775 

1776 case "StatusEnd": 1776 ↛ 1779line 1776 didn't jump to line 1779 because the pattern on line 1776 always matched

1777 # Skip node status file "epilog". 

1778 pass 

1779 case _: 

1780 _LOG.debug( 

1781 "Ignoring unknown classad type '%s' in the node status file '%s'", 

1782 ad["Type"], 

1783 filename, 

1784 ) 

1785 except (OSError, PermissionError): 

1786 pass 

1787 

1788 # Check for missing jobs (e.g., submission failure or not submitted yet) 

1789 # Use dag info to create job placeholders 

1790 for name in set(job_name_to_label) - set(job_name_to_id): 

1791 if name in log_job_name_to_id: # job was in nodes.log, but not node_status 

1792 job_id = str(log_job_name_to_id[name]) 

1793 job = dict(loginfo[job_id]) 

1794 else: 

1795 job_id = str(fake_id) 

1796 fake_id -= 1 

1797 job = {} 

1798 job["NodeStatus"] = NodeStatus.NOT_READY 

1799 

1800 job["ClusterId"] = int(float(job_id)) 

1801 job["ProcId"] = 0 

1802 job["DAGManJobID"] = wms_workflow_id 

1803 job["DAGNodeName"] = name 

1804 job["bps_job_label"] = job_name_to_label[name] 

1805 job["wms_node_type"] = job_name_to_type[name] 

1806 jobs[f"{job['ClusterId']}.{job['ProcId']}"] = job 

1807 

1808 for job_info in jobs.values(): 

1809 job_info["from_dag_job"] = f"wms_{filename.stem}" 

1810 

1811 return jobs 

1812 

1813 

1814def read_node_status(wms_path: str | os.PathLike) -> dict[str, dict[str, Any]]: 

1815 """Read entire node status file. 

1816 

1817 Parameters 

1818 ---------- 

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

1820 Path that includes node status file for a run. 

1821 

1822 Returns 

1823 ------- 

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

1825 DAG summary information compiled from the node status file combined 

1826 with the information found in the node event log. 

1827 

1828 Currently, if the same job attribute is found in both files, its value 

1829 from the event log takes precedence over the value from the node status 

1830 file. 

1831 """ 

1832 jobs: dict[str, dict[str, Any]] = {} 

1833 init_fake_id = -1 

1834 

1835 # subdags may not have run so wouldn't have node_status file 

1836 # use dag files and let read_single_node_status handle missing 

1837 # node_status file. 

1838 for dag_filename in Path(wms_path).glob("*.dag"): 

1839 filename = dag_filename.with_suffix(".node_status") 

1840 info = read_single_node_status(filename, init_fake_id) 

1841 init_fake_id -= len(info) 

1842 _update_dicts(jobs, info) 

1843 

1844 for dag_filename in Path(wms_path).glob("subdags/*/*.dag"): 

1845 filename = dag_filename.with_suffix(".node_status") 

1846 info = read_single_node_status(filename, init_fake_id) 

1847 init_fake_id -= len(info) 

1848 _update_dicts(jobs, info) 

1849 

1850 # Propagate pruned from subdags to jobs 

1851 name_to_id: dict[str, str] = {} 

1852 missing_status: dict[str, list[str]] = {} 

1853 for id_, job in jobs.items(): 

1854 if job["DAGNodeName"].startswith("wms_"): 

1855 name_to_id[job["DAGNodeName"]] = id_ 

1856 if "NodeStatus" not in job or job["NodeStatus"] == NodeStatus.NOT_READY: 

1857 missing_status.setdefault(job["from_dag_job"], []).append(id_) 

1858 

1859 for name, dag_id in name_to_id.items(): 

1860 dag_status = jobs[dag_id].get("NodeStatus", NodeStatus.NOT_READY) 

1861 if dag_status in {NodeStatus.NOT_READY, NodeStatus.FUTILE}: 

1862 for id_ in missing_status.get(name, []): 

1863 jobs[id_]["NodeStatus"] = dag_status 

1864 

1865 return jobs 

1866 

1867 

1868def read_single_dag_log(log_filename: str | os.PathLike) -> tuple[str, dict[str, dict[str, Any]]]: 

1869 """Read job information from the DAGMan log file. 

1870 

1871 Parameters 

1872 ---------- 

1873 log_filename : `str` or `os.PathLike` 

1874 DAGMan log filename. 

1875 

1876 Returns 

1877 ------- 

1878 wms_workflow_id : `str` 

1879 HTCondor job id (i.e., <ClusterId>.<ProcId>) of the DAGMan job. 

1880 dag_info : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

1881 HTCondor job information read from the log file mapped to HTCondor 

1882 job id. 

1883 

1884 Raises 

1885 ------ 

1886 FileNotFoundError 

1887 If cannot find DAGMan log in given wms_path. 

1888 """ 

1889 wms_workflow_id = MISSING_ID 

1890 dag_info: dict[str, dict[str, Any]] = {} 

1891 

1892 filename = Path(log_filename) 

1893 if filename.exists(): 

1894 _LOG.debug("dag node log filename: %s", filename) 

1895 

1896 info: dict[str, Any] = {} 

1897 job_event_log = JobEventLog(str(filename)) 

1898 for event in job_event_log.events(stop_after=0): 

1899 id_ = f"{event['Cluster']}.{event['Proc']}" 

1900 if id_ not in info: 

1901 info[id_] = {} 

1902 wms_workflow_id = id_ # taking last job id in case of restarts 

1903 _update_dicts(info[id_], event) 

1904 info[id_][f"{event.type.name.lower()}_time"] = event["EventTime"] 

1905 

1906 # only save latest DAG job 

1907 dag_info = {wms_workflow_id: info[wms_workflow_id]} 

1908 

1909 return wms_workflow_id, dag_info 

1910 

1911 

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

1913 """Read job information from the DAGMan log file. 

1914 

1915 Parameters 

1916 ---------- 

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

1918 Path containing the DAGMan log file. 

1919 

1920 Returns 

1921 ------- 

1922 wms_workflow_id : `str` 

1923 HTCondor job id (i.e., <ClusterId>.<ProcId>) of the DAGMan job. 

1924 dag_info : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

1925 HTCondor job information read from the log file mapped to HTCondor 

1926 job id. 

1927 

1928 Raises 

1929 ------ 

1930 FileNotFoundError 

1931 If cannot find DAGMan log in given wms_path. 

1932 """ 

1933 wms_workflow_id = MISSING_ID 

1934 dag_info: dict[str, dict[str, Any]] = {} 

1935 

1936 path = Path(wms_path) 

1937 if path.exists(): 1937 ↛ 1949line 1937 didn't jump to line 1949 because the condition on line 1937 was always true

1938 # Can be more than one dag file in directory. Assume one 

1939 # with lowest ID is main DAG 

1940 ids = [] 

1941 for filename in path.glob("*.dag.dagman.log"): 

1942 _LOG.debug("dag log filename: %s", filename) 

1943 single_id, single_dag_info = read_single_dag_log(filename) 

1944 _update_dicts(dag_info, single_dag_info) 

1945 ids.append(single_id) 

1946 if ids: 

1947 wms_workflow_id = min(ids) 

1948 

1949 if wms_workflow_id == MISSING_ID: 

1950 raise FileNotFoundError(f"DAGMan log not found in {wms_path}") 

1951 

1952 return wms_workflow_id, dag_info 

1953 

1954 

1955def read_single_dag_nodes_log(filename: str | os.PathLike) -> dict[str, dict[str, Any]]: 

1956 """Read job information from the DAGMan nodes log file. 

1957 

1958 Parameters 

1959 ---------- 

1960 filename : `str` or `os.PathLike` 

1961 Path containing the DAGMan nodes log file. 

1962 

1963 Returns 

1964 ------- 

1965 info : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

1966 HTCondor job information read from the log file mapped to HTCondor 

1967 job id. 

1968 

1969 Raises 

1970 ------ 

1971 FileNotFoundError 

1972 If cannot find DAGMan node log in given wms_path. 

1973 """ 

1974 _LOG.debug("dag node log filename: %s", filename) 

1975 filename = Path(filename) 

1976 

1977 info: dict[str, dict[str, Any]] = {} 

1978 if not filename.exists(): 

1979 raise FileNotFoundError(f"{filename} does not exist") 

1980 

1981 try: 

1982 job_event_log = JobEventLog(str(filename)) 

1983 except HTCondorException as ex: 

1984 _LOG.error("Problem reading nodes log file (%s): %s", filename, ex) 

1985 import traceback 

1986 

1987 traceback.print_stack() 

1988 raise 

1989 for event in job_event_log.events(stop_after=0): 

1990 _LOG.debug("log event type = %s, keys = %s", event["EventTypeNumber"], event.keys()) 

1991 

1992 try: 

1993 id_ = f"{event['Cluster']}.{event['Proc']}" 

1994 except KeyError: 

1995 _LOG.warning( 

1996 "Log event missing ids (DAGNodeName=%s, EventTime=%s, EventTypeNumber=%s)", 

1997 event.get("DAGNodeName", "UNK"), 

1998 event.get("EventTime", "UNK"), 

1999 event.get("EventTypeNumber", "UNK"), 

2000 ) 

2001 else: 

2002 if id_ not in info: 

2003 info[id_] = {} 

2004 

2005 # Workaround: Please check to see if still problem in 

2006 # future HTCondor versions. Sometimes get a 

2007 # JobAbortedEvent for a subdag job after it already 

2008 # terminated normally. Seems to happen when using job 

2009 # plus subdags. 

2010 if ( 2010 ↛ 2014line 2010 didn't jump to line 2014 because the condition on line 2010 was never true

2011 event["EventTypeNumber"] == JobEventType.JOB_ABORTED 

2012 and info[id_].get("EventTypeNumber", -1) == JobEventType.JOB_TERMINATED 

2013 ): 

2014 _LOG.debug("Skipping spurious JobAbortedEvent: %s", dict(event)) 

2015 elif event["EventTypeNumber"] == 16 and event["DAGNodeName"] == "finalJob": 

2016 # FINAL job's post script exit code is special and indicates 

2017 # status of DAG instead of just the FINAL job. Save the 

2018 # information separately. 

2019 info[id_]["post"] = dict(event) 

2020 else: 

2021 _update_dicts(info[id_], event) 

2022 info[id_][f"{event.type.name.lower()}_time"] = event["EventTime"] 

2023 

2024 return info 

2025 

2026 

2027def read_dag_nodes_log(wms_path: str | os.PathLike) -> dict[str, dict[str, Any]]: 

2028 """Read job information from the DAGMan nodes log file. 

2029 

2030 Parameters 

2031 ---------- 

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

2033 Path containing the DAGMan nodes log file. 

2034 

2035 Returns 

2036 ------- 

2037 info : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

2038 HTCondor job information read from the log file mapped to HTCondor 

2039 job id. 

2040 

2041 Raises 

2042 ------ 

2043 FileNotFoundError 

2044 If cannot find DAGMan node log in given wms_path. 

2045 """ 

2046 info: dict[str, dict[str, Any]] = {} 

2047 for filename in Path(wms_path).glob("*.dag.nodes.log"): 

2048 _LOG.debug("dag node log filename: %s", filename) 

2049 _update_dicts(info, read_single_dag_nodes_log(filename)) 

2050 

2051 # If submitted, the main nodes log file should exist 

2052 if not info: 

2053 raise FileNotFoundError(f"DAGMan node log not found in {wms_path}") 

2054 

2055 # Subdags will not have dag nodes log files if they haven't 

2056 # started running yet (so missing is not an error). 

2057 for filename in Path(wms_path).glob("subdags/*/*.dag.nodes.log"): 

2058 _LOG.debug("dag node log filename: %s", filename) 

2059 _update_dicts(info, read_single_dag_nodes_log(filename)) 

2060 

2061 return info 

2062 

2063 

2064def read_dag_info(wms_path: str | os.PathLike) -> tuple[Path, dict[str, dict[str, Any]]]: 

2065 """Read custom DAGMan job information from the file. 

2066 

2067 Parameters 

2068 ---------- 

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

2070 Path containing the file with the DAGMan job info. 

2071 

2072 Returns 

2073 ------- 

2074 filename : `pathlib.Path` 

2075 Name of file containing the dag information. 

2076 dag_info : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

2077 HTCondor job information. 

2078 

2079 Raises 

2080 ------ 

2081 FileNotFoundError 

2082 If cannot find DAGMan job info file in the given location. 

2083 """ 

2084 dag_info: dict[str, dict[str, Any]] = {} 

2085 try: 

2086 filename = next(Path(wms_path).glob("*.info.json")) 

2087 except StopIteration as exc: 

2088 raise FileNotFoundError(f"File with DAGMan job information not found in {wms_path}") from exc 

2089 _LOG.debug("DAGMan job information filename: %s", filename) 

2090 try: 

2091 with open(filename) as fh: 

2092 dag_info = json.load(fh) 

2093 except (OSError, PermissionError) as exc: 

2094 _LOG.debug("Retrieving DAGMan job information failed: %s", exc) 

2095 return filename, dag_info 

2096 

2097 

2098def write_dag_info(filename: str, schedd_dag_info: dict[str, dict[str, Any]]): 

2099 """Write custom job information about DAGMan job. 

2100 

2101 Parameters 

2102 ---------- 

2103 filename : `str` 

2104 Name of the file where the information will be stored. If not given, 

2105 creates a filename using bps_run value. 

2106 schedd_dag_info : `dict` [`str` `dict` [`str`, `~typing.Any`]] 

2107 Information about the DAGMan job. 

2108 """ 

2109 _LOG.debug("schedd_dag_info = %s", schedd_dag_info) 

2110 schedd_name, dag_info = next(iter(schedd_dag_info.items())) 

2111 dag_id, dag_ad = next(iter(dag_info.items())) 

2112 ad = {"ClusterId": dag_ad["ClusterId"], "GlobalJobId": dag_ad["GlobalJobId"]} 

2113 ad.update({key: val for key, val in dag_ad.items() if key.startswith("bps")}) 

2114 try: 

2115 with open(filename, "w") as fh: 

2116 info = {schedd_name: {dag_id: ad}} 

2117 json.dump(info, fh) 

2118 except (KeyError, OSError, PermissionError) as exc: 

2119 _LOG.debug("Persisting DAGMan job information failed: %s", exc) 

2120 

2121 return filename 

2122 

2123 

2124def htc_tweak_log_info(wms_path: str | Path, job: dict[str, Any]) -> None: 

2125 """Massage the given job info has same structure as if came from condor_q. 

2126 

2127 Parameters 

2128 ---------- 

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

2130 Path containing an HTCondor event log file. 

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

2132 A mapping between HTCondor job id and job information read from 

2133 the log. 

2134 

2135 Notes 

2136 ----- 

2137 FIXME: this function works by side effect, replacing the ``job`` reference 

2138 with a new object. It should instead return the new object (or ``None``) 

2139 and resolve the object references in the caller. 

2140 """ 

2141 _LOG.debug("htc_tweak_log_info: %s %s", wms_path, job) 

2142 

2143 # Use the presence of 'MyType' key as a proxy to determine if the job ad 

2144 # contains the info extracted from the event log. Exit early if it doesn't 

2145 # (e.g. it is a job ad for a pruned job). 

2146 if "MyType" not in job: 

2147 return 

2148 

2149 try: 

2150 job["ClusterId"] = job["Cluster"] 

2151 job["ProcId"] = job["Proc"] 

2152 except KeyError as e: 

2153 _LOG.error("Missing key %s in job: %s", str(e), job) 

2154 raise 

2155 job["Iwd"] = str(wms_path) 

2156 job["Owner"] = Path(wms_path).owner() 

2157 

2158 if "LogNotes" in job: 

2159 m = re.match(r"DAG Node: (\S+)", job["LogNotes"]) 

2160 if m: 2160 ↛ 2163line 2160 didn't jump to line 2163 because the condition on line 2160 was always true

2161 job["DAGNodeName"] = m.group(1) 

2162 

2163 match job["MyType"]: 

2164 case "ExecuteEvent": 

2165 job["JobStatus"] = JobStatus.RUNNING 

2166 case "JobTerminatedEvent" | "PostScriptTerminatedEvent": 

2167 job["JobStatus"] = JobStatus.COMPLETED 

2168 case "SubmitEvent": 

2169 job["JobStatus"] = JobStatus.IDLE 

2170 case "JobAbortedEvent": 

2171 job["JobStatus"] = JobStatus.REMOVED 

2172 case "JobHeldEvent": 

2173 job["JobStatus"] = JobStatus.HELD 

2174 case "JobReleaseEvent": 

2175 # If the job managing the execution of the root DAG is held and 

2176 # released this will be the last event showing up in its 

2177 # job event log even if the job is still running. If this is 

2178 # the last event for a job corresponding to the workflow node 

2179 # (either a normal payload job or the job managing the execution 

2180 # of an inner DAG), its final status will be determined later 

2181 # using node status log (see _htc_status_to_wms_state()). 

2182 job["JobStatus"] = JobStatus.RUNNING if "DAGNodeName" not in job else None 

2183 case _: 

2184 _LOG.debug("Unknown log event type: %s", job["MyType"]) 

2185 job["JobStatus"] = None 

2186 

2187 # Use available information to add either "ExitCode" or "ExitSignal" 

2188 # attribute that captures respectively job's exit status (if it finished 

2189 # on its own accord) or its exit signal (if it was terminated by 

2190 # a signal). Also, include a flag "ExitBySignal" to make distinguishing 

2191 # between these two cases easy later on. 

2192 if job["JobStatus"] in { 

2193 JobStatus.COMPLETED, 

2194 JobStatus.HELD, 

2195 JobStatus.REMOVED, 

2196 }: 

2197 new_job = HTC_JOB_AD_HANDLERS.handle(job) 

2198 if new_job is not None: 

2199 job = new_job 

2200 else: 

2201 _LOG.error("Could not determine exit status for job '%s.%s'", job["ClusterId"], job["ProcId"]) 

2202 

2203 

2204def htc_check_dagman_output(wms_path: str | os.PathLike) -> str: 

2205 """Check the DAGMan output for error messages. 

2206 

2207 Parameters 

2208 ---------- 

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

2210 Directory containing the DAGman output file. 

2211 

2212 Returns 

2213 ------- 

2214 message : `str` 

2215 Message containing error messages from the DAGMan output. Empty 

2216 string if no messages. 

2217 

2218 Raises 

2219 ------ 

2220 FileNotFoundError 

2221 If cannot find DAGMan standard output file in given wms_path. 

2222 """ 

2223 try: 

2224 filename = next(Path(wms_path).glob("*.dag.dagman.out")) 

2225 except StopIteration as exc: 

2226 raise FileNotFoundError(f"DAGMan standard output file not found in {wms_path}") from exc 

2227 _LOG.debug("dag output filename: %s", filename) 

2228 

2229 p = re.compile(r"^(\d\d/\d\d/\d\d \d\d:\d\d:\d\d) (Job submit try \d+/\d+ failed|Warning:.*$|ERROR:.*$)") 

2230 

2231 message = "" 

2232 # Shouldn't normally see this warning. Initializing it just in case. 

2233 last_warning = ( 

2234 "Missing warning: Usually means the .dagman.out file has been truncated " 

2235 "or temporarily had a full quota." 

2236 ) 

2237 try: 

2238 with open(filename) as fh: 

2239 last_submit_failed = "" # Since submit retries multiple times only report last one 

2240 for line in fh: 

2241 m = p.match(line) 

2242 if m: 

2243 if m.group(2).startswith("Job submit try"): 

2244 last_submit_failed = m.group(1) 

2245 elif m.group(2).startswith("ERROR: submit attempt failed"): 

2246 pass # Should be handled by Job submit try 

2247 elif m.group(2).startswith("Warning"): 

2248 if ".dag.nodes.log is in /tmp" in m.group(2): 

2249 last_warning = "Cannot submit from /tmp." 

2250 else: 

2251 last_warning = m.group(2) 

2252 elif m.group(2) == "ERROR: Warning is fatal error because of DAGMAN_USE_STRICT setting": 

2253 message += "ERROR: " 

2254 message += last_warning 

2255 message += "\n" 

2256 elif m.group(2) in [ 2256 ↛ 2262line 2256 didn't jump to line 2262 because the condition on line 2256 was always true

2257 "ERROR: the following job(s) failed:", 

2258 "ERROR: the following Node(s) failed:", 

2259 ]: 

2260 pass 

2261 else: 

2262 message += m.group(2) 

2263 message += "\n" 

2264 

2265 if last_submit_failed: 

2266 message += f"Warn: Job submission issues (last: {last_submit_failed})" 

2267 except (OSError, PermissionError): 

2268 message = f"Warn: Could not read dagman output file from {wms_path}." 

2269 _LOG.debug("dag output file message: %s", message) 

2270 return message 

2271 

2272 

2273def _read_rescue_headers(infh: TextIO) -> list[str]: 

2274 """Read header lines from a rescue file. 

2275 

2276 Parameters 

2277 ---------- 

2278 infh : `TextIO` 

2279 The rescue file from which to read the header lines. 

2280 

2281 Returns 

2282 ------- 

2283 header_lines : `list` [`str`] 

2284 Header lines read from the rescue file. 

2285 """ 

2286 header_lines = [] 

2287 for line in infh: 

2288 line = line.strip() 

2289 if not line.startswith("#"): 

2290 break 

2291 header_lines.append(line) 

2292 return header_lines 

2293 

2294 

2295def _update_rescue_headers(header_lines: list[str]) -> list[str]: 

2296 """Update rescue header lines in place. 

2297 

2298 Replaces ``wms_check_status`` node name prefixes with the corresponding 

2299 group node names and adjusts the count of nodes premarked DONE. 

2300 

2301 Parameters 

2302 ---------- 

2303 header_lines : `list` [`str`] 

2304 Header lines to update in place. 

2305 

2306 Returns 

2307 ------- 

2308 failed_subdags : `list` [`str`] 

2309 Names of failed subdag jobs. 

2310 """ 

2311 failed_subdags = [] 

2312 

2313 # Relies on HTCondor writing all failed node names on a single line 

2314 # (see src/condor_dagman/dag.cpp in https://github.com/htcondor/htcondor). 

2315 for i, line in enumerate(header_lines): 

2316 if line.startswith("# Nodes that failed:"): 

2317 nodes = header_lines[i + 1][1:].strip().split(",") 

2318 new_nodes = [] 

2319 for node in nodes: 

2320 if node.startswith("wms_check_status"): 

2321 node = node[17:] 

2322 failed_subdags.append(node) 

2323 new_nodes.append(node) 

2324 header_lines[i + 1] = f"# {','.join(new_nodes)}" 

2325 break 

2326 

2327 for i, line in enumerate(header_lines): 

2328 m = re.match(r"^(# Nodes premarked DONE:)\s+(\d+)", line) 

2329 if m: 

2330 header_lines[i] = f"{m.group(1)} {int(m.group(2)) - len(failed_subdags)}" 

2331 break 

2332 

2333 return failed_subdags 

2334 

2335 

2336def _write_rescue_headers(header_lines: list[str], outfh: TextIO) -> None: 

2337 """Write the header lines to the new rescue file. 

2338 

2339 Parameters 

2340 ---------- 

2341 header_lines : `list` [`str`] 

2342 Header lines to write to the new rescue file. 

2343 outfh : `TextIO` 

2344 New rescue file. 

2345 """ 

2346 for line in header_lines: 

2347 print(line, file=outfh) 

2348 print("", file=outfh) 

2349 

2350 

2351def _copy_done_lines(failed_subdags: list[str], infh: TextIO, outfh: TextIO) -> None: 

2352 """Copy the DONE lines from the original rescue file skipping 

2353 the failed group jobs. 

2354 

2355 Parameters 

2356 ---------- 

2357 failed_subdags : `list` [`str`] 

2358 List of job names for the failed subdags 

2359 infh : `TextIO` 

2360 Original rescue file to copy from. 

2361 outfh : `TextIO` 

2362 New rescue file to copy to. 

2363 """ 

2364 for line in infh: 

2365 line = line.strip() 

2366 try: 

2367 _, node_name = line.split() 

2368 except ValueError: 

2369 _LOG.error(f"Unexpected line in rescue file = '{line}'") 

2370 raise 

2371 if node_name not in failed_subdags: 

2372 print(line, file=outfh) 

2373 

2374 

2375def _update_rescue_file(rescue_file: Path) -> list[str]: 

2376 """Update the subdag failures in the main rescue file. 

2377 

2378 Parameters 

2379 ---------- 

2380 rescue_file : `pathlib.Path` 

2381 The main rescue file that needs to be updated. 

2382 

2383 Returns 

2384 ------- 

2385 failed_subdags : `list` [`str`] 

2386 Names of failed subdag jobs found in the DAGMan rescue file. 

2387 """ 

2388 # To reduce memory requirements, not reading entire file into memory. 

2389 rescue_tmp = rescue_file.with_suffix(rescue_file.suffix + ".tmp") 

2390 with open(rescue_file) as infh: 

2391 header_lines = _read_rescue_headers(infh) 

2392 failed_subdags = _update_rescue_headers(header_lines) 

2393 with open(rescue_tmp, "w") as outfh: 

2394 _write_rescue_headers(header_lines, outfh) 

2395 _copy_done_lines(failed_subdags, infh, outfh) 

2396 rescue_file.unlink() 

2397 rescue_tmp.rename(rescue_file) 

2398 return failed_subdags 

2399 

2400 

2401def _update_dicts(dict1, dict2): 

2402 """Update dict1 with info in dict2. 

2403 

2404 (Basically an update for nested dictionaries.) 

2405 

2406 Parameters 

2407 ---------- 

2408 dict1 : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

2409 HTCondor job information to be updated. 

2410 dict2 : `dict` [`str`, `dict` [`str`, `~typing.Any`]] 

2411 Additional HTCondor job information. 

2412 """ 

2413 for key, value in dict2.items(): 

2414 if key in dict1 and isinstance(dict1[key], dict) and isinstance(value, dict): 2414 ↛ 2415line 2414 didn't jump to line 2415 because the condition on line 2414 was never true

2415 _update_dicts(dict1[key], value) 

2416 else: 

2417 dict1[key] = value 

2418 

2419 

2420def _locate_schedds(locate_all=False): 

2421 """Find out Scheduler daemons in an HTCondor pool. 

2422 

2423 Parameters 

2424 ---------- 

2425 locate_all : `bool`, optional 

2426 If True, all available schedulers in the HTCondor pool will be located. 

2427 False by default which means that the search will be limited to looking 

2428 for the Scheduler running on a local host. 

2429 

2430 Returns 

2431 ------- 

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

2433 A mapping between Scheduler names and Python objects allowing for 

2434 interacting with them. 

2435 """ 

2436 coll = Collector() 

2437 

2438 schedd_ads = [] 

2439 if locate_all: 

2440 schedd_ads.extend(coll.locateAll(DaemonTypes.Schedd)) 

2441 else: 

2442 schedd_ads.append(coll.locate(DaemonTypes.Schedd)) 

2443 return {ad["Name"]: Schedd(ad) for ad in schedd_ads}