Skip to content

Commit 9632668

Browse files
SamBryenclaude
andcommitted
Address PR review round 1
Six related changes from the PR review, mapped to the reviewers' points: - Split the two synthetic-proband stages out of stages.py into a new synthetic_proband_stages.py so the pre-workflow's file doesn't have to share stages.py with the main workflow's stages (Matt). - Hash-scope Stage 2's PED and gVCF-manifest into `families-<hash>/` subdirectories of self.prefix, driven by the SG set + qualifying family set. The family_set_marker sentinel and its Batch job go away - existing hash-scoped output paths are now what cpg-flow's REUSE check keys off (Matt). - Collapse each Stage's queue_jobs onto a single public factory call, and make the per-artifact builders module-private with a leading underscore. Matches the "one factory call per Stage" pattern used everywhere else in the repo, and makes the stage-scheduling / stage-doing separation cleaner if this ever gets ported to another orchestration language (Matt). - Rewrite Stage 2's per-artifact builders as driver-side writes via Path.open('w'), gated on workflow.dry_run. Deletes the two thin Bash jobs that only ran `cat > $output <<HEREDOC` and the heredoc-quoting hazard that went with them (Eddie + Matt). - Rewrite scripts/register_synthetic_gvcf_analysis.py to call cpg_utils.metamist_registration.create_new instead of hand-rolling the AnalysisApi calls. Uses cpg_utils.config.dataset_for_access_level to resolve the metamist project name against access level, drops the bespoke find_existing_registration + deactivate_analysis idempotency layer (cpg-flow's REUSE + our per-family sentinel is enough - matches what every other analysis_type-decorated stage in the repo does), and picks up the newer `outputs` block shape for free (Matt, Eddie). - Remove the last inline `ravenscroft-rpl` reference from utils.py (Eddie's steer earlier in the review). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3010008 commit 9632668

8 files changed

Lines changed: 238 additions & 397 deletions

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ hail = ["hail"]
117117
# suppress the ARG002 "Unused method argument" warning in the stages.py file
118118
## - we don't need generic cpg-flow arguments for every Stage, but need to fit the required method signature
119119
"src/cpg_seqr_loader/stages.py" = ["ARG002"]
120+
"src/cpg_seqr_loader/synthetic_proband_stages.py" = ["ARG002"]
120121
"src/cpg_seqr_loader/scripts/mt_to_es.py" = ["ANN202"]
121122
"src/cpg_seqr_loader/scripts/annotate_cohort.py" = ["E501"]
122123

Lines changed: 32 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,59 @@
11
"""
2-
Job factories for the synthetic proband combiner-inputs stage.
2+
Driver-side writer for the synthetic proband combiner-inputs stage.
33
4-
Two artifacts, one Batch job each:
4+
The single public entry point `write_combiner_inputs` produces both of the stage's output
5+
artifacts from the driver, no Batch jobs required:
56
67
- Pedigree PED file: 6-column TSV with three rows per qualifying duo family (mother, father,
7-
synthetic proband). Persistent so seqr sync can reuse it across loads.
8+
synthetic proband), plus a row for every real SG in the multicohort.
89
910
- gVCF manifest: newline-separated list of every gVCF that will go into the synthetic-trio
1011
combiner run. It includes every real SG in the multicohort that has a gVCF (qualifying or
1112
not, so we don't silently drop samples from the seqr load), plus every synthetic gVCF
1213
from Stage 1's outputs.
1314
14-
Content for each file is computed in the driver via the pure builders in `utils`, then dropped
15-
into a Batch job via a quoted heredoc; `write_output` uploads the produced file to the durable
16-
gs:// path once the job succeeds. This keeps the writes gated on Batch success and gives the
17-
framework a real job to depend on rather than a driver-side side effect.
18-
"""
15+
Both files are tiny text blobs, so the previous approach of spinning up a Batch job per file
16+
(only to run `cat > $output <<HEREDOC`) added VM + container startup cost and a heredoc-quoting
17+
hazard for no benefit. Writing directly to `gs://` via `cpg_utils.Path.open('w')` is the same
18+
pattern used elsewhere in the repo (see jobs/SubsetMtToDatasetWithHail.py).
1919
20-
from typing import TYPE_CHECKING
20+
The write is skipped in dry-run mode - unlike Batch jobs, which cpg_flow describes rather than
21+
executes under `workflow.dry_run`, a driver-side `.open('w')` executes unconditionally, so the
22+
callers must gate on that config themselves.
23+
"""
2124

2225
from cpg_flow import targets
23-
from cpg_utils import Path, config, hail_batch
26+
from cpg_utils import Path, config
2427

2528
from cpg_seqr_loader.utils import (
2629
SyntheticProbandFamily,
2730
build_gvcf_manifest_content,
2831
build_synthetic_pedigree_content,
2932
)
3033

31-
if TYPE_CHECKING:
32-
from hailtop.batch.job import BashJob
33-
34-
35-
def _write_content_job(name: str, content: str, out_path: Path, job_attrs: dict) -> 'BashJob':
36-
"""Return a Batch job that writes a fixed string to a durable output path.
37-
38-
Uses a quoted heredoc (`<<'EOF'`) so tabs / newlines pass through literally with no shell
39-
expansion. Safe for our alphanumeric family_id / SG_id content; would need escaping for
40-
arbitrary user input.
41-
"""
42-
job = hail_batch.get_batch().new_bash_job(name, attributes=job_attrs)
43-
job.image(config.config_retrieve(['workflow', 'driver_image']))
44-
job.command(f"cat > {job.output} <<'STAGE2_EOF'\n{content}STAGE2_EOF\n")
45-
hail_batch.get_batch().write_output(job.output, str(out_path))
46-
return job
47-
4834

49-
def create_pedigree_job(
35+
def write_combiner_inputs(
5036
families: list[SyntheticProbandFamily],
5137
multicohort: targets.MultiCohort,
52-
output_ped: Path,
53-
job_attrs: dict,
54-
) -> 'BashJob':
55-
"""One Batch job that writes the synthetic-trio PED for the whole multicohort.
56-
57-
Content includes every real SG in the multicohort plus a synthetic-proband row for each
58-
qualifying duo family. See utils.build_synthetic_pedigree_content for the row semantics.
59-
"""
60-
content = build_synthetic_pedigree_content(multicohort, families)
61-
return _write_content_job(
62-
name='WriteSyntheticPedigree',
63-
content=content,
64-
out_path=output_ped,
65-
job_attrs=job_attrs | {'tool': 'python'},
66-
)
67-
68-
69-
def create_family_set_marker_job(
70-
marker_path: Path,
71-
upstream_jobs: list['BashJob'],
72-
job_attrs: dict,
73-
) -> 'BashJob':
74-
"""Touch an empty file at `marker_path` once the PED and manifest jobs have both succeeded.
75-
76-
The marker's *filename* encodes a hash of the family set (see stages.py) - when the family
77-
set changes, the hash changes, this marker path doesn't exist, and cpg_flow's REUSE check
78-
forces Stage 2 to re-run. Without this, Stage 2's fixed-name outputs (PED, manifest) would
79-
silently ship stale content whenever qualifying families come and go.
80-
81-
Depends on the pedigree + manifest jobs so the marker only exists once their outputs land.
82-
"""
83-
job = hail_batch.get_batch().new_bash_job('WriteFamilySetMarker', attributes=job_attrs | {'tool': 'python'})
84-
job.image(config.config_retrieve(['workflow', 'driver_image']))
85-
for upstream in upstream_jobs:
86-
job.depends_on(upstream)
87-
job.command(f'touch {job.output}')
88-
hail_batch.get_batch().write_output(job.output, str(marker_path))
89-
return job
90-
91-
92-
def create_manifest_job(
93-
families: list[SyntheticProbandFamily],
9438
synthetic_gvcf_paths: dict[str, Path],
95-
multicohort: targets.MultiCohort,
39+
output_ped: Path,
9640
output_manifest: Path,
97-
job_attrs: dict,
98-
) -> 'BashJob':
99-
"""One Batch job that writes the combiner gVCF manifest.
100-
101-
Manifest composition (deliberately inclusive so samples aren't silently dropped from seqr):
102-
- every real SG in the multicohort whose `.gvcf` is set, whether or not their family
103-
qualified for synthetic proband synthesis;
104-
- every synthetic gVCF from Stage 1's outputs (indexed by family_id).
41+
) -> None:
42+
"""Write the PED and gVCF manifest for the synthetic-trio combiner run.
10543
106-
Real SGs whose `.gvcf` is None are logged upstream by cpg_flow and simply won't appear here
107-
- there's no useful path to write for them.
44+
No Batch jobs are queued - both files are written directly from the driver. The stage's
45+
`queue_jobs` still returns an empty job list so cpg_flow's REUSE check keys off the output
46+
paths existing on disk, exactly as it would with jobs.
10847
"""
109-
real_paths: list[str] = []
110-
for sg in multicohort.get_sequencing_groups():
111-
if sg.gvcf is None:
112-
continue
113-
real_paths.append(str(sg.gvcf))
114-
115-
synthetic_paths = [str(synthetic_gvcf_paths[f.family_id]) for f in families]
116-
117-
content = build_gvcf_manifest_content(real_paths + synthetic_paths)
118-
return _write_content_job(
119-
name='WriteGvcfManifestWithSynthetics',
120-
content=content,
121-
out_path=output_manifest,
122-
job_attrs=job_attrs | {'tool': 'python'},
123-
)
48+
if config.config_retrieve(['workflow', 'dry_run'], False):
49+
return
50+
51+
pedigree_content = build_synthetic_pedigree_content(multicohort, families)
52+
with output_ped.open('w') as f:
53+
f.write(pedigree_content)
54+
55+
real_paths = [str(sg.gvcf) for sg in multicohort.get_sequencing_groups() if sg.gvcf is not None]
56+
synthetic_paths = [str(synthetic_gvcf_paths[family.family_id]) for family in families]
57+
manifest_content = build_gvcf_manifest_content(real_paths + synthetic_paths)
58+
with output_manifest.open('w') as f:
59+
f.write(manifest_content)

src/cpg_seqr_loader/jobs/GenerateSyntheticProbandGvcfs.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""
2-
Job factories for the synthetic proband gVCF stage.
2+
Job factory for the synthetic proband gVCF stage.
33
4-
Two factories work together:
4+
The single public entry point `create_synthetic_gvcf_jobs` builds every Batch job the stage
5+
needs and returns them in queue order. Internally it uses two private per-artifact factories:
56
6-
- create_synthesis_jobs: one BashJob per duo family that invokes
7+
- _create_synthesis_jobs: one BashJob per duo family that invokes
78
scripts/create_synthetic_proband_gvcf.py with the family's parental gVCFs, writing the
89
output gVCF to the durable per-family path supplied by the stage.
910
10-
- create_analysis_registration_jobs: one BashJob per duo family that invokes
11+
- _create_analysis_registration_jobs: one BashJob per duo family that invokes
1112
scripts/register_synthetic_gvcf_analysis.py to record (or refresh) the synthetic gVCF as a
1213
metamist Analysis of type SYNTHETIC_GVCF_ANALYSIS_TYPE. Each registration job depends_on
1314
the matching synthesis job so registration only fires once the gVCF actually exists.
@@ -26,7 +27,7 @@
2627
from hailtop.batch.job import BashJob
2728

2829

29-
def create_synthesis_jobs(
30+
def _create_synthesis_jobs(
3031
families: list[SyntheticProbandFamily],
3132
output_paths: dict[str, Path],
3233
job_attrs: dict,
@@ -78,7 +79,7 @@ def create_synthesis_jobs(
7879
return jobs
7980

8081

81-
def create_analysis_registration_jobs(
82+
def _create_analysis_registration_jobs(
8283
families: list[SyntheticProbandFamily],
8384
gvcf_paths: dict[str, Path],
8485
marker_paths: dict[str, Path],
@@ -142,3 +143,44 @@ def create_analysis_registration_jobs(
142143
jobs.append(job)
143144

144145
return jobs
146+
147+
148+
def create_synthetic_gvcf_jobs(
149+
families: list[SyntheticProbandFamily],
150+
gvcf_paths: dict[str, Path],
151+
marker_paths: dict[str, Path],
152+
job_attrs: dict,
153+
) -> list['BashJob']:
154+
"""Build every Batch job the synthetic-gVCF stage needs, in the order they'll be queued.
155+
156+
Returns the synthesis jobs (one per family) followed by the registration jobs (one per
157+
family). Registration jobs depend on their matching synthesis job so they only fire once
158+
the gVCF exists.
159+
160+
Callers (the Stage class) should treat this as the single entry point for the stage - the
161+
per-artifact factories are private to this module and shouldn't be invoked directly.
162+
"""
163+
synthesis_jobs = _create_synthesis_jobs(
164+
families=families,
165+
output_paths=gvcf_paths,
166+
job_attrs=job_attrs,
167+
)
168+
169+
# Register per family: each Analysis lives in its parents' metamist project (which is the
170+
# dataset owning the mother SG), so we can't batch across families that live in different
171+
# datasets. cpg-flow's get_metamist().create_analysis handles the access-level suffix
172+
# internally.
173+
registration_jobs = []
174+
for family in families:
175+
registration_jobs.extend(
176+
_create_analysis_registration_jobs(
177+
families=[family],
178+
gvcf_paths=gvcf_paths,
179+
marker_paths=marker_paths,
180+
synthesis_jobs=synthesis_jobs,
181+
project=family.mother_sg.dataset.name,
182+
job_attrs=job_attrs,
183+
),
184+
)
185+
186+
return list(synthesis_jobs.values()) + registration_jobs

0 commit comments

Comments
 (0)