HTTP Streaming Hot Path
v2.6.1 is a performance and bugfix release. It removes work the HTTP client was doing once per streamed chunk — work that produced no observable result — and fixes two defects found while measuring it. There are no new APIs. Measured against v2.6.0 on a deterministic replay benchmark (1,131-event SSE responses, one process per run, PHP 8.5):
Extraction accuracy is byte-identical across the two versions — usable, silent-error and
loud-error rates, schema validity and every per-check pass rate match exactly. The change
is meant to alter cost, not behaviour.
Chunk Events Are Built Only When Something Listens
HttpResponseChunkReceived was constructed for every streamed chunk regardless of
whether anything consumed it. The event object is the argument to dispatch(), so it was
built, timestamped and given a UUID even with no listeners registered — on a 205 KB SSE
response, 822 event objects and roughly 1 MB of garbage nobody read.
The curl, PSR and Laravel adapters now ask before building:
CanCheckListeners already existed for exactly this purpose. A dispatcher that does not
implement it is assumed to be listening and still receives every event, so custom
dispatchers keep working unchanged. The check is resolved once per stream, not per chunk.
Attaching a wiretap counts as listening — EventDispatcher::wiretap() registers under
'*' — so debugging and telemetry setups see exactly what they saw before.
Streamed Chunks Are No Longer Re-Fragmented
This one is observable. The defaultstreamChunkSize moves from 256 to 16384
bytes.
curl never hands a write callback more than CURL_MAX_WRITE_SIZE (16 KB), so at the old
default every ~8 KB write was sliced into 32 pieces that the SSE parser immediately
reassembled: 822 substr() copies per response, for byte-identical parse output.
Consumers of HttpResponse::stream() now receive fewer, larger chunks. Nothing is lost or
reordered and the parsed event stream is identical, but if your code depends on the old
granularity — a progress bar ticking per 256 bytes, a test asserting a chunk count — set
it back explicitly:
streamChunkSize is an upper bound on slicing
bytes that have already arrived, not a threshold to fill. The curl driver yields whatever
its write callback received — a 40-byte token delta arriving alone is yielded as 40 bytes
— and on PSR streams read(16384) returns what is available rather than blocking for a
full buffer. Measured against a server trickling 27-byte SSE events 120 ms apart, reads at
256 and at 16384 land within 2 ms of each other on every event. Time to first token in the
replay benchmark moved from 2.82 ms to 2.28 ms. The granularity your code sees was never
the transport chunk anyway: EventSourceStream yields one parsed SSE event per \n\n,
identically at every framing.
A value of 0 or less now means “do not split”, which is what it always read like.
It previously meant the opposite: splitChunk() computed max(1, 0) and emitted
one-byte fragments. Anyone who set streamChunkSize: 0 to disable splitting was
getting the most expensive possible framing. The bundled symfony preset, which ships
streamChunkSize: 0, was affected.
The http-pool presets are aligned with the same default.
SSE Parser Reads By Offset
EventSourceStream dropped each parsed event with $buffer = substr($buffer, $pos + 2),
reallocating everything still unparsed once per event — 1,132 reallocations for a 205 KB
response. It now tracks a consumed offset and compacts in bulk, plus two smaller wins: CR
normalisation is skipped when the chunk contains no CR, and a block that is a single
data: line takes a fast path instead of the general field loop.
One behavioural note: maxBufferBytes now measures unparsed bytes rather than the raw
buffer length. That is what the limit was always meant to catch — a single event block
that never terminates — and it stops a long but perfectly-consumed stream from tripping a
limit it should never have reached.
Event Ids Are Correlation Ids, Not Random UUIDs
Event::__construct() drew a fresh random_bytes(16) for every event in the framework.
Event::$id now comes from Uuid::correlationId(): one CSPRNG draw per process, mixed
with the pid, plus a counter. Measured at 0.245 µs against 0.594 µs over 100k draws.
The result is the same shape, passes Uuid::isValid(), and is unique within a process.
Fixed: Laravel Streaming Was Invisible To Telemetry
LaravelHttpResponseAdapter dispatched new HttpResponseChunkReceived($chunk) with a raw
string, while the curl and PSR adapters dispatch ['requestId' => ..., 'chunk' => ...].
HttpClientTelemetryProjector::onChunkReceived() reads both keys and returns early when
either is null, so under the Laravel driver every streamed chunk was silently dropped
from telemetry and carried no correlation id. The payload now matches the other
adapters, and the driver passes the request id through.
Known Trade-Off
Per-call peak memory on streaming calls rises from 323 KB to 356 KB at p95 (+10%); p50 is unchanged at 83 KB, and resident memory is flat. Holding one whole curl write instead of 32 slices is what buys roughly half the latency win. Isolated measurement: withstreamChunkSize back at 256 the peak returns to 324 KB and the median call only
improves to 2.43 ms instead of 2.10 ms.
If transient peak matters more to you than latency, streamChunkSize: 256 restores the
old profile without giving up the listener guard, the parser rewrite, or the fixes.
Upgrading
composer update cognesy/instructor-php. No API changed. Review the two observable
points above if you consume HttpResponse::stream() directly or depend on Event::$id
being unpredictable.