Skip to content

infra(api): single instance, non-blocking prewarm, OG renders off the loop - #11828

Merged
MarkusNeusinger merged 3 commits into
mainfrom
infra/api-single-instance
Sep 11, 2026
Merged

infra(api): single instance, non-blocking prewarm, OG renders off the loop#11828
MarkusNeusinger merged 3 commits into
mainfrom
infra/api-single-instance

Conversation

@MarkusNeusinger

Copy link
Copy Markdown
Owner

Summary

  • anyplot-api runs 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 --concurrency alone 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 (revision anyplot-api-00131-s6t, created 2026-09-10 21:41Z); the pipeline promotes it with this PR's deploy.
  • The startup cache prewarm no longer blocks the port. uvicorn binds only after the lifespan returns, so the six awaited prewarm queries added a stable ~2.5 s to every cold start (2.0–3.6 s of an ~11 s start 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 instead of duplicating its query — and is cancelled on shutdown before the DB engine closes. init_db() stays awaited.
  • OG image compositing runs in a worker thread, at most two at once. The collage and branded-image endpoints called PIL inline in the event loop, stalling every other request on the instance for the 1–3 s a render takes; a few concurrent collages produced the 647 MiB memory peak of 2026-08-26. With a single instance both would hit the whole service, so the renders go through asyncio.to_thread behind a two-slot semaphore.
  • Small blocker fixed on the way (CLAUDE.md rule): a 0-byte MonoLisa cache file — what download_to_filename leaves 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. Plus docs/reference/performance.md describes the live services again (frontend min 0 / 512Mi since 2026-08-29, Cloud SQL db-custom-1-3840 on 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)

  • Concurrency 40, not higher: with max 1 the limit is the whole capacity, but the OG path is the one CPU-heavy request class and its own semaphore (2) is what bounds memory, not the request limit.
  • The prewarm keeps its lightest-first order and swallow-and-continue semantics; only the awaiting moved.
  • Font download cooldown 600 s, in-process (a dict keyed by cache filename). A per-instance negative cache, not a persisted one, so a fresh instance always tries once.

Test plan

  • uv run ruff check . && uv run ruff format --check . — clean
  • uv run --extra typecheck mypy api core --pretty — clean
  • uv 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)
  • New tests: the lifespan returns while the prewarm is still running and shutdown cancels it before 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-api against the branch code locally (shared prod DB, reads only): startup log shows Uvicorn running before the six Cache prewarm: … OK lines (prewarm finished 3.4 s after bind); /health, /libraries, /specs?limit=3, /stats, /plots/filter?limit=3 answer as before; /og/home.png 200 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.png 404; three uncached collages requested concurrently finished in 1.0–1.4 s while /health answered in 1.5–2 ms throughout (the event loop stays free)
  • api/cloudbuild.yaml parses; deploy step carries --min-instances=1 --max-instances=1 --concurrency=40; uv run python -m tools.changelog check --base origin/main passes
  • After merge: Cloud Build deploy-api green and the promoted revision shows maxScale 1, containerConcurrency 40; then a week of run.googleapis.com/request_count by 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

… 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
Copilot AI balanced review requested due to automatic review settings September 10, 2026 21:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread core/images.py Outdated
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

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.16129% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
core/images.py 91.89% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@MarkusNeusinger
MarkusNeusinger merged commit 94fac8a into main Sep 11, 2026
14 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the infra/api-single-instance branch September 11, 2026 20:00
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>
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>
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.

2 participants