Skip to content

perf: fix performance audit findings and production-log bottlenecks - #47

Merged
jplacht merged 21 commits into
mainfrom
perf/audit-fixes
Sep 27, 2026
Merged

jplacht merged 21 commits into
mainfrom
perf/audit-fixes

Conversation

@jplacht

@jplacht jplacht commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the performance audit findings pinned by #46, plus the items from a week of production logs (Axiom, prunplanner_prod). One commit per fix. Every commit passes ruff check, ruff format --check, ty check and pytest (with the CI placeholder broker env). pytest --runxfail reports no remaining audit failures: 291 passed, 0 xfailed.

Audit fixes

# Fix Commit
1 Planning cache invalidation. Keys are PLANNING:{uid}:v{n}:.... Any plan, empire, junction or CX change bumps the user's counter once on commit (add, then incr). No more delete_pattern SCANs, no scan per cascaded junction, no *1:empire:retrieve* hitting users 11/101, and no stale CX list after plan or empire renames. sync_state no longer invalidates: update_state writes via a queryset update. ce8464d
2 Rebuild stampede. On a miss, get_or_set_response takes a cache.add lock (30s TTL). Waiters poll for up to 3s, then build themselves. f8f03bb
3 Planet multiple cache key is built from the sorted, de-duplicated ids. See the decisions below: no throttle, no id cap. 40972f8
4 Querysets. defer('empire_state') in the empire, plan, CX and shared views. CX views prefetch empires with their plans. Shared retrieve prefetches the plan's empires with CX and drops the refresh_from_db. 31aa677
5 Auth overhead. The API-key lookup joins the user (via a UserAPIKeyManager.get_usable_keys, so get_from_key stays the entry point). last_used is written at most every 5 min. The user pre_save reads the previous row once with .only(...). 3148f08
6 Snapshot task clears the flag with filter(pk, modified_at=<read>).update(...): no signals, no lost update. 23846a0
7 Gamedata cache correctness. private=True on CacheManager, used by the planning endpoints and FIO storage. The exchange CSV is cached as rendered bytes. latest_popr and the analytics aggregate retrieve check the cache first. import_all_buildings invalidates the building list. The planet list expires after 15 min (was 1 day) instead of being invalidated per import, since planets refresh about every 9s. ab9811f, 37abeaa
8a Webhook total_calls uses an F() update. 5847008
8b One shared httpx.Client per process, created lazily after fork and closed on worker_process_shutdown. get_fio_service() keeps its interface. Also covers log item 2. e647e2a
8c gamedata_refresh_user_fiodata(user_id) loads the credentials itself; the FIO key no longer goes through the broker. All callers are updated: dispatcher, user signals, user tasks, and the admin action. Old positional args are accepted and ignored for one release. 29e00e6
9a Fix the shadowed ticker in _get_recipe_distribution. 3b3405c
9b Drop idx_ticker_exchange (migration generated by makemigrations). 4ed1560
10 GZipMiddleware, placed after WhiteNoise. 2674ede
10 Delete the tests for the declined items (a, b, c). a4cb056

Production-log items

# Change Commit Tests
1 Non-full CXPC refreshes insert history only for a (ticker, exchange) pair with no rows yet. Full runs still insert everything. The last 3 days keep the upsert. Chord and callback are unchanged. 54cc7c9 tests/gamedata/test_tasks.py::TestRefreshCXPCHistory
2 Shared httpx client, see 8b. e647e2a tests/gamedata/fio/test_services.py::TestFIOServiceConnectionReuse
3 Token refresh queues the FIO refresh at most once, and not while the FIO lock is held (a cache read). The gamedata_clean_user_fiodata branch for users without credentials is removed. update_last_login() already triggered a queue through the user post_save signal, so the task no longer queues its own; the lock check lives in the signal. A credential change clears the lock first, so it still refreshes. b5f4480, c8ab05c tests/user/test_user_tasks.py (new), tests/user/test_user_signals.py::TestTriggerFioRefresh
4 Drop priority=10 on the verification and reset code emails, so the annotation's priority 1 applies. 0144997 test_verification_service.py::test_codes_are_sent_with_the_annotated_priority
5 GamePlanetViewSet.get_queryset() builds the queryset per request. The class attribute is removed; the generated OpenAPI schema is byte-identical without it. 84eacdc test_gamedata_viewsets.py::TestGamePlanetActiveCOGC
6 gamedata_refresh_planet drops the redundant update_refresh_result(), and the pending mark saves only automation_refresh_status. This also fixes a bug: import_planet returns False after recording an error, and the extra call overwrote that error with ok. 31d4c6f tests/gamedata/test_tasks.py::TestRefreshPlanetResult

Unchanged as required: the CXPC refresh (3h) and planet refresh (~9s) schedules, and worker --concurrency 1.

Decisions

  • (a) Plan list cx_data per empire: declined. No API change; test deleted.
  • (b) Shared view exposing the owner's empires and CX: declined. No API change; test deleted. The N+1 on this view is still fixed (4).
  • (c) BasicAuthentication removal: declined. It stays in the DRF defaults; test deleted.
  • (d) GZipMiddleware: accepted.
  • (e) Celery concurrency: stays at --concurrency 1, single queue. No change.
  • Planet search is not throttled or capped (owner's call). The audit's planet_search throttle and 100-id limit on multiple were dropped with their tests; only the cache-key change remains.

Notes for review

  • Planet list freshness. The planet list can be up to 15 min stale (it was up to 1 day). Per-import invalidation would rebuild the full list about every 9s, once per planet refresh.
  • needs_state_sync in cached empire payloads. PlanningEmpireDetailSerializer serializes it, so cached empire payloads can show a stale value now that state syncs and snapshot runs don't invalidate. It is an internal flag.
  • CSV cache key. The exchange CSV key is now ...:exchange:list:csv-v2, so JSON bytes cached by the old code are never served as CSV after deploy.
  • CXPC gaps. A non-full run no longer backfills history gaps older than 3 days for known pairs. A full run does.

Out of scope

  • Running the API under WSGI and SSE under ASGI. UvicornWorker has no concurrency cap in front of the DB pool.
  • The DEBUG = True default in base settings.
  • The unrelated Dependabot alerts on main.

🤖 Generated with Claude Code

jplacht and others added 19 commits September 27, 2026 11:23
…counter

Planning keys become PLANNING:{uid}:v{n}:...; any plan, empire, junction or
cx change bumps the user's counter once on commit (add, then incr). This
drops the full-keyspace SCANs, the scan per cascaded junction row, the
'*1:empire:retrieve*' match hitting users 11 and 101, and the stale cx list
after plan or empire renames.

sync_state no longer invalidates: update_state writes through a queryset
update, since empire_state is in no cached payload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_or_set_response takes a short rebuild lock with cache.add on a miss.
Concurrent misses poll the key for up to 3s instead of all rebuilding the
payload, and build themselves if the builder is slow or died.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The multiple endpoint builds its cache key from the sorted, de-duplicated
planet ids, so the same set of planets hits the cache regardless of request
order or repeated ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
empire_state is in no serialized payload, so empires loaded for the empire,
plan, cx and shared views defer it. The cx views prefetch empires with their
plans, and the public shared retrieve prefetches the plan's empires with cx
instead of querying cx per empire. Shared retrieve no longer reloads the
row after bumping view_count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API-key lookup joins the user instead of loading it lazily, and
last_used is written at most every 5 minutes instead of on every request.
The user pre_save signal reads the previous row once, with only the fields
it compares.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The snapshot task clears the flag with a queryset update conditioned on the
modified_at it read. It fires no post_save, so planning caches stay put,
and a state sync landing mid-run keeps the empire dirty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t lookups

- CacheManager.get_or_set_response takes private=True; planning endpoints
  and FIO storage send Cache-Control: private instead of public.
- The exchange CSV is rendered once on a miss and cached as bytes (new
  csv-v2 key so old JSON entries are never served as CSV); hits skip JSON
  parsing and CSV rendering.
- latest_popr and the analytics plan aggregate retrieve check the cache
  before touching the DB.
- import_planet invalidates the planet list, import_all_buildings the
  building list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
total_calls was a read-modify-write on the row loaded at request start and
lost concurrent increments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FIOService used a new httpx.Client per task, a fresh TLS handshake for each
of the ~2,200 CXPC requests per trigger. The client is now created lazily
once per process (after the prefork fork, never at import), reused across
tasks and closed on worker_process_shutdown. get_fio_service() keeps its
interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gamedata_refresh_user_fiodata takes only user_id and loads the credentials
itself. The dispatcher, user signals, user tasks and the admin action pass
the id only; the dispatcher reads just the user ids instead of full player
data rows. The old (prun_username, fio_apikey) positional args are accepted
and ignored for one release so already queued tasks still run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_get_recipe_distribution rebound `ticker` inside the recipe loop, so each
building's top recipes landed under the last recipe's prefix instead of the
building.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unique (ticker, exchange_code, date_epoch) constraint's index already
serves lookups by its leading columns; the extra index only cost writes on
every CXPC upsert. Migration generated with makemigrations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… rows

Non-full CXPC refreshes bulk-inserted the whole history with
ignore_conflicts on every run, ~2,200 times per trigger. History is now
inserted only on full refreshes or for a (ticker, exchange_code) pair with no
rows yet (one exists() check); the last 3 days keep their upsert. Chord and
callback are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
user_handle_post_refresh runs on every token refresh (~78k/week). It no
longer queues gamedata_refresh_user_fiodata while the user's FIO refresh
lock is held (a cache read; the task still takes the lock itself), and no
longer queues gamedata_clean_user_fiodata for users without credentials:
user signals already clean up when credentials are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ority

apply_async(..., priority=10) overrode the priority 1 from
CELERY_TASK_ANNOTATIONS. On the Redis broker 0 is the highest priority, so
the code emails were queued at the lowest one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GamePlanetViewSet.queryset was built at import, freezing now_ms in the
active COGC subquery (gunicorn preloads the app), so active programs were
computed against the process start time. get_queryset() now builds it per
request; the generated OpenAPI schema is unchanged without the class
attribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
import_planet records its own refresh result, so the task's second
update_refresh_result() was an extra save and cache invalidation every ~9s.
It also overwrote an error import_planet had recorded (it returns False
instead of raising) with status 'ok'. The pending mark now saves only
automation_refresh_status. Error handling is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Multi-MB list payloads (planets, exchanges) were sent uncompressed. The
middleware sits after WhiteNoise (which serves its own precompressed files)
and before everything else that touches the body. Streaming responses,
including the SSE stream, are compressed with a flush per chunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner kept the current behaviour for these, so their pinned tests go:
- plan list keeps nesting full cx_data per empire (frontend may rely on it)
- the public shared view keeps exposing the plan's empires and cx
- BasicAuthentication stays in the DRF defaults

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codacy-production

codacy-production Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 13 complexity · 0 duplication

Metric Results
Complexity 13
Duplication 0

View in Codacy

🟢 Coverage 97.79% diff coverage · +0.31% coverage variation

Metric Results
Coverage variation ✅ +0.31% coverage variation (-1.00%)
Diff coverage ✅ 97.79% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (bee9950) 3278 2958 90.24%
Head commit (37abeaa) 3322 (+44) 3008 (+50) 90.55% (+0.31%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#47) 136 133 97.79%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

jplacht and others added 2 commits September 27, 2026 11:49
update_last_login() saves the user, and the post_save signal already queued
gamedata_refresh_user_fiodata on every save, so user_handle_post_refresh
queued it a second time. The task now only updates last_login and leaves
queueing to the signal. The signal skips it while the refresh lock is held
(a cache read; a credential change clears the lock first, so it still
refreshes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…port

Invalidating the planet list on every import_planet meant rebuilding the
full list about every 9s, the planet refresh rate. The list is no longer
invalidated per import and expires after 15 min instead of 1 day, so it is
at most 15 min stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jplacht
jplacht merged commit a0333a9 into main Sep 27, 2026
6 checks passed
@jplacht
jplacht deleted the perf/audit-fixes branch September 27, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant