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:
@@ -1,18 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type Sample } from '$lib/api';
|
||||
import { api, type Assembly, type Sample } from '$lib/api';
|
||||
|
||||
let samples = $state<Sample[]>([]);
|
||||
let loaded = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
let name = $state('');
|
||||
let vcfUri = $state('');
|
||||
let assembly = $state<Assembly>('GRCh38');
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function refresh() { samples = await api.samples(); }
|
||||
async function refresh() {
|
||||
try {
|
||||
samples = await api.samples();
|
||||
loadError = null;
|
||||
} catch (e) {
|
||||
loadError = (e as Error).message;
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
onMount(refresh);
|
||||
|
||||
async function add() {
|
||||
error = null;
|
||||
try { await api.createSample({ name, vcf_uri: vcfUri }); name = ''; vcfUri = ''; await refresh(); }
|
||||
try { await api.createSample({ name, vcf_uri: vcfUri, assembly }); name = ''; vcfUri = ''; await refresh(); }
|
||||
catch (e) { error = (e as Error).message; }
|
||||
}
|
||||
</script>
|
||||
@@ -24,12 +36,19 @@
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
|
||||
<input placeholder="Sample name" bind:value={name} aria-label="Sample name" />
|
||||
<input placeholder="gs://bucket/sample.vcf.gz or /data/example.vcf.gz" bind:value={vcfUri} aria-label="VCF path" style="flex:1; min-width: 20rem" />
|
||||
<select bind:value={assembly} aria-label="Assembly">
|
||||
<option>GRCh38</option><option>GRCh37</option>
|
||||
</select>
|
||||
<button onclick={add} disabled={!name || !vcfUri}>Add sample</button>
|
||||
</div>
|
||||
{#if error}<p role="alert">Could not add the sample: {error}</p>{/if}
|
||||
|
||||
<h2>Samples</h2>
|
||||
{#if samples.length === 0}
|
||||
{#if loadError}
|
||||
<p role="alert">Could not load samples: {loadError}. <button class="quiet" onclick={refresh}>Retry</button></p>
|
||||
{:else if !loaded}
|
||||
<p style="color:var(--ink-soft)">Loading samples…</p>
|
||||
{:else if samples.length === 0}
|
||||
<div class="empty">No samples yet. Add one above to run the annotation pipeline.</div>
|
||||
{:else}
|
||||
<table>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { proxyToApi } from '$lib/proxy';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const handle: RequestHandler = ({ request, params, url, fetch }) =>
|
||||
proxyToApi({
|
||||
request,
|
||||
path: params.path,
|
||||
search: url.search,
|
||||
base: env.API_INTERNAL_URL ?? 'http://localhost:8000',
|
||||
fetch
|
||||
});
|
||||
|
||||
export const GET = handle;
|
||||
export const POST = handle;
|
||||
@@ -1,38 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { api, type Job, type VariantPage } from '$lib/api';
|
||||
import { poll } from '$lib/poll';
|
||||
|
||||
const POLL_MS = 3000;
|
||||
const finished = (j: Job) => j.status === 'succeeded' || j.status === 'failed';
|
||||
|
||||
let { data } = $props();
|
||||
let job = $state<Job | null>(null);
|
||||
let page = $state<VariantPage | null>(null);
|
||||
let gene = $state('');
|
||||
let impact = $state('');
|
||||
let maxAf = $state('0.01');
|
||||
let maxAf = $state<number | null>(0.01);
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let stopPolling: (() => void) | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
const jobs = await api.jobsForSample(data.sampleId);
|
||||
const done = jobs.find(j => j.status === 'succeeded');
|
||||
if (done) { job = done; await loadVariants(); }
|
||||
else if (jobs.length) job = jobs[0];
|
||||
try {
|
||||
const jobs = await api.jobsForSample(data.sampleId); // newest first
|
||||
const latest = jobs[0] ?? null;
|
||||
const lastGood = jobs.find((j) => j.status === 'succeeded') ?? null;
|
||||
// Follow a run in progress; otherwise show the latest results, else the latest failure.
|
||||
job = latest && !finished(latest) ? latest : (lastGood ?? latest);
|
||||
if (job) await follow(job);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
});
|
||||
onDestroy(() => stopPolling?.());
|
||||
|
||||
async function follow(j: Job) {
|
||||
stopPolling?.();
|
||||
if (j.status === 'succeeded') return loadVariants();
|
||||
if (finished(j)) return;
|
||||
stopPolling = poll(() => api.job(j.id), {
|
||||
intervalMs: POLL_MS,
|
||||
done: finished,
|
||||
onValue: (next) => {
|
||||
job = next;
|
||||
if (next.status === 'succeeded') loadVariants();
|
||||
},
|
||||
onError: (e) => { error = `Lost track of the job: ${(e as Error).message}`; }
|
||||
});
|
||||
}
|
||||
|
||||
async function runAnnotation() {
|
||||
busy = true;
|
||||
job = await api.annotate(data.sampleId);
|
||||
const poll = setInterval(async () => {
|
||||
if (!job) return;
|
||||
job = await api.job(job.id);
|
||||
if (job.status === 'succeeded' || job.status === 'failed') { clearInterval(poll); busy = false; if (job.status === 'succeeded') await loadVariants(); }
|
||||
}, 3000);
|
||||
error = null;
|
||||
page = null;
|
||||
try {
|
||||
job = await api.annotate(data.sampleId);
|
||||
await follow(job);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVariants() {
|
||||
if (!job) return;
|
||||
const q: Record<string, string> = { job_id: job.id, max_af: maxAf, limit: '100' };
|
||||
if (gene) q.gene = gene;
|
||||
if (impact) q.impact = impact;
|
||||
page = await api.variants(q);
|
||||
error = null;
|
||||
try {
|
||||
page = await api.variants({ jobId: job.id, gene, impact, maxAf, limit: 100 });
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
const scoreClass = (s: number) => (s >= 0.8 ? 'score high' : s >= 0.5 ? 'score mid' : 'score');
|
||||
@@ -41,10 +75,16 @@
|
||||
<a href="/">All samples</a>
|
||||
<h1>Sample {data.sampleId.slice(0, 8)}</h1>
|
||||
|
||||
{#if !job}
|
||||
<button onclick={runAnnotation} disabled={busy}>Run VEP annotation</button>
|
||||
{:else}
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
|
||||
{#if job}
|
||||
<p>Job <span class="hgvs">{job.id.slice(0, 8)}</span>: <span class="status {job.status}">{job.status}</span></p>
|
||||
{#if job.status === 'failed' && job.log}
|
||||
<pre class="hgvs" style="white-space:pre-wrap; background:white; border:1px solid var(--line); padding:0.75rem; max-height:16rem; overflow:auto">{job.log}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !job || job.status === 'failed'}
|
||||
<button onclick={runAnnotation} disabled={busy}>{job ? 'Run VEP annotation again' : 'Run VEP annotation'}</button>
|
||||
{/if}
|
||||
|
||||
{#if job?.status === 'succeeded'}
|
||||
|
||||
Reference in New Issue
Block a user