Skip to content

Commit 9b948de

Browse files
authored
[Fusion] Parse newline-delimited JSONL records as a single arena span (#10077)
1 parent 26608e5 commit 9b948de

1 file changed

Lines changed: 35 additions & 3 deletions

File tree

src/HotChocolate/AspNetCore/src/Transport.Http/JsonLines/JsonLinesReader.cs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ internal class JsonLinesReader(HttpResponseMessage message, IMemoryArenaSource a
2424
internal class JsonLinesReader(HttpResponseMessage message) : IAsyncEnumerable<OperationResult>
2525
#endif
2626
{
27+
#if FUSION
28+
private const int MaxSingleSpanRecordLength = 16 * 1024;
29+
30+
#endif
2731
private static readonly StreamPipeReaderOptions s_options = new(
2832
pool: MemoryPool<byte>.Shared,
2933
bufferSize: 4096,
@@ -98,9 +102,37 @@ public async IAsyncEnumerator<OperationResult> GetAsyncEnumerator(
98102
#if FUSION
99103
private static SourceResultDocument ParseDocument(IMemoryArena arena, ReadOnlySequence<byte> lineBuffer)
100104
{
101-
// Each record is one newline-delimited line. Its content bytes are filled once, directly into
102-
// the arena's geometric segments using the same per-record mechanic as the SSE reader, then
103-
// parsed in place via ParseFilled so no bytes are copied a second time.
105+
var length = lineBuffer.Length;
106+
107+
// A record whose length fits within MaxSingleSpanRecordLength is filled once into a single
108+
// exact-length arena chunk and parsed in place as one span. This skips the geometric ramp fill
109+
// and the multi-segment reader it produces. The record length is known from newline framing, so
110+
// this does not depend on a Content-Length header.
111+
//
112+
// The threshold is capped at 16 KB because the request-scoped arena is shared by all concurrent
113+
// subgraph fetches and lives until the response is written. A large exact-length rent that does
114+
// not fit the current page strands the whole remaining page tail for the rest of the request,
115+
// while the geometric ramp's small leading chunks can fill those tails. Small records, which
116+
// cover all realistic traffic, get the single-span win. Large records keep the tail-filling ramp.
117+
if (length > 0 && length <= MaxSingleSpanRecordLength)
118+
{
119+
var lineLength = (int)length;
120+
var buffer = arena.Rent(lineLength);
121+
lineBuffer.CopyTo(buffer.Span);
122+
123+
var segments = arena.RentSegmentTable(1);
124+
segments[0] = buffer;
125+
126+
return SourceResultDocument.ParseFilled(
127+
arena,
128+
segments,
129+
usedChunks: 1,
130+
lastLength: lineLength);
131+
}
132+
133+
// Fallback: larger records (and the degenerate empty line) are filled across the geometric data
134+
// chunk schedule and parsed as a multi-segment sequence, using the same per-record mechanic as
135+
// the SSE reader.
104136
var chunks = arena.RentSegmentTable(64);
105137
var chunkIndex = 0;
106138
var chunkSize = SourceResultDocument.GetDataChunkSize(chunkIndex);

0 commit comments

Comments
 (0)