Skip to content

Commit 7c3fcdd

Browse files
committed
fix(workflow): keep vignette dependencies unconditional on resume
Fix forward the unsafe conditional edge in ca224f7. A completion manifest does not mean a chunk will not run: after the vignets manifest is swept, a newer tile_detect input schedules all ngmix chunks, but the conditional removed their dependency on the writer. They could start before the store exists or race its rebuild. Remove ngmix_vignets and restore every chunk unconditional vignets input. Keep the unconditional make_cat input. The accepted trade-off is that sweeping a tile now rebuilds its store and reruns all ngmix chunks and their merge, even if earlier outputs survived. Correct ordering is worth the extra ngmix time on these resumed tiles; no completed-output inference may remove a reader edge. Replace the reuse-oriented tests and documentation. Test the original swept-store scenario and the review counterexample with newer tile_detect, in data and image-sims modes, with completed, reclaimed, partial and missing chunk products. Assert every scheduled chunk and make_cat depends directly on tile_vignets. The new merged-state regressions fail on ca224f7. Shell bodies and params.pre are unchanged.
1 parent ca224f7 commit 7c3fcdd

4 files changed

Lines changed: 44 additions & 90 deletions

File tree

‎tests/workflow/test_resume.py‎

Lines changed: 34 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Resume a tile after its node-local vignette manifest has been swept."""
1+
"""Every resumed reader must wait for the rebuilt node-local vignette store."""
22

33
import os
44
import shutil
@@ -9,11 +9,6 @@
99
from snakemake.io import is_flagged
1010

1111

12-
def test_resume_edge_cut_requires_input_trigger_off(profile):
13-
"""Changing a satisfied chunk's input set must not invalidate it."""
14-
assert "input" not in profile["rerun-triggers"]
15-
16-
1712
def seed_outputs(campaign, resolve_dag):
1813
"""Materialise satisfied outputs without executing the science pipeline."""
1914
with resolve_dag(campaign) as dag:
@@ -32,74 +27,57 @@ def seed_outputs(campaign, resolve_dag):
3227

3328

3429
@pytest.mark.parametrize("state", [
35-
"merged", "chunks_done", "partial", "missing_chunkdir",
30+
"merged", "merged_reclaimed", "chunks_done", "partial", "missing_chunkdir",
31+
])
32+
@pytest.mark.parametrize("invalidate_detect", [False, True], ids=[
33+
"swept_only", "swept_and_upstream_invalidated",
3634
])
37-
def test_resume_after_vignets_swept(campaign, resolve_dag, state):
38-
"""Only unfinished chunks read the rebuilt store; make_cat always does."""
35+
def test_resume_after_vignets_swept(
36+
campaign, resolve_dag, state, invalidate_detect,
37+
):
38+
"""Sweep alone and sweep plus upstream invalidation both preserve ordering.
39+
40+
The newer tile_detect input reproduces the review's counterexample:
41+
completed chunks can still be scheduled and must retain their writer edge.
42+
"""
3943
seed_outputs(campaign, resolve_dag)
4044
tile = next(iter(campaign.ready))
4145
root = campaign.tile_manifest(tile, "tile_vignets").parents[1]
4246
campaign.tile_manifest(tile, "tile_vignets").unlink()
4347
campaign.tile_manifest(tile, "tile_make_cat").unlink()
4448
campaign.final_cat(tile).unlink()
45-
if state == "merged":
49+
if state == "merged_reclaimed":
4650
# merge_cats reclaims its temp inputs in production.
4751
for path in (root / "output").glob("run_sp_tile_ngmix_Ng*u"):
4852
shutil.rmtree(path)
49-
else:
53+
elif state != "merged":
5054
campaign.tile_manifest(tile, "tile_merge_cats").unlink()
5155
if state == "partial":
5256
campaign.tile_manifest(tile, "tile_ngmix_8").unlink()
5357
if state == "missing_chunkdir":
5458
shutil.rmtree(root / "output/run_sp_tile_ngmix_Ng8u")
59+
if invalidate_detect:
60+
campaign.tile_manifest(tile, "tile_detect").touch()
5561
with resolve_dag(campaign) as dag:
5662
pending = list(dag.workflow.dag.needrun_jobs())
5763
counts = Counter(job.rule.name for job in pending)
58-
print(f"Resume dry-run jobs ({state}):", dict(sorted(counts.items())))
64+
print(
65+
f"Resume dry-run jobs ({state}, invalidate_detect={invalidate_detect}):",
66+
dict(sorted(counts.items())),
67+
)
5968
assert counts["tile_vignets"] == 1
69+
assert counts["tile_ngmix"] == dag.namespace["NGMIX_CHUNKS"]
70+
assert counts["tile_merge_cats"] == 1
6071
assert counts["tile_make_cat"] == 1
61-
assert counts["tile_ngmix"] == (state in {"partial", "missing_chunkdir"})
62-
assert counts["tile_merge_cats"] == (state != "merged")
63-
vignets = next(j for j in pending if j.rule.name == "tile_vignets")
64-
for job in pending:
65-
if job.rule.name in {"tile_make_cat", "tile_ngmix"}:
66-
assert vignets in dag.workflow.dag.dependencies[job]
67-
68-
69-
def test_finished_tile_after_store_only_rebuild(campaign, resolve_dag):
70-
"""A resumed tile must stay finished on the next invocation."""
71-
seed_outputs(campaign, resolve_dag)
72-
tile = next(iter(campaign.ready))
73-
# The store-only rebuild is newer than the preserved chunk products;
74-
# make_cat then succeeds. alloc_resume skips tiles with a final catalogue.
75-
campaign.tile_manifest(tile, "tile_vignets").touch()
76-
with resolve_dag(campaign) as dag:
77-
outputs = [
78-
Path(output)
79-
for job in dag.jobs_for("tile_make_cat")
80-
if job.wildcards.tile == tile
81-
for output in job.output
72+
writer = next(j for j in pending if j.rule.name == "tile_vignets")
73+
readers = [
74+
job for job in pending
75+
if job.rule.name in {"tile_make_cat", "tile_ngmix"}
8276
]
83-
# Include any additional durable outputs, such as published tile stats.
84-
for path in outputs:
85-
path.touch()
86-
with resolve_dag(campaign) as dag:
87-
counts = Counter(j.rule.name for j in dag.workflow.dag.needrun_jobs())
88-
assert counts["tile_ngmix"] == 0
89-
assert counts["tile_merge_cats"] == 0
90-
assert counts["tile_vignets"] == 0
91-
assert counts["tile_make_cat"] == 0
92-
93-
94-
def test_real_vignets_rebuild_still_invalidates_chunks(campaign, resolve_dag):
95-
"""The storage-only exception must not hide upstream science changes."""
96-
seed_outputs(campaign, resolve_dag)
97-
tile = next(iter(campaign.ready))
98-
campaign.tile_manifest(tile, "tile_detect").touch()
99-
campaign.tile_manifest(tile, "tile_make_cat").unlink()
100-
campaign.final_cat(tile).unlink()
101-
with resolve_dag(campaign) as dag:
102-
pending = list(dag.workflow.dag.needrun_jobs())
103-
counts = Counter(j.rule.name for j in pending)
104-
assert counts["tile_vignets"] == 1
105-
assert counts["tile_ngmix"] == dag.namespace["NGMIX_CHUNKS"]
77+
assert len(readers) == dag.namespace["NGMIX_CHUNKS"] + 1
78+
for job in readers:
79+
assert job.wildcards.tile == tile
80+
assert writer in dag.workflow.dag.dependencies[job], (
81+
f"pending reader {job.rule.name} {job.wildcards} has no writer "
82+
f"dependency; inputs={list(job.input)}"
83+
)

‎workflow/README.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,10 +275,9 @@ a multi-node allocation would split a tile's vignets from its ngmix chunks.
275275
A job cut short by its time limit is resumed by submitting the same script again.
276276
The store died with the job, so before it runs `sp` deletes `tile_vignets.json` for each listed tile that has no `final_cat` yet.
277277
`tile_make_cat` declares that manifest as an input, so `tile_vignets` rebuilds the store even if all chunks and their merge are complete.
278-
Completed chunks keep their products; only unfinished chunks wait for the rebuilt store and run again.
279-
A chunk whose temporary directory is missing must also rerun if its merge has not succeeded.
280-
This storage-only recovery trusts the chunk completion manifests: to explicitly recompute a completed chunk, remove its manifest too.
281-
To rebuild a finished tile, remove its final catalogue first, as for the exposure-edge cut in `tile.smk`.
278+
Every ngmix chunk also declares the vignets manifest unconditionally, so all chunks rerun after the store is rebuilt, followed by their merge and `make_cat`.
279+
This spends ngmix time on swept tiles, but guarantees that every scheduled reader waits for the writer, even if another input or rerun trigger invalidates a previously completed chunk.
280+
A completion manifest alone cannot prove that a chunk will not run in this invocation.
282281
`sp` also records the job holding the campaign's state dir, and releases that job's lock once the job has left the queue; any other lock stops the run.
283282
With `-n` both only report.
284283

‎workflow/bin/sp‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,10 @@ STATE_DIR="${SP_STATE_DIR:-${RUN_DIR}-state}"
195195
# tile.smk's "node-local vignette store is missing" guard, so that
196196
# manifest is deleted first. tile_make_cat declares it as an input, so
197197
# tile_vignets rebuilds the store even if merge_cats is already complete.
198-
# Completed chunks skip this storage-only rebuild (ngmix_vignets in
199-
# tile.smk); unfinished chunks wait for it. Tiles with a final_cat are
200-
# never touched.
198+
# Every ngmix chunk retains its dependency on the writer and reruns after
199+
# it, even if its previous output survived. This costs ngmix time on resume
200+
# but ensures no reader can race the rebuild. Tiles with a final_cat are
201+
# never swept.
201202
# * A lock left by a killed in-allocation run is released. sp records the
202203
# job that holds the state dir; when that job is no longer in the queue,
203204
# its lock is stale. Any other lock (a run sp did not start in an

‎workflow/rules/tile.smk‎

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -614,30 +614,6 @@ rule tile_vignets:
614614
sp_shell("tile_vignets", f"config_tile_PiViVi_{PSF_MODEL}.ini",
615615
check_args=' --run-dir "$SP_LOCAL" --unit {wildcards.tile}')
616616

617-
def ngmix_vignets(wc):
618-
"""Keep completed chunks when only the node-local store was swept.
619-
620-
A missing temp chunk directory is harmless after merge_cats succeeds, but
621-
before that merge it must be rebuilt and therefore needs the store edge.
622-
Unfinished tiles with existing vignets manifests retain normal rerun
623-
propagation. Finished tiles also cut the edge, so a restored manifest's
624-
newer timestamp cannot wake old chunks on the next invocation. As with
625-
the finished-tile exposure cut above, the profile disables the input
626-
trigger. To force a chunk to recompute after a sweep, remove its manifest
627-
too; to rebuild a finished tile, remove its final catalogue first.
628-
"""
629-
root = Path(tile_dir(wc.tile))
630-
vignets = root / "manifests/tile_vignets.json"
631-
chunk = root / f"manifests/tile_ngmix_{wc.chunk}.json"
632-
chunkdir = root / f"output/run_sp_tile_ngmix_Ng{wc.chunk}u"
633-
merged = root / "manifests/tile_merge_cats.json"
634-
if (not vignets.exists() or tile_finished(wc.tile)) and chunk.exists() and (
635-
chunkdir.exists() or merged.exists()
636-
):
637-
return []
638-
return [str(vignets)]
639-
640-
641617
# ngmix shape measurement — N chunks per tile (D4). Each chunk LOOKS UP its own
642618
# CLOSED catalogue-row range in the file tile_vignets materialised at the top of this
643619
# group job (TILE_NGMIX_RANGES); the ranges are knowable only at EXECUTION time,
@@ -650,9 +626,9 @@ def ngmix_vignets(wc):
650626
rule tile_ngmix:
651627
group: TILE_GROUP
652628
input:
653-
# A completed chunk doesn't read a rebuilt store on resume. Missing
654-
# chunks keep the manifest edge and wait for this group's writer.
655-
vignets = ngmix_vignets,
629+
# Unconditional: even a completed chunk can be invalidated by another
630+
# input or rerun trigger. Every scheduled reader must wait for the store.
631+
vignets = rules.tile_vignets.output.manifest,
656632
sx = rules.tile_detect.output.manifest,
657633
output:
658634
manifest = f"{TILE_DIR}/manifests/tile_ngmix_{{chunk}}.json",

0 commit comments

Comments
 (0)