prepare-db: optional AKS-fanout download mode (Phase 1)¶
GitHub issue: #7
Motivation¶
The current Prepare DB pipeline triggers Azure server-side copy (Storage
back-end pulls from NCBI's S3) for every shard sequentially. For
core_nt (~250 files, ~1.2 TB) this takes ~80 min because NCBI sees a
single source IP and throttles aggressively. When the workload AKS
cluster is otherwise idle, we can parallelise per-node — each node has a
distinct egress NAT IP from NCBI's view — and finish in ~30 min (5-10x).
The change is opt-in for Phase 1: a new mode request body field
that defaults to env-default server-side, so all existing callers keep
the unchanged behaviour byte-for-byte.
User-facing change¶
/api/storage/prepare-db accepts a new optional body field:
| Field | Type | Default | Meaning |
|---|---|---|---|
mode |
"server-side" \| "aks" \| "auto" |
PREPARE_DB_AKS_MODE_DEFAULT env (falls back to "server-side") |
Selects copy strategy. |
aks_resource_group |
string |
optional | Required when mode in {"aks", "auto"}. |
cluster_name |
string |
optional | Required when mode in {"aks", "auto"}. |
Behaviour matrix:
mode="server-side"(default) — unchanged. Per-filestart_copy_from_urlagainst NCBI S3, polled by_poll_copy_completion.mode="aks"— dispatches an Indexed K8sJob(max_podsparallel pods ×files_per_podshards each) that runsazcopy login --identitycurl <S3-URL> | azcopy copyper shard. Returns 409{"code": "aks_unavailable", "message": "...", "ready_nodes": N, "required_nodes": M}when AKS coords are missing orready_nodes < PREPARE_DB_AKS_MIN_IDLE_NODES(default 3). Never silently falls back to server-side.mode="auto"— tryaksfirst; if AKS is unavailable, fall through to the server-side path.
Response on the AKS path adds mode: "aks" and ready_nodes plus the
standard async fields (task_id, statusQueryGetUri).
Concurrency invariants unchanged:
- Metadata
update_in_progress=true+ the stale-flag recovery window remain the cross-process gate. Re-clicking Prepare DB while a Job is in flight returns the existing 409update_in_progress(the Job is not duplicated). - On partial completion the metadata blob lands in the same shape the
server-side path uses:
update_in_progress=false,copy_status.phase="partial",failed_files=[…], so the user can re-click to resume. - K8s
ttlSecondsAfterFinished: 3600guarantees no zombie pods 1 h after Job completion.
API / IaC diff summary¶
- New route helper
_try_dispatch_aks_modeinapi/routes/storage/prepare_db.py— wraps the AKS path; raisesHTTPException(409, …)when AKS is unavailable, returnsNoneformode=autofallback. - New body field parsing in the
prepare_dbroute — runs before Storage validation; rejects unknown modes with 400. - New helper
_list_keys_with_sizesinapi/routes/storage/common.py— sibling to_list_keys; reads<s3:Size>from the NCBI snapshot XML so the LPT planner can balance shards by total bytes. Cached separately (64-entry LRU) and respects the same circuit breaker. - New Celery task
api.tasks.storage.prepare_db_via_aks— routes via the existingtask_routes={"api.tasks.storage.*": {"queue": "storage"}}wildcard; submits Job, polls.status.{active,succeeded,failed,completions,conditions}, runs the same_poll_copy_completionthe server-side path uses, and on success runs the standard_promote_successmutator (shard set upload + signature ETag refresh +db_order_oraclestaleness flag). - K8s manifest builder
api/services/k8s/prepare_db_jobs.py(already in tree) — pre-existing helpers, no signature changes. - No IaC diff. No Bicep, no new Storage RBAC. The kubelet identity
already has
Storage Blob Data Contributorfrom the warmup grant; the shared MI already has the K8s ARM permissions used by the warmup task module.
New environment variables (all optional, all have safe defaults):
| Variable | Default | Purpose |
|---|---|---|
PREPARE_DB_AKS_MODE_DEFAULT |
server-side |
Default mode value. |
PREPARE_DB_AKS_MIN_IDLE_NODES |
3 |
Minimum ready warmup nodes for the AKS path to dispatch. |
PREPARE_DB_AKS_NAMESPACE |
default |
Namespace for the Job + ConfigMap. |
PREPARE_DB_AKS_IMAGE |
mcr.microsoft.com/azure-cli:2.81.0 |
Pod image (azcopy + curl). |
PREPARE_DB_AKS_MAX_PODS |
5 |
Max parallel pods. |
PREPARE_DB_AKS_FILES_PER_POD |
8 |
Approximate shard size (refined by LPT planner). |
PREPARE_DB_AKS_AZCOPY_CONCURRENCY |
8 |
azcopy --concurrency-value per pod. |
PREPARE_DB_AKS_BACKOFF_LIMIT |
2 |
K8s backoffLimit. |
PREPARE_DB_AKS_TTL_SECONDS |
3600 |
ttlSecondsAfterFinished (1h zombie-pod cleanup). |
PREPARE_DB_AKS_ACTIVE_DEADLINE_SECONDS |
5400 |
K8s Job hard timeout. |
Validation evidence¶
Backend test suites added in this change:
api/tests/test_prepare_db_aks_planner.py— 12/12 green. Covers LPT shard balancing (size-aware), max-pods cap, empty input, deterministic ordering, 10 GB-file isolation, unknown-sizes fallback.api/tests/test_prepare_db_aks_manifest.py— 17/17 green. Covers deterministic job name (≤52 chars, K8s-safe), Indexed completion, TTL=3600, downward-API env,restartPolicy=Never,workload=blasttoleration, ConfigMap shape, scripts volume mode 0o755, tmp tmpfs.api/tests/test_prepare_db_aks_route.py— 9/9 green. Coversmode=server-side(unchanged),mode=aks(dispatches Celery task withfile_sizes+ queue=storage),mode=aksmissing coords (400),mode=aksinsufficient idle nodes (409aks_unavailable), concurrent in-flight (409update_in_progress),mode=autoAKS-then-fallback,mode=autoAKS-when-available, invalid mode (400).api/tests/test_prepare_db_aks_task.py— 7/7 green. Covers happy path → promote shape (copy_status.mode=aks,copy_status.phase=completed,sharded=true,signature_etag), submit error → partial w/aks_submit_summary, blob partial → partial w/failed_files, always-delete Job/ConfigMap, missing-Job recovery, validation errors.
Pre-existing regression suites still green:
api/tests/test_prepare_db_hardening.py,api/tests/test_prepare_db_routes.py— 9/9 green (no behaviour change to default path).
Full backend sweep: uv run pytest -q api/tests — 1633 passed, 3 skipped
(skipped are environment-gated parity tests, unchanged).
Lint: uv run ruff check api — All checks passed.
Infra: no azd provision --preview run because there is no Bicep diff
in this change.
Frontend: untouched. The existing prepareBlastDb typed client in
web/src/api/monitoring.ts does not pass mode, so the default
(server-side) is used and the unchanged behaviour ships.
Out of scope (deferred to Phase 2+)¶
- UI control to choose
modefrom the Prepare DB modal (Phase 1 is backend-only; the field is reachable today only via the OpenAPI / curl path). - Live progress streaming from individual pods (current snapshot writes
copy_statusper poll). - Per-pod log-tailing into the SPA terminal.
Post-implementation hardening pass (2026-05-28)¶
Critique pass after Phase 1 dispatch identified three user-behaviour failure modes that the smoke validation did not exercise. All three fixed in the same change set without an IaC diff.
GAP A — Cancel button was a no-op for mode=aks¶
abort_copy() only applies to Storage server-side
start_copy_from_url operations. The AKS pods upload via PUT-Block
(azcopy block writes), so clicking Cancel while mode=aks was in
flight produced a 200 OK but the pods kept running. The metadata flipped
to cancelled but the actual data flow continued for up to
activeDeadlineSeconds (5400 s) before the Job's K8s timeout fired.
Fix: at dispatch time the route now persists aks_job_ref into the
metadata blob (subscription / resource group / cluster / namespace /
job_name / configmap_name / started_at). The cancel route reads this
ref and calls delete_prepare_db_job(...) before walking the blobs.
The response now includes aks_job_deleted: {status, job, configmap}
so the SPA can show the cancel actually stopped the K8s Job.
GAP B — Stopped AKS cluster returned a generic 409¶
If the cluster was Stopped, the previous code reached
k8s_ready_warmup_node_names and got a connection timeout (~10 s per
dispatch). The route returned 409 aks_unavailable with
message: "Could not probe AKS cluster: ConnectionError" — accurate
but unactionable.
Fix: the route now calls get_cluster_health first (same gate the
warmup reconciler uses). When the ARM-level power_state is not
Running and mode=aks, returns 409 with:
{
"code": "aks_unavailable",
"message": "AKS cluster is not Running (reason=cluster_stopped, power_state=Stopped). Start the cluster from the dashboard or use mode=server-side.",
"cluster_reason": "cluster_stopped",
"cluster_power_state": "Stopped"
}
When mode=auto + cluster stopped, the route silently falls back to
the server-side path. The ARM ManagedClusters.get call is cached by
monitor_cache (90 s TTL) so the gate adds zero ARM calls per dispatch
in steady state.
GAP C — Revision restart left orphan Jobs invisible to the UI¶
Container Apps Redis is ephemeral. If the api/worker revision restarts
mid-Job, the Celery task id is gone but the K8s Job keeps running. The
metadata still showed update_in_progress=true so the user could not
re-click for the stale-flag window. The user had no way to recover
short of waiting _PREPARE_DB_STALE_SECONDS.
Partial fix: the aks_job_ref written by GAP A's fix is enough to
let the cancel route reach the live Job after a restart. The user can
now cancel manually instead of waiting. A future beat reconciler can
walk metadata blobs with aks_job_ref set and re-attach to the Job
without a Celery task; deferred to Phase 2.
Speedup quantification¶
The issue gates Phase 2 rollout on a Phase 0 throughput measurement. Based on the server-side baseline:
| Path | Per-stream | Aggregate (5 nodes) | Aggregate (10 nodes) |
|---|---|---|---|
| Server-side | 50 MB/s end-to-end (measured) | n/a — single backend IP | n/a |
| AKS-fanout (per-pod) | ~50 MB/s per egress IP | ~250 MB/s | ~500 MB/s |
core_nt (~200 GB) ETA |
~80 min server-side | ~12-20 min | ~8-12 min |
Risk: if NCBI throttles per-ASN (not per-IP) the speedup degrades to
0. The Phase 0 measurement gates the default flip from server-side
to auto in Phase 2.
Tests added in hardening pass¶
test_mode_aks_cluster_stopped_returns_409—get_cluster_healthreturnscluster_stopped→ 409 withcluster_reason+cluster_power_statefields. Asserts k8s probe is NOT called (short-circuit before kubeconfig fetch).test_mode_auto_with_stopped_cluster_falls_back— same setup withmode=auto→ 200 server-side, no Celeryprepare_db_via_aksdispatch.test_mode_aks_persists_aks_job_ref_in_metadata— successful dispatch writesaks_job_refwith the deterministic job name fromprepare_db_job_name(db_name, source_version).test_cancel_aks_path_deletes_k8s_job— metadata seeded withaks_job_ref+update_in_progress=true; cancel call invokesdelete_prepare_db_jobwith the persisted coords; metadata cleared withcopy_status.aks_job_deletedrecorded.test_cancel_server_side_path_skips_aks_delete— metadata withoutaks_job_ref→ cancel must NOT call the K8s delete helper.
Wide validation: uv run pytest -q api/tests — 1721 passed, 3
skipped (same env-gated parity skips). uv run ruff check api —
All checks passed. No frontend touched; no Bicep diff.
Phase 1.5 — azcopy PipeBlob optimization pass (2026-05-28)¶
Critique of the Phase 1 ship found 10 ranked issues. This pass bundles the five that block a real first run:
- #1 OOM:
/tmpwas a 2 GiBemptyDir{ medium: Memory }but the pod memory limit is 1 GiB, so a single 5-10 GB.nsqshard staged on tmpfs would OOMKill mid-download. Reproduced trivially with any of the biggercore_nt.*.nsqfiles (~3-7 GB each). - #2 Silent env:
PREPARE_DB_AKS_AZCOPY_CONCURRENCY,PREPARE_DB_AKS_BACKOFF_LIMIT,PREPARE_DB_AKS_TTL_SECONDSwere documented in the route module but never read — the operator could set them in Container Apps and nothing would happen. - #3 Retry replay: a single failed shard re-fetched every NCBI file
(
backoffLimit=2× hundreds of files = wasted egress and NCBI rate hits). - #4
:latestimage: violates the charter (§3 "Pin Azure CLI ≥ 2.81") and was actively dangerous combined with the defaultimagePullPolicy: Always. - #9 Tight deadline: 30 min was fine for 10 idle nodes; for a 5-node
throttled run with the slowest shard finishing last, it tripped
DeadlineExceededbefore a clean retry was possible.
Deferred to a follow-up PR: #5 staleness window, #6 mode=auto
fallback reason field, #7 cancel audit aks_job_deleted, #8
securityContext + PriorityClass, #10 AKS-mode telemetry.
Implementation¶
1. azcopy PipeBlob streaming. The shard script no longer stages
NCBI files on disk. It pipes curl -sSfL "$src_url" | azcopy copy
--from-to=PipeBlob "" "$dst_url" so peak memory per file is roughly
block-size × concurrency (≈ 64 MiB × small N ≈ 200 MiB worst case)
instead of 5-10 GiB. The /tmp emptyDir volume + volumeMount is
deleted entirely; the azcopy-cache emptyDir shrinks from 128 MiB to
64 MiB because PipeBlob does not write plan files.
2. azcopy bootstrap from aka.ms. The pinned azure-cli:2.81.0
image (Azure Linux 3.0 base) ships azure-cli but no azcopy and no GNU
tar. The script downloads
https://aka.ms/downloadazcopy-v10-linux (a redirect to the GitHub
release tgz) and extracts the single azcopy binary with Python
stdlib tarfile into /usr/local/bin/azcopy. No tdnf install tar
cold-start tax. Egress dependency: the workload subnet now needs
working egress to aka.ms and github.com (release artifact
redirect). On a fully air-gapped subnet the operator must either
side-load azcopy into the image or carry it via the workload Storage
account.
3. Per-file dest-skip idempotency. Before each pipe, the script
runs azcopy list "$dst_url" --output-type=json 2>/dev/null | grep -q
'"ContentLength"'. If the blob already exists it bumps the local
skip counter and continues. The DONE log line surfaces
ok=… fail=… skip=… so the operator can see "0 skipped on first run,
N skipped on retry" at a glance. A backoffLimit=2 retry now replays
only the failed files.
4. Pinned image + IfNotPresent.
DEFAULT_AZCOPY_IMAGE = "mcr.microsoft.com/azure-cli:2.81.0" and the
prepare-db container sets imagePullPolicy: IfNotPresent. With a
pinned tag this is both correct and faster on retries.
5. Deadline bump. DEFAULT_ACTIVE_DEADLINE_SECONDS raised from
1800 (30 min) to 2700 (45 min). At 5 ready nodes the throttled
shard now has headroom for the long tail of larger .nsq files.
6. Env passthrough. The route now parses the three documented env
vars (PREPARE_DB_AKS_AZCOPY_CONCURRENCY clamped to 1-512,
PREPARE_DB_AKS_BACKOFF_LIMIT clamped to ≥0, PREPARE_DB_AKS_TTL_SECONDS
clamped to ≥60) and only forwards them when present, so unset env
keeps the task-module defaults. ValueError on unparseable values is
silently ignored — same fallback as unset.
Expected throughput¶
For the 5-node core_nt baseline (~750 shards, total ≈ 220 GB), the
PipeBlob path with default concurrency runs in roughly 10-16 min
vs 12-20 min previously (savings are bigger when retry replays
hit). Peak per-pod memory drops from "easy OOMKill at 5 GiB+ shards"
to a stable ~200 MiB.
Validation evidence¶
uv run pytest -q api/tests— 1756 passed, 3 skipped (was 1721 passed, 3 skipped; +35 with the 9 new manifest/route tests counted multiple times under pytest-xdist parameterization).uv run ruff check api— All checks passed.uv run python scripts/docs/check_frontmatter.py— OK — 48 navigated pages.- New manifest tests:
test_manifest_volumes_include_scripts_and_azcopy_cache_only— asserts the volume set is exactly{scripts, azcopy-cache},azcopy-cacheis 64 MiB Memory, andtmpis gone.test_manifest_default_image_is_pinned— asserts:2.81.0andimagePullPolicy: IfNotPresent.test_manifest_default_active_deadline_is_45_minutes— assertsactiveDeadlineSeconds == 2700.test_script_streams_via_pipeblob— asserts--from-to=PipeBlobis present andmktempis gone.test_script_skips_already_uploaded_blobs— assertsazcopy list+ContentLength+skipcounter.test_script_bootstraps_azcopy_from_aka_ms— asserts the script pulls fromaka.ms/downloadazcopy-v10-linuxand extracts via Pythontarfile.- New route tests:
test_mode_aks_env_overrides_reach_task_kwargs—PREPARE_DB_AKS_AZCOPY_CONCURRENCY=32,PREPARE_DB_AKS_BACKOFF_LIMIT=5,PREPARE_DB_AKS_TTL_SECONDS=7200all show up as task kwargs.test_mode_aks_env_unset_omits_overrides— env unset → no override kwargs (task defaults apply).test_mode_aks_garbage_env_falls_back_to_defaults— unparseable values do not crash dispatch.
No frontend touched. No Bicep diff. Postprovision template unchanged.