Skip to content

Commit 27af672

Browse files
rdimitrovclaude
andauthored
fix(list): use row-constructor cursor + restore per-phase publish timings + enable pg_stat_statements (#1215)
## Summary Three changes from the 2026-04-27 incident: 1. **Cursor SQL fix** — `addCursorCondition` rewritten to use a row-constructor comparison `(server_name, version) > ($1, $2)` instead of the OR-decomposed form. Postgres can index-seek on the row constructor; it can't on the OR form, which scanned from the start of the index and filtered rows before the cursor. Cost grew linearly with cursor depth. 2. **Per-phase publish timings restored** — `createServerInTransaction` was simplified during #1211 review to log only `validate_ms`. The incident showed publishes spending 50+ s in `acquire_lock` / `version_checks` / `db_create` while `validate_ms` reported a few hundred ms — the diagnostic signal was hidden. Restored timings for every phase, refactored into a small `runPhase` helper. 3. **`pg_stat_statements` enabled** in the CNPG cluster spec. We had no aggregate query visibility during the incident — could only EXPLAIN queries we happened to suspect. ## Cursor fix evidence Local benchmark, 100K rows, cold cache: | Form | Rows filtered | Buffer reads | Time | |------|--------------:|-------------:|-----:| | OLD | 80,001 | 7,679 | 31.6 ms | | NEW | 2 | 1 | **0.05 ms** | Maps to prod's measured 8,911 buffer hits → 760 ms. End-to-end API: `/v0/servers?limit=100&cursor=…` returns in 4–7 ms regardless of depth. Risk check: confirmed `(server_name, version)` index exists in that column order (both `servers_pkey` and `idx_servers_name_version`), and both columns are `NOT NULL` so row-constructor comparison can't silently drop rows. ## Tests - New `TestPostgreSQL_PerformanceScenarios/compound_cursor_across_versions_of_same_server` pins multi-version pagination semantics. The existing cursor tests only exercised the fallback (single-component cursor) and degenerate (one version per server) cases. - `make lint` clean, `go test -race ./internal/... ./cmd/...` green. ## Deployment The cursor + slog changes are zero-downtime. The CNPG spec change triggers a brief PG restart (single-instance cluster). After the restart, run once on prod: ```bash kubectl exec -i registry-pg-1 -c postgres \ --context gke_mcp-registry-prod_us-central1-b_mcp-registry-prod \ -- psql -U postgres -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements" ``` Time the merge for a low-traffic UTC window. v1.7.1's DB retry-with-backoff covers the brief PG restart. ## Out of scope `MaxConns` bump, per-IP rate limiting at nginx, response caching, the pre-existing `superfluous WriteHeader` warnings — separate follow-ups. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 762a484 commit 27af672

4 files changed

Lines changed: 232 additions & 50 deletions

File tree

deploy/pkg/k8s/postgres.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ func DeployPostgresDatabases(ctx *pulumi.Context, cluster *providers.ProviderInf
5959
"storage": map[string]any{
6060
"size": "50Gi",
6161
},
62+
// Enable pg_stat_statements so we can attribute slow time to specific
63+
// queries (the 2026-04-27 incident took an EXPLAIN-the-one-query-I-saw
64+
// approach because we had no aggregate visibility). CNPG triggers a PG
65+
// restart on shared_preload_libraries change — with instances: 1 this
66+
// is brief downtime. CREATE EXTENSION still needs to run once as a
67+
// superuser on existing clusters; new clusters get it via the
68+
// postInitApplicationSQL hook below.
69+
"postgresql": map[string]any{
70+
"shared_preload_libraries": []any{"pg_stat_statements"},
71+
"parameters": map[string]any{
72+
"pg_stat_statements.track": "all",
73+
},
74+
},
75+
"bootstrap": map[string]any{
76+
"initdb": map[string]any{
77+
"postInitApplicationSQL": []any{
78+
"CREATE EXTENSION IF NOT EXISTS pg_stat_statements",
79+
},
80+
},
81+
},
6282
},
6383
},
6484
}, pulumi.Provider(cluster.Provider), pulumi.DependsOnInputs(cloudNativePG.Ready), pulumi.RetainOnDelete(true))

internal/database/postgres.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,16 @@ func buildFilterConditions(filter *ServerFilter, argIndex int) ([]string, []any,
127127
return conditions, args, argIndex
128128
}
129129

130-
// addCursorCondition adds pagination cursor condition to WHERE clause
130+
// addCursorCondition adds pagination cursor condition to WHERE clause.
131+
//
132+
// The compound cursor uses a row-constructor comparison so PostgreSQL can seek
133+
// directly into the (server_name, version) B-tree index. The OR-decomposed form
134+
// `server_name > X OR (server_name = X AND version > Y)` is logically equivalent
135+
// but PostgreSQL's planner cannot use it for an index seek — it scans the index
136+
// from the start and filters everything before the cursor, making cost grow
137+
// linearly with cursor depth (a 20K-row table at a deep cursor took ~760ms in
138+
// prod). The row-constructor form `(server_name, version) > (X, Y)` is special-
139+
// cased and stays constant-time regardless of cursor depth.
131140
func addCursorCondition(cursor string, argIndex int) (string, []any, int) {
132141
if cursor == "" {
133142
return "", nil, argIndex
@@ -138,9 +147,8 @@ func addCursorCondition(cursor string, argIndex int) (string, []any, int) {
138147
if len(parts) == 2 {
139148
cursorServerName := parts[0]
140149
cursorVersion := parts[1]
141-
// Use compound condition: (server_name > cursor_name) OR (server_name = cursor_name AND version > cursor_version)
142-
condition := fmt.Sprintf("(server_name > $%d OR (server_name = $%d AND version > $%d))", argIndex, argIndex+1, argIndex+2)
143-
return condition, []any{cursorServerName, cursorServerName, cursorVersion}, argIndex + 3
150+
condition := fmt.Sprintf("(server_name, version) > ($%d, $%d)", argIndex, argIndex+1)
151+
return condition, []any{cursorServerName, cursorVersion}, argIndex + 2
144152
}
145153

146154
// Fallback for malformed cursor - treat as server name only for backwards compatibility

internal/database/postgres_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,104 @@ func TestPostgreSQL_PerformanceScenarios(t *testing.T) {
11501150
// Should have retrieved all servers including the ones we just created
11511151
assert.GreaterOrEqual(t, len(allResults), serverCount)
11521152
})
1153+
1154+
t.Run("compound cursor across versions of same server", func(t *testing.T) {
1155+
// Insert multiple versions of two servers so cursor pagination has to
1156+
// correctly seek across the (server_name, version) boundary. This is the
1157+
// case that the row-constructor cursor predicate `(server_name, version) > ($1, $2)`
1158+
// has to handle — the OR-decomposed form had the same semantics but a
1159+
// linear-scan plan; this test pins the semantic behaviour so a future
1160+
// rewrite can't silently break it.
1161+
serverA := "com.example/cursor-test-a"
1162+
serverB := "com.example/cursor-test-b"
1163+
versionsA := []string{"1.0.0", "2.0.0", "3.0.0"}
1164+
versionsB := []string{"1.0.0", "2.0.0"}
1165+
1166+
// First mark the latest version of each as latest=true; older ones false
1167+
// so the pkey + uniqueness constraints are satisfied.
1168+
mkServer := func(name, version string, isLatest bool) {
1169+
_, err := db.CreateServer(ctx, nil, &apiv0.ServerJSON{
1170+
Name: name, Description: "compound cursor test", Version: version,
1171+
}, &apiv0.RegistryExtensions{
1172+
Status: model.StatusActive,
1173+
StatusChangedAt: timeNow, PublishedAt: timeNow, UpdatedAt: timeNow,
1174+
IsLatest: isLatest,
1175+
})
1176+
require.NoError(t, err)
1177+
}
1178+
for i, v := range versionsA {
1179+
mkServer(serverA, v, i == len(versionsA)-1)
1180+
}
1181+
for i, v := range versionsB {
1182+
mkServer(serverB, v, i == len(versionsB)-1)
1183+
}
1184+
1185+
// Filter to just the two servers we just created so unrelated rows in the
1186+
// shared test DB don't bleed in.
1187+
filterTo := func(rs []*apiv0.ServerResponse) []*apiv0.ServerResponse {
1188+
out := make([]*apiv0.ServerResponse, 0, len(rs))
1189+
for _, r := range rs {
1190+
if r.Server.Name == serverA || r.Server.Name == serverB {
1191+
out = append(out, r)
1192+
}
1193+
}
1194+
return out
1195+
}
1196+
1197+
// Cursor at (serverA, "1.0.0") must skip 1.0.0 and return 2.0.0, 3.0.0,
1198+
// then both versions of serverB. Specifically tests the compound predicate:
1199+
// without it, the OR form would still skip 1.0.0 correctly but the version
1200+
// boundary of (serverA, "3.0.0") → (serverB, "1.0.0") is what depends on
1201+
// the second-column comparison.
1202+
results, _, err := db.ListServers(ctx, nil, nil, serverA+":1.0.0", 100)
1203+
require.NoError(t, err)
1204+
got := filterTo(results)
1205+
require.Len(t, got, 4, "expected 4 rows after cursor at A:1.0.0")
1206+
assert.Equal(t, serverA, got[0].Server.Name)
1207+
assert.Equal(t, "2.0.0", got[0].Server.Version)
1208+
assert.Equal(t, serverA, got[1].Server.Name)
1209+
assert.Equal(t, "3.0.0", got[1].Server.Version)
1210+
assert.Equal(t, serverB, got[2].Server.Name)
1211+
assert.Equal(t, "1.0.0", got[2].Server.Version)
1212+
assert.Equal(t, serverB, got[3].Server.Name)
1213+
assert.Equal(t, "2.0.0", got[3].Server.Version)
1214+
1215+
// Cursor at the *last* version of serverA must cross the server boundary
1216+
// and return serverB rows only.
1217+
results, _, err = db.ListServers(ctx, nil, nil, serverA+":3.0.0", 100)
1218+
require.NoError(t, err)
1219+
got = filterTo(results)
1220+
require.Len(t, got, 2, "expected 2 rows after cursor at A:3.0.0")
1221+
assert.Equal(t, serverB, got[0].Server.Name)
1222+
assert.Equal(t, "1.0.0", got[0].Server.Version)
1223+
assert.Equal(t, serverB, got[1].Server.Name)
1224+
assert.Equal(t, "2.0.0", got[1].Server.Version)
1225+
1226+
// Page-by-page traversal with size=2 must produce the same global ordering
1227+
// (A 1.0.0, A 2.0.0, A 3.0.0, B 1.0.0, B 2.0.0).
1228+
var paged []*apiv0.ServerResponse
1229+
cursor := ""
1230+
for {
1231+
rs, next, err := db.ListServers(ctx, nil,
1232+
&database.ServerFilter{SubstringName: stringPtr("cursor-test-")},
1233+
cursor, 2)
1234+
require.NoError(t, err)
1235+
paged = append(paged, rs...)
1236+
if next == "" || len(rs) < 2 {
1237+
break
1238+
}
1239+
cursor = next
1240+
}
1241+
require.Len(t, paged, 5)
1242+
want := []struct{ name, version string }{
1243+
{serverA, "1.0.0"}, {serverA, "2.0.0"}, {serverA, "3.0.0"},
1244+
{serverB, "1.0.0"}, {serverB, "2.0.0"},
1245+
}
1246+
for i, w := range want {
1247+
assert.Equal(t, w.name, paged[i].Server.Name, "row %d name", i)
1248+
assert.Equal(t, w.version, paged[i].Server.Version, "row %d version", i)
1249+
}
1250+
})
11531251
}
11541252

11551253
func TestPostgreSQL_NewStatusFields(t *testing.T) {

internal/service/registry_service.go

Lines changed: 102 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ import (
1818

1919
const maxServerVersionsPerServer = 10000
2020

21+
// Publish phase names emitted on the structured "publish complete"/"publish failed"
22+
// log from createServerInTransaction. Constants because version_checks is reported
23+
// from multiple branches.
24+
const (
25+
phaseValidate = "validate"
26+
phaseAcquireLock = "acquire_lock"
27+
phaseValidateRemoteURLs = "validate_remote_urls"
28+
phaseVersionChecks = "version_checks"
29+
phaseUnmarkLatest = "unmark_latest"
30+
phaseDBCreate = "db_create"
31+
)
32+
2133
// registryServiceImpl implements the RegistryService interface using our Database
2234
type registryServiceImpl struct {
2335
db database.Database
@@ -87,60 +99,105 @@ func (s *registryServiceImpl) CreateServer(ctx context.Context, req *apiv0.Serve
8799
}
88100

89101
// createServerInTransaction contains the actual CreateServer logic within a transaction.
90-
func (s *registryServiceImpl) createServerInTransaction(ctx context.Context, tx pgx.Tx, req *apiv0.ServerJSON) (*apiv0.ServerResponse, error) {
102+
//
103+
// Phases are individually timed and emitted as a single structured log event per call.
104+
// During the 2026-04-27 incident the validate-only timing (the previous shape) hid
105+
// pool-exhaustion stalls in acquire_lock / version_checks / db_create — we saw 50s+
106+
// total publish times even though validate_ms was a few hundred ms. With every phase
107+
// reported the next slow publish tells us which step to blame.
108+
func (s *registryServiceImpl) createServerInTransaction(ctx context.Context, tx pgx.Tx, req *apiv0.ServerJSON) (resp *apiv0.ServerResponse, err error) {
109+
start := time.Now()
91110
serverJSON := *req
111+
var (
112+
validateMs, lockMs, remotesMs, versionChecksMs, unmarkMs, createMs int64
113+
failedPhase string
114+
)
92115

93-
// Validate the request. ValidatePublishRequest fans out to npm/PyPI/OCI for
94-
// registry-ownership checks with 10s per-host timeouts, so it's the most likely
95-
// contributor to publish latency. Log validate_ms on both success and failure
96-
// so the next /v0/publish latency alert is diagnostic — a slow upstream that
97-
// times out into a validation error is exactly the case we need to see.
98-
validateStart := time.Now()
99-
validateErr := validators.ValidatePublishRequest(ctx, serverJSON, s.cfg)
100-
validateMs := time.Since(validateStart).Milliseconds()
101-
if validateErr != nil {
102-
slog.WarnContext(ctx, "publish validate failed",
116+
defer func() {
117+
attrs := []any{
103118
"server_name", serverJSON.Name,
104119
"version", serverJSON.Version,
120+
"total_ms", time.Since(start).Milliseconds(),
105121
"validate_ms", validateMs,
106-
"error", validateErr.Error(),
107-
)
108-
return nil, validateErr
122+
"lock_ms", lockMs,
123+
"remotes_ms", remotesMs,
124+
"version_checks_ms", versionChecksMs,
125+
"unmark_ms", unmarkMs,
126+
"create_ms", createMs,
127+
}
128+
if err != nil {
129+
attrs = append(attrs, "failed_phase", failedPhase, "error", err.Error())
130+
slog.WarnContext(ctx, "publish failed", attrs...)
131+
} else {
132+
slog.InfoContext(ctx, "publish complete", attrs...)
133+
}
134+
}()
135+
136+
// runPhase times fn into *ms and, on error, stashes the phase name + error
137+
// onto the closed-over failedPhase / err. Returns true on success so callers
138+
// can `if !runPhase(...) { return nil, err }`.
139+
runPhase := func(name string, ms *int64, fn func() error) bool {
140+
t := time.Now()
141+
e := fn()
142+
*ms = time.Since(t).Milliseconds()
143+
if e != nil {
144+
failedPhase = name
145+
err = e
146+
return false
147+
}
148+
return true
109149
}
110150

111-
publishTime := time.Now()
112-
113-
// Acquire advisory lock to prevent concurrent publishes of the same server
114-
if err := s.db.AcquirePublishLock(ctx, tx, serverJSON.Name); err != nil {
151+
// Validate the request — registry-ownership checks fan out to npm/PyPI/OCI with
152+
// 10s per-host timeouts. Was historically the most likely slow phase; now any
153+
// phase can be the slow one when the connection pool is starved.
154+
if !runPhase(phaseValidate, &validateMs, func() error {
155+
return validators.ValidatePublishRequest(ctx, serverJSON, s.cfg)
156+
}) {
115157
return nil, err
116158
}
117159

118-
// Check for duplicate remote URLs
119-
if err := s.validateNoDuplicateRemoteURLs(ctx, tx, serverJSON); err != nil {
120-
return nil, err
121-
}
160+
publishTime := time.Now()
122161

123-
// Check we haven't exceeded the maximum versions allowed for a server
124-
versionCount, err := s.db.CountServerVersions(ctx, tx, serverJSON.Name)
125-
if err != nil && !errors.Is(err, database.ErrNotFound) {
162+
// Acquire advisory lock to prevent concurrent publishes of the same server
163+
if !runPhase(phaseAcquireLock, &lockMs, func() error {
164+
return s.db.AcquirePublishLock(ctx, tx, serverJSON.Name)
165+
}) {
126166
return nil, err
127167
}
128-
if versionCount >= maxServerVersionsPerServer {
129-
return nil, database.ErrMaxServersReached
130-
}
131168

132-
// Check this isn't a duplicate version
133-
versionExists, err := s.db.CheckVersionExists(ctx, tx, serverJSON.Name, serverJSON.Version)
134-
if err != nil {
169+
// Check for duplicate remote URLs
170+
if !runPhase(phaseValidateRemoteURLs, &remotesMs, func() error {
171+
return s.validateNoDuplicateRemoteURLs(ctx, tx, serverJSON)
172+
}) {
135173
return nil, err
136174
}
137-
if versionExists {
138-
return nil, database.ErrInvalidVersion
139-
}
140175

141-
// Get current latest version to determine if new version should be latest
142-
currentLatest, err := s.db.GetCurrentLatestVersion(ctx, tx, serverJSON.Name)
143-
if err != nil && !errors.Is(err, database.ErrNotFound) {
176+
// Version checks: count, exists, current-latest (small DB lookups, but on a
177+
// starved pool any of them stalls until a connection is free). Bundled under
178+
// one phase since they share a logical step.
179+
var currentLatest *apiv0.ServerResponse
180+
if !runPhase(phaseVersionChecks, &versionChecksMs, func() error {
181+
versionCount, e := s.db.CountServerVersions(ctx, tx, serverJSON.Name)
182+
if e != nil && !errors.Is(e, database.ErrNotFound) {
183+
return e
184+
}
185+
if versionCount >= maxServerVersionsPerServer {
186+
return database.ErrMaxServersReached
187+
}
188+
versionExists, e := s.db.CheckVersionExists(ctx, tx, serverJSON.Name, serverJSON.Version)
189+
if e != nil {
190+
return e
191+
}
192+
if versionExists {
193+
return database.ErrInvalidVersion
194+
}
195+
currentLatest, e = s.db.GetCurrentLatestVersion(ctx, tx, serverJSON.Name)
196+
if e != nil && !errors.Is(e, database.ErrNotFound) {
197+
return e
198+
}
199+
return nil
200+
}) {
144201
return nil, err
145202
}
146203

@@ -161,7 +218,9 @@ func (s *registryServiceImpl) createServerInTransaction(ctx context.Context, tx
161218

162219
// Unmark old latest version if needed
163220
if isNewLatest && currentLatest != nil {
164-
if err := s.db.UnmarkAsLatest(ctx, tx, serverJSON.Name); err != nil {
221+
if !runPhase(phaseUnmarkLatest, &unmarkMs, func() error {
222+
return s.db.UnmarkAsLatest(ctx, tx, serverJSON.Name)
223+
}) {
165224
return nil, err
166225
}
167226
}
@@ -176,16 +235,13 @@ func (s *registryServiceImpl) createServerInTransaction(ctx context.Context, tx
176235
}
177236

178237
// Insert new server version
179-
resp, err := s.db.CreateServer(ctx, tx, &serverJSON, officialMeta)
180-
if err != nil {
238+
if !runPhase(phaseDBCreate, &createMs, func() error {
239+
var e error
240+
resp, e = s.db.CreateServer(ctx, tx, &serverJSON, officialMeta)
241+
return e
242+
}) {
181243
return nil, err
182244
}
183-
184-
slog.InfoContext(ctx, "publish complete",
185-
"server_name", serverJSON.Name,
186-
"version", serverJSON.Version,
187-
"validate_ms", validateMs,
188-
)
189245
return resp, nil
190246
}
191247

0 commit comments

Comments
 (0)