Makes a real annotation runnable locally without the 25 GB VEP cache, which is what the demo needs and what a reviewer can reproduce in minutes. - params.vep_database (VEP_DATABASE=true) queries Ensembl's public database instead of a local cache. Slower per variant and fewer fields, so --everything is swapped for the flags the loader actually stores. Its cache placeholder is NO_CACHE, not NO_FILE: Nextflow rejects two staged inputs sharing a filename. - PIPELINE_DATABASE_URL is handed to the pipeline when set. The loader runs inside a container, where the API's own localhost URL would point at the container itself. - README: how to run the UI's annotate button locally against host Nextflow + Docker. Verified end to end on pipeline/tests/data/tiny.vcf: bcftools norm split the multiallelic record, VEP 113 annotated 4 variants live, the loader wrote them and marked the job succeeded, and the UI shows them. The deletion came back as 22:42126611 CT>C with exact VCF alleles, which is the case the audit's ID-tagging fix exists for. Tests: api 51, loader 16, stub run 3/3; ruff, mypy clean.
143 lines
5.4 KiB
Python
143 lines
5.4 KiB
Python
"""Start the annotation pipeline for a job.
|
|
|
|
Serverless (CLOUDRUN_JOB set): execute a Cloud Run job that runs the Nextflow driver, which
|
|
submits the pipeline tasks to Google Batch. Nothing runs, or is billed, between annotations.
|
|
Kubernetes (PUBSUB_TOPIC set): publish to Pub/Sub; an Argo Events sensor submits the annotate-vcf
|
|
WorkflowTemplate, whose exit handler marks the job failed if the workflow does not succeed.
|
|
Local: run Nextflow as a child process and watch it. The docker-compose API image has neither
|
|
Nextflow nor Docker, so there the job fails at once with instructions to run it from the host.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from functools import cache
|
|
from typing import Any
|
|
|
|
from app.config import settings
|
|
from app.db import SessionLocal
|
|
from app.models import Job, JobStatus
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PIPELINE_ENTRYPOINT = "/pipeline/main.nf"
|
|
LOG_TAIL_CHARS = 4000
|
|
PUBLISH_TIMEOUT_S = 30
|
|
_watchers: set[asyncio.Task[None]] = set()
|
|
|
|
|
|
class LaunchError(Exception):
|
|
"""The pipeline could not be started; the message is stored on the job for the user."""
|
|
|
|
|
|
async def launch(job_id: uuid.UUID, vcf_uri: str, assembly: str) -> str:
|
|
"""Start the pipeline and return a reference to the run (stored as jobs.workflow_ref)."""
|
|
if settings.cloudrun_job:
|
|
return await _run_cloud_run_job(job_id, vcf_uri, assembly)
|
|
if settings.pubsub_topic:
|
|
message = {"job_id": str(job_id), "vcf_uri": vcf_uri, "assembly": assembly}
|
|
return await _publish(settings.pubsub_topic, message)
|
|
return await _run_local(job_id, vcf_uri, assembly)
|
|
|
|
|
|
async def drain() -> None:
|
|
"""Wait for local pipeline watchers (tests, graceful shutdown)."""
|
|
await asyncio.gather(*_watchers)
|
|
|
|
|
|
@cache
|
|
def _jobs_client() -> Any:
|
|
from google.cloud import run_v2
|
|
|
|
return run_v2.JobsClient()
|
|
|
|
|
|
async def _run_cloud_run_job(job_id: uuid.UUID, vcf_uri: str, assembly: str) -> str:
|
|
if not settings.gcp_project:
|
|
raise LaunchError("CLOUDRUN_JOB is set but GCP_PROJECT is not")
|
|
name = (
|
|
f"projects/{settings.gcp_project}/locations/{settings.gcp_region}"
|
|
f"/jobs/{settings.cloudrun_job}"
|
|
)
|
|
args = [
|
|
"run", PIPELINE_ENTRYPOINT, "-profile", "gcp",
|
|
"--vcf", vcf_uri, "--job_id", str(job_id), "--assembly", assembly,
|
|
]
|
|
request = {"name": name, "overrides": {"container_overrides": [{"args": args}]}}
|
|
try:
|
|
operation = await asyncio.to_thread(_jobs_client().run_job, request=request)
|
|
except Exception as e:
|
|
logger.exception("starting Cloud Run job %s failed", name)
|
|
raise LaunchError(f"could not start {settings.cloudrun_job}: {e}") from e
|
|
name_or_job = getattr(getattr(operation, "metadata", None), "name", "")
|
|
execution = name_or_job or settings.cloudrun_job or "started"
|
|
return f"cloudrun:{execution.rsplit('/', 1)[-1]}"
|
|
|
|
|
|
@cache
|
|
def _publisher() -> Any:
|
|
from google.cloud import pubsub_v1
|
|
|
|
return pubsub_v1.PublisherClient()
|
|
|
|
|
|
async def _publish(topic_name: str, message: dict[str, str]) -> str:
|
|
if not settings.gcp_project:
|
|
raise LaunchError("PUBSUB_TOPIC is set but GCP_PROJECT is not")
|
|
publisher = _publisher()
|
|
topic = publisher.topic_path(settings.gcp_project, topic_name)
|
|
try:
|
|
future = publisher.publish(topic, json.dumps(message).encode())
|
|
message_id = await asyncio.to_thread(future.result, timeout=PUBLISH_TIMEOUT_S)
|
|
except Exception as e:
|
|
logger.exception("publishing to %s failed", topic)
|
|
raise LaunchError(f"could not publish to {topic}: {e}") from e
|
|
return f"pubsub:{message_id}"
|
|
|
|
|
|
async def _run_local(job_id: uuid.UUID, vcf_uri: str, assembly: str) -> str:
|
|
nextflow = shutil.which("nextflow")
|
|
if nextflow is None:
|
|
raise LaunchError(
|
|
"Nextflow is not installed where the API runs. Start the pipeline from the host with "
|
|
f"`make annotate JOB={job_id} VCF=<path to the VCF>`."
|
|
)
|
|
cmd = [
|
|
nextflow, "run", str(settings.pipeline_dir / "main.nf"),
|
|
"-profile", settings.nextflow_profile,
|
|
"--vcf", vcf_uri, "--job_id", str(job_id), "--assembly", assembly,
|
|
]
|
|
# The loader reads DATABASE_URL from its environment; keep it off the command line.
|
|
env = {**os.environ, "DATABASE_URL": settings.pipeline_database_url or settings.database_url}
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
cwd=settings.pipeline_dir,
|
|
env=env,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.STDOUT,
|
|
)
|
|
except OSError as e:
|
|
raise LaunchError(f"could not start Nextflow: {e}") from e
|
|
task = asyncio.create_task(_watch(job_id, proc))
|
|
_watchers.add(task)
|
|
task.add_done_callback(_watchers.discard)
|
|
return f"nextflow:pid-{proc.pid}"
|
|
|
|
|
|
async def _watch(job_id: uuid.UUID, proc: Any) -> None:
|
|
out, err = await proc.communicate()
|
|
if proc.returncode == 0:
|
|
return # the loader marks the job succeeded
|
|
tail = ((out or b"") + (err or b"")).decode(errors="replace")[-LOG_TAIL_CHARS:]
|
|
async with SessionLocal() as session:
|
|
job = await session.get(Job, job_id)
|
|
if job is not None and job.status != JobStatus.succeeded:
|
|
job.status = JobStatus.failed
|
|
job.log = f"nextflow exited with code {proc.returncode}\n{tail}"
|
|
job.finished_at = datetime.now(UTC)
|
|
await session.commit()
|