Skip to content

perf(gateway): add mixed-load benchmark - #1896

Draft
BYK wants to merge 2 commits into
mainfrom
perf/1733-mixed-load-benchmark
Draft

BYK wants to merge 2 commits into
mainfrom
perf/1733-mixed-load-benchmark

Conversation

@BYK

@BYK BYK commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • generate deterministic 5,580- and 7,228-message Responses histories that normalize to the claimed counts and produce measured 180K–210K active raw windows
  • run base, warm-append, restart-append, embedding, read-worker, cancellation, and concurrent-session phases through two fresh gateway processes sharing one owned database
  • synchronize every fault with explicit active and release barriers; report exact upstream tool/provenance hashes, queue peaks, process CPU, quiescent heap plateaus, SQL scope, persistence, and health schema checks
  • split the stable CI contract from a committed measured reference report, and raise source-checkpoint retention to cover the 7,228-message profile

Validation

  • pnpm exec vitest run packages/gateway/test/mixed-load-benchmark.test.ts packages/gateway/test/source-checkpoint.test.ts --printConsoleTrace
  • pnpm --filter @loreai/gateway run typecheck
  • pnpm exec oxlint --type-aware packages/gateway/benchmark/mixed-load packages/gateway/src/benchmark-timing.ts packages/gateway/src/source-checkpoint.ts packages/gateway/test/mixed-load-benchmark.test.ts
  • pnpm run benchmark:mixed-load -- --quick --output /tmp/opencode/mixed-load-final.json
  • pnpm run format:check

Full-repository lint still reports pre-existing warnings outside this change; the scoped lint command passes.

Closes #1733

@devin-ai-integration devin-ai-integration Bot 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.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 4 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment thread packages/gateway/benchmark/mixed-load/run.ts Outdated
Comment thread packages/gateway/benchmark/mixed-load/run.ts Outdated
Comment thread packages/gateway/benchmark/mixed-load/workload.ts Outdated
Comment on lines +113 to +118
async start(options: ChildStartOptions): Promise<void> {
this.send({ type: "start", options });
await this.ready;
if (this.fatal) throw this.fatal;
if (this.port === 0)
throw new Error("benchmark child did not report a port");

@devin-ai-integration devin-ai-integration Bot Sep 25, 2026 •

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.

🔴 Restart races live benchmark process

When restarting, stop() returns immediately after signaling the child, so the next process can open its still-active database. Cleanup can also remove the database before the child exits.

Learn more

The parent creates two serving processes against the same fixture database and expects a clean process boundary. stop() sends a signal and disconnects IPC without awaiting the child’s exit; runMixedLoadBenchmark immediately starts the second child, and its cleanup removes the fixture after another nonblocking stop. The child has a shutdown handler for an IPC stop message, but the parent never sends it.

Example: Process A receives SIGTERM while finishing a database operation. Process B starts on the same root before A exits; process A can finish writing after B's initial persistence snapshot.

Recommended fix: Send the IPC stop message to permit orderly stop, wait for the child's exit event with a bounded deadline and SIGKILL fallback, and remove the root only after exit.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Results 📊

✅ Patch coverage is 81.42% (263 of 323 changed executable lines covered; target 80%).
Project statement coverage is 84.65% (down 0.02 percentage points from base (14152e2) to head (5d8e106)).

Changed files with executable lines (5)
File Patch coverage Changed executable lines
packages/gateway/benchmark/mixed-load/run.ts 80.57% 170/211 covered; missed: 95, 96, 100, 107, 118, 126, 127, 128, 129, 131, 132, 133, 134, 143, 144, 208, 308, 309, 310, 311, 312, 315, 316, 317, 322, 323, 327, 331, 348, 388, 395, 456, 494, 518, 601, 603, 670, 675, 676, 686, 687; partial branches: 90, 99, 106, 109, 116, 117, 125, 156, 199, 303, 307, 347, 394, 399, 400, 401, 489, 513, 602, 639, 669, 685
packages/gateway/benchmark/mixed-load/workload.ts 90.00% 72/80 covered; missed: 77, 142, 221, 255, 262, 269, 271, 281; partial branches: 76, 81, 121, 122, 123, 138, 220, 254, 261, 268, 270, 276, 305
packages/gateway/src/benchmark-timing.ts 56.00% 14/25 covered; missed: 33, 45, 50, 51, 52, 60, 67, 68, 69, 93, 94; partial branches: 49, 59, 65, 66, 82, 90, 91, 92
packages/gateway/src/pipeline.ts 100.00% 3/3 covered
packages/gateway/src/routes/openai.ts 100.00% 4/4 covered
Coverage diff
@@            Coverage Diff             @@
##          main     #1896       +/-##
==========================================
- Coverage    84.67%    84.65%    -0.02%
==========================================
  Files          306       309        +3
  Tracked lines     48411     48734      +323
  Branches     39354     39573      +219
==========================================
+ Hits         40990     41253      +263
- Misses        7421      7481       +60
- Partials      4212      4254       +42

Generated by Coverage Action

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 4 new potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +915 to +918
const allStats = [...heldStats, ...finalStats];
const upstreamMeasurements = allStats.flatMap(
(stats) => stats.upstreamMeasurements,
);

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.

🟡 Upstream measurements double-count earlier requests

When held snapshots exist, upstreamMeasurements concatenates them with cumulative final snapshots. Requests captured before each held snapshot appear twice in the report.

Learn more

Each statsReader returns every upstream measurement recorded since child startup. heldStats and finalStats contain overlapping snapshots for the same process, but the report concatenates both arrays. The checked-in reference report consequently contains duplicated upstream rows, obscuring actual request counts and distorting any downstream aggregation.

Example: One base request and one append request produce two measurements in the final snapshot; a held snapshot already contains both. Concatenation reports four upstream requests for that process rather than two.

Recommended fix: Build upstream measurements from one final snapshot per process, or store only the delta between successive snapshots.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1066 to +1071
exactUpstreamHashes: upstreamMeasurements.every(
(measurement) =>
/^[0-9a-f]{64}$/.test(measurement.bodyHash) &&
/^[0-9a-f]{64}$/.test(measurement.toolSequenceHash) &&
/^[0-9a-f]{64}$/.test(measurement.provenanceHash),
),

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.

🟡 Upstream hash gate misses changed payloads

If the gateway alters tool or provenance content, exactUpstreamHashes still passes because it checks only SHA-256 formatting. The report marks mismatched upstream requests as exact.

Learn more

The upstream server computes bodyHash, toolSequenceHash, and provenanceHash from the actual forwarded input in upstreamMeasurements. The gate validates only their 64-character shape. Every SHA-256 result has that shape regardless of whether the forwarded payload matches the generated workload, so the invariant does not detect a content regression.

Example: If translation drops every reasoning item, provenanceHash changes but remains 64 hexadecimal characters; exactUpstreamHashes stays true.

Recommended fix: Independently derive expected hashes from the generated workload or an explicitly defined allowed transformation, then compare forwarded values against them; include count and order expectations for each sampled request.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +74 to +75
if (!timing || !observer) return;
timings.set(timing.requestId, { ...timing, decodedAt: performance.now() });

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.

🟡 Early responses retain benchmark timing records

For a benchmark-tagged request handled before normal upstream forwarding, timings retains its entry. Repeated early responses grow the map and can leave stale measurements for reused IDs.

Learn more

The Responses route calls finishBenchmarkDecode before sending every parsed request to the pipeline. Only the normal conversation's upstream path calls clearBenchmarkTiming; handleRequestInner also has nonthrowing early returns, and its slash, compaction, and meta branches return without clearing timings. Entries persist as long as the process keeps the observer active.

Example: A benchmark-tagged Responses request rejected for conflicting auth headers stores its ID in timings and receives HTTP 400. Sending thousands of such requests accumulates thousands of entries.

Recommended fix: Give each decoded request a guaranteed cleanup path when request handling settles, while preserving the upstream timing event; also clear any remaining entries when disabling the observer.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +781 to +789
for (;;) {
await new Promise<void>((resolve) => setImmediate(resolve));
if (embeddingState.running === 0) break;
for (const worker of controlledEmbeddingWorkers)
worker.releaseAll(core.config().search.embeddings.dimensions);
}
if (activeDrain) {
try {
await activeDrain;

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.

🟡 Embedding release misses late follow-up work

If the drain starts another embed after running reaches zero, releaseScenario stops releasing workers before awaiting activeDrain. The barrier can stall on an unreleased embed.

Learn more

The release loop looks only at the current embeddingState.running count, but the comment explains that a completed temporal write can start follow-up embedding work as the original drain settles. The code checks for zero before awaiting the outstanding activeDrain, so a later embed can be added after the loop ends. That embed stays held by ControlledEmbeddingWorker until its 30-second timeout.

Example: The first embed completes; a setImmediate sees zero running embeds and exits the loop. The drain resumes and starts a second controlled embed. The subsequent await activeDrain waits on that embed without any remaining release loop.

Recommended fix: Continue releasing pending controlled embeds until the drain has settled and the running count remains zero after a scheduling turn; impose a bounded overall release deadline.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@BYK
BYK marked this pull request as draft September 25, 2026 18:02
return protocol.startsWith(SOURCE_CHECKPOINT_PROTOCOL_PREFIX);
}
export const SOURCE_WINDOW_MAX_MESSAGES = 2048;
export const SOURCE_WINDOW_MAX_MESSAGES = 8192;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Increasing SOURCE_WINDOW_MAX_MESSAGES without updating PREFIX_COUNTS causes checkpoints to become ineffective for large conversations, forcing a fallback to full-source processing.
Severity: HIGH

Suggested Fix

Increase the PREFIX_COUNTS constant to match or exceed the new SOURCE_WINDOW_MAX_MESSAGES value of 8192. This ensures the prefixTokens array is correctly populated for offsets up to the new maximum window size, preventing the fallback to full-source processing.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/gateway/src/source-checkpoint.ts#L33

Potential issue: Increasing `SOURCE_WINDOW_MAX_MESSAGES` to 8192 without a corresponding
increase in `PREFIX_COUNTS` (which remains at 4096) causes checkpointing to fail for
large conversations. When a conversation is pruned and the cumulative message offset
exceeds 4096, the `finishWindow` method no longer extends the `prefixTokens` array. On
subsequent requests, code attempting to access `prefixTokens` at an offset greater than
4096 will find an `undefined` value. This triggers a `FullSourceRequired("calibration")`
exception, forcing a fallback to slower, full-source processing and negating the
performance benefit of using checkpoints for the exact workloads this change is intended
to improve.

Did we get this right? 👍 / 👎 to inform future reviews.

This branch has not been deployed

No deployments
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.

test(perf): add a reproducible long-session workload with background pressure

1 participant