fix(publish): validate registries outside the transaction and time the pool wait - #1562
Open
rdimitrov wants to merge 4 commits into
Open
fix(publish): validate registries outside the transaction and time the pool wait#1562rdimitrov wants to merge 4 commits into
rdimitrov wants to merge 4 commits into
Conversation
…e pool wait Two changes to the publish path, both driven by the same measurement. Over 7 days the HTTP histogram recorded 17 publishes above 10s while the per-phase publish log recorded none above 6.5s, with validate_ms peaking at 3.9s. The two layers disagreed because the log could not see the time that mattered: InTransaction calls pool.Begin before invoking the transaction body, and the timing started inside that body. A publish that waited 40s for a free connection logged total_ms=200 and looked healthy. Time the pool wait. The gap between the timestamp taken before InTransactionT and the first line of its callback is exactly how long pool.Begin blocked, reported as pool_wait_ms. Move registry-ownership validation ahead of the transaction. It fans out to npm, PyPI, NuGet, Cargo, OCI and MCPB with a 10s timeout per host, and running it inside the transaction pinned a pgxpool connection for the duration of those round-trips. Under a publish burst - up to 276 in a 4-minute window - connections are then held by requests doing no database work at all, starving a pool that already sits at 150 of 200 connections and stalling unrelated requests in pool.Begin. The check reads none of our own state, so hoisting it introduces no race, and the per-server advisory lock was already acquired after validation. To keep one log event per publish, the timing and logging move up to CreateServer via a shared publishTimings value; createServerInTransaction fills in the phases that still run inside the transaction. Note total_ms now covers the whole CreateServer call rather than just the transaction body, so the residual after the named phases is commit plus overhead. No dashboard or alert rule consumes these log fields; all four Grafana rules are built on mcp_registry_* metrics. The edit path keeps its validation inside the transaction, with a comment explaining why: skipRegistryValidation is derived from a read inside that transaction, so hoisting it needs a second read and a decision about the resulting race. It carries a fraction of publish traffic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ting zero
pool_wait_ms was only assigned from inside the transaction callback, but
InTransaction skips that callback entirely when pool.Begin fails or when
the context is already cancelled. A publish that blocked on a starved pool
until its deadline therefore logged pool_wait_ms=0 with an empty
failed_phase — hiding the exact condition this instrumentation was added
to expose:
total_ms=151 validate_ms=0 pool_wait_ms=0 ... failed_phase="" \
error="context deadline exceeded"
Track whether the callback was entered. When it was not, record the full
elapsed wait and attribute it to a new pool_begin phase, so the worst
starvation reads as the longest wait rather than the shortest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d on /v0 and /v0.1 register the same publish handler against the same service instance, so nothing below the handler could tell them apart. The log's existing "version" field is the server's own semver, which reads as an API version but is not one — so a slow publish could not be attributed to a route at all. Tag the API version prefix onto the request context in the handler and report it as api_version. Callers that do not tag it — the importer, tests — report "unknown" rather than an empty value that reads as missing data. Only /v0/publish carries real traffic today (/v0.1/publish is at 0 req/s), but that changes as publishers migrate, and pool_wait_ms is only useful if we can say which route the slow publishes came in on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Moves registry validation outside publish transactions and expands latency attribution.
Changes:
- Validates ownership before acquiring a database connection.
- Adds transaction-start timing and structured publish logging.
- Tags and tests publish requests by API version.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
internal/service/registry_service.go |
Refactors publish validation, transaction timing, and logging. |
internal/service/registry_service_publish_transaction_test.go |
Tests transaction boundaries and timing logs. |
internal/api/handlers/v0/publish.go |
Adds API-version context attribution. |
internal/api/handlers/v0/publish_api_version_test.go |
Tests API-version logging end to end. |
Suppressed comments (1)
internal/service/registry_service.go:211
- A transaction commit failure leaves
gotConnectiontrue while no timed callback phase failed, so the deferred event logspublish failedwith an emptyfailed_phase.PostgreSQL.InTransactioncan return a commit error after the callback succeeds (internal/database/postgres.go:755-756). Attribute this case explicitly so the new outer logger does not emit an ambiguous failure.
if !gotConnection {
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…lures
Three findings from review, all valid.
pool_wait_ms claimed to isolate the wait for a free connection, but
pgxpool's BeginTx is Acquire followed by Exec("BEGIN") — so the figure was
acquisition plus one database round-trip. A slow database or network
inflated it and looked like pool starvation. Renamed to tx_begin_ms, with
the composition spelled out: BEGIN is normally sub-millisecond so a large
value still points at acquisition, but it is not proof on its own.
Isolating the two would mean timing pool.Acquire in the database layer.
A commit failure left the publish log reporting a failure with no phase.
InTransaction returns a commit error after the callback has already
succeeded, so every timed phase passes and failedPhase stays empty — the
same unattributed-failure gap as a failed Begin, at the other end of the
transaction. Attributed to a new commit phase.
The edit-path comment told maintainers to watch pool_wait_ms there, which
was impossible: only CreateServer builds publishTimings, so the edit path
emits no phase log at all. Points at the HTTP request-duration metric for
the edit routes instead.
Co-Authored-By: Claude Opus 5 (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.
Why
Publish latency has been the only alert firing in production — 1,285 times since 2025-09-11, while the read-path rules fired 6 times in total (last one 2025-09-14). Digging into where that time actually goes turned up a contradiction:
/v0/publish)createServerInTransaction)validate_ms— the external registry calls — peaks at 3.9s (p50 18ms, p99 1.1s). So the slow requests were spending their time somewhere the phase log could not see.The log had a blind spot exactly where the problem is.
InTransactioncallspool.Beginand then invokes the transaction body, but the timing started inside that body. A publish that waited 40s for a free connection loggedtotal_ms=200and looked healthy.What
Time the pool wait. The gap between a timestamp taken before
InTransactionTand the first line of its callback is exactly how longpool.Beginblocked. Reported aspool_wait_ms.Move registry-ownership validation ahead of the transaction. It fans out to npm, PyPI, NuGet, Cargo, OCI and MCPB with a 10s timeout per host. Running it inside the transaction pinned a pgxpool connection for the duration of those round-trips — so under a publish burst (up to 276 in a 4-minute window) connections are held by requests doing no database work at all, starving a pool already sitting at 150 of 200 connections and stalling unrelated requests in
pool.Begin.The check reads none of our own state, so hoisting it introduces no race, and the per-server advisory lock was already acquired after validation.
To keep one log event per publish, timing and logging move up into
CreateServervia a sharedpublishTimings;createServerInTransactionfills in the phases that still run inside.Note on
total_mstotal_msnow covers the wholeCreateServercall rather than just the transaction body, so the residual after the named phases is commit plus overhead. Nothing consumes these log fields — all four Grafana rules are built onmcp_registry_*metrics.Out of scope
The edit path keeps its validation inside the transaction, with a comment explaining why:
skipRegistryValidationis derived from a read inside that transaction, so hoisting needs a second read before it opens plus a decision about the resulting race. It carries a fraction of publish traffic — worth revisiting if it shows up inpool_wait_ms.Testing
New tests in
internal/service/registry_service_publish_transaction_test.go, using a smalldatabase.Databasedouble to observe the transaction boundary (which a real database cannot):pool_wait_mscovers timepool.Beginblocked — via a double whoseInTransactionsleepspublish failednamingfailed_phase=validateExisting tests pass unchanged.
go test ./...(12 packages),-raceclean on the service package,golangci-lint0 issues.Verifying this in production
The payoff is only visible once deployed:
pool_wait_mswill either confirm pool starvation as the mechanism or rule it out. If it comes back near zero on the slow publishes, the remaining suspects are JWT validation, body decode, and the per-request JSON schema compile (schema.go:196-210builds a fresh compiler per call — measured at 676µs and 882KB per call, deliberately not touched here).🤖 Generated with Claude Code