2026-05-29 — Auto-stop critique fixes wave A (#9.x / #10–#19)¶
Motivation¶
A self-critique pass on the auto-stop / NCBI surfaces flagged 11 hardening items grouped under GitHub issues #9–#19. This change ships waves 1-6 (NCBI quota + auto-stop route + evaluator + idle_autostop beat + file backend + AutoStopPanel UX) as one coherent backend+frontend fix so the round can close together.
User-facing change¶
- Toast on auto-stop save / extend failure (#9.1). Previously, if the backend rejected the toggle (RBAC mismatch, 503, …), the UI silently stayed "checked" while the server kept the old value. Now the panel rolls the optimistic toggle back to the last-committed server state and surfaces the error message via the global toast.
- No more empty
auto_stop.json.lock/auto_warmup.json.locksentinels (#14). The local-dev file backend stops creating a sibling.lockfile every time it saves. Replaced thefcntl.flockcross-process lock with an in-processthreading.Lockkeyed by state file path (file backend is always single-process — Container Apps uses the Table backend). - AutoStopPanel stops polling when no transition is possible (#9.8).
When the cluster is stopped, or when auto-stop is disabled and the
verdict is already
"disabled", the SPA stops the 60 s status poll entirely. It resumes automatically on the next user toggle or the nextclusterIsRunningflip. - AutoStopPanel converges instantly when the cluster stops (#17).
When the parent flips
clusterIsRunningfrom true to false (Start/Stop button, idle auto-stop tick, externalazCLI), the panel invalidates the status query so the warn-banner / countdown disappears immediately instead of waiting up to a minute for the next poll. - Concurrent auto-stop toggle no longer clobbers the user's edit (#12).
The idle_autostop beat used to read the preference at decision time
and write it back wholesale at rollback time. If the user toggled the
cluster off between the read and the write, the toggle would silently
reappear. The rollback path now re-fetches the row and only writes
last_stop_at/last_stop_reasononto the fresh copy. - "No jobs ever" auto-stop verdict is now stable (#9.2).
The evaluator used to anchor its "X minutes since enrolment" message
on
updated_at, which moved every time the beat ticked a warning → the displayed elapsed clock got reset every minute. It now anchors on the newcreated_atfield (stamped on first save), so the dashboard shows a monotonically increasing "Idle for Nm" message. - NCBI per-caller rate limit is now correct under dev-bypass and empty
oid (#10, #11, #13, #19).
Multiple local dashboards running under
AUTH_DEV_BYPASS=trueno longer share one quota bucket. Real callers without an oid get a 401 instead of being silently bucketed as "anonymous". The bucket map is nowOrderedDict-LRU bounded at 4096 keys, and a 5xx error refunds the timestamp the caller just spent so retries are not double-billed.
API / IaC diff summary¶
Backend (api/)¶
| File | Change |
|---|---|
| api/routes/ncbi.py | LRU-bounded caller buckets, dev-bypass UPN namespace, empty-oid 401, refund on 5xx (#10, #11, #13, #19) |
| api/routes/aks/autostop.py | async def + asyncio.to_thread for SDK calls, threading.Event singleflight, _pref_response returns DEFAULT_COOLDOWN_MINUTES instead of bare None, oid masking (#9.4, #9.5, #9.7, #9.9, #9.10) |
| api/services/auto_stop.py | New created_at field on AutoStopPreference, _file_backend_lock (per-path threading.Lock) replaces fcntl.flock + .lock sentinel (#9.2 schema, #14) |
| api/services/auto_warmup.py | Mirror of _file_backend_lock to eliminate auto_warmup.json.lock (#14 sibling) |
| api/services/auto_stop_evaluator.py | raw = pref.created_at or pref.updated_at anchor for "no jobs ever" (#9.2) |
| api/tasks/azure/idle_autostop.py | _batch_power_states now returns (states, summary); per-RG try/except with WARNING-level structured log; rollback re-fetches preference + writes only stop fields (#9.3, #12, #15) |
Frontend (web/)¶
| File | Change |
|---|---|
| web/src/components/ClusterItem/AutoStopPanel.tsx | useToast integration, lastCommittedRef for optimistic-rollback, refetchInterval returns false for !enabled && verdict==="disabled", useEffect invalidating statusKey on clusterIsRunning true→false (#9.1, #9.8, #17) |
Tests¶
| File | Change |
|---|---|
| api/tests/test_ncbi_nuccore.py | 9 new tests for bucket key namespacing, LRU cap, refund, empty-oid 401, module-load guard (#10, #11, #13, #19) |
| api/tests/test_auto_stop.py | test_file_backend_save_does_not_create_lock_sentinel — guards #14 |
| api/tests/test_auto_warmup.py | Same sentinel guard for warmup file backend |
| api/tests/test_auto_stop_task.py | _batch_power_states tests updated for new (states, summary) return shape; rg_groups/rg_failed/failed_rgs assertions added |
IaC¶
No infra changes in this wave.
Validation evidence¶
$ uv run pytest -q api/tests
....................................................................... [100%]
1883 passed, 3 skipped in 36.11s
$ cd web && npm test -- --run
Test Files 54 passed (54)
Tests 425 passed (425)
Duration 4.86s
$ uv run ruff check api
All checks passed!
$ cd web && npm run build
✓ built in 7.04s
Focused suites used during development:
uv run pytest -q api/tests/test_ncbi_nuccore.py→ 52 passeduv run pytest -q api/tests/test_aks_autostop_route.py→ 17 passeduv run pytest -q api/tests/test_auto_stop.py api/tests/test_auto_stop_task.py api/tests/test_auto_warmup.py api/tests/test_auto_stop_evaluator.py→ 66 passed
Self-review¶
- Consumer search for
_batch_power_statesreturned 2 direct call sites intest_auto_stop_task.py; both updated to unpack(states, summary). - Consumer search for
AutoStopPreference.created_atconfirmed backward-compat default (""→ falls back toupdated_atuntil next save). - Consumer search for
_save_file/_state_filein bothauto_stop.pyandauto_warmup.pyconfirmed no caller depends on a.locksentinel file existing. - Frontend:
AutoStopPanel.tsxuseToastimport is the same hook used elsewhere viaweb/src/main.tsxToastProvider— no new provider wiring needed. - Lint + build clean. Full backend + frontend suites green.