Skip to content

Commit d8ee877

Browse files
lguerardclaude
andcommitted
fix(workflow): 🐛 make stale Snakemake locks recoverable in a multi-run
Snakemake only releases its lock on a clean exit, so a run that was killed -- Ctrl-C, a dropped SSH session, an OOM, or a failed conversion -- leaves the directory locked. run_multi made that worse: it puts each phase in its own state directory, including a `.snakemake_convert` the user never named, so clearing the lock meant reconstructing paths that are not written down anywhere. `--unlock` now releases every state directory the script manages, and the conversion failure message names the exact command instead of leaving the Snakemake hint to be translated into this layout. Also documents both this and the glob/sequence_pattern error in the troubleshooting table, since each cost a cluster round-trip to diagnose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fe0c459 commit d8ee877

2 files changed

Lines changed: 60 additions & 1 deletion

File tree

docs/guide/snakemake.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,19 @@ the GPU partition stays busy instead of idling through every config's
346346
`prepare` and multi-hour `merge` in turn. A config that fails does **not**
347347
abort the others; you get a per-config status and a non-zero exit.
348348

349+
!!! tip "After a killed run"
350+
Snakemake only releases its lock on a clean exit, so a run that was killed
351+
(Ctrl-C, an SSH drop, an OOM) leaves the directory locked. Each phase has
352+
its own state directory, so releasing them by hand means reconstructing
353+
several paths — use the flag instead:
354+
355+
```bash
356+
pixi run multi -- --unlock # then re-run normally
357+
```
358+
359+
Running the orchestrator under `tmux` avoids most of these in the first
360+
place: it survives a dropped connection.
361+
349362
!!! warning "`jobs:` is per config"
350363
The profile's `jobs:` caps one Snakemake process. Running three configs
351364
concurrently can therefore have 3× that many jobs in flight — lower it if
@@ -450,6 +463,8 @@ prologue. The simplest path is a single shared env that the compute nodes see.
450463
| Symptom | Fix |
451464
|---------|-----|
452465
| `snakemake: command not found` | use `python -m snakemake` |
466+
| `Directory cannot be locked` | a previous run was killed instead of exiting cleanly. For a multi-run: `pixi run multi -- --unlock` (it covers every state directory, including the conversion one). For a single config: add `--unlock --directory <the same one you ran with>` |
467+
| `BioImage does not support the image: '.../*tif'` | a glob input needs `sequence_pattern` — see [A folder of single-plane TIFFs](ome_zarr_napari.md#a-folder-of-single-plane-tiffs) |
453468
| Segment jobs pend forever | wrong `slurm_partition`/GPU request; on scicore use `gres: "gpu:1"` |
454469
| Segment dies, `Network is unreachable` | offline GPU nodes — the `fetch_model` localrule caches the model on the submit host first; if it still fails, your submit host has no network either (pre-download manually) |
455470
| `cellpose is not installed` in a job | the job's env lacks `patchworks[cellpose]` |

workflow/scripts/run_multi.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def _snakemake_cmd(
4646
dry_run: bool,
4747
state_dir: Path | None = None,
4848
targets: list[str] | None = None,
49+
extra: list[str] | None = None,
4950
) -> list[str]:
5051
"""Build one snakemake invocation.
5152
@@ -69,6 +70,8 @@ def _snakemake_cmd(
6970
cmd += ["--cores", str(cores), "--rerun-triggers", "mtime"]
7071
if dry_run:
7172
cmd += ["-n", "-p"]
73+
if extra:
74+
cmd += extra
7275
if targets:
7376
# "--" ends option parsing: --rerun-triggers takes a variable number
7477
# of values and would otherwise swallow the target path.
@@ -172,6 +175,15 @@ def main() -> None:
172175
action="store_true",
173176
help="pass -n -p to every Snakemake run; skips relations",
174177
)
178+
parser.add_argument(
179+
"--unlock",
180+
action="store_true",
181+
help=(
182+
"release stale Snakemake locks in every state directory this "
183+
"script manages, then exit. Needed after a run was killed or "
184+
"died: the lock is only released on a clean exit."
185+
),
186+
)
175187
args = parser.parse_args()
176188

177189
workflow_dir = Path(__file__).resolve().parent.parent
@@ -185,6 +197,32 @@ def main() -> None:
185197
work_dir = _validate_configs(seg_config_paths, seg_cfgs)
186198
image_store = f"{work_dir}/image.zarr"
187199

200+
# Each phase gets its own Snakemake state directory (the lock lives in the
201+
# working directory, not the config), so unlocking has to cover all of
202+
# them -- and nobody should have to reconstruct these paths by hand.
203+
state_dirs = [Path(work_dir) / ".snakemake_convert"] + [
204+
Path(cfg["work_dir"]) / cfg["label_name"] / ".snakemake"
205+
for cfg in seg_cfgs
206+
]
207+
if args.unlock:
208+
for state_dir in state_dirs:
209+
if not state_dir.exists():
210+
continue
211+
_run(
212+
_snakemake_cmd(
213+
seg_config_paths[0],
214+
workflow_dir=workflow_dir,
215+
profile=args.profile,
216+
cores=args.cores,
217+
dry_run=False,
218+
state_dir=state_dir,
219+
extra=["--unlock"],
220+
),
221+
workflow_dir,
222+
)
223+
print("[run_multi] unlocked; re-run without --unlock", flush=True)
224+
return
225+
188226
# Phase A: convert exactly once. The three runs are about to go concurrent
189227
# and `convert` writes with overwrite=True, so letting them race on it
190228
# would have them clobbering one store. Ask for its marker explicitly.
@@ -201,7 +239,13 @@ def main() -> None:
201239
workflow_dir,
202240
)
203241
if rc != 0:
204-
print("[run_multi] ERROR: conversion failed", file=sys.stderr)
242+
print(
243+
"[run_multi] ERROR: conversion failed.\n"
244+
" If the log says the directory cannot be locked, a previous run "
245+
"was killed rather than exiting cleanly; release it with:\n"
246+
f" {Path(sys.argv[0]).name} --config {args.config} --unlock",
247+
file=sys.stderr,
248+
)
205249
sys.exit(rc)
206250

207251
# Still phase A: build the occupancy map here too. Every config's `prepare`

0 commit comments

Comments
 (0)