Skip to content

Commit 73c245a

Browse files
committed
Merge dev: recoverable locks and a straight answer for glob inputs (v2.1.1)
Two failures that cost a cluster round-trip each to diagnose, both because the message pointed somewhere other than the cause. A glob input without sequence_pattern fell through to bioio, which reads single files and could only report "BioImage does not support the image: '.../*tif'" -- naming the path rather than the missing setting. to_ome_zarr now says which key is absent and shows an example regex, and run_multi checks it in its pre-flight so the run fails before submitting anything. Snakemake releases its lock only on a clean exit, so a killed run leaves the directory locked. run_multi made that harder than it should be by giving each phase its own state directory, including a `.snakemake_convert` that is never named anywhere -- so clearing a lock meant guessing paths. `--unlock` now covers every directory it manages, and the conversion failure prints that exact command.
2 parents 1cdf8e4 + d8ee877 commit 73c245a

4 files changed

Lines changed: 104 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]` |

src/patchworks/plugins/ome_zarr.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545

4646
from __future__ import annotations
4747

48+
import glob
4849
import logging
4950
import math
5051
from concurrent.futures import ThreadPoolExecutor
@@ -1201,6 +1202,21 @@ def _to_dask(
12011202
if sequence_pattern is not None:
12021203
arr, detected, ps = _open_tiff_sequence(path, sequence_pattern)
12031204
return arr, axes or detected, ps
1205+
if any(ch in path for ch in "*?[") or (
1206+
not Path(path).exists() and glob.glob(path)
1207+
):
1208+
# Without sequence_pattern this would be handed to bioio, which reads
1209+
# single files and reports only that it cannot handle the "format" --
1210+
# pointing at the glob rather than the missing setting.
1211+
raise ValueError(
1212+
f"{path!r} looks like a glob over several files, but "
1213+
"sequence_pattern is not set, so there is no way to tell which "
1214+
"part of each filename is Z, C or T.\n"
1215+
"Set it to a regex with named groups, e.g.\n"
1216+
" sequence_pattern: '_Z(?P<Z>\\d+)_C(?P<C>\\d+)_V\\d+'\n"
1217+
"for files like sample_Z001_C0_V0.tif. Pass a single file "
1218+
"instead if you did not mean a sequence."
1219+
)
12041220
if path.endswith(".zarr"):
12051221
arr = load_ome_zarr(source, channel=None)
12061222
ax = axes or _default_axes(arr.ndim)

tests/test_ome_zarr.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,19 @@ def test_sharding(tmp_path):
399399
assert (
400400
getattr(_zarr.open_array(f"{out3}/0", mode="r"), "shards", None) is None
401401
)
402+
403+
404+
def test_glob_without_sequence_pattern_says_so(tmp_path):
405+
"""A glob input must name the missing setting, not blame the format.
406+
407+
Without sequence_pattern the glob fell through to bioio, which reads one
408+
file and could only report "does not support the image" -- pointing at the
409+
path instead of at the config key that was missing.
410+
"""
411+
with pytest.raises(ValueError, match="sequence_pattern is not set"):
412+
to_ome_zarr(str(tmp_path / "*.tif"), tmp_path / "out.zarr")
413+
414+
# A real single file must still reach the normal readers.
415+
with pytest.raises(Exception) as exc:
416+
to_ome_zarr(str(tmp_path / "scan.ims"), tmp_path / "o2.zarr")
417+
assert "sequence_pattern" not in str(exc.value)

workflow/scripts/run_multi.py

Lines changed: 57 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.
@@ -114,6 +117,18 @@ def _spread(key):
114117
f"share a chunk layout; got {_spread(key)}"
115118
)
116119

120+
for path, cfg in zip(paths, cfgs):
121+
source = str(cfg.get("input", ""))
122+
if any(ch in source for ch in "*?[") and not cfg.get(
123+
"sequence_pattern"
124+
):
125+
problems.append(
126+
f"{path.name}: input {source!r} is a glob over several files "
127+
"but sequence_pattern is unset, so nothing says which part of "
128+
"each filename is Z/C/T. Set e.g. "
129+
r"sequence_pattern: '_Z(?P<Z>\d+)_C(?P<C>\d+)_V\d+'"
130+
)
131+
117132
names = [cfg.get("label_name") for cfg in cfgs]
118133
duplicates = {n for n in names if names.count(n) > 1}
119134
if duplicates:
@@ -160,6 +175,15 @@ def main() -> None:
160175
action="store_true",
161176
help="pass -n -p to every Snakemake run; skips relations",
162177
)
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+
)
163187
args = parser.parse_args()
164188

165189
workflow_dir = Path(__file__).resolve().parent.parent
@@ -173,6 +197,32 @@ def main() -> None:
173197
work_dir = _validate_configs(seg_config_paths, seg_cfgs)
174198
image_store = f"{work_dir}/image.zarr"
175199

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+
176226
# Phase A: convert exactly once. The three runs are about to go concurrent
177227
# and `convert` writes with overwrite=True, so letting them race on it
178228
# would have them clobbering one store. Ask for its marker explicitly.
@@ -189,7 +239,13 @@ def main() -> None:
189239
workflow_dir,
190240
)
191241
if rc != 0:
192-
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+
)
193249
sys.exit(rc)
194250

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

0 commit comments

Comments
 (0)