-
Notifications
You must be signed in to change notification settings - Fork 190
perf(gateway): fix streaming performance for large payloads #1698
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -107,6 +107,9 @@ import type { ServerTypes } from "@/vars.js"; | |||||||||||||||||||||||||||
| // Pre-compiled regex pattern to avoid recompilation per request | ||||||||||||||||||||||||||||
| const SSE_FIELD_PATTERN = /^[a-zA-Z_-]+:\s*/; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Reusable TextDecoder to avoid per-chunk allocation in the streaming hot path | ||||||||||||||||||||||||||||
| const sharedTextDecoder = new TextDecoder(); | ||||||||||||||||||||||||||||
|
Comment on lines
+110
to
+111
|
||||||||||||||||||||||||||||
| // Reusable TextDecoder to avoid per-chunk allocation in the streaming hot path | |
| const sharedTextDecoder = new TextDecoder(); | |
| // TextDecoder wrapper: creates a fresh TextDecoder per decode call to avoid | |
| // sharing internal streaming state across concurrent requests. | |
| const sharedTextDecoder = { | |
| decode( | |
| input?: BufferSource, | |
| options?: TextDecodeOptions, | |
| ): string { | |
| const decoder = new TextDecoder(); | |
| return decoder.decode(input, options); | |
| }, | |
| }; |
Copilot
AI
Feb 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using the shared TextDecoder with stream: true creates a state corruption issue when multiple requests are processed concurrently. Create a new TextDecoder instance at the start of the streaming section instead, e.g., before line 3151: const decoder = new TextDecoder(); and use it with decoder.decode(value, { stream: true }).
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,7 +2,11 @@ import type { ImageObject } from "./types.js"; | |||||||||||||
| import type { Provider } from "@llmgateway/models"; | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Extracts images from streaming data based on provider format | ||||||||||||||
| * Extracts images from streaming data based on provider format. | ||||||||||||||
| * | ||||||||||||||
| * For large base64 image data, we reference the original inlineData fields | ||||||||||||||
| * directly rather than creating new concatenated strings, to avoid unnecessary | ||||||||||||||
| * multi-MB string copies. | ||||||||||||||
|
Comment on lines
+7
to
+9
|
||||||||||||||
| * For large base64 image data, we reference the original inlineData fields | |
| * directly rather than creating new concatenated strings, to avoid unnecessary | |
| * multi-MB string copies. | |
| * For providers that return inlineData (for example, Google formats), this | |
| * function locates image parts and constructs data URLs from their mimeType | |
| * and base64-encoded data. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -75,4 +75,62 @@ describe("mightBeCompleteJson", () => { | |||||||||||||||
| it("returns false for actually unbalanced brackets", () => { | ||||||||||||||||
| expect(mightBeCompleteJson("[1,2]]")).toBe(false); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| // Tests for large payload optimization (>100KB threshold) | ||||||||||||||||
| describe("large payloads (>100KB)", () => { | ||||||||||||||||
| const LARGE_SIZE = 120 * 1024; // 120KB to exceed the 100KB threshold | ||||||||||||||||
|
|
||||||||||||||||
| it("returns true for large valid object with big base64 string value", () => { | ||||||||||||||||
| // Simulate a Google streaming chunk with inline base64 image data | ||||||||||||||||
| const base64Data = "A".repeat(LARGE_SIZE); | ||||||||||||||||
| const json = `{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"${base64Data}"}}]}}]}`; | ||||||||||||||||
| expect(mightBeCompleteJson(json)).toBe(true); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("returns false for incomplete large object", () => { | ||||||||||||||||
| const base64Data = "A".repeat(LARGE_SIZE); | ||||||||||||||||
| // Missing closing brackets | ||||||||||||||||
| const json = `{"candidates":[{"content":{"parts":[{"inlineData":{"data":"${base64Data}"`; | ||||||||||||||||
| expect(mightBeCompleteJson(json)).toBe(false); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("returns true for large object with escaped quotes in string value", () => { | ||||||||||||||||
| const base64Data = "A".repeat(LARGE_SIZE); | ||||||||||||||||
| const json = `{"key":"value with \\"escaped\\" quotes","data":"${base64Data}"}`; | ||||||||||||||||
| expect(mightBeCompleteJson(json)).toBe(true); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("returns false for large object missing closing brace", () => { | ||||||||||||||||
| const base64Data = "A".repeat(LARGE_SIZE); | ||||||||||||||||
| const json = `{"data":"${base64Data}"`; | ||||||||||||||||
| expect(mightBeCompleteJson(json)).toBe(false); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("returns true for large nested object", () => { | ||||||||||||||||
| const base64Data = "A".repeat(LARGE_SIZE); | ||||||||||||||||
| const json = `{"outer":{"inner":{"data":"${base64Data}"}}}`; | ||||||||||||||||
| expect(mightBeCompleteJson(json)).toBe(true); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("returns false for large nested object with unbalanced braces", () => { | ||||||||||||||||
| const base64Data = "A".repeat(LARGE_SIZE); | ||||||||||||||||
| // Extra opening brace | ||||||||||||||||
| const json = `{"outer":{{"inner":{"data":"${base64Data}"}}}`; | ||||||||||||||||
| expect(mightBeCompleteJson(json)).toBe(false); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("handles large payload performance efficiently", () => { | ||||||||||||||||
| // 5MB base64 data simulating a real image | ||||||||||||||||
| const base64Data = "A".repeat(5 * 1024 * 1024); | ||||||||||||||||
| const json = `{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"${base64Data}"}}]}}]}`; | ||||||||||||||||
|
|
||||||||||||||||
| const start = performance.now(); | ||||||||||||||||
| const result = mightBeCompleteJson(json); | ||||||||||||||||
| const elapsed = performance.now() - start; | ||||||||||||||||
|
|
||||||||||||||||
| expect(result).toBe(true); | ||||||||||||||||
| // Should complete in under 10ms for 5MB (vs 100ms+ without optimization) | ||||||||||||||||
|
||||||||||||||||
| // Should complete in under 10ms for 5MB (vs 100ms+ without optimization) | |
| // Should complete in under 50ms for 5MB (vs 100ms+ without optimization) |
Copilot
AI
Feb 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Performance tests that measure elapsed time can be flaky on slower CI systems or under load. Consider making this test more resilient by either: (1) increasing the threshold further (e.g., to 100ms or 200ms), (2) running multiple iterations and checking the average/median, or (3) making this test optional/skippable in CI environments where timing is unreliable.
| // Should complete in under 10ms for 5MB (vs 100ms+ without optimization) | |
| expect(elapsed).toBeLessThan(50); | |
| // Performance check: in non-CI environments, this should typically | |
| // complete well under 200ms for a 5MB payload. | |
| if (!process.env.CI) { | |
| expect(elapsed).toBeLessThan(200); | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Performance test: comment says "under 10ms" but assertion uses 50ms.
Line 132 comment says "Should complete in under 10ms" but line 133 asserts < 50. Minor inconsistency — either tighten the comment or the assertion. Also, be aware that time-based assertions can be flaky in resource-constrained CI environments.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/gateway/src/chat/tools/might-be-complete-json.spec.ts` around lines 122
- 134, Test comment and assertion mismatch: the comment in the
might-be-complete-json.spec.ts test for mightBeCompleteJson says "under 10ms"
but the assertion checks elapsed < 50ms; either update the comment to match the
50ms assertion or tighten the assertion to < 10ms. Preferably remove or relax
the strict timing assertion to a safer threshold (or mock performance.now() /
avoid wall-clock timing) to prevent flaky CI; update the inline comment to
reflect the chosen threshold and ensure the test still validates correctness of
mightBeCompleteJson rather than brittle timing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
TextDecoder stream:true internal state buffer concurrent calls💡 Result:
TextDecoderhas mutable internal state: a decoder instance plus an internal I/O queue (byte buffer) and flags. On eachdecode(input, { stream })call, the input bytes are pushed into that internal queue and the decoder consumes from it. (encoding.spec.whatwg.org){ stream: true }means “do not flush”: when the internal queue reaches end-of-queue, the algorithm returns without resetting the decoder, so any incomplete multi‑byte sequence (or any other state for stateful encodings) can be completed by later calls. (encoding.spec.whatwg.org)decode(..., {stream:true})zero or more times, then calldecode()once withstreamomitted/false to flush remaining buffered state (often producing""if nothing was buffered). (encoding.spec.whatwg.org)Concurrent / interleaved calls:
decode()itself is synchronous, so you don’t get true parallel execution in one JS thread—but you can accidentally interleave use of the sameTextDecoderacross multiple byte streams (e.g., two async sources). Because the decoder and internal queue are shared mutable state, interleaving will corrupt decoding. Use oneTextDecoderper byte stream, or useTextDecoderStreamper stream pipeline. (encoding.spec.whatwg.org)Citations:
🏁 Script executed:
Repository: theopenco/llmgateway
Length of output: 46
🏁 Script executed:
Repository: theopenco/llmgateway
Length of output: 95
🏁 Script executed:
Repository: theopenco/llmgateway
Length of output: 100
🏁 Script executed:
sed -n '100,120p' ./apps/gateway/src/chat/chat.tsRepository: theopenco/llmgateway
Length of output: 929
🏁 Script executed:
sed -n '3120,3200p' ./apps/gateway/src/chat/chat.tsRepository: theopenco/llmgateway
Length of output: 3029
🏁 Script executed:
sed -n '3420,3440p' ./apps/gateway/src/chat/chat.tsRepository: theopenco/llmgateway
Length of output: 769
🏁 Script executed:
sed -n '3100,3130p' ./apps/gateway/src/chat/chat.tsRepository: theopenco/llmgateway
Length of output: 1266
🏁 Script executed:
grep -n "sharedTextDecoder" ./apps/gateway/src/chat/chat.tsRepository: theopenco/llmgateway
Length of output: 183
Critical: shared
TextDecoderwith{ stream: true }corrupts data across concurrent requests.The module-level
sharedTextDecoderis used inside an async streaming loop that awaitsreader.read(). While one request is blocked on the await, another concurrent request can execute and callsharedTextDecoder.decode(). SinceTextDecoderwith{ stream: true }maintains mutable internal state (buffered incomplete multi-byte sequences), interleaved calls corrupt both streams—the decoder will prepend bytes buffered from request A to request B's input.Each streaming request needs its own
TextDecoderinstance. Move the declaration from the module level into the streaming handler (around line 3122, afterlet buffer = "").🤖 Prompt for AI Agents