Skip to content

fix(publish): validate registries outside the transaction and time the pool wait - #1562

Open
rdimitrov wants to merge 4 commits into
mainfrom
rdimitrov/scale-read-throughput
Open

fix(publish): validate registries outside the transaction and time the pool wait#1562
rdimitrov wants to merge 4 commits into
mainfrom
rdimitrov/scale-read-throughput

Conversation

@rdimitrov

Copy link
Copy Markdown
Member

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:

Layer Publishes (7d) Over 10s Max
HTTP histogram (/v0/publish) 9,939 17 45.5s observed
Per-phase publish log (createServerInTransaction) 7,845 0 6.5s

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. InTransaction calls pool.Begin and then invokes the transaction body, but the timing started inside that body. A publish that waited 40s for a free connection logged total_ms=200 and looked healthy.

What

Time the pool wait. The gap between a 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. 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 CreateServer via a shared publishTimings; createServerInTransaction fills in the phases that still run inside.

Note on total_ms

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. Nothing consumes these log fields — all four Grafana rules are built on mcp_registry_* metrics.

Out of scope

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 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 in pool_wait_ms.

Testing

New tests in internal/service/registry_service_publish_transaction_test.go, using a small database.Database double to observe the transaction boundary (which a real database cannot):

  • validation failure opens no transaction — the invariant this change introduces
  • pool_wait_ms covers time pool.Begin blocked — via a double whose InTransaction sleeps
  • a validation failure still emits one publish failed naming failed_phase=validate
  • a failure raised inside the transaction is still captured by the now-outer deferred logger

Existing tests pass unchanged. go test ./... (12 packages), -race clean on the service package, golangci-lint 0 issues.

Verifying this in production

The payoff is only visible once deployed: pool_wait_ms will 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-210 builds a fresh compiler per call — measured at 676µs and 882KB per call, deliberately not touched here).

🤖 Generated with Claude Code

rdimitrov and others added 3 commits August 21, 2026 15:08
…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>

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.

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 gotConnection true while no timed callback phase failed, so the deferred event logs publish failed with an empty failed_phase. PostgreSQL.InTransaction can 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.

Comment thread internal/service/registry_service.go Outdated
Comment thread internal/service/registry_service.go Outdated
…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>
@rdimitrov
rdimitrov requested a review from pree-dew August 21, 2026 23:33
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