fix(serving): stop a usage-accounting write from 500-ing a served request - #172
Merged
Conversation
…uest
Every authenticated request appends an api_usage row from a worker thread,
and the analytics/admin routers build a throwaway store per request. Each
of those opened its own duckdb.connect() on the usage file. The last close
destroys the DuckDB instance, so a close racing an open leaves the file
attached by two instances and DuckDB raises
BinderException: Unique file handle conflict
That escaped AuthMiddleware and turned requests that had already succeeded
into 500s: the 2026-07-09 Load Test on main saw 19 of 1712 fail across all
six endpoints, every traceback ending in record_api_usage -> connect_duckdb.
Root cause: EmbeddedControlPlaneStore now holds one owning connection per
usage-db path for the life of the process and hands out .cursor() children,
the shape DuckDBPool already uses for the serving database. Call sites are
untouched — they still close() what they are given, and closing a cursor
leaves the connection alive. 80 concurrent usage writes: 80 connects -> 1.
Defence in depth: record_api_usage still raises on exhausted retries (its
caller depends on that to skip the audit publish), but the middleware now
catches it, counts agentflow_usage_record_failures_total, logs
api_usage_record_skipped, and serves the request. Accounting is a
side-channel and must not fail the request it was counting.
Four tests pinned the old per-call-connect mechanism rather than the
behaviour; they now drop the cached connection before injecting their
fault and assert on retry attempts instead of connect counts.
Verified: 1670 unit + 263 integration pass locally; the middleware
regression test fails on the pre-fix tree with the exact BinderException.
The race's timing reproduces only on the CI runner, so the new tests pin
the mechanism that removes it rather than the timing.
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. |
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.
The bug
Load Testonmainwent red afteraf20c9dand again after617bbb7. It was not runner noise and not a latency threshold: 19 of 1712 requests returned500, spread across all six endpoints, each with the same traceback:Every authenticated request writes an
api_usagerow from a worker thread, andanalytics.py/admin_ui.pybuild a throwaway store per request — each opening its ownduckdb.connect()on the same file. The last close destroys the DuckDB instance, so a close racing an open leaves the file momentarily attached by two instances, and DuckDB refuses. Nothing caught it, so a request that had authenticated and would have served a200came back a500.Not caused by the recent merges:
duckdbis1.5.4in both the red and green runs, and the sibling failure ataf20c9d(docs+compose only) has the identical signature with 38 occurrences. The green run in between has zero. It is chronic and load-dependent.The fix
Root.
EmbeddedControlPlaneStorekeeps one owning connection per usage-db path for the life of the process and hands out.cursor()children — the shapeDuckDBPoolalready uses for the serving database. There is no longer a destroy/recreate window to race. Call sites are untouched:_usage_cursor()is a drop-in forconnect_duckdb(...), and theconn.close()in eachfinallynow closes a cursor, leaving the connection alive.Measured on the store's own code path:
duckdb.connectcallsDefence in depth.
record_api_usagestill raises on exhausted retries —record_usagedepends on the exception to skip its audit publish, andstore.pydocuments that. But the exception stops atAuthMiddleware, which counts the newagentflow_usage_record_failures_total, logsapi_usage_record_skipped, and serves the request. Accounting is a side-channel; a dropped row must not fail the request it was counting. The port docstring now says so.Verification
tests/unit/test_auth_usage_write_failure.py— fails on the pre-fix tree with the exactBinderException, passes here.tests/unit/test_usage_db_connection_reuse.py— 8 threads × 8 stores × 10 writes open the database exactly once; cursor close leaves the connection usable; a poisoned connection is evicted and reopened.test_versionenv failure and the 4 Docker-gated integration errors are unrelated — both also occur on unmodifiedmain.connect_duckdbcalled once per retry). They now drop the cached connection before injecting their fault and assert on retry attempts rather than connect counts — the behaviour they guard is unchanged.Load Testruns on this PR (it touchessrc/**) — that lane is the real-world confirmation.What is not claimed
The race's timing does not reproduce on this workstation, on
duckdb1.5.1 or 1.5.4, with or without a synchronising barrier. So the regression tests pin the mechanism that removes the race (one connection, never destroyed) rather than the timing. The CI Load Test is the end-to-end check.🤖 Generated with Claude Code