Skip to content

Commit 03973c3

Browse files
fix: backup output files marked for update and restore them in case of job failures
1 parent 804be9f commit 03973c3

4 files changed

Lines changed: 56 additions & 4 deletions

File tree

docs/snakefiles/rules.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3371,7 +3371,8 @@ Consider the following example:
33713371
33723372
Here, the statement ``test`` is appended to the output file ``test.txt``.
33733373
Hence, we declare it as being updated via the ``update`` flag.
3374-
This way, Snakemake will not delete the file before the job is executed.
3374+
This way, Snakemake will not delete the file or directory before the job is executed.
3375+
Furthermore, Snakemake will restore the previous version of the file/directory if the job fails.
33753376

33763377
If such a file/directory has to be considered as input **before the update** for another rule
33773378
it can be marked as ``before_update``.

src/snakemake/jobs.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from collections.abc import AsyncGenerator
2121
from abc import abstractmethod
2222
from snakemake import wrapper
23+
from snakemake.rules import Rule
2324
from snakemake.settings.types import DeploymentMethod
2425

2526
from snakemake.template_rendering import check_template_output
@@ -229,7 +230,7 @@ def __init__(
229230
targetfile=None,
230231
groupid=None,
231232
):
232-
self.rule = rule
233+
self.rule: Rule = rule
233234
self.dag = dag
234235

235236
# the targetfile that led to the job
@@ -837,7 +838,9 @@ async def prepare(self):
837838
if self.resources.get("tmpdir"):
838839
os.makedirs(self.resources.tmpdir, exist_ok=True)
839840

840-
for f, f_ in zip(self.output, self.rule.output):
841+
for f in self.output:
842+
if is_flagged(f, "update"):
843+
self.rule.workflow.persistence.backup_output(Path(f))
841844
f.prepare()
842845

843846
for f in self.log:
@@ -950,7 +953,7 @@ def is_pipe_or_service_input(self, path) -> bool:
950953

951954
async def cleanup(self):
952955
"""Cleanup output files."""
953-
to_remove = [f for f in self.output if await f.exists()]
956+
to_remove = [f for f in self.output if await f.exists() and not is_flagged(f, "update")]
954957
to_remove.extend(
955958
[
956959
f
@@ -1229,6 +1232,13 @@ async def postprocess(
12291232
or (self.dag.workflow.remote_exec and not shared_input_output)
12301233
or self.is_local
12311234
):
1235+
for f in self.output:
1236+
if is_flagged(f, "update"):
1237+
if error:
1238+
logger.warning(f"Restoring previous version of {fmt_iofile(f)} (updating job failed).")
1239+
self.dag.workflow.persistence.restore_output(Path(f))
1240+
else:
1241+
self.dag.workflow.persistence.cleanup_backup(Path(f))
12321242
if not error and handle_touch:
12331243
self.dag.handle_touch(self)
12341244
if handle_log:

src/snakemake/persistence.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def __init__(
7070
self.dag = dag
7171
self._lockfile = dict()
7272

73+
self._backup_path = self.path / "backups"
74+
7375
self._metadata_path = os.path.join(self.path, "metadata")
7476
self._incomplete_path = os.path.join(self.path, "incomplete")
7577

@@ -298,6 +300,41 @@ def conda_cleanup_envs(self):
298300
if d not in in_use:
299301
shutil.rmtree(os.path.join(self.conda_env_archive_path, d))
300302

303+
def backup_output(self, path: Path) -> None:
304+
backup_path = self._get_backup_path(path)
305+
backup_path.parent.mkdir(parents=True, exist_ok=True)
306+
if path.is_dir():
307+
shutil.copytree(path, backup_path)
308+
else:
309+
shutil.copy(path, backup_path)
310+
311+
def restore_output(self, path: Path) -> None:
312+
backup_path = self._get_backup_path(path)
313+
if not backup_path.exists():
314+
raise WorkflowError(f"Cannot restore {path}: no backup found.")
315+
if backup_path.is_dir():
316+
if path.exists():
317+
shutil.rmtree(path)
318+
shutil.copytree(backup_path, path)
319+
shutil.rmtree(backup_path)
320+
else:
321+
shutil.copy(backup_path, path)
322+
backup_path.unlink()
323+
324+
def cleanup_backup(self, path: Path) -> None:
325+
backup_path = self._get_backup_path(path)
326+
if backup_path.exists():
327+
if backup_path.is_dir():
328+
shutil.rmtree(backup_path)
329+
else:
330+
backup_path.unlink()
331+
332+
def _get_backup_path(self, path: Path) -> Path:
333+
if path.is_absolute():
334+
path = path.relative_to(path.parents[-1])
335+
backup_path = self._backup_path / path
336+
return backup_path
337+
301338
def started(self, job, external_jobid: Optional[str] = None):
302339
for f in job.output:
303340
self._record(self._incomplete_path, {"external_jobid": external_jobid}, f)

tests/tests.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2301,6 +2301,10 @@ def test_default_flags():
23012301
run(dpath("test_default_flags"), executor="dryrun", check_results=False)
23022302

23032303

2304+
def test_update_flag_fail():
2305+
run(dpath("test_update_flag_fail"), shouldfail=True, check_results=True)
2306+
2307+
23042308
@skip_on_windows
23052309
@apptainer
23062310
def test_shell_exec_singularity():

0 commit comments

Comments
 (0)