BLAST capacity gate — Stage 3 (submit-task wiring + Bicep env defaults)¶
Issue: #23 — AKS Capacity Gate for BLAST submit. Builds on: Stage 1 change note, Stage 2 change note, Stage 3a change note.
Motivation¶
Stage 1 shipped the pure capacity gate primitives. Stage 3a shipped the live
signal resolver (api.services.blast.capacity_signals). Stage 3 closes the
loop by wiring both into the BLAST submit Celery task — but does so behind a
default-OFF feature flag so the rollout is reversible per Charter §12a Rule 4.
Without the gate, BLAST submits serialize through a single per-cluster Redis
lock (acquire_submit_lock) which is equivalent to max_slots=1. Flipping
BLAST_GATE_ENABLED=true swaps that for the cluster-aware admission control
that watches CPU/memory request watermarks, counts pending pods, and reserves
a slot atomically before invoking elastic-blast submit.
User-facing change¶
Nothing visible by default. After the rollout owner sets
BLAST_GATE_ENABLED=true on the api / worker / beat sidecars:
- New job state phases the dashboard renders:
waiting_for_capacity(replaceswaiting_for_submit_slotonce the gate is the active admission control) — temporary, retryable; the dashboard surfacesretry_after_seconds: 30.rejected_capacity— hard reject,retry_after_seconds: 600. Used when a single submit exceeds the cluster's headroom by definition (e.g. estimated demand exceeds total cluster capacity).- Worker logs gain three structured events:
blast_gate_admit,blast_gate_deny,blast_gate_release, plus the race-loss lineblast_gate_reserve_lost.
API / IaC diff summary¶
api/tasks/blast/submit_task.py¶
- Added
_capacity_gate_enabled()helper — parsesBLAST_GATE_ENABLED(truthy values:1 true yes on, case-insensitive). Default OFF. - Wrapped the critical submit-time section in
if gate_enabled / else: - gate_enabled=True: calls
capacity_signals.resolve_capacity_signals→capacity_gate.list_active_reservations→predict_demand→evaluate_capacity_gate. On retryable deny:_update_statewith phase=waiting_for_capacity, requeues viasubmit.apply_asyncwithcountdown=30. On non-retryable deny: phase=rejected_capacity, status=failed. On admit:reserve_slot; if the atomic reserve loses the race (reserve_slotreturnsNone), treats it as a retryable deny witherror_code="capacity_reserve_lost". - gate_enabled=False (default): byte-equivalent to the previous
acquire_submit_lockpath — required by Charter §12a Rule 4. finallyblock: whensubmit_lockis present, releases the lock; whencapacity_reservationis present, callscapacity_gate.release_slotand emits ablast_gate_releaselog line.
infra/modules/containerAppControl.bicep¶
- Added
BLAST_GATE_ENABLED=falseto theenvlists of three sidecars (api,worker,beat). Default is intentionally'false'so a freshazd upships with the gate off; flipping the rollout to ON is an operational env edit (no Bicep change needed beyond bumping the default in a future PR). - The optional knobs read by
api.services.blast.capacity_gateandcapacity_signalsare documented inline next to the api sidecar entry:BLAST_GATE_MAX_SLOTS_PER_CLUSTER(default 1),BLAST_GATE_CPU_WATERMARK_PCT(default 75),BLAST_GATE_MEM_WATERMARK_PCT(default 75),BLAST_GATE_SIGNAL_CACHE_S(default 30).
Tests¶
- New:
api/tests/test_blast_submit_capacity_gate.py(7 tests). _capacity_gate_enabledparses truthy / falsey env values.- Gate-disabled (default): asserts
acquire_submit_lockis called once andcapacity_gate.{evaluate,reserve,release}_slotare never touched. - Gate-enabled admit:
reserve_slotis called with the predicted demand andrelease_slotruns in thefinallyblock. - Gate-enabled retryable deny: phase=
waiting_for_capacity, status=running,submit.apply_async(countdown=30, queue="blast")requeue. - Gate-enabled hard reject: phase=
rejected_capacity, status=failed, no requeue. - Reserve-lost race: admit decision but
reserve_slotreturnsNone— requeues witherror_code="capacity_reserve_lost".
Validation evidence¶
$ cd /home/moonchoi/dev/elb-dashboard && uv run pytest -q \
api/tests/test_blast_submit_capacity_gate.py \
api/tests/test_blast_capacity_gate.py \
api/tests/test_blast_capacity_signals.py \
api/tests/test_blast_tasks.py \
api/tests/test_terminal_exec_workdir.py
... 172 passed in ~4s ...
$ uv run ruff check api/tasks/blast/submit_task.py \
api/services/blast/capacity_signals.py \
api/tests/test_blast_capacity_signals.py \
api/tests/test_terminal_exec_workdir.py \
api/tests/test_blast_submit_capacity_gate.py
All checks passed!
Phase 1 / Phase 2 / Phase 3 rollout (operational, not code)¶
| Phase | Action | Owner |
|---|---|---|
| Phase 1 | Ship Stage 1 + Stage 3 with default OFF. Soak. | done in this change |
| Phase 2 | Set BLAST_GATE_ENABLED=true on api+worker+beat env. Verify dashboard shows waiting_for_capacity instead of waiting_for_submit_slot under contention. |
operator |
| Phase 3 | After ≥1 release cycle in Phase 2, optionally raise BLAST_GATE_MAX_SLOTS_PER_CLUSTER from 1. Blocker: see Stage 2 note — upstream elastic-blast's K8s metadata.name does not embed ${BLAST_ELB_JOB_ID} per submit, so concurrent submits in the same cluster/namespace collide today. Stage 2 deferred to upstream. |
requires upstream elastic-blast change |