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

937 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-10 09:08 +0000

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 itertools 

73import json 

74import logging 

75import os 

76import pprint 

77import re 

78import subprocess 

79from collections import Counter, defaultdict 

80from collections.abc import MutableMapping 

81from datetime import datetime, timedelta 

82from enum import IntEnum, auto 

83from pathlib import Path 

84from typing import Any, TextIO 

85 

86import networkx 

87from classad2 import parseAds, parseNext 

88from htcondor2 import ( 

89 Collector, 

90 DaemonTypes, 

91 JobEventLog, 

92 JobEventType, 

93 JobStatus, 

94 Schedd, 

95 Submit, 

96 param, 

97) 

98 

99from .handlers import HTC_JOB_AD_HANDLERS 

100from .htcondor_compat import HTCondorException, compat_submit_ad 

101 

102_LOG = logging.getLogger(__name__) 

103 

104MISSING_ID = "-99999" 

105 

106 

107class DagStatus(IntEnum): 

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

109 

110 OK = 0 

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

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

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

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

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

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

117 

118 

119class NodeStatus(IntEnum): 

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

121 

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

123 # is a FINAL node. 

124 NOT_READY = 0 

125 

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

127 # running. 

128 READY = 1 

129 

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

131 PRERUN = 2 

132 

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

134 # StatusDetails = "not_idle" -> running. 

135 # JobProcsHeld = 1-> hold. 

136 # JobProcsQueued = 1 -> idle. 

137 SUBMITTED = 3 

138 

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

140 POSTRUN = 4 

141 

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

143 DONE = 5 

144 

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

146 # ULOG_JOB_ABORTED for deleted job). 

147 ERROR = 6 

148 

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

150 FUTILE = 7 

151 

152 

153class WmsNodeType(IntEnum): 

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

155 

156 UNKNOWN = auto() 

157 """Dummy value when missing.""" 

158 

159 PAYLOAD = auto() 

160 """Payload job.""" 

161 

162 FINAL = auto() 

163 """Final job.""" 

164 

165 SERVICE = auto() 

166 """Service job.""" 

167 

168 NOOP = auto() 

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

170 

171 SUBDAG = auto() 

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

173 

174 SUBDAG_CHECK = auto() 

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

176 

177 

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

179HTC_VALID_JOB_KEYS = { 

180 "universe", 

181 "executable", 

182 "arguments", 

183 "environment", 

184 "log", 

185 "error", 

186 "output", 

187 "should_transfer_files", 

188 "when_to_transfer_output", 

189 "getenv", 

190 "notification", 

191 "notify_user", 

192 "concurrency_limit", 

193 "transfer_executable", 

194 "transfer_input_files", 

195 "transfer_output_files", 

196 "transfer_output_remaps", 

197 "request_cpus", 

198 "request_memory", 

199 "request_disk", 

200 "priority", 

201 "category", 

202 "requirements", 

203 "on_exit_hold", 

204 "on_exit_hold_reason", 

205 "on_exit_hold_subcode", 

206 "max_retries", 

207 "retry_until", 

208 "periodic_release", 

209 "periodic_remove", 

210 "accounting_group", 

211 "accounting_group_user", 

212 "kill_sig", 

213 "want_graceful_removal", 

214 "job_max_vacate_time", 

215} 

216HTC_VALID_JOB_DAG_KEYS = { 

217 "dir", 

218 "noop", 

219 "done", 

220 "vars", 

221 "pre", 

222 "post", 

223 "retry", 

224 "retry_unless_exit", 

225 "abort_dag_on", 

226 "abort_exit", 

227 "priority", 

228} 

229 

230 

231class RestrictedDict(MutableMapping): 

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

233 

234 Parameters 

235 ---------- 

236 valid_keys : `~collections.abc.Container` 

237 Strings that are valid keys. 

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

239 Initial data. 

240 

241 Raises 

242 ------ 

243 KeyError 

244 If invalid key(s) in init_data. 

245 """ 

246 

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

248 self.valid_keys = valid_keys 

249 self.data = {} 

250 self.update(init_data) 

251 

252 def __getitem__(self, key): 

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

254 

255 Parameters 

256 ---------- 

257 key : `str` 

258 Identifier for value to return. 

259 

260 Returns 

261 ------- 

262 value : `~typing.Any` 

263 Value associated with given key. 

264 

265 Raises 

266 ------ 

267 KeyError 

268 If key doesn't exist. 

269 """ 

270 return self.data[key] 

271 

272 def __delitem__(self, key): 

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

274 

275 Parameters 

276 ---------- 

277 key : `str` 

278 Identifier for value to delete. 

279 

280 Raises 

281 ------ 

282 KeyError 

283 If key doesn't exist. 

284 """ 

285 del self.data[key] 

286 

287 def __setitem__(self, key, value): 

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

289 

290 Parameters 

291 ---------- 

292 key : `str` 

293 Identifier to associate with given value. 

294 value : `~typing.Any` 

295 Value to store. 

296 

297 Raises 

298 ------ 

299 KeyError 

300 If key is invalid. 

301 """ 

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

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

304 self.data[key] = value 

305 

306 def __iter__(self): 

307 return self.data.__iter__() 

308 

309 def __len__(self): 

310 return len(self.data) 

311 

312 def __str__(self): 

313 return str(self.data) 

314 

315 

316def htc_backup_files( 

317 wms_path: str | os.PathLike, 

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

319 limit: int = 100, 

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

321) -> None: 

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

323 

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

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

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

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

328 

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

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

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

332 will be copied to '000' subdirectory. 

333 

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

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

336 overwritten by HTCondor after during the next run. 

337 

338 Parameters 

339 ---------- 

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

341 Path to the submit directory either absolute or relative. 

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

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

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

345 subdirectories will be placed directly in the submit directory. 

346 limit : `int`, optional 

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

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

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

350 version 8.8+. 

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

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

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

354 files are backed up. 

355 

356 Raises 

357 ------ 

358 FileNotFoundError 

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

360 exist. 

361 OSError 

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

363 either due to permission or filesystem related issues. 

364 """ 

365 width = len(str(limit)) 

366 

367 path = Path(wms_path).resolve() 

368 if not path.is_dir(): 

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

370 

371 # Initialize the backup counter. 

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

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

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

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

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

377 

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

379 dest = path 

380 if subdir: 

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

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

383 # way if it is an absolute path. 

384 subdir = Path(subdir) 

385 if subdir.is_absolute(): 

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

387 if dest not in subdir.parents: 

388 _LOG.warning( 

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

390 subdir, 

391 wms_path, 

392 ) 

393 else: 

394 dest /= subdir 

395 else: 

396 dest /= subdir 

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

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

399 try: 

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

401 except FileExistsError: 

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

403 else: 

404 htc_backup_files_single_path(path, dest) 

405 

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

407 # 

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

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

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

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

412 # reporting incorrect job status counts. 

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

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

415 continue 

416 subdag_dest = dest / subdag_dir.relative_to(path) 

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

418 htc_backup_files_single_path(subdag_dir, subdag_dest) 

419 

420 

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

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

423 

424 Parameters 

425 ---------- 

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

427 Directory from which to back up particular files. 

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

429 Directory to which particular files are moved. 

430 

431 Raises 

432 ------ 

433 RuntimeError 

434 If given dest directory matches given src directory. 

435 OSError 

436 If problems moving file. 

437 FileNotFoundError 

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

439 """ 

440 src = Path(src) 

441 dest = Path(dest) 

442 if dest.samefile(src): 

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

444 

445 for patt in [ 

446 "*.info.*", 

447 "*.dag.metrics", 

448 "*.dag.nodes.log", 

449 "*.node_status", 

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

451 "wms_*.status.txt", 

452 ]: 

453 for source in src.glob(patt): 

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

455 target = dest / source.relative_to(src) 

456 try: 

457 source.rename(target) 

458 except OSError as exc: 

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

460 else: 

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

462 

463 

464def htc_escape(value): 

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

466 

467 Parameters 

468 ---------- 

469 value : `~typing.Any` 

470 Value that needs to have characters escaped if string. 

471 

472 Returns 

473 ------- 

474 new_value : `~typing.Any` 

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

476 """ 

477 if isinstance(value, str): 

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

479 else: 

480 newval = value 

481 

482 return newval 

483 

484 

485def htc_write_attribs(stream, attrs): 

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

487 

488 Parameters 

489 ---------- 

490 stream : `~typing.TextIO` 

491 Output text stream (typically an open file). 

492 attrs : `dict` 

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

494 """ 

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

496 # Make sure strings are syntactically correct for HTCondor. 

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

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

499 else: 

500 pval = value 

501 

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

503 

504 

505def htc_write_condor_file( 

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

507) -> None: 

508 """Write an HTCondor submit file. 

509 

510 Parameters 

511 ---------- 

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

513 Filename for the HTCondor submit file. 

514 job_name : `str` 

515 Job name to use in submit file. 

516 job : `RestrictedDict` 

517 Submit script information. 

518 job_attrs : `dict` 

519 Job attributes. 

520 """ 

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

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

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

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

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

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

527 else: 

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

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

530 if key not in job: 

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

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

533 

534 if job_attrs is not None: 

535 htc_write_attribs(fh, job_attrs) 

536 print("queue", file=fh) 

537 

538 

539def htc_tune_schedd_args(**kwargs): 

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

541 

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

543 necessary. Effectively, a no-op. 

544 

545 Parameters 

546 ---------- 

547 **kwargs 

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

549 Schedd.xquery() accepts. 

550 

551 Returns 

552 ------- 

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

554 Keywords arguments that were passed to the function. 

555 

556 Notes 

557 ----- 

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

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

560 """ 

561 return kwargs 

562 

563 

564def htc_query_history(schedds, **kwargs): 

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

566 

567 Parameters 

568 ---------- 

569 schedds : `htcondor.Schedd` 

570 HTCondor schedulers which to query for job information. 

571 **kwargs 

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

573 

574 Yields 

575 ------ 

576 schedd_name : `str` 

577 Name of the HTCondor scheduler managing the job queue. 

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

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

580 job attributes names to values of the ClassAd expressions they 

581 represent. 

582 """ 

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

584 kwargs.setdefault("constraint", None) 

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

586 kwargs = htc_tune_schedd_args(**kwargs) 

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

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

589 yield schedd_name, dict(job_ad) 

590 

591 

592def htc_query_present(schedds, **kwargs): 

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

594 

595 Parameters 

596 ---------- 

597 schedds : `htcondor.Schedd` 

598 HTCondor schedulers which to query for job information. 

599 **kwargs 

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

601 

602 Yields 

603 ------ 

604 schedd_name : `str` 

605 Name of the HTCondor scheduler managing the job queue. 

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

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

608 job attributes names to values of the ClassAd expressions they 

609 represent. 

610 """ 

611 kwargs = htc_tune_schedd_args(**kwargs) 

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

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

614 yield schedd_name, dict(job_ad) 

615 

616 

617def htc_submit_dag(sub: Submit): 

618 """Submit job for execution. 

619 

620 Parameters 

621 ---------- 

622 sub : `htcondor.Submit` 

623 An object representing a job submit description. 

624 

625 Returns 

626 ------- 

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

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

629 Information about jobs satisfying the search criteria where for each 

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

631 classads. 

632 """ 

633 coll = Collector() 

634 schedd_ad = coll.locate(DaemonTypes.Schedd) 

635 schedd = Schedd(schedd_ad) 

636 

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

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

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

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

641 

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

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

644 schedd_name = schedd_ad["Name"] 

645 schedd_dag_info = condor_q( 

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

647 ) 

648 return schedd_dag_info 

649 

650 

651def htc_create_submit_from_dag( 

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

653) -> Submit: 

654 """Create a DAGMan job submit description. 

655 

656 Parameters 

657 ---------- 

658 dag_filename : `str` 

659 Name of file containing HTCondor DAG commands. 

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

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

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

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

664 file (None). 

665 

666 Returns 

667 ------- 

668 sub : `htcondor.Submit` 

669 An object representing a job submit description. 

670 

671 Notes 

672 ----- 

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

674 i.e., 8.9.3 or newer. 

675 """ 

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

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

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

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

680 # in the broken versions. 

681 if "MaxIdle" not in submit_options: 

682 max_jobs_idle: int | None = None 

683 config_var_name = "DAGMAN_MAX_JOBS_IDLE" 

684 

685 if dagman_conf_filename: 

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

687 with open(dagman_conf_filename) as fh: 

688 for line in fh: 

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

690 parts = line.split("=") 

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

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

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

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

695 break 

696 if max_jobs_idle is None: 

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

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

699 elif config_var_name in param: 

700 max_jobs_idle = param[config_var_name] 

701 

702 if max_jobs_idle: 

703 submit_options["MaxIdle"] = max_jobs_idle 

704 else: 

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

706 

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

708 submit_ad = Submit.from_dag(dag_filename, submit_options) 

709 submit_ad = compat_submit_ad(submit_ad) 

710 return submit_ad 

711 

712 

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

714 """Create a DAGMan job submit description. 

715 

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

717 on given DAG description file. 

718 

719 Parameters 

720 ---------- 

721 dag_filename : `str` 

722 Name of file containing HTCondor DAG commands. 

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

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

725 

726 Returns 

727 ------- 

728 sub : `htcondor.Submit` 

729 An object representing a job submit description. 

730 

731 Notes 

732 ----- 

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

734 i.e., older than 8.9.3. 

735 """ 

736 # Run command line condor_submit_dag command. 

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

738 

739 if submit_options is not None: 

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

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

742 cmd += f"{dag_filename}" 

743 

744 process = subprocess.Popen( 

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

746 ) 

747 process.wait() 

748 

749 if process.returncode != 0: 

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

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

752 raise RuntimeError("Problems running condor_submit_dag") 

753 

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

755 

756 

757def htc_create_submit_from_file(submit_file): 

758 """Parse a submission file. 

759 

760 Parameters 

761 ---------- 

762 submit_file : `str` 

763 Name of the HTCondor submit file. 

764 

765 Returns 

766 ------- 

767 sub : `htcondor.Submit` 

768 An object representing a job submit description. 

769 """ 

770 descriptors = {} 

771 with open(submit_file) as fh: 

772 for line in fh: 

773 line = line.strip() 

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

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

776 descriptors[key] = val 

777 

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

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

780 try: 

781 del descriptors["copy_to_spool"] 

782 except KeyError: 

783 pass 

784 

785 submit_ad = Submit(descriptors) 

786 submit_ad = compat_submit_ad(submit_ad) 

787 return submit_ad 

788 

789 

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

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

792 

793 Parameters 

794 ---------- 

795 stream : `~typing.TextIO` 

796 Writeable text stream (typically an opened file). 

797 name : `str` 

798 Job name. 

799 commands : `RestrictedDict` 

800 DAG commands for a job. 

801 node_type : `str`, optional 

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

803 """ 

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

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

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

807 defer = "" 

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

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

810 

811 debug = "" 

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

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

814 

815 arguments = "" 

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

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

818 

819 executable = commands["pre"]["executable"] 

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

821 

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

823 defer = "" 

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

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

826 

827 debug = "" 

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

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

830 

831 arguments = "" 

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

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

834 

835 executable = commands["post"]["executable"] 

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

837 

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

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

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

841 

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

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

844 

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

846 if node_type != "FINAL": 

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

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

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

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

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

852 

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

854 print( 

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

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

857 file=stream, 

858 ) 

859 

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

861 print( 

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

863 file=stream, 

864 ) 

865 

866 

867class HTCJob: 

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

869 

870 Parameters 

871 ---------- 

872 name : `str` 

873 Name of the job. 

874 label : `str` 

875 Label that can used for grouping or lookup. 

876 initcmds : `RestrictedDict` 

877 Initial job commands for submit file. 

878 initdagcmds : `RestrictedDict` 

879 Initial commands for job inside DAG. 

880 initattrs : `dict` 

881 Initial dictionary of job attributes. 

882 """ 

883 

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

885 self.name = name 

886 self.label = label 

887 self.cmds = RestrictedDict(HTC_VALID_JOB_KEYS, initcmds) 

888 self.dagcmds = RestrictedDict(HTC_VALID_JOB_DAG_KEYS, initdagcmds) 

889 self.attrs = initattrs 

890 self.subfile = None 

891 self.subdir = None 

892 self.subdag = None 

893 

894 def __str__(self): 

895 return self.name 

896 

897 def add_job_cmds(self, new_commands): 

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

899 

900 Parameters 

901 ---------- 

902 new_commands : `dict` 

903 Submit file commands to be added to Job. 

904 """ 

905 self.cmds.update(new_commands) 

906 

907 def add_dag_cmds(self, new_commands): 

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

909 

910 Parameters 

911 ---------- 

912 new_commands : `dict` 

913 DAG file commands to be added to Job. 

914 """ 

915 self.dagcmds.update(new_commands) 

916 

917 def add_job_attrs(self, new_attrs): 

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

919 

920 Parameters 

921 ---------- 

922 new_attrs : `dict` 

923 Attributes to be added to Job. 

924 """ 

925 if self.attrs is None: 

926 self.attrs = {} 

927 if new_attrs: 

928 self.attrs.update(new_attrs) 

929 

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

931 """Write job description to submit file. 

932 

933 Parameters 

934 ---------- 

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

936 Prefix path for the submit file. 

937 """ 

938 if not self.subfile: 

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

940 

941 subfile = self.subfile 

942 if self.subdir: 

943 subfile = Path(self.subdir) / subfile 

944 

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

946 if not subfile.is_absolute(): 

947 subfile = Path(submit_path) / subfile 

948 if not subfile.exists(): 

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

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

951 else: 

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

953 

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

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

956 

957 Parameters 

958 ---------- 

959 stream : `~typing.TextIO` 

960 Output Stream. 

961 dag_rel_path : `str` 

962 Relative path of dag to submit directory. 

963 command_name : `str` 

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

965 """ 

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

967 

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

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

970 if "dir" in self.dagcmds: 

971 dir_val = self.dagcmds["dir"] 

972 if dag_rel_path: 

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

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

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

976 job_line += " NOOP" 

977 

978 print(job_line, file=stream) 

979 if self.dagcmds: 

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

981 

982 def dump(self, fh): 

983 """Dump job information to output stream. 

984 

985 Parameters 

986 ---------- 

987 fh : `~typing.TextIO` 

988 Output stream. 

989 """ 

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

991 printer.pprint(self.name) 

992 printer.pprint(self.cmds) 

993 printer.pprint(self.attrs) 

994 

995 

996class HTCDag(networkx.DiGraph): 

997 """HTCondor DAG. 

998 

999 Parameters 

1000 ---------- 

1001 data : `~typing.Any` 

1002 Initial graph data of any format that is supported 

1003 by the to_network_graph() function. 

1004 name : `str` 

1005 Name for DAG. 

1006 """ 

1007 

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

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

1010 

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

1012 self.graph["run_id"] = None 

1013 self.graph["submit_path"] = None 

1014 self.graph["final_job"] = None 

1015 self.graph["service_job"] = None 

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

1017 

1018 def __str__(self): 

1019 """Represent basic DAG info as string. 

1020 

1021 Returns 

1022 ------- 

1023 info : `str` 

1024 String containing basic DAG info. 

1025 """ 

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

1027 

1028 def add_attribs(self, attribs=None): 

1029 """Add attributes to the DAG. 

1030 

1031 Parameters 

1032 ---------- 

1033 attribs : `dict` 

1034 DAG attributes. 

1035 """ 

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

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

1038 

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

1040 """Add an HTCJob to the HTCDag. 

1041 

1042 Parameters 

1043 ---------- 

1044 job : `HTCJob` 

1045 HTCJob to add to the HTCDag. 

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

1047 Names of parent jobs. 

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

1049 Names of child jobs. 

1050 """ 

1051 assert isinstance(job, HTCJob) 

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

1053 

1054 # Add dag level attributes to each job 

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

1056 

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

1058 

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

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

1061 

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

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

1064 

1065 def add_job_relationships(self, parents, children): 

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

1067 

1068 Parameters 

1069 ---------- 

1070 parents : `list` [`str`] 

1071 Contains parent job name(s). 

1072 children : `list` [`str`] 

1073 Contains children job name(s). 

1074 """ 

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

1076 

1077 def add_final_job(self, job): 

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

1079 

1080 Parameters 

1081 ---------- 

1082 job : `HTCJob` 

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

1084 """ 

1085 # Add dag level attributes to each job 

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

1087 

1088 self.graph["final_job"] = job 

1089 

1090 def add_service_job(self, job): 

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

1092 

1093 Parameters 

1094 ---------- 

1095 job : `HTCJob` 

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

1097 """ 

1098 # Add dag level attributes to each job 

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

1100 

1101 self.graph["service_job"] = job 

1102 

1103 def del_job(self, job_name): 

1104 """Delete the job from the DAG. 

1105 

1106 Parameters 

1107 ---------- 

1108 job_name : `str` 

1109 Name of job in DAG to delete. 

1110 """ 

1111 # Reconnect edges around node to delete 

1112 parents = self.predecessors(job_name) 

1113 children = self.successors(job_name) 

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

1115 

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

1117 self.remove_node(job_name) 

1118 

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

1120 """Write DAG to a file. 

1121 

1122 Parameters 

1123 ---------- 

1124 submit_path : `str` 

1125 Prefix path for all outputs. 

1126 job_subdir : `str`, optional 

1127 Template for job subdir (submit_path + job_subdir). 

1128 dag_subdir : `str`, optional 

1129 DAG subdir (submit_path + dag_subdir). 

1130 dag_rel_path : `str`, optional 

1131 Prefix to job_subdir for jobs inside subdag. 

1132 """ 

1133 self.graph["submit_path"] = submit_path 

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

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

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

1137 

1138 try: 

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

1140 except KeyError: 

1141 dagman_config_path = None 

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

1143 if dagman_config_path is not None: 

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

1145 

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

1147 try: 

1148 job = nodeval["data"] 

1149 except KeyError: 

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

1151 raise 

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

1153 if job.subfile: 

1154 this_dag_rel_path = "" 

1155 else: 

1156 this_dag_rel_path = "../.." 

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

1158 if "dir" in job.dagcmds: 

1159 subdir = job.dagcmds["dir"] 

1160 else: 

1161 subdir = job_subdir 

1162 if dagman_config_path is not None: 

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

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

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

1166 if dag_subdir: 

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

1168 fh.write("\n") 

1169 if job.dagcmds: 

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

1171 else: 

1172 job.write_submit_file(submit_path) 

1173 job.write_dag_commands(fh, dag_rel_path) 

1174 

1175 for edge in self.edges(): 

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

1177 

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

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

1180 

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

1182 

1183 # Add bps attributes to dag submission 

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

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

1186 

1187 # Add special nodes if any. 

1188 special_jobs = { 

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

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

1191 } 

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

1193 if job is not None: 

1194 job.write_submit_file(submit_path) 

1195 job.write_dag_commands(fh, dag_rel_path, dagcmd) 

1196 

1197 def dump(self, fh): 

1198 """Dump DAG info to output stream. 

1199 

1200 Parameters 

1201 ---------- 

1202 fh : `typing.IO` 

1203 Where to dump DAG info as text. 

1204 """ 

1205 for key, value in self.graph: 

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

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

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

1209 data.dump(fh) 

1210 for edge in self.edges(): 

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

1212 if self.graph["final_job"]: 

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

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

1215 

1216 def write_dot(self, filename): 

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

1218 

1219 Parameters 

1220 ---------- 

1221 filename : `str` 

1222 Name of the dot file. 

1223 """ 

1224 pos = networkx.nx_agraph.graphviz_layout(self) 

1225 networkx.draw(self, pos=pos) 

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

1227 

1228 

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

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

1231 

1232 Parameters 

1233 ---------- 

1234 constraint : `str`, optional 

1235 Constraints to be passed to job query. 

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

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

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

1239 **kwargs : `~typing.Any` 

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

1241 query method. 

1242 

1243 Returns 

1244 ------- 

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

1246 Information about jobs satisfying the search criteria where for each 

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

1248 classads. 

1249 """ 

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

1251 

1252 

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

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

1255 

1256 Parameters 

1257 ---------- 

1258 constraint : `str`, optional 

1259 Constraints to be passed to job query. 

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

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

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

1263 the local scheduler only. 

1264 **kwargs : `~typing.Any` 

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

1266 query method. 

1267 

1268 Returns 

1269 ------- 

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

1271 Information about jobs satisfying the search criteria where for each 

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

1273 classads. 

1274 """ 

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

1276 

1277 

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

1279 """Get information about HTCondor jobs. 

1280 

1281 Parameters 

1282 ---------- 

1283 constraint : `str`, optional 

1284 Constraints to be passed to job query. 

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

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

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

1288 the local scheduler only. 

1289 query_func : `~collections.abc.Callable` 

1290 An query function which takes following arguments: 

1291 

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

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

1294 function. 

1295 **kwargs : `~typing.Any` 

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

1297 method. 

1298 

1299 Returns 

1300 ------- 

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

1302 Information about jobs satisfying the search criteria where for each 

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

1304 classads. 

1305 """ 

1306 if not schedds: 

1307 coll = Collector() 

1308 schedd_ad = coll.locate(DaemonTypes.Schedd) 

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

1310 

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

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

1313 added_attrs = set() 

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

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

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

1317 added_attrs = required_attrs - requested_attrs 

1318 for attr in added_attrs: 

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

1320 

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

1322 job_info = defaultdict(dict) 

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

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

1325 for attr in set(job_ad) & unwanted_attrs: 

1326 del job_ad[attr] 

1327 job_info[schedd_name][id_] = job_ad 

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

1329 

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

1331 # if needed. 

1332 if added_attrs: 

1333 for attr in added_attrs: 

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

1335 

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

1337 # matching the search criteria. 

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

1339 

1340 

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

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

1343 

1344 Parameters 

1345 ---------- 

1346 constraint : `str`, optional 

1347 Constraints to be passed to job query. 

1348 hist : `float` 

1349 Limit history search to this many days. 

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

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

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

1353 

1354 Returns 

1355 ------- 

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

1357 Information about jobs satisfying the search criteria where for each 

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

1359 classads. 

1360 """ 

1361 if constraint is None: 

1362 constraint = "" 

1363 

1364 if not schedds: 

1365 coll = Collector() 

1366 schedd_ad = coll.locate(DaemonTypes.Schedd) 

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

1368 

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

1370 if hist is not None: 

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

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

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

1374 hist_info = condor_history(constraint, schedds=schedds) 

1375 update_job_info(job_info, hist_info) 

1376 return job_info 

1377 

1378 

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

1380 """Get information about HTCondor pool. 

1381 

1382 Parameters 

1383 ---------- 

1384 constraint : `str`, optional 

1385 Constraints to be passed to the query. 

1386 coll : `htcondor.Collector`, optional 

1387 Object representing HTCondor collector daemon. 

1388 

1389 Returns 

1390 ------- 

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

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

1393 """ 

1394 if coll is None: 

1395 coll = Collector() 

1396 try: 

1397 pool_ads = coll.query(constraint=constraint) 

1398 except HTCondorException as ex: 

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

1400 

1401 pool_info = {} 

1402 for slot in pool_ads: 

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

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

1405 return pool_info 

1406 

1407 

1408def update_job_info(job_info, other_info): 

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

1410 

1411 Parameters 

1412 ---------- 

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

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

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

1416 Results of the other job query. 

1417 

1418 Returns 

1419 ------- 

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

1421 The updated results. 

1422 """ 

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

1424 try: 

1425 jobs = job_info[schedd_name] 

1426 except KeyError: 

1427 job_info[schedd_name] = others 

1428 else: 

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

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

1431 return job_info 

1432 

1433 

1434def count_jobs_in_single_dag( 

1435 filename: str | os.PathLike, 

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

1437 """Build bps_run_summary string from dag file. 

1438 

1439 Parameters 

1440 ---------- 

1441 filename : `str` 

1442 Path that includes dag file for a run. 

1443 

1444 Returns 

1445 ------- 

1446 counts : `Counter` [`str`] 

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

1448 (Same format as saved in dag classad). 

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

1450 Mapping of job names to job labels. 

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

1452 Mapping of job names to job types 

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

1454 """ 

1455 # Later code depends upon insertion order 

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

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

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

1459 with open(filename) as fh: 

1460 for line in fh: 

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

1462 if not line.startswith( 

1463 ( 

1464 "JOB", 

1465 "FINAL", 

1466 "SERVICE", 

1467 "SUBDAG EXTERNAL", 

1468 ) 

1469 ): 

1470 continue 

1471 

1472 m = re.match( 

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

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

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

1476 line, 

1477 ) 

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

1479 job_name = m.group("jobname") 

1480 job_type = WmsNodeType.UNKNOWN 

1481 name_parts = job_name.split("_") 

1482 

1483 label = "" 

1484 if m.group("dir"): 

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

1486 if dir_match: 

1487 label = dir_match.group(1) 

1488 else: 

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

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

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

1492 if subfile_match: 

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

1494 else: 

1495 label = pegasus_name_to_label(job_name) 

1496 

1497 match m.group("command"): 

1498 case "JOB": 

1499 if m.group("noop"): 

1500 job_type = WmsNodeType.NOOP 

1501 # wms_noop_label 

1502 label = name_parts[2] 

1503 elif m.group("wms"): 

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

1505 job_type = WmsNodeType.SUBDAG_CHECK 

1506 # wms_check_status_wms_group_label 

1507 label = name_parts[5] 

1508 else: 

1509 _LOG.warning( 

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

1511 ) 

1512 else: 

1513 job_type = WmsNodeType.PAYLOAD 

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

1515 label = "pipetaskInit" 

1516 counts[label] += 1 

1517 case "FINAL": 

1518 job_type = WmsNodeType.FINAL 

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

1520 case "SERVICE": 

1521 job_type = WmsNodeType.SERVICE 

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

1523 job_type = WmsNodeType.SUBDAG 

1524 label = name_parts[2] 

1525 

1526 job_name_to_label[job_name] = label 

1527 job_name_to_type[job_name] = job_type 

1528 else: 

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

1530 # problems with regex. 

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

1532 

1533 return counts, job_name_to_label, job_name_to_type 

1534 

1535 

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

1537 """Build bps_run_summary string from dag file. 

1538 

1539 Parameters 

1540 ---------- 

1541 dir_name : `str` 

1542 Path that includes dag file for a run. 

1543 

1544 Returns 

1545 ------- 

1546 summary : `str` 

1547 Semi-colon separated list of job labels and counts 

1548 (Same format as saved in dag classad). 

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

1550 Mapping of job names to job labels. 

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

1552 Mapping of job names to job types 

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

1554 """ 

1555 # Later code depends upon insertion order 

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

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

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

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

1560 single_counts, single_job_name_to_label, single_job_name_to_type = count_jobs_in_single_dag(filename) 

1561 counts += single_counts 

1562 _update_dicts(job_name_to_label, single_job_name_to_label) 

1563 _update_dicts(job_name_to_type, single_job_name_to_type) 

1564 

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

1566 single_counts, single_job_name_to_label, single_job_name_to_type = count_jobs_in_single_dag(filename) 

1567 counts += single_counts 

1568 _update_dicts(job_name_to_label, single_job_name_to_label) 

1569 _update_dicts(job_name_to_type, single_job_name_to_type) 

1570 

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

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

1573 return summary, job_name_to_label, job_name_to_type 

1574 

1575 

1576def pegasus_name_to_label(name): 

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

1578 

1579 Parameters 

1580 ---------- 

1581 name : `str` 

1582 Name of job. 

1583 

1584 Returns 

1585 ------- 

1586 label : `str` 

1587 Label for job. 

1588 """ 

1589 label = "UNK" 

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

1591 label = "pegasus" 

1592 else: 

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

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

1595 label = m.group(2) 

1596 if label == "init": 

1597 label = "pipetaskInit" 

1598 

1599 return label 

1600 

1601 

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

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

1604 

1605 Parameters 

1606 ---------- 

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

1608 Node status filename. 

1609 

1610 Returns 

1611 ------- 

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

1613 DAG summary information. 

1614 """ 

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

1616 

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

1618 # file if need to. 

1619 try: 

1620 node_stat_file = Path(filename) 

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

1622 with open(node_stat_file) as infh: 

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

1624 

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

1626 # Pegasus check here 

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

1628 if metrics_file.exists(): 

1629 with open(metrics_file) as infh: 

1630 metrics = json.load(infh) 

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

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

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

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

1635 with open(metrics_file) as infh: 

1636 metrics = json.load(infh) 

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

1638 except (OSError, PermissionError): 

1639 pass 

1640 

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

1642 return dag_ad 

1643 

1644 

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

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

1647 

1648 Parameters 

1649 ---------- 

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

1651 Path that includes node status file for a run. 

1652 

1653 Returns 

1654 ------- 

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

1656 DAG summary information, counts summed across any subdags. 

1657 """ 

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

1659 path = Path(wms_path) 

1660 try: 

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

1662 except StopIteration as exc: 

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

1664 

1665 dag_ads = read_single_dag_status(node_stat_file) 

1666 

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

1668 dag_ad = read_single_dag_status(node_stat_file) 

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

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

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

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

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

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

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

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

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

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

1679 

1680 return dag_ads 

1681 

1682 

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

1684 """Read entire node status file. 

1685 

1686 Parameters 

1687 ---------- 

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

1689 Node status filename. 

1690 init_fake_id : `int` 

1691 Initial fake id value. 

1692 

1693 Returns 

1694 ------- 

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

1696 DAG summary information compiled from the node status file combined 

1697 with the information found in the node event log. 

1698 

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

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

1701 file. 

1702 """ 

1703 filename = Path(filename) 

1704 

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

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

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

1708 wms_workflow_id = MISSING_ID 

1709 try: 

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

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

1712 except (HTCondorException, OSError, PermissionError): 

1713 pass 

1714 

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

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

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

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

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

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

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

1722 job_name = m.group(1) 

1723 log_job_name_to_id[job_name] = job_id 

1724 job_info["DAGNodeName"] = job_name 

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

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

1727 

1728 jobs = {} 

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

1730 try: 

1731 with open(filename) as fh: 

1732 for ad in parseAds(fh): 

1733 match ad["Type"]: 

1734 case "DagStatus": 

1735 # Skip DAG summary. 

1736 pass 

1737 case "NodeStatus": 

1738 job_name = ad["Node"] 

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

1740 job_label = job_name_to_label[job_name] 

1741 elif "_" in job_name: 

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

1743 else: 

1744 job_label = job_name 

1745 

1746 job = dict(ad) 

1747 if job_name in log_job_name_to_id: 

1748 job_id = str(log_job_name_to_id[job_name]) 

1749 _update_dicts(job, loginfo[job_id]) 

1750 else: 

1751 job_id = str(fake_id) 

1752 job = dict(ad) 

1753 fake_id -= 1 

1754 jobs[job_id] = job 

1755 job_name_to_id[job_name] = job_id 

1756 

1757 # Make job info as if came from condor_q. 

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

1759 job["DAGManJobID"] = wms_workflow_id 

1760 job["DAGNodeName"] = job_name 

1761 job["bps_job_label"] = job_label 

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

1763 

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

1765 # Skip node status file "epilog". 

1766 pass 

1767 case _: 

1768 _LOG.debug( 

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

1770 ad["Type"], 

1771 filename, 

1772 ) 

1773 except (OSError, PermissionError): 

1774 pass 

1775 

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

1777 # Use dag info to create job placeholders 

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

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

1780 job_id = str(log_job_name_to_id[name]) 

1781 job = dict(loginfo[job_id]) 

1782 else: 

1783 job_id = str(fake_id) 

1784 fake_id -= 1 

1785 job = {} 

1786 job["NodeStatus"] = NodeStatus.NOT_READY 

1787 

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

1789 job["ProcId"] = 0 

1790 job["DAGManJobID"] = wms_workflow_id 

1791 job["DAGNodeName"] = name 

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

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

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

1795 

1796 for job_info in jobs.values(): 

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

1798 

1799 return jobs 

1800 

1801 

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

1803 """Read entire node status file. 

1804 

1805 Parameters 

1806 ---------- 

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

1808 Path that includes node status file for a run. 

1809 

1810 Returns 

1811 ------- 

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

1813 DAG summary information compiled from the node status file combined 

1814 with the information found in the node event log. 

1815 

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

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

1818 file. 

1819 """ 

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

1821 init_fake_id = -1 

1822 

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

1824 # use dag files and let read_single_node_status handle missing 

1825 # node_status file. 

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

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

1828 info = read_single_node_status(filename, init_fake_id) 

1829 init_fake_id -= len(info) 

1830 _update_dicts(jobs, info) 

1831 

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

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

1834 info = read_single_node_status(filename, init_fake_id) 

1835 init_fake_id -= len(info) 

1836 _update_dicts(jobs, info) 

1837 

1838 # Propagate pruned from subdags to jobs 

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

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

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

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

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

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

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

1846 

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

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

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

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

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

1852 

1853 return jobs 

1854 

1855 

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

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

1858 

1859 Parameters 

1860 ---------- 

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

1862 DAGMan log filename. 

1863 

1864 Returns 

1865 ------- 

1866 wms_workflow_id : `str` 

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

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

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

1870 job id. 

1871 

1872 Raises 

1873 ------ 

1874 FileNotFoundError 

1875 If cannot find DAGMan log in given wms_path. 

1876 """ 

1877 wms_workflow_id = MISSING_ID 

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

1879 

1880 filename = Path(log_filename) 

1881 if filename.exists(): 

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

1883 

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

1885 job_event_log = JobEventLog(str(filename)) 

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

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

1888 if id_ not in info: 

1889 info[id_] = {} 

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

1891 _update_dicts(info[id_], event) 

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

1893 

1894 # only save latest DAG job 

1895 dag_info = {wms_workflow_id: info[wms_workflow_id]} 

1896 

1897 return wms_workflow_id, dag_info 

1898 

1899 

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

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

1902 

1903 Parameters 

1904 ---------- 

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

1906 Path containing the DAGMan log file. 

1907 

1908 Returns 

1909 ------- 

1910 wms_workflow_id : `str` 

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

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

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

1914 job id. 

1915 

1916 Raises 

1917 ------ 

1918 FileNotFoundError 

1919 If cannot find DAGMan log in given wms_path. 

1920 """ 

1921 wms_workflow_id = MISSING_ID 

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

1923 

1924 path = Path(wms_path) 

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

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

1927 # with lowest ID is main DAG 

1928 ids = [] 

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

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

1931 single_id, single_dag_info = read_single_dag_log(filename) 

1932 _update_dicts(dag_info, single_dag_info) 

1933 ids.append(single_id) 

1934 if ids: 

1935 wms_workflow_id = min(ids) 

1936 

1937 if wms_workflow_id == MISSING_ID: 

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

1939 

1940 return wms_workflow_id, dag_info 

1941 

1942 

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

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

1945 

1946 Parameters 

1947 ---------- 

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

1949 Path containing the DAGMan nodes log file. 

1950 

1951 Returns 

1952 ------- 

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

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

1955 job id. 

1956 

1957 Raises 

1958 ------ 

1959 FileNotFoundError 

1960 If cannot find DAGMan node log in given wms_path. 

1961 """ 

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

1963 filename = Path(filename) 

1964 

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

1966 if not filename.exists(): 

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

1968 

1969 try: 

1970 job_event_log = JobEventLog(str(filename)) 

1971 except HTCondorException as ex: 

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

1973 import traceback 

1974 

1975 traceback.print_stack() 

1976 raise 

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

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

1979 

1980 try: 

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

1982 except KeyError: 

1983 _LOG.warning( 

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

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

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

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

1988 ) 

1989 else: 

1990 if id_ not in info: 

1991 info[id_] = {} 

1992 

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

1994 # future HTCondor versions. Sometimes get a 

1995 # JobAbortedEvent for a subdag job after it already 

1996 # terminated normally. Seems to happen when using job 

1997 # plus subdags. 

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

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

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

2001 ): 

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

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

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

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

2006 # information separately. 

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

2008 else: 

2009 _update_dicts(info[id_], event) 

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

2011 

2012 return info 

2013 

2014 

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

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

2017 

2018 Parameters 

2019 ---------- 

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

2021 Path containing the DAGMan nodes log file. 

2022 

2023 Returns 

2024 ------- 

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

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

2027 job id. 

2028 

2029 Raises 

2030 ------ 

2031 FileNotFoundError 

2032 If cannot find DAGMan node log in given wms_path. 

2033 """ 

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

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

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

2037 _update_dicts(info, read_single_dag_nodes_log(filename)) 

2038 

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

2040 if not info: 

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

2042 

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

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

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

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

2047 _update_dicts(info, read_single_dag_nodes_log(filename)) 

2048 

2049 return info 

2050 

2051 

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

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

2054 

2055 Parameters 

2056 ---------- 

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

2058 Path containing the file with the DAGMan job info. 

2059 

2060 Returns 

2061 ------- 

2062 filename : `pathlib.Path` 

2063 Name of file containing the dag information. 

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

2065 HTCondor job information. 

2066 

2067 Raises 

2068 ------ 

2069 FileNotFoundError 

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

2071 """ 

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

2073 try: 

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

2075 except StopIteration as exc: 

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

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

2078 try: 

2079 with open(filename) as fh: 

2080 dag_info = json.load(fh) 

2081 except (OSError, PermissionError) as exc: 

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

2083 return filename, dag_info 

2084 

2085 

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

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

2088 

2089 Parameters 

2090 ---------- 

2091 filename : `str` 

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

2093 creates a filename using bps_run value. 

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

2095 Information about the DAGMan job. 

2096 """ 

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

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

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

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

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

2102 try: 

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

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

2105 json.dump(info, fh) 

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

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

2108 

2109 return filename 

2110 

2111 

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

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

2114 

2115 Parameters 

2116 ---------- 

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

2118 Path containing an HTCondor event log file. 

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

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

2121 the log. 

2122 

2123 Notes 

2124 ----- 

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

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

2127 and resolve the object references in the caller. 

2128 """ 

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

2130 

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

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

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

2134 if "MyType" not in job: 

2135 return 

2136 

2137 try: 

2138 job["ClusterId"] = job["Cluster"] 

2139 job["ProcId"] = job["Proc"] 

2140 except KeyError as e: 

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

2142 raise 

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

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

2145 

2146 if "LogNotes" in job: 

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

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

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

2150 

2151 match job["MyType"]: 

2152 case "ExecuteEvent": 

2153 job["JobStatus"] = JobStatus.RUNNING 

2154 case "JobTerminatedEvent" | "PostScriptTerminatedEvent": 

2155 job["JobStatus"] = JobStatus.COMPLETED 

2156 case "SubmitEvent": 

2157 job["JobStatus"] = JobStatus.IDLE 

2158 case "JobAbortedEvent": 

2159 job["JobStatus"] = JobStatus.REMOVED 

2160 case "JobHeldEvent": 

2161 job["JobStatus"] = JobStatus.HELD 

2162 case "JobReleaseEvent": 

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

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

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

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

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

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

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

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

2171 case _: 

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

2173 job["JobStatus"] = None 

2174 

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

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

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

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

2179 # between these two cases easy later on. 

2180 if job["JobStatus"] in { 

2181 JobStatus.COMPLETED, 

2182 JobStatus.HELD, 

2183 JobStatus.REMOVED, 

2184 }: 

2185 new_job = HTC_JOB_AD_HANDLERS.handle(job) 

2186 if new_job is not None: 

2187 job = new_job 

2188 else: 

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

2190 

2191 

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

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

2194 

2195 Parameters 

2196 ---------- 

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

2198 Directory containing the DAGman output file. 

2199 

2200 Returns 

2201 ------- 

2202 message : `str` 

2203 Message containing error messages from the DAGMan output. Empty 

2204 string if no messages. 

2205 

2206 Raises 

2207 ------ 

2208 FileNotFoundError 

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

2210 """ 

2211 try: 

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

2213 except StopIteration as exc: 

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

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

2216 

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

2218 

2219 message = "" 

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

2221 last_warning = ( 

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

2223 "or temporarily had a full quota." 

2224 ) 

2225 try: 

2226 with open(filename) as fh: 

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

2228 for line in fh: 

2229 m = p.match(line) 

2230 if m: 

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

2232 last_submit_failed = m.group(1) 

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

2234 pass # Should be handled by Job submit try 

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

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

2237 last_warning = "Cannot submit from /tmp." 

2238 else: 

2239 last_warning = m.group(2) 

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

2241 message += "ERROR: " 

2242 message += last_warning 

2243 message += "\n" 

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

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

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

2247 ]: 

2248 pass 

2249 else: 

2250 message += m.group(2) 

2251 message += "\n" 

2252 

2253 if last_submit_failed: 

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

2255 except (OSError, PermissionError): 

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

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

2258 return message 

2259 

2260 

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

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

2263 

2264 Parameters 

2265 ---------- 

2266 infh : `TextIO` 

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

2268 

2269 Returns 

2270 ------- 

2271 header_lines : `list` [`str`] 

2272 Header lines read from the rescue file. 

2273 """ 

2274 header_lines = [] 

2275 for line in infh: 

2276 line = line.strip() 

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

2278 break 

2279 header_lines.append(line) 

2280 return header_lines 

2281 

2282 

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

2284 """Update rescue header lines in place. 

2285 

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

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

2288 

2289 Parameters 

2290 ---------- 

2291 header_lines : `list` [`str`] 

2292 Header lines to update in place. 

2293 

2294 Returns 

2295 ------- 

2296 failed_subdags : `list` [`str`] 

2297 Names of failed subdag jobs. 

2298 """ 

2299 failed_subdags = [] 

2300 

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

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

2303 for i, line in enumerate(header_lines): 

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

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

2306 new_nodes = [] 

2307 for node in nodes: 

2308 if node.startswith("wms_check_status"): 

2309 node = node[17:] 

2310 failed_subdags.append(node) 

2311 new_nodes.append(node) 

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

2313 break 

2314 

2315 for i, line in enumerate(header_lines): 

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

2317 if m: 

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

2319 break 

2320 

2321 return failed_subdags 

2322 

2323 

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

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

2326 

2327 Parameters 

2328 ---------- 

2329 header_lines : `list` [`str`] 

2330 Header lines to write to the new rescue file. 

2331 outfh : `TextIO` 

2332 New rescue file. 

2333 """ 

2334 for line in header_lines: 

2335 print(line, file=outfh) 

2336 print("", file=outfh) 

2337 

2338 

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

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

2341 the failed group jobs. 

2342 

2343 Parameters 

2344 ---------- 

2345 failed_subdags : `list` [`str`] 

2346 List of job names for the failed subdags 

2347 infh : `TextIO` 

2348 Original rescue file to copy from. 

2349 outfh : `TextIO` 

2350 New rescue file to copy to. 

2351 """ 

2352 for line in infh: 

2353 line = line.strip() 

2354 try: 

2355 _, node_name = line.split() 

2356 except ValueError: 

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

2358 raise 

2359 if node_name not in failed_subdags: 

2360 print(line, file=outfh) 

2361 

2362 

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

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

2365 

2366 Parameters 

2367 ---------- 

2368 rescue_file : `pathlib.Path` 

2369 The main rescue file that needs to be updated. 

2370 

2371 Returns 

2372 ------- 

2373 failed_subdags : `list` [`str`] 

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

2375 """ 

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

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

2378 with open(rescue_file) as infh: 

2379 header_lines = _read_rescue_headers(infh) 

2380 failed_subdags = _update_rescue_headers(header_lines) 

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

2382 _write_rescue_headers(header_lines, outfh) 

2383 _copy_done_lines(failed_subdags, infh, outfh) 

2384 rescue_file.unlink() 

2385 rescue_tmp.rename(rescue_file) 

2386 return failed_subdags 

2387 

2388 

2389def _update_dicts(dict1, dict2): 

2390 """Update dict1 with info in dict2. 

2391 

2392 (Basically an update for nested dictionaries.) 

2393 

2394 Parameters 

2395 ---------- 

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

2397 HTCondor job information to be updated. 

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

2399 Additional HTCondor job information. 

2400 """ 

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

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

2403 _update_dicts(dict1[key], value) 

2404 else: 

2405 dict1[key] = value 

2406 

2407 

2408def _locate_schedds(locate_all=False): 

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

2410 

2411 Parameters 

2412 ---------- 

2413 locate_all : `bool`, optional 

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

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

2416 for the Scheduler running on a local host. 

2417 

2418 Returns 

2419 ------- 

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

2421 A mapping between Scheduler names and Python objects allowing for 

2422 interacting with them. 

2423 """ 

2424 coll = Collector() 

2425 

2426 schedd_ads = [] 

2427 if locate_all: 

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

2429 else: 

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

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