feat(benchmark): pool benchmark harness with standalone mock server and per-API QPS tracking - #1518
feat(benchmark): pool benchmark harness with standalone mock server and per-API QPS tracking#1518Pangjiping wants to merge 17 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e533355c29
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9c3ecc7d36
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| repeat(cfg.steadyWorkers) { | ||
| threads.submit { | ||
| while (System.nanoTime() < deadline) { | ||
| pacer.waitForSlot() |
There was a problem hiding this comment.
Recheck the deadline after pacing each acquire
When --acquire-rate-per-min is low relative to --steady-workers and --steady-duration-s, workers can enter the loop before the deadline, reserve slots far in the future in RatePacer, and then still perform pool.acquire() after the scenario duration has expired. For example, 16 workers at 1 acquire/minute can reserve slots out to ~15 minutes during a 60-second run; awaitTermination(duration+300s) can return while non-daemon worker tasks are still sleeping/acquiring, so the benchmark duration and metrics are inflated and the process may keep running long after the configured scenario window.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 8c25c68: workers recheck the scenario deadline after RatePacer sleeps and break before acquiring past the run window.
| # 4. run the driver (Kotlin SDK is built from source via composite build, | ||
| # see kotlin/settings.gradle.kts) | ||
| echo "== running benchmark driver ==" | ||
| DRIVER_ARGS+=("--report-dir" "${RUN_DIR}") |
There was a problem hiding this comment.
Pass overridden mock address to the driver
When users set the documented LIFECYCLE_ADDR to run the mock on a non-default port or host, the script health-checks that address but never forwards it to the Kotlin driver, so Cli.parse falls back to http://127.0.0.1:18080 and the run fails unless the user redundantly supplies --mock-base-url. Add the derived http://${LIFECYCLE_ADDR} to DRIVER_ARGS (unless already provided) alongside the report dir so the orchestration works with the advertised environment override.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 8c25c68: run.sh now forwards the effective LIFECYCLE_ADDR to the driver as --mock-base-url unless the user pinned it.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f367f7b033
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| token := r.Header.Get("X-EXECD-ACCESS-TOKEN") | ||
| id := strings.TrimPrefix(token, "mock-token-") | ||
| if token != "" && token == execdToken(id) { |
There was a problem hiding this comment.
Reject execd requests without a valid endpoint token
When an execd request is missing X-EXECD-ACCESS-TOKEN or sends a malformed token, this condition is skipped and the handler falls through to a 200 response. That means any SDK/client path that drops endpoint headers will appear ready immediately, bypassing the mock's Pending/expired/poisoned checks and invalidating readiness, stale-idle, and cross-SDK header-propagation benchmark results; reject missing or invalid tokens instead of treating them as unauthenticated success.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 8c25c68: execd requests without a valid per-sandbox endpoint token are rejected with 401 instead of answered 200.
| } catch (t: Throwable) { | ||
| System.err.println("scenario $name failed: $t") | ||
| mapOf("error" to (t.message ?: t.toString())) |
There was a problem hiding this comment.
Clean up failed scenarios before continuing
When a scenario throws after starting its pool, this catch records the error and then proceeds to collect stats and run the remaining scenarios. For example, an unguarded acquire in replenish-lag can throw before its pool.shutdown, leaving reconcile/warmup threads and live mock sandboxes active so later QPS/alive metrics are contaminated and the JVM may keep running; either abort here or ensure every scenario shuts its pool down in finally before returning an error section.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 8c25c68: every scenario wraps its pool lifecycle in try/finally (return try { ... } finally { pool.shutdown(...) }), so a failed scenario cannot leak threads or mock sandboxes.
…er-API QPS tracking Standalone Go mock server (lifecycle + execd API per specs/) with configurable per-route response time (uniform/fixed/lognormal), fault injection (create failure, execd failure, poisoned sandboxes), server-side TTL expiry, and exact per-second per-API QPS tracking with full time series. Kotlin/JVM driver runs 7 reproducible scenarios against the mock (cold-start, warm-latency, steady-state, replenish-lag, failure-injection, stale-idle, idle-expiry) and writes JSON + Markdown reports including per-scenario server-side QPS. run.sh orchestrates SDK publish, mock start, and driver run; the mock is reusable from any SDK via ConnectionConfig.
…e build Replace the mavenLocal-published artifact dependency with includeBuild on sdks/sandbox/kotlin so the benchmark always runs the checked-out SDK code. Drops the publishToMavenLocal step from run.sh and the -PsandboxVersion injection.
…ainingTtl, primaryLockTtl, degradedThreshold) Map a production large-pool / high-frequency profile 1:1 onto driver options; document the example profile and scale caveats in README.
…ofile Keeps the high-frequency acquire case realistic without the 1-5s readiness probe capping the sustainable acquire rate.
…/replenish metrics PoolProbe samples JVM threads, heap/GC, and pool health every 500ms during warm-latency and steady-state. Scenarios now report successRate, replenishRatePerSec/killRatePerSec, directCreateRatio, and the client probe block; steady-state idle stats come from the probe.
run.sh creates results/run-<ts>/ holding the mock log, mock config, driver args, reports, per-scenario server timeseries CSVs (alive + per-API QPS) and client probe CSVs (threads/heap/idle/inFlight), plus a raw end-of-run mock stats snapshot. Adds failure classification (readyTimeout/poolNotRunning/ storeUnavailable/...), resize, shutdown-race, store-outage scenarios, partial poisoning, and mock alive gauge (max + per-second series).
…ation awaitTermination(15min) silently truncated 30-min runs to ~15min. Wait for duration+5min and report the actual loader duration so achieved rate is computed against real runtime.
…se experiments PoolWarmupDiagnostics records warmup queue-wait, createOneSandbox duration, commit duration, reconcile-tick cadence, in-flight warmup peak, and create failure reasons. Cold-start scenario reports them; --shared-connection-pool flag added to test connection-reuse hypotheses. Diagnosis: at wc=1000 the submission chain fully saturates (in-flight peak 1000, queue wait ~20ms); fill degradation is caused by per-sandbox fresh-TCP connection churn producing intermittent Connection reset failures against the mock listener (accept backlog), amplifying attempts via retries.
…on-pool-size) Replace the boolean flag with a configurable OkHttp ConnectionPool idle size. Evidence: at wc=1000, pool size 0/100/200/500 -> fill 19.9s/9.1s/7.1s/4.3s, connection-reset failures 8024/2696/1341/0, attempt amplification 5x/2.3x/ 1.7x/1x. A properly sized shared pool eliminates the TCP churn entirely.
…pool guidance Adds --shared-connection-pool-size to the driver options table and a 'Warmup throughput and connection reuse' section describing the connection-reset/retry-amplification finding at high warmupConcurrency, the pool-size sweep evidence, and configuration guidance for benchmark and production (including the upcoming SDK default from opensandbox-group#1517).
…ent pool guide Move the problem description, sweep evidence, and configuration guidance (shared ConnectionPool via ConnectionConfig, sizing rule of thumb, upcoming SDK default) to docs/guides/client-pool.md. The benchmark README keeps only a pointer plus the --shared-connection-pool-size repro flag.
…ized)
--shared-connection-pool-size now defaults to auto =
max(warmupConcurrency, 200) instead of 0, matching the guidance documented
in docs/guides/client-pool.md. Explicit 0 disables sharing (reproduces the
connection-reset pathology); N sweeps pool sizes. Report shows the resolved
mode ('auto'/0/N).
Replace the auto-derived pool size with a plain fixed default (500 idle slots, 5min keep-alive) so the benchmark follows the documented guidance without any derivation logic. 0 still disables sharing to reproduce the connection-reset pathology; N sweeps sizes.
… scenario Loaders race the pool fill instead of waiting for idle; fixes the CLI parser for valueless boolean flags (previously swallowed the next --key as its value).
f367f7b to
e28aaaf
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e28aaaf833
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| func (m *MockServer) handleCreate(w http.ResponseWriter, r *http.Request) { | ||
| start := time.Now() | ||
| defer m.recordQps("lifecycle.create", start) | ||
| _, _ = io.Copy(io.Discard, r.Body) |
There was a problem hiding this comment.
Honor the requested timeout when creating sandboxes
The Kotlin pool sends its configured idleTimeout in CreateSandboxRequest.timeout, but this handler discards the request body and always calculates expiry from defaultTtlSeconds. Consequently --idle-timeout-s does not control the mock's server-side TTL, and the default idle-expiry scenario's 20-second sandboxes remain server-alive for 3600 seconds, so it measures local pool reaping rather than the advertised server-expiry behavior. Parse the request timeout and use it when present.
AGENTS.md reference: AGENTS.md:L43-L44
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 8c25c68: handleCreate now parses CreateSandboxRequest.timeout and applies it to the server-side TTL (verified: timeout=30 -> expiresAt now+30s); idle-expiry now measures real server expiry.
| m.mu.RLock() | ||
| sb := m.sandboxes[id] | ||
| m.mu.RUnlock() | ||
| if sb == nil || !sb.running(time.Now()) { |
There was a problem hiding this comment.
Keep sandbox fields protected while reading them
During concurrent boot, renewal, deletion, or poisoning, the handler releases m.mu before reading State, ExpiresAt, and Poisoned through sb. Those fields are written under m.mu, so normal readiness traffic races with the mock's mutation goroutines and can observe inconsistent state; the same pattern occurs in lifecycle get/renew/endpoint handlers. Hold the read lock through the checks or copy the sandbox value while locked.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 47b6dd4: the execd handler now reads and judges State/ExpiresAt/Poisoned while holding m.mu.
| latency.recordFailure(classifyFailure(t)) | ||
| } | ||
| } | ||
| val stats = mock.stats() |
There was a problem hiding this comment.
Collect stale-idle stats after the refill completes
When stale candidates leave asynchronous warmups still creating, this snapshot is taken before waitForIdle waits for recovery. Any creations completing during that wait are therefore absent from serverCreatedDelta, and serverAliveAfter describes the pre-refill state, making the scenario unable to validate the fresh refill it claims to report. Take the stats snapshot after the refill wait.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 47b6dd4: stale-idle snapshots server stats after the refill wait, so creations completing during recovery are included.
| if (slot == 0L) { | ||
| if (nextSlot.compareAndSet(0L, System.nanoTime())) { | ||
| slot = System.nanoTime() |
There was a problem hiding this comment.
Reserve the next interval after the first pacing slot
With pacing enabled, the first caller stores the current time in nextSlot and runs immediately, but the second caller claims that same stored timestamp and also runs immediately; only the third caller waits one interval. This doubles the initial allowance, so short or low-rate runs can substantially exceed the requested rate—for example, a one-minute run at one acquire per minute starts two acquires instead of one. Initialize nextSlot to the first caller's next interval rather than its current slot.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 47b6dd4: the first caller runs immediately but reserves the next slot one interval out, so the second caller waits one interval instead of doubling the initial allowance.
| if cur := s.maxCreateLatency.Load(); ms > cur { | ||
| s.maxCreateLatency.CompareAndSwap(cur, ms) | ||
| } |
There was a problem hiding this comment.
Retry the atomic maximum update after contention
Under concurrent creates, a larger latency can lose its single CompareAndSwap to a smaller concurrent update and then never retry. For example, samples of 100 ms and 200 ms can leave maxCreateLatencyMs at 100 ms, corrupting the server latency summary precisely in high-concurrency benchmark runs. Use a CAS loop or derive the maximum from the already locked sample slice.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 47b6dd4: max-latency update now loops the CAS so a larger concurrent sample cannot be lost.
… robustness - warm-latency hitRatio: subtract replenish creates (one per kill) before computing misses, so background refills no longer depress the ratio - steady-state: recheck the scenario deadline after RatePacer sleeps so workers cannot issue acquires past the run window - run.sh: forward the effective LIFECYCLE_ADDR to the driver as --mock-base-url unless the user pinned it - mock execd: reject requests without a valid per-sandbox endpoint token (401) instead of answering 200, so missing headers cannot fake readiness - mock create: honor CreateSandboxRequest.timeout for the server-side TTL (idle-expiry now measures real server expiry) - scenarios: wrap every pool lifecycle in try/finally so a failed scenario cannot leak reconcile/warmup threads or mock sandboxes
- RatePacer: reserve the next slot one interval after the first caller so the second caller does not also run immediately (doubled initial allowance) - mock execd: judge sandbox state while holding the lock (boot/renew/delete/ poison write those fields under m.mu) - mock stats: retry the max-latency CAS so a larger concurrent sample cannot be lost to a smaller one - stale-idle: snapshot server stats after the refill wait so late creations are included in createdDelta/aliveAfter
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 47b6dd4224
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } finally { | ||
| pool.shutdown(graceful = false) |
There was a problem hiding this comment.
Clear idle sandboxes before starting the next scenario
With the default --scenarios all, shutdown(graceful = false) stops pool workers but neither removes nor kills the pool's idle sandboxes, while the next scenario's mock.reset() clears only metrics and retains the sandbox registry. Consequently every later alive/aliveStats value includes all earlier pools—for example, resize.serverAliveAtEnd cannot describe the resized pool—and the advertised pool-size and over-creation trajectories are corrupted. Release each pool's idles or reset the mock registry between scenarios.
AGENTS.md reference: AGENTS.md:L43-L44
Useful? React with 👍 / 👎.
| reconcileIntervalMs = 500, | ||
| idleTimeoutS = idleTimeoutS, |
There was a problem hiding this comment.
Disable proactive TTL reaping in the server-expiry scenario
Although the create handler now honors the 20-second timeout, fresh evidence shows this scenario still cannot exercise server-side expiry: because no explicit acquireMinRemainingTtl override is supplied, the SDK derives a 10-second threshold and PoolReconciler removes and kills these idles roughly halfway through their TTL. Thus serverKilled and serverCreatedDelta measure proactive client-side reaping rather than sandboxes expiring on the mock server; configure a zero threshold specifically for this scenario before claiming server-expiry behavior.
AGENTS.md reference: AGENTS.md:L28-L29
Useful? React with 👍 / 👎.
| threads.shutdown() | ||
| // The loaders run for the full configured duration; the wait must not | ||
| // truncate them (a 15-min cap would silently halve a 30-min run). | ||
| threads.awaitTermination(durationMs / 1000 + 300, TimeUnit.SECONDS) |
There was a problem hiding this comment.
Cancel paced workers when the termination wait expires
Although the deadline recheck prevents late acquires, low pacing rates still leave non-daemon executor threads sleeping after this bounded awaitTermination returns, because its result is ignored and shutdownNow() is never called. For example, 16 workers at one acquire per minute reserve slots up to about 15 minutes out, but a 60-second scenario waits only 360 seconds before continuing; the report may finish while the JVM remains alive for many additional minutes. Cancel the remaining workers after the deadline or make pacing deadline-aware.
Useful? React with 👍 / 👎.
Summary
Adds a reproducible, cross-SDK benchmark harness for the sandbox client pool under
tests/benchmark/:specs/, with per-route response-time control (uniform/fixed/lognormal), fault injection (create failures, execd failures, partial sandbox poisoning), server-side TTL expiry, and exact per-second per-API QPS tracking with full time series.--acquire-rate-per-min), replenish-lag, failure-injection, stale-idle, idle-expiry, resize, shutdown-race, store-outage. Reports acquire latency percentiles, success rate + failure classification, pool health (idle trajectory, degraded/backoff, in-flight), replenish throughput, client threads/heap/GC, and per-scenario server QPS.run.shorchestrates mock build/start + driver run; every artifact of a run (reports, per-second timeseries CSVs, client probe CSVs, mock config, args, logs) lands in oneresults/run-<ts>/directory.includeBuild), so the harness always runs the checked-out SDK.SDK companion
PoolWarmupDiagnostics(diagnostic-only, zero-config, resettable) was added to the Kotlin SDK to instrument the warmup pipeline (queue-wait, create duration, commit, tick cadence, in-flight peak, failure reasons). It powered the diagnosis in #1514. It is additive and non-breaking; reviewers may prefer it dropped or formalized separately.Evidence produced
reconcileInterval).Verification
go build/vet on the mock server;./gradlew buildon the driver; full 7+3 scenario runs and a 30-minute steady-state run (2000/min, maxIdle=13815) exercised end-to-end.tests/benchmark/README.md.