infra(api): single instance, non-blocking prewarm, OG renders off the loop - #11828
Merged
Conversation
… event loop anyplot-api runs as one instance (min 1, max 1, concurrency 40). Every scale-out was a cold start that a live request paid for: between 2026-08-29 and 09-10 Cloud Run started 464 extra instances (16-54 a day) while the warm one sat at a concurrency of ~2, because a page load fans out 4-6 API calls in the same instant, and it pinned one call of each burst to the new instance for its full 9-13 s start. That was 8-37 browser requests a day, referer anyplot.ai, median 11.3 s, on /libraries, /languages, /stats and /specs. With one instance a burst queues on the warm one for milliseconds. The limit has headroom: request p50 32 ms, CPU p95 6 %, more than 9 concurrent requests in about two minutes a day, no 429 in the window. The startup cache prewarm no longer blocks the port: uvicorn binds only after the lifespan returns, so the six awaited queries added a stable ~2.5 s to every cold start (2.0-3.6 s of ~11 s over 197 starts). It is now a background task that runs each key through get_or_set_cache, sharing the per-key lock with a request that arrives first, and it is cancelled on shutdown before the DB engine closes. OG image compositing runs in a worker thread behind a two-slot semaphore instead of inline in the event loop, where a 1-3 s render stalled every other request on the instance and a few concurrent collages produced the 647 MiB memory peak of 2026-08-26. Also: an empty MonoLisa cache file (the leftover of a failed download) no longer disables the brand font, a failed download deletes its leftover and is retried once per cooldown, and docs/reference/performance.md describes the live services again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CELiZYpFBQc5bncjWXYrue
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Concurrent render threads can race while downloading and deleting the same cached font file.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Pins the API to one warm instance, moves startup prewarming and image rendering off critical paths, and hardens font caching.
Changes:
- Configures single-instance Cloud Run deployment with concurrency 40.
- Runs cache prewarming asynchronously and bounds threaded OG rendering.
- Handles empty or failed font downloads and updates tests and documentation.
File summaries
| File | Description |
|---|---|
api/cloudbuild.yaml |
Updates Cloud Run scaling and concurrency. |
api/main.py |
Makes cache prewarming non-blocking and cancellable. |
api/routers/og_images.py |
Offloads PIL rendering with bounded concurrency. |
core/images.py |
Validates cached fonts and adds download cooldowns. |
tests/unit/api/test_main.py |
Tests prewarming and lifespan behavior. |
tests/unit/core/test_images.py |
Tests invalid cache files and failed downloads. |
docs/reference/performance.md |
Documents current production configuration. |
changelog.d/api-single-instance.md |
Records the operational changes and fixes. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Two render threads could miss the cache in the same instant, download to the same path, and a failing one could unlink the file its peer had just published. Check, download and publish now run under one re-entrant lock, the bytes land in a .part file that is renamed into place, and only that partial file is ever removed. Covered by a two-thread test (one download, one usable font, a later failure leaves the published file alone). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CELiZYpFBQc5bncjWXYrue
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This was referenced Sep 11, 2026
MarkusNeusinger
added a commit
that referenced
this pull request
Sep 11, 2026
## Summary - **`anyplot-app` runs as a single instance with concurrency 80** (was max 3, concurrency 15; `min-instances` stays 0). A static nginx never needed a second instance for capacity — its files answer in milliseconds and it sits at 15 MiB p99 of 512Mi — yet every crawler burst filled the 15 slots and started one or two more instances (50 AUTOSCALING starts in the ten days to 2026-09-10), because nginx holds a proxied bot request open for as long as the API takes to answer. One forged-UA burst on 2026-09-09 filled the instance and started two more. With the API on one warm instance (#11828) those waits are milliseconds, and one nginx with 80 slots absorbs the burst without a 0.26 s cold start. - Owner decision 2026-09-11: one instance per service in both projects (the sibling PR in kurrentschrift does the same there). If 429s ever appear, max 2 is the next step. - `docs/reference/performance.md` still says max 3 / concurrency 15 for the frontend after this PR: #11828 edits the same table row, so the row is updated in a follow-up once both have merged, to keep the two PRs from conflicting. ## Plan N/A — measurements in the Todoist task "Nachprüfen: merkt jemand, dass anyplot-app auf null skaliert?" (closed 2026-09-10 with the numbers) and in #11828. ## Test plan - [x] `app/cloudbuild.yaml` parses; the deploy step carries `--min-instances 0 --max-instances 1 --concurrency 80` - [x] `uv run python -m tools.changelog check --base origin/main` passes - [x] No image or nginx change: the app image and the origin-gate smoke in CI are unaffected; the candidate smoke in Cloud Build probes the new revision before traffic moves - [ ] After merge: Cloud Build `deploy-app` green; `gcloud run revisions describe` on the promoted revision shows `maxScale 1`, `containerConcurrency 80`; then two weeks of `run.googleapis.com/request_count` by response_code (zero 429s expected) and instance-start logs (AUTOSCALING starts should stop) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CELiZYpFBQc5bncjWXYrue --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
MarkusNeusinger
added a commit
to MarkusNeusinger/kurrentschrift
that referenced
this pull request
Sep 11, 2026
) ## Summary - **`kurrentschrift-api`: max 3 → 1, concurrency 15 → 30, min stays 1.** The scale-outs were the cold starts that `min=1` had left. In the ten days to 2026-09-10 every one of the 14 AUTOSCALING starts was the owner's admin page firing 10–30 calls at once while the warm instance sat at a concurrency of 2–3; Cloud Run pinned one or two calls of each burst to a NEW instance for its full 8–14 s start (eleven such admin waits in that window, e.g. 2026-09-06 21:07:24 `/word-samples/Galoppi…` 11.2 s and `/word-samples/Gaul` 11.0 s on two brand-new instances while the warm one answered the other 30 calls in 0.1–1.3 s). Public visitors were never among them. With one instance the burst queues on the warm one for a few hundred milliseconds; the heavy endpoints already run in `run_in_threadpool`, so 30 in flight is 30 threads on one core, not a stall of the event loop. 30 rather than anyplot's 40 because this service has 512Mi (p99 250 MiB since `min=1`). - **`kurrentschrift-app`: concurrency 15 → 80, max stays 1, min stays 0.** With `max=1` the slots are the whole capacity, and nginx holds a proxied crawler request open for as long as the API takes to answer; 15 was a default a single bot burst could fill. - **The comment that argued for max 3** ("min=1 with max=1 forces a deploy to REPLACE the only instance") predates the candidate chain: `--max-instances` is per revision (gcloud's own flag help), so the candidate warms its own instance in the smoke before promote moves traffic. The claim is rewritten with the measurements; if 429s ever appear, max 2 is the next step. - Owner decision 2026-09-11: one instance per service in both projects. Sibling changes in anyplot: MarkusNeusinger/anyplot#11828 (API) and the app PR there. ## Plan N/A — measurements from the 2026-09-10 Cloud Run check across both projects (Monitoring API, request logs, instance-start logs). ## Test plan - [x] Both `cloudbuild.yaml` files parse; the deploy steps carry `--min-instances=1 --max-instances=1 --concurrency=30` (api) and `--min-instances 0 --max-instances 1 --concurrency 80` (app) - [x] `python3 -m tools.changelog check --base origin/main` passes - [x] No code or image change; the candidate smoke in both Cloud Build chains probes the new revision before traffic moves - [ ] After merge: both Cloud Builds green; `gcloud run revisions describe` on the promoted revisions shows `maxScale 1` and `containerConcurrency 30` / `80`; a deploy produces no zero-instance minute for the API (`instance_count`); then two weeks of `request_count` by response_code (zero 429s expected) and instance-start logs (AUTOSCALING should stop; the admin bursts should show sub-second queueing instead) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CELiZYpFBQc5bncjWXYrue --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2 tasks
MarkusNeusinger
added a commit
that referenced
this pull request
Sep 13, 2026
…11830) ## Summary - `docs/reference/performance.md`: the frontend row of the infrastructure table now says max-instances=1, concurrency 80, matching `app/cloudbuild.yaml` after #11829. #11828 and #11829 edited the same row from different branches, so this line was left behind on purpose to keep the two from conflicting. ## Plan N/A ## Test plan - [x] `uv run python -m tools.changelog check --base origin/main` passes - [x] Docs-only change; no code touched 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CELiZYpFBQc5bncjWXYrue --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
anyplot-apiruns as a single instance (min 1, max 1, concurrency 40). Every scale-out was a cold start that a live request paid for: between 2026-08-29 and 09-10 Cloud Run started 464 extra instances (16–54 a day) while the warm one sat at a concurrency of ~2, because a page load fans out 4–6 API calls in the same instant, and it pinned one call of each burst to the new instance for its full 9–13 s start — 8 to 37 browser requests a day (referer anyplot.ai, median 11.3 s) on/libraries,/languages,/stats,/specs. With one instance a burst queues on the warm one for milliseconds. Headroom: request p50 32 ms, CPU p95 6 %, more than 9 concurrent requests in ~2 minutes a day, no 429 in the window. Raising--concurrencyalone would not have helped (the starts never came from the concurrency target); it is raised so the cap has headroom. If 429s ever appear, max 2 is the next step. The live service already carries this configuration (revisionanyplot-api-00131-s6t, created 2026-09-10 21:41Z); the pipeline promotes it with this PR's deploy.get_or_set_cache— sharing the per-key lock with a request that arrives first instead of duplicating its query — and is cancelled on shutdown before the DB engine closes.init_db()stays awaited.asyncio.to_threadbehind a two-slot semaphore.download_to_filenameleaves behind when the fetch fails — was accepted as the cached font, so PIL failed on every render and the OG cards silently fell back to DejaVu for the life of the instance (and the swash test failed locally instead of skipping). Empty files now count as missing, a failed download deletes its leftover, and the failure is remembered in-process for ten minutes so a GCS outage costs one attempt per cooldown rather than one per render. Plusdocs/reference/performance.mddescribes the live services again (frontend min 0 / 512Mi since 2026-08-29, Cloud SQLdb-custom-1-3840on a 3-year commitment).Plan
N/A — the measurements behind this change (Cloud Monitoring, request logs, BigQuery billing export, twelve independent re-verifications) are recorded in the Todoist tasks "Nachprüfen: merkt jemand, dass anyplot-app auf null skaliert?" and "api/Dockerfile verschlanken" (both closed 2026-09-10 with the numbers).
Decisions taken here (routine, flagged for override)
Test plan
uv run ruff check . && uv run ruff format --check .— cleanuv run --extra typecheck mypy api core --pretty— cleanuv run pytest tests/unit tests/integration— 2008 passed, 1 skipped (MonoLisa italic not cached locally; the skip is the fixed behaviour, it used to fail on the 0-byte file)close_db; a key a request already computed is not recomputed by the prewarm; an empty cache file counts as missing; a failed download leaves no file and is not retried within the cooldown/verify-apiagainst the branch code locally (shared prod DB, reads only): startup log showsUvicorn runningbefore the sixCache prewarm: … OKlines (prewarm finished 3.4 s after bind);/health,/libraries,/specs?limit=3,/stats,/plots/filter?limit=3answer as before;/og/home.png200 in 91 ms,/og/scatter-basic.png(collage) 200 in 1.23 s,/og/scatter-basic/python/matplotlib.png(branded) 200 in 0.73 s,/og/does-not-exist-xyz.png404; three uncached collages requested concurrently finished in 1.0–1.4 s while/healthanswered in 1.5–2 ms throughout (the event loop stays free)api/cloudbuild.yamlparses; deploy step carries--min-instances=1 --max-instances=1 --concurrency=40;uv run python -m tools.changelog check --base origin/mainpassesdeploy-apigreen and the promoted revision showsmaxScale 1,containerConcurrency 40; then a week ofrun.googleapis.com/request_countby response_code — zero 429s expected — and the API's own request log for browser requests ≥ 5 s (referer anyplot.ai), which should drop from 8–37/day to the deploy/replacement starts only (~1–2/day)🤖 Generated with Claude Code
https://claude.ai/code/session_01CELiZYpFBQc5bncjWXYrue