BLAST submit coordination — Phase 0¶
Motivation¶
The single Celery worker in the ca-elb-dashboard revision serializes dashboard
submits in-process, but that lock is not visible to the on-AKS elb-openapi
submit path, and the in-revision Redis broker is loopback-only (not reachable
from pods). So two independent submitters can race, and nothing caps the number
of concurrently running BLAST jobs against finite AKS capacity. The truth for
coordination must therefore live in the cluster (AKS etcd), not in the
dashboard process.
Phase 0 introduces a cluster-authoritative admission gate, shipped
default-OFF behind BLAST_COORD_BACKEND=k8s (Charter §12a Rule 4). With the
flag unset the submit path is byte-equivalent to the legacy
BLAST_GATE_ENABLED / submit-lock behaviour.
User-facing change¶
When BLAST_COORD_BACKEND=k8s is set on the api/worker sidecars:
- Gate A (submit mutex) — a Kubernetes
Lease(coordination.k8s.io/v1,elb-blast-submit-<namespace>,leaseDurationSeconds=900, resourceVersion CAS) serializes submits to one at a time. A job that cannot get the Lease is requeued (phase=waiting_for_submit_slot,countdown=30s) until a bounded deadline. - Gate B (run-concurrency ceiling, default 3) — counts DISTINCT
elb-job-idamong non-terminalapp=finalizerJobs. Over the ceiling → requeue (phase=waiting_for_capacity). Fail-closed: a count error releases the Lease and requeues rather than admitting blindly. - Both gates are per-namespace (
default). The Lease is released in afinallyblock after the submit completes; a hard SIGKILL leaks it only until the 900s TTL, and a startup invariant assertion guaranteessubmit_exec(600s) < soft_time_limit < hard_time_limitandsubmit_exec < lease_ttlso a submit can never outlive its own Lease silently. - The split (sharded) fan-out gates each child inline via
wait_for_k8s_admission(bounded by a small per-child cap AND a single parent wall-clock budget — see the hardening follow-up below), since children dispatch sequentially within one parent task and cannot Celery-requeue mid-fan-out.
§2a precedence: BLAST_COORD_BACKEND=k8s wins over BLAST_GATE_ENABLED — the
Redis capacity gate and submit lock are bypassed entirely (reserve_slot /
acquire_submit_lock never run).
⚠️ Production-enablement precondition (critique C1)¶
Do NOT set BLAST_COORD_BACKEND=k8s in a deployed environment until the
sibling dotnetpower/elastic-blast-azure on-AKS submit path acquires the SAME
Lease (elb-blast-submit-<namespace>) before it runs elastic-blast submit.
Phase 0 only serializes the dashboard submit path against itself. The headline "serializes both paths" is true only once the sibling repo also takes the Lease — until then, enabling the flag gives a false sense of mutual exclusion because the on-AKS path can still submit concurrently with a dashboard submit that holds the Lease. The flag stays default-OFF precisely so this precondition is met first (tracked cross-repo per Charter §13).
Critique-hardening follow-up (2026-06-04)¶
A 26-item design critique drove the following Phase-0 hardening (all still
default-OFF, no behaviour change when BLAST_COORD_BACKEND is unset):
- Fail-closed Lease liveness (M16) — a present-but-unparseable
renewTimeis now treated as held (not taken over); only a truly absentrenewTimekey counts as available. Prevents two submitters racing when the sibling repo writes a timestamp format we don't recognise. - Forbidden surfaced loudly (H7) — a
401/403on the Lease GET (admin kubeconfig rejected → credential rotation / apiserver network fault, NOT lock contention) raises a clearSubmitLeaseApiErrorinstead of looping forever. - Gate A/Gate B namespace consistency (C3) — the Gate B count now scopes to
the same raw namespace the Lease locks, instead of a
_namespace_or_defaultcluster-default lookup that could resolve to a divergent namespace and enforce the ceiling against the wrong population. - Label-less finalizer fail-closed count (M15) — a non-terminal
app=finalizerJob missing theelb-job-idlabel now counts as one occupied slot (synthetic per-Job key) instead of being skipped (which under-counted and over-admitted past the ceiling). - Split fan-out head-of-line bound (H4/H5) — each split child now waits at
most
BLAST_SPLIT_CHILD_GATE_WAIT_MAX_SECONDS(default 300s, far below the 1800s requeue-path budget), and the whole fan-out is bounded by a singleBLAST_SPLIT_PARENT_GATE_BUDGET_SECONDS(default 1800s) wall-clock budget computed ONCE before the loop, so a Celery retry of the parent cannot reset each child's wait window and monopolise the single worker. - Requeue de-sync jitter (H6/L25/L26) — the gate-deny
countdownadds +0..10s jitter around the 30s base (single source) and the inline split-wait sleep is jittered ±20%, so a herd of submitters denied at the same instant do not re-poll the apiserver in lock-step. - Explicit deadline carry (M11) — the requeue now carries both wait deadlines via explicit locals instead of relying on dict-key-overwrite order.
- Single exec-timeout source (M19) — the submit/split
terminal_exectimeout is now imported fromSUBMIT_EXEC_TIMEOUT_SECONDS(one constant) at both call sites instead of a literal600. - Invariant parses Celery limits unclamped (M18) —
assert_coordination_invariantsnow parsesCELERY_TASK_*_TIME_LIMITwith a bareint()(matchingcelery_app), so it validates the exact numbers the worker runs with rather than a clamped value.
Known Phase-0 tradeoffs (deferred by design)¶
These are documented rather than fixed in Phase 0 because the correct fix is heavier than the default-OFF gate warrants; they are revisited before the flag flips ON:
- Over-admit-on-lag (C2) — the finalizer becomes visible to Gate B only
after the
elastic-blast submitsubprocess returns; the Lease is released at the same point. Thefinalizer_grace_secondswindow mitigates the async batch-creation lag but does not fully close a sub-second admit overlap. Proper fix (count a "reserved" marker written before release) is Phase 1. - No job-id idempotency fencing (M12) — a duplicate submit of the same
job_idis not fenced at the Lease layer; the UI/route layer is expected to dedupe. Phase 1 may add a fencing token. - Split partial-failure compensation (M13) — when some split children fail the gate, already-submitted siblings are not rolled back; the parent reports partial failure and leaves cleanup to the operator.
- Late results-export finalizer undercount (M14) — a finalizer that a results-export step recreates late could momentarily undercount; bounded by the same grace window.
- Grace (300s) vs Lease TTL (900s) window (M20) — a phantom finalizer past grace but within TTL is not counted; acceptable because the Lease still serialises submits during that window.
Critique items re-classified as non-issues¶
- Per-poll token storm (L23) — invalid. K8s sessions AND admin credentials
are pooled with a 5-min TTL in
api/services/k8s/client.py, so a poll loop does not mint a newlistClusterAdminCredentialper iteration. - RBAC-403 capability probe (H8) — intentionally skipped. The Lease verbs
run under the admin kubeconfig (
list_cluster_admin_credentials), which bypasses Kubernetes RBAC, so a Role-binding probe would test nothing the manifest-deploy path doesn't already exercise. A real Lease probe would have to mutate cluster state (create a probe Lease), violating the probe's read-only/no-mutate contract, so it is deliberately not added.
Critique round 5 — diverse-scenario pass, split fan-out partial-failure (2026-06-04)¶
A fifth diverse-scenario critique focused on partial-failure correctness of the split fan-out (what happens when a single shard's bookkeeping — not its submit — fails mid-loop). It surfaced one genuine HIGH defect.
Fixed — HIGH¶
- H5 — unguarded per-child state/history writes in the split fan-out could
trigger mass re-submission.
_dispatch_split_child_submitsruns every shard submit inside one Celery task. After each shard it wrote child status viarepo.update(...)/repo.append_history(...). Those writes hit Azure Storage Table and raise on a transient fault (429 throttle, network blip) —state/repository.py::update()does not swallow errors and itself triggers a second history write. An exception from any one of those ~11 write sites would escape_dispatch_split_child_submits, propagate to the submit task'sexcept → _retry_or_fail(max_retries=12), and re-run the entire fan-out — re-submitting every already-succeeded shard, up to 12×. Each re-submit creates a duplicate BLAST job and a duplicate finalizer, which also inflates the Gate B running-count ceiling. Blast radius: one throttled Table write on shard 7 of 10 re-submits shards 1–6 plus 8–10. Fix: a module-level best-effort wrapper_safe_child_state_write(action, *args, **kwargs)now guards all per-childrepo.update/repo.append_historycalls in the loop. The authoritative parent summary is the in-memorydispatchedlist (plain list appends, cannot throw); a write failure is logged at WARNING (fn + job_id + exc type) and the loop proceeds, so the submit always completes and no shard is skipped or duplicated. A stale child row is later healed by the beat reconciler. Best-effort writes were chosen over per-childtry/except: continuebecause the latter would skip a shard's actual submit on a pre-submit write failure (strictly worse).
Verified non-defects (left unchanged by design)¶
repo.create(...)at the top of the loop stays bare — it is already wrapped in its owntry/exceptthat fails the child cleanly before any submit.- Post-exec CLI parsing helpers (
_last_json,_result_error,_submit_success_status,_is_retryable_result,_retry_after) are defensive (handleNone,str()-coerce, internaltry/except) and cannot raise into the fan-out. Unchanged.
Validation¶
- New regression test
test_dispatch_split_child_submits_state_write_failure_does_not_abandon_siblingsuses aFlakyRepowhoseupdate/append_historyalways raiseRuntimeError("table throttled 429")and asserts both shards still reportrunningand both submits actually ran (no exception escapes, no shard skipped). uv run ruff check apiclean;uv run pytest -q api/tests→ 2750 passed, 3 skipped (the one full-suitetest_terminal_exec.py:: test_run_truncates_stdout_above_capblip is an unrelated parallel-execution flake — passes in isolation and on re-run; it exercises subprocess stdout truncation, a different subsystem).
Critique round 4 — diverse-scenario cold pass, observability/consistency (2026-06-04)¶
A fourth, deliberately diverse-scenario critique (apiserver outage vs. capacity contention, all-shards-fail blast radius, operator paging signal, parity with the regular submit path) surfaced one genuine LOW defect. The code is mature after three prior rounds, so the remaining finding is a consistency/observability gap rather than a design defect.
Fixed — LOW¶
- L4 — split fan-out collapsed apiserver outage and capacity contention into
one error bucket. In
_dispatch_split_child_submits, the per-shard gate wait caught every failure with a singleexcept Exception:that always seterror_code="blast_submit_gate_unavailable"and logged at INFO without the exception type. When the AKS apiserver (or the admin kubeconfig) is genuinely down, every shard fails identically, and an operator reading the audit log could not distinguish "cluster down → page now" from "cluster busy → it will drain." The regular submit path already makes this distinction (admission.error→ bounded retry vs. a plain deny → requeue), so the split path was inconsistent. Fix: split the handler into two clauses —K8sGateWaitTimeoutkeepserror_code=blast_submit_gate_unavailable(history detailsubmit_lease_wait_timeout, INFO log incl. exc type), while a genuineSubmitLeaseApiError/Lease/apiserver failure gets a distincterror_code=blast_submit_lease_api_error(history detailsubmit_lease_api_error, WARNING log with exc type + traceback).error_codeis a free-form string (truncated to 128 chars incelery_signals, no whitelist), so the new value is fully backward-compatible.
Verified non-defects (left unchanged by design)¶
- Split path bypasses the Gate B finalizer-count ceiling. Documented round-2 self-deadlock prevention — a split parent must not block on its own children's running slots. Unchanged.
- Lease TTL covers the submit subprocess, not the az-login/kubeconfig
prelude. The 300 s margin (
lease_ttl 900−submit_exec 600) absorbs the prelude; tightening it would risk premature expiry under slow ARM. Unchanged. - Pooled
session.close()is a no-op.finally: session.close()call sites are safe because the credential/session pool owns lifecycle and temp cert files. Unchanged.
Validation¶
- New regression test
test_dispatch_split_child_submits_lease_api_error_distinct_codeasserts the distinctblast_submit_lease_api_errorcode +submit_lease_api_errordetail; the existingtest_dispatch_split_child_submits_k8s_gate_timeout_fails_childkeeps thesubmit_gate_unavailablecontract and now also asserts thesubmit_lease_wait_timeoutdetail. uv run ruff check apiclean;uv run pytest -q api/tests→ 2724 passed, 3 skipped.
Critique round 3 — broad/cold audit, 20+ findings (2026-06-04)¶
A colder, wider self-critique across new scenarios (auth asymmetry, partial-failure/orphaning, namespace split-brain, fail-open counting, exception-masking, observability) surfaced two genuine HIGH defects plus a set of MEDIUM/LOW hardening items. Only real defects were changed; verified non-issues are recorded so they are not re-litigated.
Fixed — HIGH¶
- H-A — split child orphaned + siblings abandoned on exec raise.
_dispatch_split_child_submitsset each child tostatus="running"before the gate, then ranterminal_run(...)inside a baretry/finally. A raisedTerminalExecError(timeout, exec-server down,az/kubectlerror) propagated out of the function, abandoning every subsequent shard and leaving the failing child stuck atrunningforever. Fix: wrap the exec intry/except/finally— the exception now fails ONLY that child (error_code=terminal_exec_unavailable, audit historysubmit_exec_error), the loop continues to the next shard, and thefinallystill releases the shard's Lease. Test:test_dispatch_split_child_submits_raised_exec_fails_child_continues. - H-B — Gate A / Gate B credential asymmetry. Gate A (the Lease,
api/services/k8s/submit_lease.py) acquires via the RBAC-bypassing admin kubeconfig, while Gate B (k8s_count_active_blast_submissions) listed Jobs via the non-admin MI token. On a cluster where the shared MI lacksjobs:listRBAC, Gate B's list403s → the fail-closed contract requeues every submit forever even though Gate A keeps admitting — a cluster-wide submit outage from an auth split. Fix: Gate B now reads underadmin=True, the same cluster-truth credential as the Lease. Existing count tests stay green (they stub_get_k8s_session).
Fixed — MEDIUM¶
- M-A — namespace split-brain (latent).
submit_taskhard-codednamespace="default"while_dispatch_split_child_submitstook anamespace: strparameter — two independent literals that could drift so the two mutexes lock different Leases and never exclude each other. Fix: a singleSUBMIT_COORDINATION_NAMESPACE = "default"constant incoordination.pynow feeds both paths. - M-B — fail-OPEN terminal count. Gate B excluded any finalizer with
status.failed > 0as "terminal". Butfailedincrements per failed Pod attempt while the Job controller is still retrying (backoffLimit not exhausted) → a retrying finalizer was dropped from the count → under-count → over-admit past the ceiling. Fix: a count-local_finalizer_is_terminaltreats a finalizer as terminal ONLY when definitively done (succeeded>0,completionTimeset, or aComplete/FailedconditionTrue); a barefailed>0with no terminal condition is counted (fail-closed). The shared_job_is_terminal(used byk8s_check_blast_status) is left unchanged to avoid broad regressions. Tests:test_retrying_finalizer_counted,test_completion_time_is_terminal, updatedtest_skips_terminal. - M-D — release in
finallycould mask the real exception. The submit_taskfinallyre-calledget_credential()for the Lease release; if that raised it would mask the original submit exception being unwound. Fix: reuse the credential captured at acquire time and wrap the release so a best-effort failure logs and never escapesfinally.
Fixed — LOW¶
- L-A — advertised vs actual requeue delay mismatch. The state row wrote
retry_after_seconds=30(the bare base) while the task re-enqueued with the jittered 30–40 s countdown. Fix: compute the jittered countdown once and use the same value for both the row and the Celerycountdown.
Verified non-issues / deferred (recorded, no change)¶
- M-C (regular gate-deny audit parity) — the regular path already emits an
event="k8s_gate_deny"state event witherror_code; this is the audit-trail equivalent of the split path'sappend_history, so no change is needed. - Pooled credentials — admin kubeconfig + sessions are pooled (5-min TTL) in
api/services/k8s/client.py, so per-poll token storms are a non-issue. - Frontend deny phases are status-keyed (Running/Failed) in the SPA, so the
waiting_for_*phase strings are cosmetic, not a state-machine surface. - Lease CAS without
resourceVersionand second-409 release with no log are residual LOW edges retained as-is: both are best-effort paths whose worst case is one extra retry, well within Phase-0 risk tolerance.
After these fixes the worst remaining severity is LOW.
Critique round 2 — split fan-out Gate B self-deadlock (2026-06-04)¶
A second design-critique pass on the round-1 hardening found one HIGH defect and two MEDIUM liveness tightenings, all still default-OFF:
- HIGH — split fan-out self-deadlock via Gate B. The split parent dispatches
shards sequentially inside one task, and each shard's
app=finalizerJob is lifecycle-long. Routing every shard through the FULL gate (Gate A + Gate B) meant a split with more shards than the run-concurrency ceiling (default 3) would deadlock on itself: the earlier shards permanently occupy the slots the later shards block on, so every shard past the ceiling waits its whole per-child cap and then FAILS — and meanwhile a single multi-shard split could starve unrelated submitters out of all ceiling slots. Fix: split children now take Gate A only (acquire_k8s_admission(..., check_capacity=False)/wait_for_k8s_admission(..., check_capacity=False)). They still serialise through the submit Lease against every other submitter, but are not counted against the Gate B ceiling. A split is one logical submit; the split planner — not Gate B — bounds shard concurrency. Gate B continues to bound distinct submit jobs on the regular path (unchanged). - MEDIUM — wait loop took the Lease one extra time past the deadline.
wait_for_k8s_admissionnow pre-checks the deadline at the TOP of the loop, so an already-expired wait raisesK8sGateWaitTimeoutbefore issuing another submit-Lease CAS write (a wasted apiserver write that briefly blocks live submitters). - MEDIUM — retry sleep could overshoot a near deadline. The jittered retry
nap is now clamped to the remaining budget (
min(nap, remaining)), so the last poll lands at the deadline instead of up to ~6s past it. - LOW (observability) — split-child
error_codewas a human sentence. The state-rowerror_codeis now a greppableblast_submit_gate_unavailable; the human-readable cause (parent_gate_budget_exhausted/submit_lease_unavailable) moves to the historydetailfield.
New regression tests in api/tests/test_blast_k8s_gate.py:
test_gate_a_only_admits_without_counting (Gate B never consulted),
test_gate_a_only_still_busy_when_lease_held (Gate A still serialises),
test_wait_forwards_check_capacity, test_wait_predeadline_raises_without_acquire
(no Lease CAS past deadline), test_wait_clamps_sleep_to_remaining_budget.
API / IaC diff summary¶
New modules:
api/services/blast/coordination.py— pure config/invariants (coordination_backend,is_k8s_backend,max_run_concurrency,submit_lease_ttl_seconds, wait caps, grace/skew,assert_coordination_invariants).api/services/k8s/submit_lease.py—k8s_acquire_submit_lease/k8s_release_submit_lease(CAS, conditional, best-effort release that never raises).api/services/blast/k8s_gate.py—acquire_k8s_admission/release_k8s_admission/wait_for_k8s_admission(combines Gate A + Gate B).
Modified:
api/services/k8s/blast_status.py—k8s_count_active_blast_submissions(fresh uncached, fail-closed, phantom-slot liveness-bounded byfinalizer_grace_seconds).api/tasks/blast/submit_task.py— k8s gate block +finallyLease release; carries both wait deadlines through requeues so an oscillation between submit_slot_busy and capacity_full cannot reset either bound.api/tasks/blast/split_pipeline.py— per-child inline gating, Gate A only (check_capacity=False) so a split cannot self-deadlock on its own shards.api/celery_app.py—assert_coordination_invariants()at import (no-op unless k8s backend).
No IaC change in this repo for Phase 0. No new env var is added to the Container
App template yet (flag is unset = legacy behaviour). The sibling repo
dotnetpower/elastic-blast-azure must pin the same app=finalizer selector +
elb-job-id dedup and have its on-AKS submit path acquire the same Lease — see
the cross-repo tracking issue (Charter §13).
Validation evidence¶
uv run pytest -q api/tests/test_blast_coordination.py api/tests/test_blast_submit_lease.py api/tests/test_blast_k8s_gate.py api/tests/test_blast_gate_b_count.py→ green (incl. new fail-closed liveness, 401/403 forbidden, label-less finalizer count, unclamped invariant, and split gate-cap default cases).uv run pytest -q api/tests/test_blast_submit_capacity_gate.py api/tests/test_blast_tasks.py→ green (incl. k8s §2a precedence, capacity-full requeue with jitter, submit-slot-busy requeue, lease-API-error retry, split-child gating, and parent-budget-exhausted fail-fast cases).uv run pytest -q api/tests→ full suite green.uv run ruff check api→ all checks passed.
All code paths are default-OFF; with BLAST_COORD_BACKEND unset the existing
suites remain green, confirming backward compatibility.