Skip to content

Backend + frontend SRP split pass

Date: 2026-05-22 Scope: Refactor only. No behaviour change, no public API change, no IaC change.

Motivation

Several files in api/tasks/blast/, api/services/, and web/src/components/ had grown to where their module docstring's Responsibility line could no longer be stated without "and" — the charter's SRP gate (see .github/copilot-instructions.md §11). While AKS was restarting, we split the largest violators into smaller, single- responsibility modules. Each extracted module ships with its own charter context header and keeps the original public API stable through alias re-exports.

User-facing change

None. All extractions preserve call signatures and module-level attribute access (including the blast.SPLIT_MERGE_REPORT_MAX_BYTES and blast.QUERY_FASTA_READ_MAX_BYTES symbols that pytest monkeypatches).

API/IaC diff summary

api/tasks/blast/

File Lines Responsibility
submit_lock.py 70 Per-(cluster, namespace) Redis lock for elastic-blast submit.
substeps.py 60 Map an elastic-blast submit log line to one of 5 sub-progress checkpoints.
submit_logs.py 52 Slice submit log events into 100-event chunks and persist them.
split_constants.py 85 Constants for split-mode parent/child plans (status sets, blob names, allowlist).

api/tasks/blast/__init__.py consumes those modules through plain imports with underscore aliases (_submit_lock_key, _acquire_submit_lock, _release_submit_lock, _persist_submit_log_events, _detect_submit_substep) for backwards compatibility. The now-redundant inline PROGRESS_STEP_ORDER tuple (already authoritative in progress.py) was removed.

api/services/

File Lines Responsibility
k8s_timestamps.py 88 Parse Kubernetes RFC3339 timestamps; compute min/max/span payloads.
warmup_scripts.py 235 Three shell-script texts injected into the BLAST DB warmup Job (container entrypoint + init-db-shard-aks.sh + blast-vmtouch-aks.sh).

api/services/k8s_monitoring.py and api/services/warmup_jobs.py now import the extracted symbols (with underscore aliases) so their internal callers stay unchanged.

web/src/components/

File Lines Responsibility
warmupSection/helpers.ts 300 WARMUP_CANDIDATES, capacity / row types, pure formatters (formatBytes, formatDuration, formatPhaseCounts, formatWarmupProgress, shortWarmupPhase, summariseWarmupCapacity, buildWarmupRows).

web/src/components/WarmupSection.tsx keeps the React-bearing components (banner, db row, skeletons, progress bar, status pill) but imports the pure helpers, dropping 265 lines.

Size summary

File Before After Delta
api/tasks/blast/__init__.py 3,110 2,993 −117
api/services/k8s_monitoring.py 1,136 1,090 −46
api/services/warmup_jobs.py 922 724 −198
web/src/components/WarmupSection.tsx 1,127 857 −270

The seven extracted modules total 925 lines and each carries a single Responsibility line in its context header.

Validation evidence

$ uv run ruff check api
All checks passed!

$ uv run pytest -q api/tests
868 passed in 26.57s

$ cd web && npx vitest --run
Test Files  26 passed (26)
     Tests  224 passed (224)

$ cd web && npm run build
 built in 9.90s

No behavioural test changed; all 868 backend + 224 frontend tests still pass against the refactored code. Pure helpers are exercised through the existing route / task test paths (test_blast_tasks.py, test_k8s_*.py, test_warmup_*.py).


Batch 3 — api/tasks/blast/__init__.py Celery-task extraction

After the first batch trimmed shared helpers, the package __init__.py still owned five large Celery-task definitions plus the split-mode pipeline (merge/upload/finalize) helpers. The Responsibility line had to enumerate "cancel and backfill and reconcile and poll and split-pipeline", which violated the SRP gate. We pulled each task into its own module while keeping the package's public attribute surface intact (every helper that the test suite accesses via blast._X or monkeypatches via monkeypatch.setattr(blast, "_X", …) is re-exported at the bottom of __init__.py).

New task modules

File Lines Responsibility
cancel_task.py 103 cancel Celery task — Redis-locked elastic-blast delete with status persistence.
backfill_task.py 234 backfill_completed_runtime_metrics Celery task — re-derive runtime metrics for already-completed jobs missing them.
reconcile_task.py 363 reconcile_stale_jobs Celery task + helpers — sweep stuck rows and pull truth from AKS/Storage.
poll_tasks.py 219 check_status (one-shot K8s probe) and poll_running_status (self-rescheduling poller) + the POLL_RUNNING_* constants.
split_pipeline.py 1,185 Split-mode parent/child query pipeline (upload, dispatch, aggregate, verify, merge) plus the merge_split_results Celery task.

Cross-module call pattern

The task submodules import the package as from api.tasks import blast as _blast and reference helpers / constants via _blast.X rather than direct from api.tasks.blast import X. This preserves the test contract that monkeypatch.setattr(blast, "_helper", fake) substitutions are honoured at call time. Bare-name calls were not safe even between helpers inside the same submodule — every intra-module reference to a helper or constant that the tests patch on the package (_upload_split_query_files, _run_split_parent_submission, QUERY_FASTA_READ_MAX_BYTES, …) goes through _blast.X.

Re-imports live at the bottom of api/tasks/blast/init.py under # noqa: E402,F401 so the Celery beat schedule entries (api.tasks.blast.cancel, api.tasks.blast.backfill_completed_runtime_metrics, api.tasks.blast.reconcile_stale_jobs, api.tasks.blast.check_status, api.tasks.blast.poll_running_status, api.tasks.blast.merge_split_results) still resolve via the package, and all @shared_task(name=…) strings are unchanged.

Size summary (cumulative)

File Pre-batch-3 After batch 3 Delta
api/tasks/blast/__init__.py 2,993 1,173 −1,820

Total api/tasks/blast/ line count (sum of all .py files in the package) is now 4,013 across nine modules, each with a single Responsibility line.

Validation evidence (batch 3)

$ uv run ruff check api
All checks passed!

$ uv run pytest -q api/tests
868 passed in 35.74s

$ cd web && npx vitest --run
Test Files  26 passed (26)
     Tests  224 passed (224)

$ cd web && npm run build
 built in 7.09s

No behaviour change — the same 868 backend + 224 frontend tests pass. Test attribute access on blast._X and blast.X_CONSTANT is preserved by the bottom-of-file re-imports in __init__.py.


Batch 4 — api/tasks/blast/__init__.py SRP final pass (2026-05-22, evening)

After batch 3 the package shell was still 1,173 lines because the submit Celery task body (≈420 lines) and a long tail of submit-side helpers and config-shim helpers were all still inline. Batch 4 splits the remainder into five focused submodules — the shell is now 209 lines and the __init__.py Responsibility line is "Re-export task entry points and shared internal helpers; no business logic."

New modules

Module Lines Responsibility
api/tasks/blast/cli_parsing.py 118 Parse elastic-blast submit argv/stdout: build the CLI args, decode the trailing JSON payload, extract elastic-blast job ID, classify retryable failures.
api/tasks/blast/config_shims.py 173 Build the elastic-blast.ini payload and apply the option/database/warmup shims (sharding suppression, strict tie-order candidate pool expansion, node warmup readiness) before submission.
api/tasks/blast/state.py 157 Persist job state + history rows via JobStateRepository, emit Celery update_state progress checkpoints, and orchestrate the retry-or-fail bookkeeping shared by submit / cancel / reconcile tasks.
api/tasks/blast/submit_runtime.py 272 Submit-side runtime helpers — terminal exec streaming, K8s/Storage probes, result-gating, and the TerminalAzureLoginError class.
api/tasks/blast/submit_task.py 525 The @shared_task(name="api.tasks.blast.submit", …) Celery task itself — preparing → warming → splitting → configuring → submitting → completed pipeline. Decorator and signature are byte-identical to the previous inline definition.

Test-compat contract preserved

Every helper that production tests monkeypatch.setattr(blast, "_X", …)_update_state, _progress, _has_parseable_result_artifact, _stream_submit_command, _ensure_terminal_azure_cli_login, TerminalAzureLoginError, resolve_db_metadata, etc. — is still accessible as api.tasks.blast.X via the bottom-of-file re-imports. Submodules look up these symbols via from api.tasks import blast as _blast + _blast.X at call time so monkeypatches on the package propagate.

Size summary (cumulative across all four batches)

File Pre-batch-1 After batch 3 After batch 4 Delta vs pre
api/tasks/blast/__init__.py 2,993 1,173 209 −2,784

Total api/tasks/blast/ package: 4,171 lines across 16 modules. The shell is now a thin re-export surface; every submodule has a single Responsibility line that fits without an "and".

Validation evidence (batch 4)

$ uv run ruff check api
All checks passed!

$ uv run pytest -q api/tests
871 passed in 25.85s

$ cd web && npx vitest --run
Test Files  26 passed (26)
     Tests  224 passed (224)

$ cd web && npm run build
 built in 6.63s

Test count grew from 868 → 871 between batches via unrelated work on other branches that landed in between; no batch-4 test was added or removed. Behaviour-identical refactor.