Supports streamed and file responses#184
Conversation
Rename first argument from $symfonyResponse to $response, like in HttpFoundationWorkerInterface.
|
@Baldinof Hello! Just a heads up — this PR has been waiting for review for 14 days now. If there are any comments or anything needs to be changed, let me know. If you don't have time at the moment, could you please suggest someone else who can review it instead? |
|
Hello! Thank you for opening this :) It overall looks great, streaming has been long-awaited! I'll try to review and test it during the next weekend. |
|
@Baldinof Hello! I fix the code and to ensure backward compatibility with older symfony versions. |
|
I tested it and it worked well.
|
|
Hello!
Example: $response = new StreamedResponse(function(): void {
echo 'some text ...'; // Echo string of 16KB
});
$responder = new ChunkedResponder(
responseClass: StreamedResponse::class,
chunkSize: 1,
);
$responder->respond($httpWorker, $response);The Symfony was originally designed for dying runtimes — Apache with mod_php and PHP-FPM. The I tried, inside the The performance impact of It would be possible to rewrite However, in the Therefore, using tagged iterator is a more flexible approach. Yes, it is slightly slower, but it can handle any logic inside |
|
@Baldinof Hello! Just writing so this PR doesn't get lost. I'd really like to get this change merged properly. Could you at least briefly let me know:
I'm totally open to making changes. Thanks for understanding! |
| // Skip, if content size less, then chunk size | ||
| if (\strlen($content) < $this->chunkSize && !$isLast) { | ||
| return ''; | ||
| } |
There was a problem hiding this comment.
Is this really required? As chunksize is already passed to ob_start?
There was a problem hiding this comment.
Yes
Because flush() breaks chunking.
When using only echo (without flush), the output buffer callback is only triggered when the buffer reaches the chunk size or the script ends. The condition strlen($content) < $chunkSize && !$isLast will never be true because the buffer is never flushed early:
$chunks = [];
ob_start(function($buffer) use (&$chunks) {
$chunks[] = $buffer;
return '';
}, chunk_size: 10);
echo "foo";
echo "bar";
echo "baz";
echo "qux";
echo "quux";
echo "quuux";
@ob_end_clean();
var_dump($chunks);Output:
array(2) {
[0] =>
string(12) "foobarbazqux"
[1] =>
string(9) "quuxquuux"
}
However, when flush() is called between echo operators, the callback is invoked on every flush, regardless of the chunk size:
$chunks = [];
ob_start(function($buffer) use (&$chunks) {
$chunks[] = $buffer; // <-- respond chunk
return '';
}, chunk_size: 10);
echo "foo"; @ob_flush(); flush();
echo "bar"; @ob_flush(); flush();
echo "baz"; @ob_flush(); flush();
echo "qux"; @ob_flush(); flush();
echo "quux"; @ob_flush(); flush();
echo "quuux"; @ob_flush(); flush();
@ob_end_clean();
var_dump($chunks);Output:
array(7) {
[0] =>
string(3) "foo"
[1] =>
string(3) "bar"
[2] =>
string(3) "baz"
[3] =>
string(3) "qux"
[4] =>
string(4) "quux"
[5] =>
string(5) "quuux"
[6] =>
string(0) ""
}
The solution
To solve this, we accumulate all incoming buffers until we either reach the chunkSize or detect the end of the stream ($isLast). This ensures that chunks are never smaller than the configured chunkSize.
Additionally, this approach reduces the number of HttpWorkerInterface::respond() invocations by batching multiple small flushes into single, properly-sized chunks.
Context
In Symfony ≥ 7.3, StreamedResponse accepts $callbackOrChunks as a callback with echo or an iterator with chunks. Symfony automatically flushes the buffer after each echo chunk.
This case is covered in unit tests: https://github.com/Kenny1911/roadrunner-bundle/blob/419915790892b49419447cc7651e23e5b835df31/tests/RoadRunnerBridge/HttpFoundationWorkerTest.php#L363
I also tested it in a benchmark project: https://github.com/Kenny1911/baldinof-roadrunner-bundle-streamed-response-benchmark/blob/master/src/Controller/Controller.php#L19
There was a problem hiding this comment.
Benchmark: With Condition vs Without Condition
The benchmark generates a response of approximately 62.8 MB, consisting of 1,000,000 iterations, each yielding a single line. The default chunk size is 16 KB.
Example controller:
public function action(): StreamedResponse
{
return new StreamedResponse(function() {
for ($i = 0; $i < 1_000_000; ++$i) {
yield "id: {$i}; title: Title; description: Description; enabled: true" . PHP_EOL;
}
});
}| Runtime | Status | Time (s) | Speed (M/s) | Size (MB) |
|---|---|---|---|---|
| with condition | ✅ 200 | 0.414 | 151.8 | 62.8 |
| without condition | ✅ 200 | 6.944 | 9.0 | 62.8 |
| fpm | ✅ 200 | 2.008 | 31.2 | 62.8 |
Results
With condition – Buffers are accumulated until the 16 KB chunk size is reached (or the stream ends). Despite 1,000,000 individual yield calls, the number of HttpWorkerInterface::respond() invocations is reduced to a minimum — approximately the number of full chunks (62.8 MB / 16 KB ≈ 4,000 calls).
Without condition – Each flush() triggers the callback immediately. As a result, every micro-chunk (often just a few bytes) is sent to the worker as a separate request. This creates a massive number of calls — around 1,000,000 (or even more, including empty chunks at the end).
FPM – Included as a baseline reference (standard Symfony with PHP-FPM, without RoadRunner optimizations). On 62.8 MB, it takes 2 seconds — slower than the with-condition approach (0.414 s), but significantly faster than the without-condition approach (6.944 s).
Conclusion
The with condition approach is approximately 16–17 times faster than the naive implementation (0.414 s vs 6.944 s) on the same data volume.
The difference is straightforward:
- Without condition – 1,000,000+ worker calls (each
yieldtriggers a separate micro-request) - With condition – ~4,000 worker calls (only when a full chunk is accumulated)
The condition if (strlen($content) < $this->chunkSize && !$isLast) ensures that partial chunks are not sent on intermediate flush() calls. Instead, data accumulates until either:
- The buffer reaches
chunkSize→ send a full chunk - The stream ends (
$isLast === true) → send the remaining data
|
@Baldinof Hello! I don't want to be a bother, but a question on the substance: are there still any open questions about the implementation of this PR? If not — I'd really appreciate a review whenever you get to it. If yes — I'm ready to make changes. Thanks! |
Baldinof
left a comment
There was a problem hiding this comment.
Sorry again for the delay, and thank you for the responses!
It's a great PR, thank you for contributing! StreamedResponse has been requested for a while now and it's the best implementation :)
|
I'll do some final tests on the default branch and release later today or tomorrow |
|
@Baldinof Hello! Apologies for the follow-up, but could you give an approximate timeline for the release? I need this change for my project - otherwise I wouldn't be asking. Thanks! |
|
I just published 3.4.0 with it, let me know if it's all good :) |
Closes #179 #130 #101
1. Updated
spiral/roadrunner-httpdependency to ^4.0In this version, the
HttpWorkerInterface::respond()method explicitly defines the$endOfStreamparameter, allowing proper stream termination control.2. Fixed streaming and file responses
StreamedResponse(generators, echo callback)StreamedJsonResponseEventStreamResponseBinaryFileResponse(large files)3. Created
HttpFoundationResponderserviceResponsible for sending responses based on their type. Implements flexible logic and serves as an extension point for future response types.
Implementations:
BufferedResponder- returns the entire response from memory. Used for simple responses and as a fallback.ChunkedResponder- splits the response into chunks and sends them to RoadRunner. Chunk size depends on response type:BinaryFileResponse1 byte - Symfony itself streams the file in chunks, we don't interfere.EventStreamResponse1 byte - for instant real-time event delivery.StreamedResponse,StreamedJsonResponse16 KB - same as default file chunk size. Can be overrides bybaldinof_road_runner.http_foundation_streamed_responder.chunk_sizeparameter.ChainResponder- sequentially applies multiple responders (for complex cases)Both main responders (
BufferedResponderandChunkedResponder) work through output buffering interception usingob_start(). This allows:echo,print, and other output functionsAdded unit tests.
4. Results
Benchmarks
Streamed (1M records)
Streamed echo (1M records)
Streamed json (1M records)
File (60 Mb)
Common
Benchmarks repository: https://github.com/Kenny1911/baldinof-roadrunner-bundle-streamed-response-benchmark