fix(serving): take the api_usage write off the request path - #173
Merged
Conversation
The docs claimed the free tier keeps three cpu-basic Spaces awake. It does not: four Docker Spaces run concurrently under this account today, and HF refuses `POST .../restart` on a paused one with an explicit cpu-basic quota error. A second account is not an escape hatch either — creating another free Docker Space returns 402 (only static Spaces are free). So `ekb` and the standalone demo stay paused until a running Space is paused to make room. Two of the four running Spaces belong to other projects, so that trade is not this project's to make. Nothing about the deployment is missing; the constraint is concurrent compute, and it is now stated as measured rather than assumed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Load Test's bimodality was not the runner. Every authenticated request wrote its own api_usage row before the response was produced, and the embedded store serializes writers and commits per row -- so each request queued behind one fsync and the API was capped at `1 / commit_latency` requests per second. The load client is closed-loop (15 users, 0.1-0.5s think time), which gives it two equilibria: an unsaturated one bounded by think time (~46 rps), and a saturated one pinned at `rps = 1/s`, independent of user count. The runner's disk only decided which side of `s ~ 20ms` the commit landed on. That is why three red runs agreed to within 1.7% (29.4 / 29.1 / 28.9 rps) while nine green runs spread across 37-46, and why rps varied 1.5x while p99 varied 10x -- a slower machine cannot do that, a queue can. Reproduced on the shipped code: sleep(34ms) inside record_api_usage yields 31.4 rps / p50 160ms against CI's red branch of 29.1 / 161. Moving the write off the request path makes throughput flat in s (34ms -> 60ms: 37.9 -> 37.2 rps, versus 31.4 -> 8.3 in-path). The request now enqueues a UsageRow and returns; one background thread drains the queue. Batching is part of the fix, not a tuning knob: a per-row background writer still commits at 1/s rows per second, so the ceiling would just move from request latency into a queue that silently overflows. record_api_usage_batch puts one commit under N rows. Accounting is a side-channel. It already could not fail the request it counted; now it cannot pace it either. Durability moves from "committed before the response" to "committed shortly after": a crash loses at most the queued rows, api_usage backs one admin read and was already droppable on exhausted retries, and reads that must see their own writes call flush_usage. A full queue sheds rows into agentflow_usage_rows_dropped_total rather than stalling the request. test_auth_usage_write_failure.py is removed: it drove its failure through AuthManager.record_usage, which the request path no longer calls, so it guarded a call rather than a promise. Its promise -- a failed usage write must not fail the request -- is now test_request_succeeds_when_the_usage_write_raises, and the new suite additionally pins the defect itself (a slow write must not delay the response), batching, backpressure, and read-your-writes. Finding N1 needs no threshold normalisation: the gate was reporting a real defect. Full measurement in docs/perf/usage-write-bifurcation-2026-07-09.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DORA Metrics
MTTR note: No failed mainline CI runs in the selected window. |
Three call sites read api_usage; the first pass flushed two of them. The one
it missed, `KeyRotator.old_key_usage_by_key_id`, backs `GET /v1/admin/keys/
{id}/rotation-status`, and CI's integration suite caught it immediately:
`assert 1 == 2` — one row had been written, the other was still queued.
Move the flush to the reader seam rather than the callers, so a future reader
of the table cannot forget it. `AuthManager.list_keys_with_usage` no longer
flushes on its own: `KeyRotator._usage_by_key` does it on the way through.
The regression test calls the readers, never `manager.store` directly — a
store call is served by whatever the previous reader happened to flush, and
would have passed against the bug. Verified to fail with the flushes removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <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.
What
Two commits:
737795d— docs: state the real Spaces quota. The docs claimed the free tier keeps threecpu-basicSpaces awake. It keeps four running under this account today, andPOST .../restarton a paused one is refused with an explicit quota error. A second account is not an escape hatch: creating another free Docker Space returns402. Closes S5 through the DoD's "or quota is stated" branch, with the measured constraint rather than the assumed one.ab7257d— fix: take theapi_usagewrite off the request path. This is finding N1, and it turned out to be a code defect rather than runner noise.N1 — the Load Test was not bimodal because of the runner
Every authenticated request wrote its own
api_usagerow before the response was produced. The embedded store serializes writers and commits per row, so each request queued behind one fsync and the API was capped at1 / commit_latencyrps.The load client is closed-loop (15 users,
between(0.1, 0.5)think time), so it has two equilibria: an unsaturated one bounded by think time (~46 rps), and a saturated one pinned atrps = 1/s— independent of user count. The runner's disk only decided which side ofs ≈ 20 msthe commit landed on.Two things falsify the "slow runner" reading:
Reproduced on the shipped code
Serving the real API with the CI load profile and
sleep(s)injected intorecord_api_usage:sCI's red branch is 29.1 rps / p50 161 ms. Off-path throughput is flat in
s; in-path throughput is1/s.The fix
The request enqueues a
UsageRowand returns; one background thread drains the queue.Batching is part of the fix, not a tuning knob — a per-row background writer still commits
1/srows per second, below the request rate, so the ceiling would move from request latency into a queue that silently overflows.record_api_usage_batchputs one commit under N rows.What it trades
Durability moves from "committed before the response" to "committed shortly after". A crash loses at most the queued rows.
api_usagebacks one admin read (GET /v1/admin/usage) — not billing, not rate limiting — and rows were already droppable when the store exhausted its retries. Reads that must see their own writes callflush_usage; the lifespan closes the writer on shutdown. A full queue sheds rows intoagentflow_usage_rows_dropped_totalrather than stalling the request it was counting.Tests
tests/unit/test_usage_write_off_request_path.pypins the guarantee, not the mechanism: a slow write must not delay the response (verified to fail when the write is put back on the path), a failing write still serves the request and counts the drop, a failed batch publishes no audit event, batching coalesces, a full queue sheds, andflushgives read-your-writes.test_auth_usage_write_failure.pyis removed. It drove its failure throughAuthManager.record_usage, which the request path no longer calls, so it guarded a call rather than a promise; that promise is nowtest_request_succeeds_when_the_usage_write_raises.Verification
pytest tests/unit— 1678 passed. The one failure,test_version, reproduces on a clean tree (staleagentflow-client1.6.0 in the local venv) and is not touched by this diff.ruff check src/ tests/ scripts/ sdk/— clean;ruff format --check— 329 files formatted.Full measurement:
docs/perf/usage-write-bifurcation-2026-07-09.md.🤖 Generated with Claude Code