fix: overhaul the platform skeleton, add a serverless deployment track

An end-to-end audit found the repo could not build, test or run as shipped. This
fixes every finding, then adds a Cloud Run track so the demo costs about £1/month
idle instead of ~£150.

CI (red on its first run)
- api: setuptools could not build the package (flat layout with app/ and alembic/)
- web: missing @types/node; `vitest run` exited 1 with no test files
- pipeline: the stub run needed a gitignored VCF, and no process had a stub block
- ruff pinned, mypy configured, DB tests on real Postgres (pgserver locally, service in CI)

ML serving (scores were meaningless)
- the registered model now carries its own feature engineering and returns predict_proba,
  so serving sends raw columns and cannot drift from training
- resolve by registry alias (stages are deprecated in MLflow 3) and record the real
  version; re-scoring upserts instead of failing on the unique constraint
- ClinVar labels parsed from VEP's lowercase terms

Pipeline
- exact ref/alt recovered from a CHROM_POS_REF_ALT VCF ID; loading is idempotent
- job status reaches running/failed/succeeded, so the UI stops polling dead jobs
- DATABASE_URL travels in the environment or a Nextflow secret, never on a command line
- VEP cache and plugins staged as inputs; the gcp profile runs tasks on Google Batch

Deployment
- the API serves /api (matching the ingress); the web app reads its API URL at runtime
- migrations run in an init container under a Postgres advisory lock
- terraform: custom VPC shared with Batch, private Cloud SQL, API enablement, Workload
  Identity bindings, Secret Manager, deletion protection
- serverless track, now the default: Cloud Run services scaling to zero, a Cloud Run job
  for the Nextflow driver, and Neon or Cloud SQL behind one DATABASE_URL secret. GKE and
  Argo remain, behind -var deploy_kubernetes=true. See docs/cloud.md.

Correctness and security
- 409 on duplicate sample names, 422 on bad paging, natural chromosome ordering, wider
  VEP text columns, enum dropped on downgrade, the sample's assembly actually used
- vcf_uri restricted to gs:// objects or files under the data root, blocking option injection
- CORS restricted to configured origins; `make down` no longer deletes volumes

Data
- docs/data.md records the peer-reviewed, openly licensed sources (GIAB HG002, ClinVar,
  gnomAD) with citations and an honest evaluation plan; `make data` fetches a chr22 slice

Verified: api 50 tests, ml 18, loader 16, web 12; ruff, mypy, svelte-check, terraform
validate and both kustomize overlays clean.
This commit is contained in:
Kemal Yaylali
2026-09-12 07:21:11 +01:00
parent 5463f489a3
commit 11fb6b3d73
100 changed files with 3431 additions and 340 deletions
+49 -14
View File
@@ -3,30 +3,59 @@
```mermaid
flowchart LR
U[Scientist] -->|browser| W[SvelteKit web]
W -->|REST| A[FastAPI]
W -->|REST /api| A[FastAPI]
A --> P[(PostgreSQL / Cloud SQL)]
A -->|publish vcf-uploaded| Q[Pub/Sub]
Q --> E[Argo Events sensor]
E --> AW[Argo Workflow]
AW --> NF[Nextflow: bcftools norm, VEP, load_db]
NF -->|reads VCF| G[(GCS bucket)]
NF -->|writes variants| P
A -->|models:/rarelens-pathogenicity| M[MLflow registry]
T[ml/train.py on GKE, optional GPU] --> M
E --> AW[Argo Workflow: Nextflow driver]
AW -->|tasks| B[Google Batch: bcftools norm, VEP, load_db]
B -->|reads VCF, VEP cache| G[(GCS bucket)]
B -->|writes variants, marks job succeeded| P
AW -.->|exit handler marks job failed| P
A -->|models:/rarelens-pathogenicity@production| M[MLflow registry]
T[ml/train.py] --> M
GH[GitHub Actions] -->|images via WIF| AR[Artifact Registry]
GH -->|bumps overlay tags| R[(git: infra/k8s/overlays/gcp)]
R --> CD[ArgoCD] --> K[GKE Autopilot]
```
## Two deployment tracks
The same images and the same pipeline, deployed two ways (`infra/terraform/variables.tf`):
| | Serverless (default) | Kubernetes (`-var deploy_kubernetes=true`) |
|---|---|---|
| api, web | Cloud Run, scale to zero | Deployments behind an ingress |
| dispatch | the API executes a Cloud Run job | Pub/Sub -> Argo Events -> Argo Workflow |
| pipeline tasks | Google Batch | Google Batch |
| `/api` routing | the web service proxies it | the ingress routes it |
| idle cost | ~£1/month | ~£130+/month |
`app.services.events.launch()` picks the dispatch backend from configuration: a Cloud Run job when
`CLOUDRUN_JOB` is set, Pub/Sub when `PUBSUB_TOPIC` is, and a local Nextflow process otherwise.
See [cloud.md](cloud.md) for why the serverless one is the default.
## Why these choices
**One monorepo.** The four components share a schema (`variants` table, feature columns) and the
point of the exercise is to see them evolve together. Separate repos would hide the coupling.
**Nextflow for the science, Argo Workflows for the trigger.** Nextflow is the lingua franca for
bioinformatics pipelines and has a native Kubernetes executor. Argo is what the platform team
already runs. So Argo owns *when* a pipeline runs; Nextflow owns *what* it does. The API never
talks to Kubernetes directly; it publishes an event and gets on with its life.
bioinformatics pipelines. Argo is what the platform team already runs. So Argo owns *when* a
pipeline runs; Nextflow owns *what* it does. The API never talks to Kubernetes directly; it
publishes an event and gets on with its life. The Nextflow driver runs in the Argo pod and sends
each task to Google Batch: a `gs://` work directory needs an executor that stages through GCS
(Nextflow's Kubernetes executor needs a shared ReadWriteMany volume instead).
**Job lifecycle.** The API creates a job as `running` once the pipeline is dispatched, or `failed`
with the reason in `jobs.log` when dispatch is impossible. The loader marks it `succeeded` in the
same transaction that stores the variants. Anything else (Nextflow error, eviction) is caught by
the Argo exit handler, or locally by the API watching the Nextflow process, and marked `failed`,
so the UI never polls a dead job.
**Variant identity.** NORMALISE sets each VCF ID to `CHROM_POS_REF_ALT`; VEP echoes it as
`Uploaded_variation` and the loader takes exact VCF alleles from it, because VEP's own
`Location`/`Allele` columns trim indel alleles.
**FastAPI + Pydantic v2 + SQLAlchemy 2.0 async.** Typed at both boundaries: request/response models
and ORM models are separate on purpose so the database can change without breaking the frontend
@@ -35,15 +64,21 @@ because the pipeline container should not import the API.
**SvelteKit.** Small runtime, no virtual DOM, and Svelte 5 runes make server-driven state simple.
The UI has exactly two pages; the goal is a table a scientist actually wants to filter, not a dashboard.
`PUBLIC_API_URL` is read at runtime, so one image works behind the ingress (`/api`) and elsewhere.
**GKE Autopilot + Cloud SQL, not self-managed.** The lab is about the platform patterns (Workload
Identity, GitOps, Kustomize overlays, GPU node selection), not about running etcd.
Identity, GitOps, Kustomize overlays, private networking), not about running etcd. Cloud SQL has only
a private IP; the API reaches it through a Cloud SQL Proxy sidecar, pipeline tasks directly in the VPC.
Database URLs live in Secret Manager.
**GitOps.** CI builds and tests; it never runs `kubectl apply`. It edits image tags in the `gcp` overlay
and ArgoCD reconciles. Rollback is `git revert`.
and ArgoCD reconciles. Rollback is `git revert`. Migrations run in an init container under a Postgres
advisory lock, so replicas starting together migrate once.
**MLflow registry as the model contract.** The API loads `models:/rarelens-pathogenicity/Production`.
Training writes there; serving reads there. Feature engineering lives in one module that both sides import.
**MLflow registry as the model contract.** The API loads whichever version the `production` alias
points at and records that version on every prediction. The registered model is a pyfunc that owns its
feature engineering (`rarelens_ml.features` ships inside it) and returns P(pathogenic), so serving only
sends raw columns and cannot drift from training.
## What is deliberately missing
+112
View File
@@ -0,0 +1,112 @@
# Cloud choice: Google Cloud, with a documented AWS escape hatch
Decided 2026-09-12. Scope: `infra/terraform/`, `infra/k8s/`, `pipeline/nextflow.config`.
## Decision
rarelens deploys to **Google Cloud**. AWS was the serious alternative, and it is genuinely better
on two points (below), but not by enough to justify rebuilding an estate that already works.
## Why Google Cloud
| Reason | Detail |
|---|---|
| One Kubernetes control plane is effectively free | GKE's free tier gives $74.40/month in credits per billing account, which covers one Autopilot or zonal cluster. EKS charges $0.10/hour per cluster (~$73/month) with no equivalent credit. For a self-funded lab this is the largest fixed monthly difference. |
| The executor question is already settled here | Google retired Cloud Life Sciences on 8 July 2025; Batch is its successor, and Nextflow upstream moved to Google Batch in April 2025. `pipeline/nextflow.config` uses `google-batch`, which is the supported path rather than a legacy one. |
| The estate exists and is verified | Terraform (custom VPC, private Cloud SQL, Workload Identity, Secret Manager, Batch IAM), Kustomize overlays, Argo Workflows/Events and CI all render, validate and pass tests today. Rebuilding this on AWS costs 12 weeks and mostly repeats learning already banked. |
| No data lock-in | Every dataset the platform uses is readable from either cloud (see [data.md](data.md)): gnomAD publishes to GCP, AWS and Azure; GIAB and 1000 Genomes are open on AWS and NCBI; ClinVar is a plain NCBI download. |
## What AWS is genuinely better at
- **Managed Nextflow.** AWS HealthOmics runs Nextflow (up to 26.04), WDL and CWL as a managed
service, and is available in London (`eu-west-2`). GCP has no equivalent: you operate the
driver yourself, which is exactly what `infra/argo-workflows/annotate.yaml` does.
- **UK life-sciences gravity.** The UK Biobank Research Analysis Platform is DNAnexus running on
AWS, hosted in the UK. If the aim is to mirror what Cambridge-area employers run day to day,
AWS is the more common answer.
## When to revisit this
Move to AWS if any of these becomes true:
- The lab wants a managed pipeline runner instead of an Argo + Batch driver it maintains.
- Matching an AWS-first employer's stack matters more than the two weeks it costs.
- The shape changes: several clusters, or enough managed-service spend that one free control
plane stops being material.
## Running this on a hobby budget
The cloud is not the cost driver; the always-on shape is. Estimates below are list price, and
rounded — treat them as orders of magnitude, not quotes.
### What the Kubernetes estate costs at rest
GKE Autopilot bills what pods *request*, not what they use, with a per-pod floor (250m vCPU /
512 MiB). The free tier credit covers the cluster fee only, not pod-hours.
| Always-on | Requests | ~Monthly (us-central1 rates: $0.0445/vCPU-h, $0.0049/GiB-h) |
|---|---|---|
| api + web (2 replicas each, incl. Cloud SQL proxy sidecar) | ~1.2 vCPU, ~2.3 GiB | ~$47 |
| ArgoCD, Argo Workflows, Argo Events + NATS EventBus (~11 pods at the floor) | ~2.8 vCPU, ~5.5 GiB | ~$110 |
| Cloud SQL `db-f1-micro` | — | ~$812 |
| **Total** | | **~$165170, London a bit more** |
That is the wrong shape for a portfolio that is idle 99% of the time.
### The shape that costs ~£1/month
This is what `terraform apply` builds by default (`deploy_kubernetes` and `deploy_cloud_sql` are
both `false`). Kubernetes becomes something you switch on to show, not something you rent:
| Piece | Service | Idle cost |
|---|---|---|
| api, web | Cloud Run, `min-instances=0`, `max_instances` capped | £0 — Always Free covers 2M requests, 180k vCPU-s, 360k GiB-s per month |
| Nextflow driver | Cloud Run **job**, started by the API through the Jobs API (`roles/run.jobsExecutorWithOverrides`, one job only) | £0 idle, pennies per run |
| Pipeline tasks | Google Batch on **Spot** VMs | £0 idle; a chr22 VEP run is a few pence |
| Database | Neon free tier (scale-to-zero, 0.5 GB) via `TF_VAR_database_url`, or `-var deploy_cloud_sql=true` | £0 (or ~$812) |
| Model | pyfunc artifact loaded straight from GCS (`MODEL_URI`), no MLflow server running | £0 |
| Storage | GCS + Artifact Registry | ~£1 (VEP cache dominates; Nearline halves it) |
Trade-offs worth knowing: Cloud Run cold starts add 13 s to the first request after idle; the
Cloud Run path drops Pub/Sub, Argo Events and Argo Workflows from the critical path (the API calls
the Jobs API directly); and 0.5 GB of Neon does not fit a whole chromosome once `annotations`
stores the full VEP record — demo a gene panel, or store only the annotation keys the UI uses.
Only the web service needs to be public: it serves the UI and proxies `/api` to the API service
(`web/src/routes/api/[...path]`), which is the same shape the ingress gives the Kubernetes track,
so the frontend code is identical either way.
### Keep the Kubernetes story, stop paying rent for it
`infra/k8s/` and `infra/argo-workflows/` stay in the repo and stay deployable. Bring the estate up
with `terraform apply` for an interview or a recording (roughly $0.25/hour while running, so a
two-hour demo is small change), then `terraform destroy -var deletion_protection=false`. `make kind`
runs the same manifests locally for free.
### Guardrails
- A billing budget with alerts at £5/£10, before anything else.
- `max-instances` on every Cloud Run service: scale-to-zero protects the floor, a cap protects the ceiling.
- Spot VMs for Batch, and the existing 30-day lifecycle rule on `work/` in the bucket.
- New accounts get $300 of Google Cloud credit for 90 days, which covers the experimenting phase.
### One more reason not to switch to AWS
AWS replaced its 12-month free tier on 15 July 2025 with credits ($100, up to $200) on a Free plan
that closes after six months or when the credits run out. Google's Always Free quotas, including
Cloud Run's, are permanent. For a demo meant to stay reachable indefinitely at near-zero cost,
that difference matters more than any feature comparison above.
## The escape hatch
Nextflow is the portability layer: executors are configuration, not code. An AWS run needs a new
profile in `pipeline/nextflow.config` (`process.executor = 'awsbatch'`, an S3 work directory and a
job queue), or a HealthOmics workflow definition. The processes themselves do not change. Keeping
`pipeline/bin/` cloud-agnostic (the scripts read `DATABASE_URL` from the environment, never from a
command line) is what keeps that true.
Sources: [Migrate to Batch from Cloud Life Sciences](https://docs.cloud.google.com/batch/docs/migrate-to-batch-from-cloud-life-sciences),
[GKE pricing](https://cloud.google.com/kubernetes-engine/pricing),
[HealthOmics supported languages](https://docs.aws.amazon.com/omics/latest/dev/workflows-supported-languages.html),
[HealthOmics Nextflow 26.04](https://aws.amazon.com/about-aws/whats-new/2026/06/aws-healthomics-nextflow-version-26-04/),
[UK Biobank Research Analysis Platform](https://www.ukbiobank.ac.uk/use-our-data/research-analysis-platform/).
+74
View File
@@ -0,0 +1,74 @@
# Data: what rarelens actually runs on
Everything below is public, peer-reviewed and consented for open redistribution. No patient data,
no data access agreement, nothing that needs an application. These are the references to quote
when showing the platform to someone.
Citations were verified against [PubMed](https://pubmed.ncbi.nlm.nih.gov/); each row links its DOI.
## The demo slice
`make data` fetches two real files, chromosome 22 only (roughly 100 MB, minutes rather than hours):
| File | What it is | Role |
|---|---|---|
| `data/example.vcf.gz` | GIAB HG002 (NA24385) v4.2.1 benchmark calls, GRCh38, chr22 | the sample a scientist annotates |
| `data/clinvar.chr22.vcf.gz` | ClinVar, GRCh38, chr22 | training labels, and the ClinVar column in the UI |
HG002 is the NIST Genome in a Bottle Ashkenazi son, recruited through the Personal Genome Project,
which consents participants to unrestricted public release. It is the reference genome the field
benchmarks variant callers against, so it is both realistic and unambiguously shareable.
## Datasets
| Dataset | Used for | Access | Terms | Citation |
|---|---|---|---|---|
| **ClinVar** (GRCh38) | pathogenic/benign labels, ClinVar column | `ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/` | NCBI public domain | Landrum et al., *Nucleic Acids Res* 48(D1):D835D844, 2020. [10.1093/nar/gkz972](https://doi.org/10.1093/nar/gkz972) |
| **Genome in a Bottle** HG002 v4.2.1 | the demo sample | `ftp-trace.ncbi.nlm.nih.gov/ReferenceSamples/giab/`, `s3://giab` | open, no use restriction | Zook et al., *Nat Biotechnol* 37:561566, 2019. [10.1038/s41587-019-0074-6](https://doi.org/10.1038/s41587-019-0074-6) |
| **gnomAD** v4 | allele frequency feature and filter | `gs://gcp-public-data--gnomad`, `s3://gnomad-public-us-east-1` | free use, no restriction | Chen et al., *Nature* 625:92100, 2024. [10.1038/s41586-023-06045-0](https://doi.org/10.1038/s41586-023-06045-0); Karczewski et al., *Nature* 581:434443, 2020. [10.1038/s41586-020-2308-7](https://doi.org/10.1038/s41586-020-2308-7) |
| **1000 Genomes** 30x | optional cohort/trio data | EBI FTP, `s3://1000genomes` | fully open, no access restriction | Byrska-Bishop et al., *Cell* 185(18):34263440.e19, 2022. [10.1016/j.cell.2022.08.004](https://doi.org/10.1016/j.cell.2022.08.004) |
| **MANE Select** | one transcript per gene, if transcript choice ever matters | Ensembl/RefSeq | open | Morales et al., *Nature* 604:310315, 2022. [10.1038/s41586-022-04558-8](https://doi.org/10.1038/s41586-022-04558-8) |
## Tools and scores
| Tool | Role | Terms | Citation |
|---|---|---|---|
| **Ensembl VEP** 113 | annotation (`pipeline/modules/vep.nf`) | Apache 2.0 | McLaren et al., *Genome Biol* 17:122, 2016. [10.1186/s13059-016-0974-4](https://doi.org/10.1186/s13059-016-0974-4) |
| **CADD** | `cadd_phred` feature | free for non-commercial use; commercial licence required | Rentzsch et al., *Nucleic Acids Res* 47(D1):D886D894, 2019. [10.1093/nar/gky1016](https://doi.org/10.1093/nar/gky1016); Schubach et al., *Nucleic Acids Res* 52(D1), 2024. [10.1093/nar/gkad989](https://doi.org/10.1093/nar/gkad989) |
| **AlphaMissense** | `am_pathogenicity` feature | predictions moved to CC BY 4.0 in March 2024 (originally CC BY-NC-SA) | Cheng et al., *Science* 381:eadg7492, 2023. [10.1126/science.adg7492](https://doi.org/10.1126/science.adg7492) |
Neither score is required: `rarelens_ml.features` treats a missing CADD or AlphaMissense value as
NaN and LightGBM handles it, so the pipeline runs without the plugin data.
## Evaluating the model honestly
The model trains on ClinVar labels and is scored on ClinVar-labelled variants, which is exactly
where published benchmarks go wrong. What to do about it:
1. **Never let the label into the features.** `CLIN_SIG` is excluded by construction; `clinvar_sig`
is stored for display only (`rarelens_ml/features.py` lists the five feature columns).
2. **Split by gene, not by variant.** Random splits put variants from the same gene on both sides,
and a model can then score a gene rather than a variant. Grimm et al. showed this inflates
reported accuracy for exactly this class of tool: *Hum Mutat* 36:513523, 2015.
[10.1002/humu.22768](https://doi.org/10.1002/humu.22768)
3. **Prefer a time-based holdout.** Train on an older ClinVar release (monthly archives live under
`vcf_GRCh38/archive_2.0/`) and test only on variants classified after that date. This is the
closest thing to a prospective evaluation available without new patients.
4. **Filter labels by review status.** ClinVar's `CLNREVSTAT` marks how much evidence backs a
classification; two-star and above ("multiple submitters, no conflicts") is the usual bar.
*Known gap*: VEP's `CLIN_SIG` does not carry review status, so this needs ClinVar annotated as a
custom field before it can be enforced.
5. **Report against published baselines on the same rows.** CADD PHRED and AlphaMissense are
already columns in the variant table, so AUROC and AUPRC for the model next to those two, with
the variant count, is a fair comparison rather than a number with nothing to beat.
6. **Report AUPRC, not just AUROC.** Pathogenic variants are the minority class; AUROC flatters.
## What must not be claimed
ACMG/AMP treats computational predictions as *supporting* evidence only, never sufficient on their
own for classifying a variant (Richards et al., *Genet Med* 17:405424, 2015.
[10.1038/gim.2015.30](https://doi.org/10.1038/gim.2015.30)). rarelens is a learning platform on
public data: it makes no diagnostic claim, and the UI shows a score next to the evidence rather
than a verdict. For what a real diagnostic pipeline looks like end to end, see the 100,000 Genomes
Project rare-disease pilot: Smedley et al., *N Engl J Med* 385:18681880, 2021.
[10.1056/NEJMoa2035790](https://doi.org/10.1056/NEJMoa2035790)