prepare-db AKS: raise Job deadline + per-task Celery limits so nt/core_nt finish¶
Motivation¶
A live nt prepare-db run via AKS reported "Partial copy · 1081 failed" even
though no shard actually errored. Tracing the failure through code + Log Analytics
proved a timeout mismatch, not a data error:
- The Indexed K8s Job was created with
activeDeadlineSeconds = 2700(45 min). nt(~4.8k files, the UI badges it "May take hours") was still streaming from NCBI at the 45-min mark. K8s enforced the deadline and marked the JobFailed / DeadlineExceeded, abandoning every still-in-flight and not-yet-started shard.- The Celery poller observed the terminal
Failedcondition and ran the per-blob reconcile. The 1081 files that had not yet been committed (4814 − 3733) were counted asfailed: missing— surfacing as a misleading "partial · 1081 failed".
Evidence (moonchoi prod, Log Analytics ContainerAppConsoleLogs_CL):
nt start 08:40:38; 08:40:38 + 2700s = 09:25:38 Job killed; reconcile finished
done elapsed=3200.2s at 09:33:58. The worker itself succeeded in 3201s — it was
not a Celery time-limit kill (3200 < the 3600 global hard limit), confirming the
root cause was the 45-min Job deadline, not the task limit this run.
A secondary latent defect: had the Job deadline simply been raised, the global
Celery hard limit (1h, api/celery_app.py) would have SIGKILLed the poller on the
next multi-hour run, orphaning the Job and stranding the DB at partial.
User-facing change¶
nt/core_ntprepare-db via AKS now runs to genuine completion instead of being cut off at 45 min and falsely reported as a partial failure with ~1000 "missing" files.- No change to small/medium DBs: the Job still exits the instant all shards succeed, so a larger ceiling never slows a quick run.
API / IaC diff summary¶
api/services/k8s/prepare_db_jobs.py:DEFAULT_ACTIVE_DEADLINE_SECONDS2700→4 * 60 * 60(14400). Still overridable per-job via the route envPREPARE_DB_AKS_JOB_TIMEOUT_SECONDS.api/routes/storage/prepare_db.py: the env default and theValueErrorfallback forPREPARE_DB_AKS_JOB_TIMEOUT_SECONDSboth move2700→14400.api/tasks/storage/prepare_db_via_aks.py:_JOB_POLL_MAX_SECONDS4h→ env-driven default4h15m(15300) so the poller always outlives the Job deadline and observes the terminal condition.- New
_TASK_SOFT_TIME_LIMIT/_TASK_HARD_TIME_LIMIT(env-driven, default poll-cap + 20 min / + 30 min), wired into the@shared_taskdecorator assoft_time_limit/time_limitso this task overrides the global 1h worker limit. A startup assertion enforcessoft < hardandhard > poll-cap. - No Bicep / infra change. The three new env vars
(
PREPARE_DB_AKS_JOB_POLL_MAX_SECONDS,PREPARE_DB_AKS_TASK_SOFT_TIME_LIMIT,PREPARE_DB_AKS_TASK_TIME_LIMIT) are optional overrides with safe defaults.
Timeout ladder (all overridable, defaults shown):
| Layer | Value | Why |
|---|---|---|
Job activeDeadlineSeconds |
14400 (4h) | K8s caps the whole Job |
Celery _JOB_POLL_MAX_SECONDS |
15300 (4h15m) | poller outlives the Job |
Celery soft_time_limit |
16500 (4h35m) | margin for post-job reconcile sweep |
Celery time_limit (hard) |
17100 (4h45m) | final backstop, > global 1h |
Deploy note¶
This is api/ code, so it takes effect only after the worker (and api)
sidecars are redeployed. A deploy-free interim mitigation is to set the Container
App env PREPARE_DB_AKS_JOB_TIMEOUT_SECONDS=14400 together with raised
CELERY_TASK_TIME_LIMIT / CELERY_TASK_SOFT_TIME_LIMIT — though an env change
also rolls a new revision.
Override coupling: the Job deadline (
PREPARE_DB_AKS_JOB_TIMEOUT_SECONDS, read in the route) and the poller ceiling (PREPARE_DB_AKS_JOB_POLL_MAX_SECONDS, read in the worker) are independent envs. If you raise the Job deadline beyond the poll ceiling without also raising the ceiling, the poller will declare its owntimed_outbefore the Job finishes — degrading back to a premature "partial" (the Job still completes idempotently in K8s, so it is recoverable, not data loss). Raise both together, and keepPREPARE_DB_AKS_TASK_TIME_LIMITabove the poll ceiling.
Future work (out of scope here)¶
_poll_copy_completionaccounts uncommitted-but-in-progress blobs asfailed: missing; the resume/retry path already re-runs them idempotently, but the wording is misleading. A "still copying" vs "genuinely missing" distinction would improve the partial message.- Per-index resilience:
backoffLimitis Job-wide (capped<= 5by an existing test).backoffLimitPerIndexwould isolate a single persistently-bad file from dooming an otherwise-complete large Job.
Validation¶
uv run pytest -q api/tests/test_prepare_db_aks_manifest.py api/tests/test_prepare_db_aks_task.py api/tests/test_prepare_db_aks_route.py→ 52 passed.- New regression guard
test_task_time_limits_outlive_job_poll_and_deadlineasserts the full ladder (job deadline ≤ poll cap < soft < hard, hard > 3600). - Renamed
test_manifest_default_active_deadline_is_4_hoursasserts the 14400 default in both the constant and the rendered manifest. uv run pytest -q api/tests(full suite) +uv run ruff check api.