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

938 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 15:08 -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"""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_version", 

59 "htc_write_attribs", 

60 "htc_write_condor_file", 

61 "pegasus_name_to_label", 

62 "read_dag_info", 

63 "read_dag_log", 

64 "read_dag_nodes_log", 

65 "read_dag_status", 

66 "read_node_status", 

67 "summarize_dag", 

68 "update_job_info", 

69 "write_dag_info", 

70] 

71 

72 

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 classad 

88import htcondor 

89import networkx 

90from deprecated.sphinx import deprecated 

91from packaging import version 

92 

93from .handlers import HTC_JOB_AD_HANDLERS 

94 

95_LOG = logging.getLogger(__name__) 

96 

97MISSING_ID = "-99999" 

98 

99 

100class DagStatus(IntEnum): 

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

102 

103 OK = 0 

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

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

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

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

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

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

110 

111 

112@deprecated( 

113 reason="The JobStatus is internally replaced by htcondor.JobStatus. " 

114 "External reporting code should be using ctrl_bps.WmsStates. " 

115 "This class will be removed after v30.", 

116 version="v30.0", 

117 category=FutureWarning, 

118) 

119class JobStatus(IntEnum): 

120 """HTCondor's statuses for jobs.""" 

121 

122 UNEXPANDED = 0 # Unexpanded 

123 IDLE = 1 # Idle 

124 RUNNING = 2 # Running 

125 REMOVED = 3 # Removed 

126 COMPLETED = 4 # Completed 

127 HELD = 5 # Held 

128 TRANSFERRING_OUTPUT = 6 # Transferring_Output 

129 SUSPENDED = 7 # Suspended 

130 

131 

132class NodeStatus(IntEnum): 

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

134 

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

136 # is a FINAL node. 

137 NOT_READY = 0 

138 

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

140 # running. 

141 READY = 1 

142 

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

144 PRERUN = 2 

145 

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

147 # StatusDetails = "not_idle" -> running. 

148 # JobProcsHeld = 1-> hold. 

149 # JobProcsQueued = 1 -> idle. 

150 SUBMITTED = 3 

151 

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

153 POSTRUN = 4 

154 

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

156 DONE = 5 

157 

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

159 # ULOG_JOB_ABORTED for deleted job). 

160 ERROR = 6 

161 

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

163 FUTILE = 7 

164 

165 

166class WmsNodeType(IntEnum): 

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

168 

169 UNKNOWN = auto() 

170 """Dummy value when missing.""" 

171 

172 PAYLOAD = auto() 

173 """Payload job.""" 

174 

175 FINAL = auto() 

176 """Final job.""" 

177 

178 SERVICE = auto() 

179 """Service job.""" 

180 

181 NOOP = auto() 

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

183 

184 SUBDAG = auto() 

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

186 

187 SUBDAG_CHECK = auto() 

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

189 

190 

191HTC_QUOTE_KEYS = {"environment"} 

192HTC_VALID_JOB_KEYS = { 

193 "universe", 

194 "executable", 

195 "arguments", 

196 "environment", 

197 "log", 

198 "error", 

199 "output", 

200 "should_transfer_files", 

201 "when_to_transfer_output", 

202 "getenv", 

203 "notification", 

204 "notify_user", 

205 "concurrency_limit", 

206 "transfer_executable", 

207 "transfer_input_files", 

208 "transfer_output_files", 

209 "transfer_output_remaps", 

210 "request_cpus", 

211 "request_memory", 

212 "request_disk", 

213 "priority", 

214 "category", 

215 "requirements", 

216 "on_exit_hold", 

217 "on_exit_hold_reason", 

218 "on_exit_hold_subcode", 

219 "max_retries", 

220 "retry_until", 

221 "periodic_release", 

222 "periodic_remove", 

223 "accounting_group", 

224 "accounting_group_user", 

225 "kill_sig", 

226 "want_graceful_removal", 

227 "job_max_vacate_time", 

228} 

229HTC_VALID_JOB_DAG_KEYS = { 

230 "dir", 

231 "noop", 

232 "done", 

233 "vars", 

234 "pre", 

235 "post", 

236 "retry", 

237 "retry_unless_exit", 

238 "abort_dag_on", 

239 "abort_exit", 

240 "priority", 

241} 

242HTC_VERSION = version.parse(htcondor.__version__) 

243 

244 

245class RestrictedDict(MutableMapping): 

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

247 

248 Parameters 

249 ---------- 

250 valid_keys : `~collections.abc.Container` 

251 Strings that are valid keys. 

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

253 Initial data. 

254 

255 Raises 

256 ------ 

257 KeyError 

258 If invalid key(s) in init_data. 

259 """ 

260 

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

262 self.valid_keys = valid_keys 

263 self.data = {} 

264 self.update(init_data) 

265 

266 def __getitem__(self, key): 

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

268 

269 Parameters 

270 ---------- 

271 key : `str` 

272 Identifier for value to return. 

273 

274 Returns 

275 ------- 

276 value : `~typing.Any` 

277 Value associated with given key. 

278 

279 Raises 

280 ------ 

281 KeyError 

282 If key doesn't exist. 

283 """ 

284 return self.data[key] 

285 

286 def __delitem__(self, key): 

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

288 

289 Parameters 

290 ---------- 

291 key : `str` 

292 Identifier for value to delete. 

293 

294 Raises 

295 ------ 

296 KeyError 

297 If key doesn't exist. 

298 """ 

299 del self.data[key] 

300 

301 def __setitem__(self, key, value): 

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

303 

304 Parameters 

305 ---------- 

306 key : `str` 

307 Identifier to associate with given value. 

308 value : `~typing.Any` 

309 Value to store. 

310 

311 Raises 

312 ------ 

313 KeyError 

314 If key is invalid. 

315 """ 

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

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

318 self.data[key] = value 

319 

320 def __iter__(self): 

321 return self.data.__iter__() 

322 

323 def __len__(self): 

324 return len(self.data) 

325 

326 def __str__(self): 

327 return str(self.data) 

328 

329 

330def htc_backup_files( 

331 wms_path: str | os.PathLike, subdir: str | os.PathLike | None = None, limit: int = 100 

332) -> Path | None: 

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

334 

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

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

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

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

339 

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

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

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

343 will be copied to '000' subdirectory. 

344 

345 Parameters 

346 ---------- 

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

348 Path to the submit directory either absolute or relative. 

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

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

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

352 subdirectories will be placed directly in the submit directory. 

353 limit : `int`, optional 

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

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

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

357 version 8.8+. 

358 

359 Returns 

360 ------- 

361 last_rescue_file : `pathlib.Path` or None 

362 Path to the latest rescue file or None if doesn't exist. 

363 

364 Raises 

365 ------ 

366 FileNotFoundError 

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

368 exist. 

369 OSError 

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

371 either due to permission or filesystem related issues. 

372 

373 Notes 

374 ----- 

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

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

377 overwritten by HTCondor after during the next run. 

378 """ 

379 width = len(str(limit)) 

380 

381 path = Path(wms_path).resolve() 

382 if not path.is_dir(): 

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

384 

385 # Initialize the backup counter. 

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

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

388 

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

390 dest = path 

391 if subdir: 

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

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

394 # way if it is an absolute path. 

395 subdir = Path(subdir) 

396 if subdir.is_absolute(): 

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

398 if dest not in subdir.parents: 

399 _LOG.warning( 

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

401 subdir, 

402 wms_path, 

403 ) 

404 else: 

405 dest /= subdir 

406 else: 

407 dest /= subdir 

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

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

410 try: 

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

412 except FileExistsError: 

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

414 else: 

415 htc_backup_files_single_path(path, dest) 

416 

417 # also back up any subdag info 

418 for subdag_dir in path.glob("subdags/*"): 

419 subdag_dest = dest / subdag_dir.relative_to(path) 

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

421 htc_backup_files_single_path(subdag_dir, subdag_dest) 

422 

423 last_rescue_file = rescue_dags[-1] if rescue_dags else None 

424 _LOG.debug("last_rescue_file = %s", last_rescue_file) 

425 return last_rescue_file 

426 

427 

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

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

430 

431 Parameters 

432 ---------- 

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

434 Directory from which to backup particular files. 

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

436 Directory to which particular files are moved. 

437 

438 Raises 

439 ------ 

440 RuntimeError 

441 If given dest directory matches given src directory. 

442 OSError 

443 If problems moving file. 

444 FileNotFoundError 

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

446 """ 

447 src = Path(src) 

448 dest = Path(dest) 

449 if dest.samefile(src): 

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

451 

452 for patt in [ 

453 "*.info.*", 

454 "*.dag.metrics", 

455 "*.dag.nodes.log", 

456 "*.node_status", 

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

458 "wms_*.status.txt", 

459 ]: 

460 for source in src.glob(patt): 

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

462 target = dest / source.relative_to(src) 

463 try: 

464 source.rename(target) 

465 except OSError as exc: 

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

467 else: 

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

469 

470 

471def htc_escape(value): 

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

473 

474 Parameters 

475 ---------- 

476 value : `~typing.Any` 

477 Value that needs to have characters escaped if string. 

478 

479 Returns 

480 ------- 

481 new_value : `~typing.Any` 

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

483 """ 

484 if isinstance(value, str): 

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

486 else: 

487 newval = value 

488 

489 return newval 

490 

491 

492def htc_write_attribs(stream, attrs): 

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

494 

495 Parameters 

496 ---------- 

497 stream : `~typing.TextIO` 

498 Output text stream (typically an open file). 

499 attrs : `dict` 

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

501 """ 

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

503 # Make sure strings are syntactically correct for HTCondor. 

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

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

506 else: 

507 pval = value 

508 

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

510 

511 

512def htc_write_condor_file( 

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

514) -> None: 

515 """Write an HTCondor submit file. 

516 

517 Parameters 

518 ---------- 

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

520 Filename for the HTCondor submit file. 

521 job_name : `str` 

522 Job name to use in submit file. 

523 job : `RestrictedDict` 

524 Submit script information. 

525 job_attrs : `dict` 

526 Job attributes. 

527 """ 

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

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

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

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

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

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

534 else: 

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

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

537 if key not in job: 

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

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

540 

541 if job_attrs is not None: 

542 htc_write_attribs(fh, job_attrs) 

543 print("queue", file=fh) 

544 

545 

546# To avoid doing the version check during every function call select 

547# appropriate conversion function at the import time. 

548# 

549# Make sure that *each* version specific variant of the conversion function(s) 

550# has the same signature after applying any changes! 

551if HTC_VERSION < version.parse("8.9.8"): 551 ↛ 553line 551 didn't jump to line 553 because the condition on line 551 was never true

552 

553 def htc_tune_schedd_args(**kwargs): 

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

555 

556 The old arguments: 'requirements' and 'attr_list' of 

557 'Schedd.history()', 'Schedd.query()', and 'Schedd.xquery()' were 

558 deprecated in favor of 'constraint' and 'projection', respectively, 

559 starting from version 8.9.8. The function will convert "new" keyword 

560 arguments to "old" ones. 

561 

562 Parameters 

563 ---------- 

564 **kwargs 

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

566 Schedd.xquery() accepts. 

567 

568 Returns 

569 ------- 

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

571 Keywords arguments that are guaranteed to work with the Python 

572 HTCondor API. 

573 

574 Notes 

575 ----- 

576 Function doesn't validate provided keyword arguments beyond converting 

577 selected arguments to their version specific form. For example, 

578 it won't remove keywords that are not supported by the methods 

579 mentioned earlier. 

580 """ 

581 translation_table = { 

582 "constraint": "requirements", 

583 "projection": "attr_list", 

584 } 

585 for new, old in translation_table.items(): 

586 try: 

587 kwargs[old] = kwargs.pop(new) 

588 except KeyError: 

589 pass 

590 return kwargs 

591 

592else: 

593 

594 def htc_tune_schedd_args(**kwargs): 

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

596 

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

598 necessary. Effectively, a no-op. 

599 

600 Parameters 

601 ---------- 

602 **kwargs 

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

604 Schedd.xquery() accepts. 

605 

606 Returns 

607 ------- 

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

609 Keywords arguments that were passed to the function. 

610 """ 

611 return kwargs 

612 

613 

614def htc_query_history(schedds, **kwargs): 

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

616 

617 Parameters 

618 ---------- 

619 schedds : `htcondor.Schedd` 

620 HTCondor schedulers which to query for job information. 

621 **kwargs 

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

623 

624 Yields 

625 ------ 

626 schedd_name : `str` 

627 Name of the HTCondor scheduler managing the job queue. 

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

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

630 job attributes names to values of the ClassAd expressions they 

631 represent. 

632 """ 

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

634 kwargs.setdefault("constraint", None) 

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

636 kwargs = htc_tune_schedd_args(**kwargs) 

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

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

639 yield schedd_name, dict(job_ad) 

640 

641 

642def htc_query_present(schedds, **kwargs): 

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

644 

645 Parameters 

646 ---------- 

647 schedds : `htcondor.Schedd` 

648 HTCondor schedulers which to query for job information. 

649 **kwargs 

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

651 

652 Yields 

653 ------ 

654 schedd_name : `str` 

655 Name of the HTCondor scheduler managing the job queue. 

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

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

658 job attributes names to values of the ClassAd expressions they 

659 represent. 

660 """ 

661 kwargs = htc_tune_schedd_args(**kwargs) 

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

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

664 yield schedd_name, dict(job_ad) 

665 

666 

667def htc_version(): 

668 """Return the version given by the HTCondor API. 

669 

670 Returns 

671 ------- 

672 version : `str` 

673 HTCondor version as easily comparable string. 

674 """ 

675 return str(HTC_VERSION) 

676 

677 

678def htc_submit_dag(sub): 

679 """Submit job for execution. 

680 

681 Parameters 

682 ---------- 

683 sub : `htcondor.Submit` 

684 An object representing a job submit description. 

685 

686 Returns 

687 ------- 

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

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

690 Information about jobs satisfying the search criteria where for each 

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

692 classads. 

693 """ 

694 coll = htcondor.Collector() 

695 schedd_ad = coll.locate(htcondor.DaemonTypes.Schedd) 

696 schedd = htcondor.Schedd(schedd_ad) 

697 

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

699 # that implies issues with the HTCondor pool which BPS can't address. 

700 # Hence, no effort is made to handle the exception. 

701 submit_result = schedd.submit(sub) 

702 

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

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

705 schedd_name = schedd_ad["Name"] 

706 schedd_dag_info = condor_q( 

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

708 ) 

709 return schedd_dag_info 

710 

711 

712def htc_create_submit_from_dag( 

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

714) -> htcondor.Submit: 

715 """Create a DAGMan job submit description. 

716 

717 Parameters 

718 ---------- 

719 dag_filename : `str` 

720 Name of file containing HTCondor DAG commands. 

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

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

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

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

725 file (None). 

726 

727 Returns 

728 ------- 

729 sub : `htcondor.Submit` 

730 An object representing a job submit description. 

731 

732 Notes 

733 ----- 

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

735 i.e., 8.9.3 or newer. 

736 """ 

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

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

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

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

741 # in the broken versions. 

742 if "MaxIdle" not in submit_options: 

743 max_jobs_idle: int | None = None 

744 config_var_name = "DAGMAN_MAX_JOBS_IDLE" 

745 

746 if dagman_conf_filename: 

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

748 with open(dagman_conf_filename) as fh: 

749 for line in fh: 

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

751 parts = line.split("=") 

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

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

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

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

756 break 

757 if max_jobs_idle is None: 

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

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

760 elif config_var_name in htcondor.param: 

761 max_jobs_idle = htcondor.param[config_var_name] 

762 

763 if max_jobs_idle: 

764 submit_options["MaxIdle"] = max_jobs_idle 

765 else: 

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

767 

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

769 return htcondor.Submit.from_dag(dag_filename, submit_options) 

770 

771 

772def htc_create_submit_from_cmd(dag_filename, submit_options=None): 

773 """Create a DAGMan job submit description. 

774 

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

776 on given DAG description file. 

777 

778 Parameters 

779 ---------- 

780 dag_filename : `str` 

781 Name of file containing HTCondor DAG commands. 

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

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

784 

785 Returns 

786 ------- 

787 sub : `htcondor.Submit` 

788 An object representing a job submit description. 

789 

790 Notes 

791 ----- 

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

793 i.e., older than 8.9.3. 

794 """ 

795 # Run command line condor_submit_dag command. 

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

797 

798 if submit_options is not None: 

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

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

801 cmd += f"{dag_filename}" 

802 

803 process = subprocess.Popen( 

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

805 ) 

806 process.wait() 

807 

808 if process.returncode != 0: 

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

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

811 raise RuntimeError("Problems running condor_submit_dag") 

812 

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

814 

815 

816def htc_create_submit_from_file(submit_file): 

817 """Parse a submission file. 

818 

819 Parameters 

820 ---------- 

821 submit_file : `str` 

822 Name of the HTCondor submit file. 

823 

824 Returns 

825 ------- 

826 sub : `htcondor.Submit` 

827 An object representing a job submit description. 

828 """ 

829 descriptors = {} 

830 with open(submit_file) as fh: 

831 for line in fh: 

832 line = line.strip() 

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

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

835 descriptors[key] = val 

836 

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

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

839 try: 

840 del descriptors["copy_to_spool"] 

841 except KeyError: 

842 pass 

843 

844 return htcondor.Submit(descriptors) 

845 

846 

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

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

849 

850 Parameters 

851 ---------- 

852 stream : `~typing.TextIO` 

853 Writeable text stream (typically an opened file). 

854 name : `str` 

855 Job name. 

856 commands : `RestrictedDict` 

857 DAG commands for a job. 

858 node_type : `str`, optional 

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

860 """ 

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

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

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

864 defer = "" 

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

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

867 

868 debug = "" 

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

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

871 

872 arguments = "" 

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

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

875 

876 executable = commands["pre"]["executable"] 

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

878 

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

880 defer = "" 

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

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

883 

884 debug = "" 

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

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

887 

888 arguments = "" 

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

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

891 

892 executable = commands["post"]["executable"] 

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

894 

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

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

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

898 

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

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

901 

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

903 if node_type != "FINAL": 

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

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

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

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

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

909 

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

911 print( 

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

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

914 file=stream, 

915 ) 

916 

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

918 print( 

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

920 file=stream, 

921 ) 

922 

923 

924class HTCJob: 

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

926 

927 Parameters 

928 ---------- 

929 name : `str` 

930 Name of the job. 

931 label : `str` 

932 Label that can used for grouping or lookup. 

933 initcmds : `RestrictedDict` 

934 Initial job commands for submit file. 

935 initdagcmds : `RestrictedDict` 

936 Initial commands for job inside DAG. 

937 initattrs : `dict` 

938 Initial dictionary of job attributes. 

939 """ 

940 

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

942 self.name = name 

943 self.label = label 

944 self.cmds = RestrictedDict(HTC_VALID_JOB_KEYS, initcmds) 

945 self.dagcmds = RestrictedDict(HTC_VALID_JOB_DAG_KEYS, initdagcmds) 

946 self.attrs = initattrs 

947 self.subfile = None 

948 self.subdir = None 

949 self.subdag = None 

950 

951 def __str__(self): 

952 return self.name 

953 

954 def add_job_cmds(self, new_commands): 

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

956 

957 Parameters 

958 ---------- 

959 new_commands : `dict` 

960 Submit file commands to be added to Job. 

961 """ 

962 self.cmds.update(new_commands) 

963 

964 def add_dag_cmds(self, new_commands): 

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

966 

967 Parameters 

968 ---------- 

969 new_commands : `dict` 

970 DAG file commands to be added to Job. 

971 """ 

972 self.dagcmds.update(new_commands) 

973 

974 def add_job_attrs(self, new_attrs): 

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

976 

977 Parameters 

978 ---------- 

979 new_attrs : `dict` 

980 Attributes to be added to Job. 

981 """ 

982 if self.attrs is None: 

983 self.attrs = {} 

984 if new_attrs: 

985 self.attrs.update(new_attrs) 

986 

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

988 """Write job description to submit file. 

989 

990 Parameters 

991 ---------- 

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

993 Prefix path for the submit file. 

994 """ 

995 if not self.subfile: 

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

997 

998 subfile = self.subfile 

999 if self.subdir: 999 ↛ 1000line 999 didn't jump to line 1000 because the condition on line 999 was never true

1000 subfile = Path(self.subdir) / subfile 

1001 

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

1003 if not subfile.is_absolute(): 

1004 subfile = Path(submit_path) / subfile 

1005 if not subfile.exists(): 

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

1007 

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

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

1010 

1011 Parameters 

1012 ---------- 

1013 stream : `~typing.TextIO` 

1014 Output Stream. 

1015 dag_rel_path : `str` 

1016 Relative path of dag to submit directory. 

1017 command_name : `str` 

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

1019 """ 

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

1021 

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

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

1024 if "dir" in self.dagcmds: 

1025 dir_val = self.dagcmds["dir"] 

1026 if dag_rel_path: 1026 ↛ 1028line 1026 didn't jump to line 1028 because the condition on line 1026 was always true

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

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

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

1030 job_line += " NOOP" 

1031 

1032 print(job_line, file=stream) 

1033 if self.dagcmds: 

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

1035 

1036 def dump(self, fh): 

1037 """Dump job information to output stream. 

1038 

1039 Parameters 

1040 ---------- 

1041 fh : `~typing.TextIO` 

1042 Output stream. 

1043 """ 

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

1045 printer.pprint(self.name) 

1046 printer.pprint(self.cmds) 

1047 printer.pprint(self.attrs) 

1048 

1049 

1050class HTCDag(networkx.DiGraph): 

1051 """HTCondor DAG. 

1052 

1053 Parameters 

1054 ---------- 

1055 data : `~typing.Any` 

1056 Initial graph data of any format that is supported 

1057 by the to_network_graph() function. 

1058 name : `str` 

1059 Name for DAG. 

1060 """ 

1061 

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

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

1064 

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

1066 self.graph["run_id"] = None 

1067 self.graph["submit_path"] = None 

1068 self.graph["final_job"] = None 

1069 self.graph["service_job"] = None 

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

1071 

1072 def __str__(self): 

1073 """Represent basic DAG info as string. 

1074 

1075 Returns 

1076 ------- 

1077 info : `str` 

1078 String containing basic DAG info. 

1079 """ 

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

1081 

1082 def add_attribs(self, attribs=None): 

1083 """Add attributes to the DAG. 

1084 

1085 Parameters 

1086 ---------- 

1087 attribs : `dict` 

1088 DAG attributes. 

1089 """ 

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

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

1092 

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

1094 """Add an HTCJob to the HTCDag. 

1095 

1096 Parameters 

1097 ---------- 

1098 job : `HTCJob` 

1099 HTCJob to add to the HTCDag. 

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

1101 Names of parent jobs. 

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

1103 Names of child jobs. 

1104 """ 

1105 assert isinstance(job, HTCJob) 

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

1107 

1108 # Add dag level attributes to each job 

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

1110 

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

1112 

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

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

1115 

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

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

1118 

1119 def add_job_relationships(self, parents, children): 

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

1121 

1122 Parameters 

1123 ---------- 

1124 parents : `list` [`str`] 

1125 Contains parent job name(s). 

1126 children : `list` [`str`] 

1127 Contains children job name(s). 

1128 """ 

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

1130 

1131 def add_final_job(self, job): 

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

1133 

1134 Parameters 

1135 ---------- 

1136 job : `HTCJob` 

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

1138 """ 

1139 # Add dag level attributes to each job 

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

1141 

1142 self.graph["final_job"] = job 

1143 

1144 def add_service_job(self, job): 

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

1146 

1147 Parameters 

1148 ---------- 

1149 job : `HTCJob` 

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

1151 """ 

1152 # Add dag level attributes to each job 

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

1154 

1155 self.graph["service_job"] = job 

1156 

1157 def del_job(self, job_name): 

1158 """Delete the job from the DAG. 

1159 

1160 Parameters 

1161 ---------- 

1162 job_name : `str` 

1163 Name of job in DAG to delete. 

1164 """ 

1165 # Reconnect edges around node to delete 

1166 parents = self.predecessors(job_name) 

1167 children = self.successors(job_name) 

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

1169 

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

1171 self.remove_node(job_name) 

1172 

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

1174 """Write DAG to a file. 

1175 

1176 Parameters 

1177 ---------- 

1178 submit_path : `str` 

1179 Prefix path for all outputs. 

1180 job_subdir : `str`, optional 

1181 Template for job subdir (submit_path + job_subdir). 

1182 dag_subdir : `str`, optional 

1183 DAG subdir (submit_path + dag_subdir). 

1184 dag_rel_path : `str`, optional 

1185 Prefix to job_subdir for jobs inside subdag. 

1186 """ 

1187 self.graph["submit_path"] = submit_path 

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

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

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

1191 

1192 try: 

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

1194 except KeyError: 

1195 dagman_config_path = None 

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

1197 if dagman_config_path is not None: 

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

1199 

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

1201 try: 

1202 job = nodeval["data"] 

1203 except KeyError: 

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

1205 raise 

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

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

1208 if "dir" in job.dagcmds: 

1209 subdir = job.dagcmds["dir"] 

1210 else: 

1211 subdir = job_subdir 

1212 if dagman_config_path is not None: 

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

1214 job.subdag.write(submit_path, subdir, dag_subdir, "../..") 

1215 fh.write( 

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

1217 f"DIR {dag_subdir}\n" 

1218 ) 

1219 if job.dagcmds: 

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

1221 else: 

1222 job.write_submit_file(submit_path) 

1223 job.write_dag_commands(fh, dag_rel_path) 

1224 

1225 for edge in self.edges(): 1225 ↛ 1226line 1225 didn't jump to line 1226 because the loop on line 1225 never started

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

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

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

1229 

1230 # Add bps attributes to dag submission 

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

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

1233 

1234 # Add special nodes if any. 

1235 special_jobs = { 

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

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

1238 } 

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

1240 if job is not None: 1240 ↛ 1241line 1240 didn't jump to line 1241 because the condition on line 1240 was never true

1241 job.write_submit_file(submit_path) 

1242 job.write_dag_commands(fh, dag_rel_path, dagcmd) 

1243 

1244 def dump(self, fh): 

1245 """Dump DAG info to output stream. 

1246 

1247 Parameters 

1248 ---------- 

1249 fh : `typing.IO` 

1250 Where to dump DAG info as text. 

1251 """ 

1252 for key, value in self.graph: 

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

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

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

1256 data.dump(fh) 

1257 for edge in self.edges(): 

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

1259 if self.graph["final_job"]: 

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

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

1262 

1263 def write_dot(self, filename): 

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

1265 

1266 Parameters 

1267 ---------- 

1268 filename : `str` 

1269 Name of the dot file. 

1270 """ 

1271 pos = networkx.nx_agraph.graphviz_layout(self) 

1272 networkx.draw(self, pos=pos) 

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

1274 

1275 

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

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

1278 

1279 Parameters 

1280 ---------- 

1281 constraint : `str`, optional 

1282 Constraints to be passed to job query. 

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

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

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

1286 **kwargs : `~typing.Any` 

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

1288 query method. 

1289 

1290 Returns 

1291 ------- 

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

1293 Information about jobs satisfying the search criteria where for each 

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

1295 classads. 

1296 """ 

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

1298 

1299 

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

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

1302 

1303 Parameters 

1304 ---------- 

1305 constraint : `str`, optional 

1306 Constraints to be passed to job query. 

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

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

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

1310 the local scheduler only. 

1311 **kwargs : `~typing.Any` 

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

1313 query method. 

1314 

1315 Returns 

1316 ------- 

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

1318 Information about jobs satisfying the search criteria where for each 

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

1320 classads. 

1321 """ 

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

1323 

1324 

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

1326 """Get information about HTCondor jobs. 

1327 

1328 Parameters 

1329 ---------- 

1330 constraint : `str`, optional 

1331 Constraints to be passed to job query. 

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

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

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

1335 the local scheduler only. 

1336 query_func : `~collections.abc.Callable` 

1337 An query function which takes following arguments: 

1338 

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

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

1341 function. 

1342 **kwargs : `~typing.Any` 

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

1344 method. 

1345 

1346 Returns 

1347 ------- 

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

1349 Information about jobs satisfying the search criteria where for each 

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

1351 classads. 

1352 """ 

1353 if not schedds: 

1354 coll = htcondor.Collector() 

1355 schedd_ad = coll.locate(htcondor.DaemonTypes.Schedd) 

1356 schedds = {schedd_ad["Name"]: htcondor.Schedd(schedd_ad)} 

1357 

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

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

1360 added_attrs = set() 

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

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

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

1364 added_attrs = required_attrs - requested_attrs 

1365 for attr in added_attrs: 

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

1367 

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

1369 job_info = defaultdict(dict) 

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

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

1372 for attr in set(job_ad) & unwanted_attrs: 

1373 del job_ad[attr] 

1374 job_info[schedd_name][id_] = job_ad 

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

1376 

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

1378 # if needed. 

1379 if added_attrs: 

1380 for attr in added_attrs: 

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

1382 

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

1384 # matching the search criteria. 

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

1386 

1387 

1388def condor_search(constraint=None, hist=None, schedds=None): 

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

1390 

1391 Parameters 

1392 ---------- 

1393 constraint : `str`, optional 

1394 Constraints to be passed to job query. 

1395 hist : `float` 

1396 Limit history search to this many days. 

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

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

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

1400 

1401 Returns 

1402 ------- 

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

1404 Information about jobs satisfying the search criteria where for each 

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

1406 classads. 

1407 """ 

1408 if not schedds: 

1409 coll = htcondor.Collector() 

1410 schedd_ad = coll.locate(htcondor.DaemonTypes.Schedd) 

1411 schedds = {schedd_ad["Name"]: htcondor.Schedd(locate_ad=schedd_ad)} 

1412 

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

1414 if hist is not None: 

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

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

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

1418 hist_info = condor_history(constraint, schedds=schedds) 

1419 update_job_info(job_info, hist_info) 

1420 return job_info 

1421 

1422 

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

1424 """Get information about HTCondor pool. 

1425 

1426 Parameters 

1427 ---------- 

1428 constraint : `str`, optional 

1429 Constraints to be passed to the query. 

1430 coll : `htcondor.Collector`, optional 

1431 Object representing HTCondor collector daemon. 

1432 

1433 Returns 

1434 ------- 

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

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

1437 """ 

1438 if coll is None: 

1439 coll = htcondor.Collector() 

1440 try: 

1441 pool_ads = coll.query(constraint=constraint) 

1442 except OSError as ex: 

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

1444 

1445 pool_info = {} 

1446 for slot in pool_ads: 

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

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

1449 return pool_info 

1450 

1451 

1452def update_job_info(job_info, other_info): 

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

1454 

1455 Parameters 

1456 ---------- 

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

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

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

1460 Results of the other job query. 

1461 

1462 Returns 

1463 ------- 

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

1465 The updated results. 

1466 """ 

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

1468 try: 

1469 jobs = job_info[schedd_name] 

1470 except KeyError: 

1471 job_info[schedd_name] = others 

1472 else: 

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

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

1475 return job_info 

1476 

1477 

1478def count_jobs_in_single_dag( 

1479 filename: str | os.PathLike, 

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

1481 """Build bps_run_summary string from dag file. 

1482 

1483 Parameters 

1484 ---------- 

1485 filename : `str` 

1486 Path that includes dag file for a run. 

1487 

1488 Returns 

1489 ------- 

1490 counts : `Counter` [`str`] 

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

1492 (Same format as saved in dag classad). 

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

1494 Mapping of job names to job labels. 

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

1496 Mapping of job names to job types 

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

1498 """ 

1499 # Later code depends upon insertion order 

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

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

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

1503 with open(filename) as fh: 

1504 for line in fh: 

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

1506 if not line.startswith( 

1507 ( 

1508 "JOB", 

1509 "FINAL", 

1510 "SERVICE", 

1511 "SUBDAG EXTERNAL", 

1512 ) 

1513 ): 

1514 continue 

1515 

1516 m = re.match( 

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

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

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

1520 line, 

1521 ) 

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

1523 job_name = m.group("jobname") 

1524 name_parts = job_name.split("_") 

1525 

1526 label = "" 

1527 if m.group("dir"): 

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

1529 if dir_match: 

1530 label = dir_match.group(1) 

1531 else: 

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

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

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

1535 if subfile_match: 

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

1537 else: 

1538 label = pegasus_name_to_label(job_name) 

1539 

1540 match m.group("command"): 

1541 case "JOB": 

1542 if m.group("noop"): 

1543 job_type = WmsNodeType.NOOP 

1544 # wms_noop_label 

1545 label = name_parts[2] 

1546 elif m.group("wms"): 

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

1548 job_type = WmsNodeType.SUBDAG_CHECK 

1549 # wms_check_status_wms_group_label 

1550 label = name_parts[5] 

1551 else: 

1552 _LOG.warning( 

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

1554 ) 

1555 else: 

1556 job_type = WmsNodeType.PAYLOAD 

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

1558 label = "pipetaskInit" 

1559 counts[label] += 1 

1560 case "FINAL": 

1561 job_type = WmsNodeType.FINAL 

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

1563 case "SERVICE": 

1564 job_type = WmsNodeType.SERVICE 

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

1566 job_type = WmsNodeType.SUBDAG 

1567 label = name_parts[2] 

1568 

1569 job_name_to_label[job_name] = label 

1570 job_name_to_type[job_name] = job_type 

1571 else: 

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

1573 # problems with regex. 

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

1575 

1576 return counts, job_name_to_label, job_name_to_type 

1577 

1578 

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

1580 """Build bps_run_summary string from dag file. 

1581 

1582 Parameters 

1583 ---------- 

1584 dir_name : `str` 

1585 Path that includes dag file for a run. 

1586 

1587 Returns 

1588 ------- 

1589 summary : `str` 

1590 Semi-colon separated list of job labels and counts 

1591 (Same format as saved in dag classad). 

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

1593 Mapping of job names to job labels. 

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

1595 Mapping of job names to job types 

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

1597 """ 

1598 # Later code depends upon insertion order 

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

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

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

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

1603 single_counts, single_job_name_to_label, single_job_name_to_type = count_jobs_in_single_dag(filename) 

1604 counts += single_counts 

1605 _update_dicts(job_name_to_label, single_job_name_to_label) 

1606 _update_dicts(job_name_to_type, single_job_name_to_type) 

1607 

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

1609 single_counts, single_job_name_to_label, single_job_name_to_type = count_jobs_in_single_dag(filename) 

1610 counts += single_counts 

1611 _update_dicts(job_name_to_label, single_job_name_to_label) 

1612 _update_dicts(job_name_to_type, single_job_name_to_type) 

1613 

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

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

1616 return summary, job_name_to_label, job_name_to_type 

1617 

1618 

1619def pegasus_name_to_label(name): 

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

1621 

1622 Parameters 

1623 ---------- 

1624 name : `str` 

1625 Name of job. 

1626 

1627 Returns 

1628 ------- 

1629 label : `str` 

1630 Label for job. 

1631 """ 

1632 label = "UNK" 

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

1634 label = "pegasus" 

1635 else: 

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

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

1638 label = m.group(2) 

1639 if label == "init": 

1640 label = "pipetaskInit" 

1641 

1642 return label 

1643 

1644 

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

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

1647 

1648 Parameters 

1649 ---------- 

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

1651 Node status filename. 

1652 

1653 Returns 

1654 ------- 

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

1656 DAG summary information. 

1657 """ 

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

1659 

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

1661 # file if need to. 

1662 try: 

1663 node_stat_file = Path(filename) 

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

1665 with open(node_stat_file) as infh: 

1666 dag_ad = dict(classad.parseNext(infh)) # pylint: disable=E1101 

1667 

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

1669 # Pegasus check here 

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

1671 if metrics_file.exists(): 

1672 with open(metrics_file) as infh: 

1673 metrics = json.load(infh) 

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

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

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

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

1678 with open(metrics_file) as infh: 

1679 metrics = json.load(infh) 

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

1681 except (OSError, PermissionError): 

1682 pass 

1683 

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

1685 return dag_ad 

1686 

1687 

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

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

1690 

1691 Parameters 

1692 ---------- 

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

1694 Path that includes node status file for a run. 

1695 

1696 Returns 

1697 ------- 

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

1699 DAG summary information, counts summed across any subdags. 

1700 """ 

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

1702 path = Path(wms_path) 

1703 try: 

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

1705 except StopIteration as exc: 

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

1707 

1708 dag_ads = read_single_dag_status(node_stat_file) 

1709 

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

1711 dag_ad = read_single_dag_status(node_stat_file) 

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

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

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

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

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

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

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

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

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

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

1722 

1723 return dag_ads 

1724 

1725 

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

1727 """Read entire node status file. 

1728 

1729 Parameters 

1730 ---------- 

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

1732 Node status filename. 

1733 init_fake_id : `int` 

1734 Initial fake id value. 

1735 

1736 Returns 

1737 ------- 

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

1739 DAG summary information compiled from the node status file combined 

1740 with the information found in the node event log. 

1741 

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

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

1744 file. 

1745 """ 

1746 filename = Path(filename) 

1747 

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

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

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

1751 try: 

1752 wms_workflow_id, loginfo = read_single_dag_log(filename.with_suffix(".dag.dagman.log")) 

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

1754 except (OSError, PermissionError): 

1755 pass 

1756 

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

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

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

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

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

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

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

1764 job_name = m.group(1) 

1765 log_job_name_to_id[job_name] = job_id 

1766 job_info["DAGNodeName"] = job_name 

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

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

1769 

1770 jobs = {} 

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

1772 try: 

1773 with open(filename) as fh: 

1774 for ad in classad.parseAds(fh): 

1775 match ad["Type"]: 

1776 case "DagStatus": 

1777 # Skip DAG summary. 

1778 pass 

1779 case "NodeStatus": 

1780 job_name = ad["Node"] 

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

1782 job_label = job_name_to_label[job_name] 

1783 elif "_" in job_name: 

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

1785 else: 

1786 job_label = job_name 

1787 

1788 job = dict(ad) 

1789 if job_name in log_job_name_to_id: 

1790 job_id = str(log_job_name_to_id[job_name]) 

1791 _update_dicts(job, loginfo[job_id]) 

1792 else: 

1793 job_id = str(fake_id) 

1794 job = dict(ad) 

1795 fake_id -= 1 

1796 jobs[job_id] = job 

1797 job_name_to_id[job_name] = job_id 

1798 

1799 # Make job info as if came from condor_q. 

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

1801 job["DAGManJobID"] = wms_workflow_id 

1802 job["DAGNodeName"] = job_name 

1803 job["bps_job_label"] = job_label 

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

1805 

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

1807 # Skip node status file "epilog". 

1808 pass 

1809 case _: 

1810 _LOG.debug( 

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

1812 ad["Type"], 

1813 filename, 

1814 ) 

1815 except (OSError, PermissionError): 

1816 pass 

1817 

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

1819 # Use dag info to create job placeholders 

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

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

1822 job_id = str(log_job_name_to_id[name]) 

1823 job = dict(loginfo[job_id]) 

1824 else: 

1825 job_id = str(fake_id) 

1826 fake_id -= 1 

1827 job = {} 

1828 job["NodeStatus"] = NodeStatus.NOT_READY 

1829 

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

1831 job["ProcId"] = 0 

1832 job["DAGManJobID"] = wms_workflow_id 

1833 job["DAGNodeName"] = name 

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

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

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

1837 

1838 for job_info in jobs.values(): 

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

1840 

1841 return jobs 

1842 

1843 

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

1845 """Read entire node status file. 

1846 

1847 Parameters 

1848 ---------- 

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

1850 Path that includes node status file for a run. 

1851 

1852 Returns 

1853 ------- 

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

1855 DAG summary information compiled from the node status file combined 

1856 with the information found in the node event log. 

1857 

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

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

1860 file. 

1861 """ 

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

1863 init_fake_id = -1 

1864 

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

1866 # use dag files and let read_single_node_status handle missing 

1867 # node_status file. 

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

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

1870 info = read_single_node_status(filename, init_fake_id) 

1871 init_fake_id -= len(info) 

1872 _update_dicts(jobs, info) 

1873 

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

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

1876 info = read_single_node_status(filename, init_fake_id) 

1877 init_fake_id -= len(info) 

1878 _update_dicts(jobs, info) 

1879 

1880 # Propagate pruned from subdags to jobs 

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

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

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

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

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

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

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

1888 

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

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

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

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

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

1894 

1895 return jobs 

1896 

1897 

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

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

1900 

1901 Parameters 

1902 ---------- 

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

1904 DAGMan log filename. 

1905 

1906 Returns 

1907 ------- 

1908 wms_workflow_id : `str` 

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

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

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

1912 job id. 

1913 

1914 Raises 

1915 ------ 

1916 FileNotFoundError 

1917 If cannot find DAGMan log in given wms_path. 

1918 """ 

1919 wms_workflow_id = "0" 

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

1921 

1922 filename = Path(log_filename) 

1923 if filename.exists(): 

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

1925 

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

1927 job_event_log = htcondor.JobEventLog(str(filename)) 

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

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

1930 if id_ not in info: 

1931 info[id_] = {} 

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

1933 _update_dicts(info[id_], event) 

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

1935 

1936 # only save latest DAG job 

1937 dag_info = {wms_workflow_id: info[wms_workflow_id]} 

1938 

1939 return wms_workflow_id, dag_info 

1940 

1941 

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

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

1944 

1945 Parameters 

1946 ---------- 

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

1948 Path containing the DAGMan log file. 

1949 

1950 Returns 

1951 ------- 

1952 wms_workflow_id : `str` 

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

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

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

1956 job id. 

1957 

1958 Raises 

1959 ------ 

1960 FileNotFoundError 

1961 If cannot find DAGMan log in given wms_path. 

1962 """ 

1963 wms_workflow_id = MISSING_ID 

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

1965 

1966 path = Path(wms_path) 

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

1968 try: 

1969 filename = next(path.glob("*.dag.dagman.log")) 

1970 except StopIteration as exc: 

1971 raise FileNotFoundError(f"DAGMan log not found in {wms_path}") from exc 

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

1973 wms_workflow_id, dag_info = read_single_dag_log(filename) 

1974 

1975 return wms_workflow_id, dag_info 

1976 

1977 

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

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

1980 

1981 Parameters 

1982 ---------- 

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

1984 Path containing the DAGMan nodes log file. 

1985 

1986 Returns 

1987 ------- 

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

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

1990 job id. 

1991 

1992 Raises 

1993 ------ 

1994 FileNotFoundError 

1995 If cannot find DAGMan node log in given wms_path. 

1996 """ 

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

1998 filename = Path(filename) 

1999 

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

2001 if not filename.exists(): 

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

2003 

2004 try: 

2005 job_event_log = htcondor.JobEventLog(str(filename)) 

2006 except htcondor.HTCondorIOError as ex: 

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

2008 import traceback 

2009 

2010 traceback.print_stack() 

2011 raise 

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

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

2014 

2015 try: 

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

2017 except KeyError: 

2018 _LOG.warn( 

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

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

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

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

2023 ) 

2024 else: 

2025 if id_ not in info: 

2026 info[id_] = {} 

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

2028 # future HTCondor versions. Sometimes get a 

2029 # JobAbortedEvent for a subdag job after it already 

2030 # terminated normally. Seems to happen when using job 

2031 # plus subdags. 

2032 if event["EventTypeNumber"] == 9 and info[id_].get("EventTypeNumber", -1) == 5: 2032 ↛ 2033line 2032 didn't jump to line 2033 because the condition on line 2032 was never true

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

2034 else: 

2035 _update_dicts(info[id_], event) 

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

2037 

2038 return info 

2039 

2040 

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

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

2043 

2044 Parameters 

2045 ---------- 

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

2047 Path containing the DAGMan nodes log file. 

2048 

2049 Returns 

2050 ------- 

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

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

2053 job id. 

2054 

2055 Raises 

2056 ------ 

2057 FileNotFoundError 

2058 If cannot find DAGMan node log in given wms_path. 

2059 """ 

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

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

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

2063 _update_dicts(info, read_single_dag_nodes_log(filename)) 

2064 

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

2066 if not info: 

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

2068 

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

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

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

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

2073 _update_dicts(info, read_single_dag_nodes_log(filename)) 

2074 

2075 return info 

2076 

2077 

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

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

2080 

2081 Parameters 

2082 ---------- 

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

2084 Path containing the file with the DAGMan job info. 

2085 

2086 Returns 

2087 ------- 

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

2089 HTCondor job information. 

2090 

2091 Raises 

2092 ------ 

2093 FileNotFoundError 

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

2095 """ 

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

2097 try: 

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

2099 except StopIteration as exc: 

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

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

2102 try: 

2103 with open(filename) as fh: 

2104 dag_info = json.load(fh) 

2105 except (OSError, PermissionError) as exc: 

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

2107 return dag_info 

2108 

2109 

2110def write_dag_info(filename, dag_info): 

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

2112 

2113 Parameters 

2114 ---------- 

2115 filename : `str` 

2116 Name of the file where the information will be stored. 

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

2118 Information about the DAGMan job. 

2119 """ 

2120 schedd_name = next(iter(dag_info)) 

2121 dag_id = next(iter(dag_info[schedd_name])) 

2122 dag_ad = dag_info[schedd_name][dag_id] 

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

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

2125 try: 

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

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

2128 json.dump(info, fh) 

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

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

2131 

2132 

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

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

2135 

2136 Parameters 

2137 ---------- 

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

2139 Path containing an HTCondor event log file. 

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

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

2142 the log. 

2143 """ 

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

2145 

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

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

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

2149 if "MyType" not in job: 

2150 return 

2151 

2152 try: 

2153 job["ClusterId"] = job["Cluster"] 

2154 job["ProcId"] = job["Proc"] 

2155 except KeyError as e: 

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

2157 raise 

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

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

2160 

2161 if "LogNotes" in job: 

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

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

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

2165 

2166 match job["MyType"]: 

2167 case "ExecuteEvent": 

2168 job["JobStatus"] = htcondor.JobStatus.RUNNING 

2169 case "JobTerminatedEvent" | "PostScriptTerminatedEvent": 

2170 job["JobStatus"] = htcondor.JobStatus.COMPLETED 

2171 case "SubmitEvent": 

2172 job["JobStatus"] = htcondor.JobStatus.IDLE 

2173 case "JobAbortedEvent": 

2174 job["JobStatus"] = htcondor.JobStatus.REMOVED 

2175 case "JobHeldEvent": 

2176 job["JobStatus"] = htcondor.JobStatus.HELD 

2177 case "JobReleaseEvent": 

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

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

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

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

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

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

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

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

2186 case _: 

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

2188 job["JobStatus"] = None 

2189 

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

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

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

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

2194 # between these two cases easy later on. 

2195 if job["JobStatus"] in { 

2196 htcondor.JobStatus.COMPLETED, 

2197 htcondor.JobStatus.HELD, 

2198 htcondor.JobStatus.REMOVED, 

2199 }: 

2200 new_job = HTC_JOB_AD_HANDLERS.handle(job) 

2201 if new_job is not None: 

2202 job = new_job 

2203 else: 

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

2205 

2206 

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

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

2209 

2210 Parameters 

2211 ---------- 

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

2213 Directory containing the DAGman output file. 

2214 

2215 Returns 

2216 ------- 

2217 message : `str` 

2218 Message containing error messages from the DAGMan output. Empty 

2219 string if no messages. 

2220 

2221 Raises 

2222 ------ 

2223 FileNotFoundError 

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

2225 """ 

2226 try: 

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

2228 except StopIteration as exc: 

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

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

2231 

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

2233 

2234 message = "" 

2235 try: 

2236 with open(filename) as fh: 

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

2238 for line in fh: 

2239 m = p.match(line) 

2240 if m: 

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

2242 last_submit_failed = m.group(1) 

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

2244 pass # Should be handled by Job submit try 

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

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

2247 last_warning = "Cannot submit from /tmp." 

2248 else: 

2249 last_warning = m.group(2) 

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

2251 message += "ERROR: " 

2252 message += last_warning 

2253 message += "\n" 

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

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

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

2257 ]: 

2258 pass 

2259 else: 

2260 message += m.group(2) 

2261 message += "\n" 

2262 

2263 if last_submit_failed: 

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

2265 except (OSError, PermissionError): 

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

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

2268 return message 

2269 

2270 

2271def _read_rescue_headers(infh: TextIO) -> tuple[list[str], list[str]]: 

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

2273 

2274 Parameters 

2275 ---------- 

2276 infh : `TextIO` 

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

2278 

2279 Returns 

2280 ------- 

2281 header_lines : `list` [`str`] 

2282 Header lines read from the rescue file. 

2283 failed_subdags : `list` [`str`] 

2284 Names of failed subdag jobs. 

2285 """ 

2286 header_lines: list[str] = [] 

2287 failed = False 

2288 failed_subdags: list[str] = [] 

2289 

2290 for line in infh: 2290 ↛ 2313line 2290 didn't jump to line 2313 because the loop on line 2290 didn't complete

2291 line = line.strip() 

2292 if line.startswith("#"): 

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

2294 failed = True 

2295 header_lines.append(line) 

2296 elif failed: 

2297 orig_failed_nodes = line[1:].strip().split(",") 

2298 new_failed_nodes = [] 

2299 for node in orig_failed_nodes: 

2300 if node.startswith("wms_check_status"): 

2301 group_node = node[17:] 

2302 failed_subdags.append(group_node) 

2303 new_failed_nodes.append(group_node) 

2304 else: 

2305 new_failed_nodes.append(node) 

2306 header_lines.append(f"# {','.join(new_failed_nodes)}") 

2307 if orig_failed_nodes[-1] == "<ENDLIST>": 2307 ↛ 2290line 2307 didn't jump to line 2290 because the condition on line 2307 was always true

2308 failed = False 

2309 else: 

2310 header_lines.append(line) 

2311 elif line.strip() == "": # end of headers 2311 ↛ 2290line 2311 didn't jump to line 2290 because the condition on line 2311 was always true

2312 break 

2313 return header_lines, failed_subdags 

2314 

2315 

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

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

2318 

2319 Parameters 

2320 ---------- 

2321 header_lines : `list` [`str`] 

2322 Header lines to write to the new rescue file. 

2323 failed_subdags : `list` [`str`] 

2324 Job names of the failed subdags. 

2325 outfh : `TextIO` 

2326 New rescue file. 

2327 """ 

2328 done_str = "# Nodes premarked DONE" 

2329 pattern = f"^{done_str}:\\s+(\\d+)" 

2330 for header_line in header_lines: 

2331 m = re.match(pattern, header_line) 

2332 if m: 

2333 print(f"{done_str}: {int(m.group(1)) - len(failed_subdags)}", file=outfh) 

2334 else: 

2335 print(header_line, file=outfh) 

2336 

2337 print("", file=outfh) 

2338 

2339 

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

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

2342 the failed group jobs. 

2343 

2344 Parameters 

2345 ---------- 

2346 failed_subdags : `list` [`str`] 

2347 List of job names for the failed subdags 

2348 infh : `TextIO` 

2349 Original rescue file to copy from. 

2350 outfh : `TextIO` 

2351 New rescue file to copy to. 

2352 """ 

2353 for line in infh: 

2354 line = line.strip() 

2355 try: 

2356 _, node_name = line.split() 

2357 except ValueError: 

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

2359 raise 

2360 if node_name not in failed_subdags: 

2361 print(line, file=outfh) 

2362 

2363 

2364def _update_rescue_file(rescue_file: Path) -> None: 

2365 """Update the subdag failures in the main rescue file 

2366 and backup the failed subdag dirs. 

2367 

2368 Parameters 

2369 ---------- 

2370 rescue_file : `pathlib.Path` 

2371 The main rescue file that needs to be updated. 

2372 """ 

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

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

2375 with open(rescue_file) as infh: 

2376 header_lines, failed_subdags = _read_rescue_headers(infh) 

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

2378 _write_rescue_headers(header_lines, failed_subdags, outfh) 

2379 _copy_done_lines(failed_subdags, infh, outfh) 

2380 rescue_file.unlink() 

2381 rescue_tmp.rename(rescue_file) 

2382 for failed_subdag in failed_subdags: 

2383 htc_backup_files( 

2384 rescue_file.parent / "subdags" / failed_subdag, subdir=f"backups/subdags/{failed_subdag}" 

2385 ) 

2386 

2387 

2388def _update_dicts(dict1, dict2): 

2389 """Update dict1 with info in dict2. 

2390 

2391 (Basically an update for nested dictionaries.) 

2392 

2393 Parameters 

2394 ---------- 

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

2396 HTCondor job information to be updated. 

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

2398 Additional HTCondor job information. 

2399 """ 

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

2401 if key in dict1 and isinstance(dict1[key], dict) and isinstance(value, dict): 

2402 _update_dicts(dict1[key], value) 

2403 else: 

2404 dict1[key] = value 

2405 

2406 

2407def _locate_schedds(locate_all=False): 

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

2409 

2410 Parameters 

2411 ---------- 

2412 locate_all : `bool`, optional 

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

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

2415 for the Scheduler running on a local host. 

2416 

2417 Returns 

2418 ------- 

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

2420 A mapping between Scheduler names and Python objects allowing for 

2421 interacting with them. 

2422 """ 

2423 coll = htcondor.Collector() 

2424 

2425 schedd_ads = [] 

2426 if locate_all: 

2427 schedd_ads.extend(coll.locateAll(htcondor.DaemonTypes.Schedd)) 

2428 else: 

2429 schedd_ads.append(coll.locate(htcondor.DaemonTypes.Schedd)) 

2430 return {ad["Name"]: htcondor.Schedd(ad) for ad in schedd_ads}