Repository navigation
Horizon: forked processes write their output a whole line at a time - #1
Conversation
Stock Horizon gives each worker a pipe that its supervisor reads and relays, so lines from different workers never mix. Forked processes write straight to the stdout they inherited, which they all share, and queue:work writes each job line in three or four pieces, so pieces from different workers interleave mid-line in the container log. A write filter on STDOUT and STDERR now holds output until a newline and passes each complete line on as its own write. A pipe delivers a write of up to PIPE_BUF in one piece, so each line reaches the log whole. A partial line is held up to 4096 bytes and passed on when the stream closes. It filters the streams themselves, which are what Symfony's ConsoleOutput writes to outside Windows, and is attached in Wiring::child() so it is in place before the command writes anything.
|
Thanks for this, and for the measurements. Glad the package was useful for you, and the diagnosis is right: filtering the stream fits better than a console event. While reviewing it I found a few edge cases. GitHub doesn't let me push to a branch of an organization-owned fork, so I opened #2 instead: your commit, unchanged, with mine (d251cd7) on top. Merging it marks this PR as merged too.
If you get a chance to rerun your measurement on #2's branch, I'd be glad to see the numbers. Otherwise shout if anything doesn't sit right. I'll merge once CI is green. |
Horizon: forked processes write stdout a whole line at a time (builds on #1)
|
Thanks for the fixes and the quick release. I reran the measurement on the v1.0.5 tag with the same setup as before (PHP 8.4, Horizon 5.50.1, 2 supervisors, 9 workers, the same 1,980 jobs):
Every job has its RUNNING line and its DONE line (2,341 of each), and the counts per job class match what was dispatched. The 4 fewer lines come from an event in our app that fires depending on the data, not from anything lost. Memory and speed are the same as before: 325 MB PSS idle, 602 MB after the mix, 767 MB after the class-loading jobs, no worker restarts and no failed jobs. |
Forked Horizon workers interleave their job lines in the log. This keeps each line whole.
What happens
Stock Horizon starts each worker with its own pipe; the supervisor reads it and relays what arrives, so lines from different workers never mix. A forked worker writes straight to the stdout it inherited, which every forked process in the tree shares.
queue:workwrites each job line in three or four pieces (date Job, the dots, thenRUNNING, or the run time andDONEwith awriteln), so with several workers busy the pieces interleave mid-line:Nothing is lost (Horizon's own job records are complete), but anything that reads the container log line by line gets garbage: log-based job counts, alerts on
FAIL, and the like.The change
Support\LineBufferFilter: a write filter that holds output until a newline and passes each complete line on as its own bucket, so each line goes out in onewrite(). A pipe delivers a write of up toPIPE_BUFin one piece, so the line reaches the log whole. A partial line is held up to 4096 bytes, and whatever is held is passed on when the stream closes.Support\LineBufferedOutput::attachToStdio()appends it toSTDOUTandSTDERR, which is what Symfony'sConsoleOutputwrites to outside Windows. It's called inWiring::child(), so the master, the supervisor loops and the workers all get it before their command writes anything.Runtime::CLASSES; unit tests intests/Unit/LineBufferedOutputTest.php.composer testpasses.Measured
A large Laravel 13 app in Docker on PHP 8.4 / Horizon 5.50.1: 2 supervisors, 9 workers, 1,980 jobs (search indexing, tracking events and a class-loading job), run once without and once with the change:
Memory and throughput were the same in both runs (600 MB PSS after the mix).
Notes
PIPE_BUFcan still be split by the kernel, as they could be in any process. Job lines are far shorter.CommandStartingfromWiring::child()to reach the command's output. In our forked children the listener was never called, though the kernel's Symfony dispatcher was in place when the child was wired. I didn't get to the bottom of it, so I filtered the streams instead, which doesn't depend on any event. It may be worth a look, since theCommandFinishedlistener inWiring::child()goes through the same events.ConsoleOutputreopensphp://stdoutrather than usingSTDOUT, and forking doesn't run there anyway.Thanks for the package. On our app it took Horizon from about 1.5 GB to about 0.6 GB at the same load.