Skip to content

Commit bf3f978

Browse files
committed
Respawner prototype
1 parent c71bc65 commit bf3f978

6 files changed

Lines changed: 203 additions & 12 deletions

File tree

src/qq_lib/properties/loop.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def __init__(
8282

8383
self.start = start
8484
self.end = end
85-
self.current = current or self._get_cycle()
85+
self.current = current or self.determine_cycle_from_archive()
8686

8787
if self.start < 0:
8888
raise QQError(f"Attribute 'loop-start' ({self.start}) cannot be negative.")
@@ -176,7 +176,7 @@ def to_command_line(self) -> list[str]:
176176
":".join(mode.to_str() for mode in self.archive_mode),
177177
]
178178

179-
def _get_cycle(self) -> int:
179+
def determine_cycle_from_archive(self) -> int:
180180
"""
181181
Determine the current cycle number based on files in the archive directory.
182182

src/qq_lib/qq.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from qq_lib.killall.cli import killall
1616
from qq_lib.nodes.cli import nodes
1717
from qq_lib.queues.cli import queues
18+
from qq_lib.respawn.cli import respawn
1819
from qq_lib.run.cli import run
1920
from qq_lib.shebang.cli import shebang
2021
from qq_lib.stat.cli import stat
@@ -75,3 +76,4 @@ def cli(ctx: click.Context, version: bool):
7576
cli.add_command(nodes)
7677
cli.add_command(shebang)
7778
cli.add_command(wipe)
79+
cli.add_command(respawn)

src/qq_lib/respawn/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Released under MIT License.
2+
# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab

src/qq_lib/respawn/cli.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Released under MIT License.
2+
# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab
3+
4+
import sys
5+
from pathlib import Path
6+
from typing import NoReturn
7+
8+
import click
9+
from rich.console import Console
10+
11+
from qq_lib.core.click_format import GNUHelpColorsCommand
12+
from qq_lib.core.common import get_info_files
13+
from qq_lib.core.config import CFG
14+
from qq_lib.core.error import QQError, QQNotSuitableError
15+
from qq_lib.core.error_handlers import (
16+
handle_general_qq_error,
17+
handle_not_suitable_error,
18+
)
19+
from qq_lib.core.logger import get_logger
20+
from qq_lib.core.repeater import Repeater
21+
from qq_lib.info import Informer
22+
from qq_lib.respawn.respawner import Respawner
23+
24+
logger = get_logger(__name__)
25+
console = Console()
26+
27+
28+
@click.command(
29+
short_help="Respawn a failed/killed job.",
30+
help=f"""Respawn the specified qq job, or all qq jobs in the current directory.
31+
32+
{click.style("JOB_ID", fg="green")} The identifier of the job to respawn. Optional.
33+
34+
If JOB_ID is not specified, `{CFG.binary_name} respawn` searches for qq jobs in the current directory.
35+
36+
Respawning resubmits a failed or killed job to the batch system with its original parameters.
37+
This is useful when a job fails due to a node failure, an unexpected walltime limit, a random crash,
38+
or various other types of premature termination.""",
39+
cls=GNUHelpColorsCommand,
40+
help_options_color="bright_blue",
41+
)
42+
@click.argument(
43+
"job",
44+
type=str,
45+
metavar=click.style("JOB_ID", fg="green"),
46+
required=False,
47+
default=None,
48+
)
49+
def respawn(job: str | None) -> NoReturn:
50+
try:
51+
if job:
52+
informers = [Informer.from_job_id(job)]
53+
else:
54+
if not (
55+
informers := [
56+
Informer.from_file(info) for info in get_info_files(Path.cwd())
57+
]
58+
):
59+
raise QQError("No qq job info file found.")
60+
61+
repeater = Repeater(informers, respawn_job)
62+
repeater.on_exception(QQNotSuitableError, handle_not_suitable_error)
63+
repeater.on_exception(QQError, handle_general_qq_error)
64+
repeater.run()
65+
print()
66+
sys.exit(0)
67+
# QQErrors should be caught by Repeater
68+
except QQError as e:
69+
logger.error(e)
70+
sys.exit(CFG.exit_codes.default)
71+
except Exception as e:
72+
logger.critical(e, exc_info=True, stack_info=True)
73+
sys.exit(CFG.exit_codes.unexpected_error)
74+
75+
76+
def respawn_job(informer: Informer) -> None:
77+
"""
78+
Attempt to respawn a qq job associated with the specified informer.
79+
80+
Args:
81+
informer (Informer): Informer associated with the job.
82+
83+
Raises:
84+
QQNotSuitableError: If the job is not suitable for respawn.
85+
QQError: If the job cannot be respawned.
86+
"""
87+
respawner = Respawner.from_informer(informer)
88+
respawner.print_info(console)
89+
90+
# make sure that the job can actually be respawned
91+
respawner.ensure_suitable()
92+
93+
job_id = respawner.respawn()
94+
95+
logger.info(f"Job '{informer.info.job_id}' successfully respawned as '{job_id}'.")

src/qq_lib/respawn/respawner.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Released under MIT License.
2+
# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab
3+
4+
from qq_lib.clear import Clearer
5+
from qq_lib.core.error import QQError, QQNotSuitableError
6+
from qq_lib.core.logger import get_logger
7+
from qq_lib.core.operator import Operator
8+
from qq_lib.properties.depend import Depend
9+
from qq_lib.properties.loop import LoopInfo
10+
from qq_lib.properties.states import RealState
11+
from qq_lib.submit import Submitter
12+
13+
logger = get_logger(__name__)
14+
15+
16+
class Respawner(Operator):
17+
def ensure_suitable(self) -> None:
18+
"""
19+
Verify that the job is in a state where it can be respawned.
20+
21+
Raises:
22+
QQNotSuitableError: If the job is in any other state than failed or killed.
23+
"""
24+
if self._state not in {RealState.FAILED, RealState.KILLED}:
25+
raise QQNotSuitableError(
26+
f"Job cannot be respawned. Job is {str(self._state)}."
27+
)
28+
29+
def respawn(self) -> str:
30+
informer = self.get_informer()
31+
input_dir = self._info_file.parent
32+
33+
dependencies = self._handle_dependencies(informer.info.depend)
34+
if (loop_info := informer.info.loop_info) is not None:
35+
self._ensure_archive_consistent(loop_info)
36+
37+
submitter = Submitter(
38+
batch_system=informer.batch_system,
39+
queue=informer.info.queue,
40+
account=informer.info.account,
41+
script=input_dir / informer.info.script_name,
42+
job_type=informer.info.job_type,
43+
resources=informer.info.resources,
44+
loop_info=informer.info.loop_info,
45+
exclude=informer.info.excluded_files,
46+
include=informer.info.included_files,
47+
depend=dependencies,
48+
transfer_mode=informer.info.transfer_mode,
49+
server=informer.info.server,
50+
interpreter=informer.info.interpreter,
51+
)
52+
53+
# clear files from the input directory
54+
clearer = Clearer(input_dir)
55+
clearer.clear()
56+
57+
# respawn the job
58+
return submitter.submit()
59+
60+
def _handle_dependencies(self, dependencies: list[Depend]) -> list[Depend]:
61+
"""
62+
Removes jobs from dependencies that are no longer present in the batch system.
63+
64+
Without removing these jobs, the respawned job would immediately fail.
65+
"""
66+
BatchSystem = self._informer.batch_system
67+
68+
filtered = []
69+
for depend in dependencies:
70+
# get jobs that are still present in the batch system
71+
valid_jobs = [
72+
job.get_id()
73+
for job_id in depend.jobs
74+
if not (job := BatchSystem.get_batch_job(job_id)).is_empty()
75+
]
76+
if valid_jobs:
77+
filtered.append(Depend(depend.type, valid_jobs))
78+
79+
logger.debug(f"Filtered dependencies: {filtered}.")
80+
return filtered
81+
82+
def _ensure_archive_consistent(self, loop_info: LoopInfo) -> None:
83+
"""
84+
Ensure that the current loop job cycle matches what we would expect based on the contents of the archive directory.
85+
"""
86+
if (
87+
archive_cycle := loop_info.determine_cycle_from_archive()
88+
) != loop_info.current:
89+
raise QQError(
90+
f"Respawning loop job in cycle '{loop_info.current}' but the loop job should continue from cycle '{archive_cycle}' "
91+
"based on the contents of the archive directory. Canceling job respawn."
92+
)

tests/test_properties_loop.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -162,72 +162,72 @@ def temp_dir():
162162

163163
def test_get_cycle_returns_start_if_archive_does_not_exist(tmp_path):
164164
loop_info = _create_loop_info_stub(5, tmp_path / "nonexistent", "md%04d")
165-
assert loop_info._get_cycle() == 5
165+
assert loop_info.determine_cycle_from_archive() == 5
166166

167167

168168
def test_get_cycle_returns_start_if_no_matching_files(temp_dir):
169169
(temp_dir / "foo.txt").write_text("dummy")
170170
loop_info = _create_loop_info_stub(2, temp_dir, "md%04d")
171-
assert loop_info._get_cycle() == 2
171+
assert loop_info.determine_cycle_from_archive() == 2
172172

173173

174174
def test_get_cycle_selects_highest_number(temp_dir):
175175
(temp_dir / "md0001.xtc").write_text("x")
176176
(temp_dir / "md0002.csv").write_text("x")
177177
(temp_dir / "md0007.txt").write_text("x")
178178
loop_info = _create_loop_info_stub(0, temp_dir, "md%04d")
179-
assert loop_info._get_cycle() == 7
179+
assert loop_info.determine_cycle_from_archive() == 7
180180

181181

182182
def test_get_cycle_selects_highest_number_partial_match(temp_dir):
183183
(temp_dir / "md0001.xtc").write_text("x")
184184
(temp_dir / "md0002.csv").write_text("x")
185185
(temp_dir / "md0007_px.txt").write_text("x")
186186
loop_info = _create_loop_info_stub(0, temp_dir, "md%04d")
187-
assert loop_info._get_cycle() == 7
187+
assert loop_info.determine_cycle_from_archive() == 7
188188

189189

190190
def test_get_cycle_selects_highest_number_partial_match2(temp_dir):
191191
(temp_dir / "md0001.xtc").write_text("x")
192192
(temp_dir / "md0002.csv").write_text("x")
193193
(temp_dir / "file_md0007.txt").write_text("x")
194194
loop_info = _create_loop_info_stub(0, temp_dir, "md%04d")
195-
assert loop_info._get_cycle() == 7
195+
assert loop_info.determine_cycle_from_archive() == 7
196196

197197

198198
def test_get_cycle_files_without_digits_are_ignored(temp_dir):
199199
(temp_dir / "mdabcd.md").write_text("x")
200200
(temp_dir / "mdxxxx.txt").write_text("x")
201201
loop_info = _create_loop_info_stub(3, temp_dir, "md.*")
202202
# no numerical values in filenames; use start cycle
203-
assert loop_info._get_cycle() == 3
203+
assert loop_info.determine_cycle_from_archive() == 3
204204

205205

206206
def test_get_cycle_mixed_files_some_match_some_not(temp_dir):
207207
(temp_dir / "md0002.gro").write_text("x")
208208
(temp_dir / "md25.xtc").write_text("x") # wrong stem
209209
(temp_dir / "md0005.mdp").write_text("x")
210210
loop_info = _create_loop_info_stub(0, temp_dir, "md%04d")
211-
assert loop_info._get_cycle() == 5
211+
assert loop_info.determine_cycle_from_archive() == 5
212212

213213

214214
def test_get_cycle_multiple_digit_sequences_in_stem(temp_dir):
215215
(temp_dir / "md0003extra123.tpr").write_text("x")
216216
loop_info = _create_loop_info_stub(0, temp_dir, "md.*")
217-
assert loop_info._get_cycle() == 3
217+
assert loop_info.determine_cycle_from_archive() == 3
218218

219219

220220
def test_get_cycle_start_value_is_used_as_lower_bound(temp_dir):
221221
(temp_dir / "md0001.xtc").write_text("x")
222222
loop_info = _create_loop_info_stub(5, temp_dir, "md%04d")
223-
assert loop_info._get_cycle() == 5
223+
assert loop_info.determine_cycle_from_archive() == 5
224224

225225

226226
def test_get_cycle_non_numeric_files_are_ignored_but_numeric_stems_count(temp_dir):
227227
(temp_dir / "md0010.xtc").write_text("x")
228228
(temp_dir / "mdxxxx.txt").write_text("x")
229229
loop_info = _create_loop_info_stub(0, temp_dir, "md.*")
230-
assert loop_info._get_cycle() == 10
230+
assert loop_info.determine_cycle_from_archive() == 10
231231

232232

233233
def test_to_command_line_basic():

0 commit comments

Comments
 (0)