Laravel Interruptible Jobs: interrupted(), SIGALRM and horizon:terminate
· 15 min read · Boring Observability
Verified against Laravel 13.39 · Horizon 5.50 · Skyline 1.6
When a queue worker is told to stop, Laravel lets the running job finish. For a job that takes four seconds, that is
all you need. A job that takes four hours holds the deploy until something runs out of patience and sends
SIGKILL, and then it starts again from the top. Since Laravel 13.7 a job can implement
Interruptible and hear the signal while it is still running.
The question people ask next is whether that works with horizon:terminate out of the box, or whether you
need to check for the stop signal inside your own loop. You get the delivery for free and you write the check yourself.
The timeout signal, SIGALRM, follows different rules, and Horizon's autoscaler sends the same
SIGTERM as a deploy, which is where the obvious implementation goes wrong.
Key takeaways#
horizon:terminateworks with no setup. The signal travels from the master through the supervisors to the worker, which callsinterrupted(SIGTERM)on the running job.- Your loop still has to check. When
interrupted()returns,handle()carries on from the line it was on. Set a flag and test it between units of work. SIGALRMis a last call. The worker kills itself withSIGKILLas soon asinterrupted(SIGALRM)returns, so a flag is never read andfinallyblocks never run. Cleanup belongs inside the method.SIGTERMdoes not mean a deploy. Withbalanceonautoorfalse, Horizon's scale-down sends it to busy workers when a queue empties, unlessminProcessesequalsmaxProcesses. A job that answers withrelease()uses up its tries with nothing being deployed.- Blocking calls hold signals back. A signal sent during a curl request or a database query waits until the call returns, and that includes the timeout alarm.
What the interface gives you#
Illuminate\Contracts\Queue\Interruptible has one method, and the worker passes it the signal number. This
is the version most people write first. Later sections change what happens once the flag is set:
use Illuminate\Contracts\Queue\Interruptible;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ImportProducts implements ShouldQueue, Interruptible
{
use Queueable;
private bool $shouldStop = false;
public function handle(): void
{
foreach ($this->rows() as $row) {
if ($this->shouldStop) {
$this->release();
return;
}
$this->importRow($row);
}
}
public function interrupted(int $signal): void
{
$this->shouldStop = true;
}
}
SIGTERM, SIGINT and SIGQUIT reach the job from Laravel 13.7, and
SIGALRM from 13.34. Neither change is on the 12.x branch, and all of it needs the pcntl
extension, because without it the worker installs no signal handlers at all.
Laravel's documentation covers the first
group and does not mention SIGALRM.
The worker decides who to tell in a few lines, and they set two limits:
// Illuminate\Queue\Worker::notifyJobOfSignal()
if (! $this->currentJob) {
return;
}
$handler = $this->currentJob->getResolvedJob();
if (! $handler instanceof CallQueuedHandler) {
return;
}
$job = $handler->getRunningCommand();
if ($job instanceof Interruptible) {
$job->interrupted($signal);
$this->events->dispatch(new JobInterrupted(
$this->currentJob->getConnectionName(), $this->currentJob, $signal
));
}
- Only a job that is running gets told. A worker that receives the signal between jobs simply exits.
- Only the class the worker sees gets told. Queued listeners, mailables, notifications and closures
run inside a framework wrapper such as
CallQueuedListener, and that wrapper is what gets checked. A queued listener implementingInterruptiblereceives no call.
Does it work with horizon:terminate out of the box?#
Yes. Horizon workers run horizon:work, a thin subclass of queue:work, so they use the same
Illuminate\Queue\Worker and the same signal handlers. horizon:terminate sends
SIGTERM to the master, the master terminates its supervisors, and each supervisor sends
SIGTERM to its workers. No Horizon setting is involved.
Here is the job above on Laravel 13.39 and Horizon 5.50, doing 250 ms steps, with times in seconds since
handle() began:
0.00 handle() starts
2.08 php artisan horizon:terminate
2.52 interrupted(15) sets the flag
2.52 the loop sees the flag at step 11 and the job releases itself
2.53 WorkerStopping, reason=interrupted
4.54 Horizon's master exits with status 0
fast_termination changes when Horizon exits and leaves the signal alone. With it on, the job was told 0.29
seconds after the command, the master had gone a second later, and the worker carried on by itself until the job was
done.
Do you still need to check inside your own loop?#
Yes, because interrupted() stops nothing. PHP runs the signal handler between two instructions of
handle(), and when the handler returns, handle() resumes from where it was. This is the same
command against a job whose interrupted() only writes a log line:
0.00 handle() starts
2.07 php artisan horizon:terminate
2.36 interrupted(15) runs and returns
9.89 handle() finishes all 40 steps
9.95 WorkerStopping, reason=interrupted
11.43 Horizon's master exits with status 0
Apart from that one line, the timeline matches a job that does not implement the interface at all. Stopping early is entirely your code's doing, so put the check where abandoning the rest is safe: after a row has committed, and never inside a transaction.
Which stops reach interrupted()#
The method answers to signals, and several common ways of stopping a worker do not send one. If your deploy script runs
queue:restart, the job is never told.
| What stops the worker | What the worker receives | interrupted() |
|---|---|---|
horizon:terminate |
SIGTERM |
Called |
| Horizon scaling down | SIGTERM |
Called |
supervisorctl stop |
SIGTERM |
Called |
docker stop, PHP as PID 1 |
SIGTERM |
Called |
Ctrl-C, kill -QUIT |
SIGINT, SIGQUIT |
Called |
| The job's timeout | SIGALRM |
Called, then the worker kills itself |
queue:restart |
A cache key, read between jobs | Not called, the job runs to the end |
--max-time, --max-jobs, --memory |
Nothing, checked between jobs | Not called, the job runs to the end |
horizon:pause |
SIGUSR2 |
Not called, the job runs to the end |
queue:work --once, queue:listen |
No handlers are installed | Not called. A --once worker dies on SIGTERM with exit status 143 and the job still reserved, and it does not enforce --timeout |
SIGKILL, the OOM killer |
Cannot be caught | Not called |
What to do once the flag is set#
Leaving handle() with a plain return tells Laravel the job succeeded. It is deleted and the
next link in its chain is dispatched, so the rows you had not reached are gone. A stopping job has to put its remaining
work somewhere.
release(), and what it costs#
Call it from handle(), after the check, and return straight away. Calling it inside
interrupted() puts a copy on the queue while the original keeps running, because the handler returns and
handle() resumes. With a second worker available, the job runs twice at once:
0.00 worker A: handle() starts, attempt 1
1.56 worker A: interrupted(15) calls release()
2.38 worker B: handle() starts, attempt 2
3.83 worker A: handle() finishes
6.40 worker B: handle() finishes
Even in the right place, release() has two costs. It pushes the original payload back, so anything you
stored on $this is lost: a $cursor property at step 9 reads 0 on the next attempt. Progress has
to live in your database, or in a cache key built from $this->job->uuid(), which stays the same across
releases.
It also spends an attempt. With $tries = 2 and a signal on every pickup, the third pickup fails with
MaxAttemptsExceededException before handle() runs, on a job that never threw anything. A
retryUntil() deadline avoids that, as it does for
releases on a 429.
Handing over to a new job#
The other option is to finish this job and queue a new one that starts where it stopped. The new job carries its cursor in its own payload and starts on attempt 1, so a deploy costs no tries:
if ($this->shouldStop) {
$this->prependToChain(new self($this->importId, $lastId));
return;
}
prependToChain() works on a job that is not in a chain, and it is a better choice than
self::dispatch() in the two places where they differ:
- Chains. After
self::dispatch()the original job counts as finished, so the next link is dispatched too. With two workers, the next link started at the same moment as the continuation and finished 2.5 seconds before it. WithprependToChain()it waits. ShouldBeUnique. The running job still holds the unique lock duringhandle(), soself::dispatch()is skipped without an error and the remaining work is lost. The chain route dispatches after the lock is released, and the continuation runs.
Inside a batch, use $this->batch()->add([new self($this->importId, $lastId)]) instead. With
prependToChain() the batch's then() callback ran before the continuation started, and the
continuation had no batch id. A job built with new self also needs its queue set in the constructor, or the
continuation lands on the connection's default queue.
Throwing from interrupted()#
On SIGTERM, an exception thrown from the method surfaces in handle() at whichever line was
running. The job then takes the normal exception path: it is reported, marked FAIL in the worker's output,
released with its backoff and counted against $maxExceptions. JobInterrupted does not fire. A
flag keeps the stopping point in your hands, so this is only worth using when the job has no safe point to check at.
interrupted() can run more than once#
Each signal is another call. A second SIGTERM calls it again, and a job that ignores
SIGTERM and then reaches its timeout hears 15 and later 14. Setting a flag is safe to repeat. Sending a
notification or writing a row from this method needs a guard.
SIGTERM does not mean a deploy#
Horizon's autoscaler stops workers the same way a deploy does. When a queue's ready list is empty, the target drops to
minProcesses, and the pool removes its oldest workers with no check on whether they are busy:
// Laravel\Horizon\ProcessPool::scaleDown()
$terminatingProcesses = array_slice(
$this->processes, 0, $difference
);
// ...
collect($this->terminatingProcesses)
->each(function ($process) {
$process['process']->terminate(); // SIGTERM
});
The ready list is empty as soon as every job has a worker, so a burst of long jobs triggers this while they are all
running. A job that does not implement Interruptible never notices, because its worker finishes it and then
exits. A job that answers with release() goes back on the queue, the pool scales up to take it, and the
cycle repeats.
These are five 20-second jobs with $tries = 3, on balance => 'auto' with
minProcesses 1, maxProcesses 5, and the balanceMaxShift and
balanceCooldown from Horizon's published config. Nothing is being deployed:
0.00 five jobs start on attempt 1 (the fifth at 2.84)
5.62 first interrupted(15), then one every three seconds until 14.69
22.97 first job finishes
22.99 four jobs start on attempt 2 (the last at 30.07)
32.87 three of them are interrupted by 38.90
50.18 second job finishes
50.21 three jobs start on attempt 3 (the last at 54.32)
57.05 two of them are interrupted by 60.06
74.43 third job finishes
74.47 the last two fail on their fourth pickup: MaxAttemptsExceededException
Five jobs of 20 seconds took 75 seconds, and two never completed. Handing over instead of releasing changes the outcome under the same config: five imports of 200 rows at 100 ms each were interrupted three times, finished at 25.4 seconds, imported every row exactly once and failed nothing, with every pickup on attempt 1.
A job cannot tell the two apart. Both are SIGTERM, and horizon:terminate writes its restart key
after it sends the signal, so there is nothing to read at that moment. The fix is on the supervisor side if you want
these jobs left alone, and balance => false is not enough. Horizon still scales that pool between
minProcesses and maxProcesses, and with minProcesses at its default of 1, jobs
were interrupted at 2.6, 5.6 and 8.6 seconds whether the pool was set with processes or
maxProcesses. Two configurations produced no interruptions: balance => 'simple', and equal
limits on a dedicated supervisor:
'imports' => [
'connection' => 'redis',
'queue' => ['imports'],
'balance' => 'auto',
'minProcesses' => 5,
'maxProcesses' => 5,
'timeout' => 1200,
],
Skyline uses the same scale-down path as Horizon and gave the same result on the same five jobs: three finished, two failed.
If a worker told to scale down is still running when the supervisor's timeout has passed, Horizon calls
Symfony's Process::stop(), which sends a second SIGTERM and a SIGKILL ten seconds
later. The job's own alarm usually gets there first, so this mostly reaches jobs whose $timeout is longer
than the supervisor's, which is
already a broken ordering for other reasons.
Handling SIGALRM#
The worker arms a pcntl_alarm() for every job. Since Laravel 13.34 its handler tells the job first, and what
follows is why this signal needs different code:
// Illuminate\Queue\Worker::registerTimeoutHandler(), condensed
pcntl_signal(SIGALRM, function () use ($job, $options, $connectionName, $queue) {
try {
$this->notifyJobOfSignal(SIGALRM);
} catch (Throwable $exception) {
$this->exceptions->report($exception);
}
// ...fail the job if it is out of attempts, dispatch JobTimedOut...
if (! static::$killOnTimeout) {
throw $e;
}
$this->kill(/* ... */); // posix_kill(getmypid(), SIGKILL)
});
A flag does nothing here, because handle() never gets another instruction. This is the flag-setting job under
--timeout=2:
0.00 handle() starts
2.00 interrupted(14) sets the flag and returns
2.00 JobInterrupted
2.01 JobTimedOut
2.01 WorkerStopping, reason=timed_out
worker exits with status 137, the job still reserved
SIGKILL ends the process on the spot, so a finally block around the loop does not run either.
Anything you want done has to happen inside the method:
public function interrupted(int $signal): void
{
$this->shouldStop = true;
if ($signal === SIGALRM) {
// handle() will not run again, and neither will finally blocks.
$this->saveCheckpoint();
$this->lock?->release();
$this->process?->signal(SIGTERM);
}
}
What is worth doing there#
A Cache::lock() the job holds stays locked after the kill until its TTL runs out. Released from
interrupted(SIGALRM), it is free straight away.
Child processes outlive the worker. After a timeout kill, the children of both Process::run() and
Process::start() were still running, re-parented to PID 1, and a job that retries an
ffmpeg call can pile them up. Calling $process->signal(SIGTERM) ends the child, as long as you
started it with the array form, Process::start(['ffmpeg', ...]). A string command goes through
sh, and the signal then reached the shell while sleep carried on running.
Nothing limits how long the method takes, since the alarm has already fired: three seconds of work inside it ran to the
end before the kill. The retry_after lease keeps counting during that time, though, so keep it short. An
exception thrown here is reported and the kill goes ahead anyway.
Releasing on a timeout#
Normally a timed-out job waits for retry_after to pass before anyone can retry it. Calling
$this->release(3) from interrupted(SIGALRM) brought it back on attempt 2 after that delay, with
no wait for the lease.
The catch is the last attempt, because the handler still fails the job after the method returns. On the Redis driver that
left a failed job and a copy in the delayed set. When that copy was picked up, failed() ran a second time
and the second write to failed_jobs hit its unique uuid index, which puts an integrity
constraint error in your log. Check the attempt count first:
if ($signal === SIGALRM && $this->attempts() < $this->tries) {
$this->release(30);
}
Leave pcntl_signal() and pcntl_alarm() alone#
Before 13.34 a job that wanted to hear its own timeout had to register its own handler, and that habit survives. A
process has one handler per signal and one alarm, and the worker relies on both. A job that replaced the
SIGALRM handler ran for five seconds under --timeout=2: its handler ran at 2.00 and nothing was
killed, so that job had no timeout. A job that called pcntl_alarm(1) under --timeout=60 replaced
the worker's timer, and the worker killed itself at 1.00 and reported JobTimedOut with a timeout of 60.
A deadline in the loop#
If the job can check a flag, it can also check the clock. Treating "almost out of time" the same as a stop signal means
the job leaves on its own terms and SIGALRM stays a backstop for the cases where a step hangs:
$deadline = time() + $this->timeout - 60;
// ...inside the loop
if ($this->shouldStop || time() >= $deadline) {
$this->prependToChain(new self($this->importId, $lastId));
return;
}
Worker::$killOnTimeout#
Since Laravel 13.33 you can set
Worker::$killOnTimeout = false in a service provider. interrupted(SIGALRM) still runs first,
and the handler then throws TimeoutExceededException into the job instead of killing the worker. A
finally block runs and the job is released with its backoff, while the same process moves on to the next
job.
The alarm does not re-arm, which is the risk. A job that caught the exception kept running for another four seconds under
--timeout=2, with no second alarm, and was reported as DONE. A broad
catch (Throwable) anywhere in the call stack does the same thing by accident, and the setting applies to
every job on the worker.
Blocking calls hold both signals back#
PHP runs asynchronous signal handlers between VM instructions, so a C function that is blocked keeps the handler waiting
until it returns. The timeout is a signal like any other, which means it cannot cut a stuck call short. Here a
SIGTERM is sent at two seconds in one column, and the worker runs with --timeout=2 in the other:
Inside handle() |
interrupted(SIGTERM) ran at |
Worker killed at |
|---|---|---|
| A loop of short steps | On arrival | 2.00 s |
Http::timeout(30)->get(), 8-second response |
8.08 s | 8.03 s |
DB::select('select sleep(8)') on MySQL |
8.05 s | 8.01 s |
Process::run('sleep 8') |
2.03 s, though the call itself returned at 8.02 s | 2.00 s, and sleep kept running |
sleep(30) |
On arrival, and sleep() returns early |
2.00 s |
A --timeout=2 on an 8-second HTTP call kills the worker at eight seconds, so the longest gap between two
chances to handle a signal is the longest call you make. Bound each one:
- HTTP. Set
connectTimeout()andtimeout()well below your job's timeout. The HTTP client timeouts reference has the defaults for each client. - MySQL. Add
/*+ MAX_EXECUTION_TIME(5000) */to longSELECTs and chunk long writes. With a 1,000 ms hint,select sleep(8)returned in 1.02 seconds. - Child processes.
Process::run()lets the handler run but does not return until the child exits, so useProcess::start()and pollrunning()alongside your flag. - Sleeps. Any signal cuts a
sleep()short, whether or not the job implements the interface:sleep(30)returned 28 after one second. A job sleeping out a rate limit wakes early during a deploy and calls the API inside the window, sorelease()it for the wait instead.
How long you have before SIGKILL#
Whatever sends the signal also decides how long it will wait:
| Sender | Default wait | Setting |
|---|---|---|
| Supervisor | 10 s | stopwaitsecs |
docker stop |
10 s | --time, stop_grace_period in Compose |
| Kubernetes | 30 s | terminationGracePeriodSeconds |
| Horizon scaling down | Supervisor timeout, then 10 s more |
timeout |
Laravel's documentation asks for a stopwaitsecs longer than your longest job. With a stop check, the number
that matters is the longest stretch between two checks, plus up to about two seconds for Horizon's own processes to exit
after the workers do. Under stopwaitsecs=3, a job ignoring the flag was killed three seconds after
interrupted() ran, with the reservation still held and no JobTimedOut, while a job checking the
flag let supervisorctl stop return in a fifth of a second.
In a container, the signal goes to PID 1 and only PID 1. With
sh -c 'php artisan horizon; echo bye' as the command, the shell took it and passed nothing on, so
interrupted() never ran and docker stop -t 5 killed everything five seconds later. Run Horizon
with exec, or as the container's command directly.
horizon:terminate has no deadline of its own for a worker, but the master stops waiting once the longest
supervisor timeout has passed. A job whose $timeout is longer than that
keeps running as an orphan
after Horizon has gone.
Every path in this post can still end in SIGKILL, so Interruptible reduces how much work you
repeat. It does not replace
making the job idempotent.
The job, put together#
This one handles a deploy, a scale-down and an approaching timeout the same way, and it passed through all three with every row imported once:
class ImportProducts implements ShouldQueue, Interruptible
{
use Queueable;
public int $timeout = 900;
public int $tries = 3;
private bool $shouldStop = false;
public function __construct(public int $importId, public int $afterId = 0)
{
$this->onQueue('imports');
}
public function handle(): void
{
$deadline = time() + $this->timeout - 60;
ImportRow::where('import_id', $this->importId)
->where('id', '>', $this->afterId)
->lazyById(100)
->each(function (ImportRow $row) use ($deadline) {
$this->importRow($row);
if ($this->shouldStop || time() >= $deadline) {
$this->prependToChain(new self($this->importId, $row->id));
return false;
}
});
}
public function interrupted(int $signal): void
{
$this->shouldStop = true;
}
}
The 60 seconds has to be longer than your slowest row, and importRow() needs its own bounded timeouts for
the reasons above. $tries now only counts real failures, because stopping early spends none of them. With
$timeout = 900, the supervisor's timeout and the connection's retry_after both
have to be higher, in that order, as the
timeout and retry_after post
explains.
Testing it#
interrupted() is an ordinary method, so a test can call it before handle() and assert what the
job did at its first check:
public function test_it_hands_over_at_the_next_row_when_the_worker_is_stopping(): void
{
$first = ImportRow::create(['import_id' => 1]);
ImportRow::create(['import_id' => 1]);
$job = new ImportProducts(1);
$job->interrupted(SIGTERM);
$job->handle();
$next = unserialize($job->chained[0]);
$this->assertSame($first->id, $next->afterId);
}
That covers your logic and none of the signal path, so exercise the job once against a real worker with
kill -TERM before you trust it in a deploy.
Seeing it in production#
Laravel dispatches two events. WorkerInterrupted fires on the stop signals whatever job is running, and
JobInterrupted fires after an Interruptible job has been told. JobInterrupted also
fires on a timeout, so check the signal before you count it as a stop:
Event::listen(function (JobInterrupted $event) {
Log::info('Job interrupted', [
'job' => $event->job->resolveName(),
'signal' => $event->signal,
]);
});
Many of these with no deploy in progress means your supervisor is scaling down under running jobs.
Skyline's job lifecycle logging writes this without a listener, along with what the job did next. These are two of the interruptions one job took during the scale-down above:
[2026-11-02 19:46:33] production.INFO: [job:4e50ce8e-68e3-4951-bcfa-e11bc827771f] interrupted on [default] — the worker received SIGTERM while it was running.
[2026-11-02 19:46:33] production.INFO: [job:4e50ce8e-68e3-4951-bcfa-e11bc827771f] released back to [default] (reason=interrupted, delay=0s)
[2026-11-02 19:46:57] production.INFO: [job:4e50ce8e-68e3-4951-bcfa-e11bc827771f] interrupted on [default] — the worker received SIGTERM while it was running.
[2026-11-02 19:46:57] production.INFO: [job:4e50ce8e-68e3-4951-bcfa-e11bc827771f] released back to [default] (reason=interrupted, delay=0s)
Further reading#
The life of a Laravel queued job follows a job from
dispatch to deletion, including what a restart does to a job that is not interruptible.
Horizon's balancing trade-offs covers
choosing between auto, simple and false for reasons beyond this one.
Skyline vs Horizon covers what else changes if you swap the package.
Frequently asked questions
Does Interruptible work with Horizon's terminate out of the box?
Yes. horizon:terminate sends SIGTERM to the master, the master terminates its supervisors, and each supervisor sends SIGTERM to its workers. Horizon workers run Laravel's own queue worker, so the running job's interrupted() method is called with no Horizon configuration. You need Laravel 13.7 or later, the pcntl extension, and a job class that implements Interruptible.
Do I need to check for the stop signal inside my own loop?
Yes. interrupted() is only a callback. PHP runs it between two instructions of handle(), and when it returns, handle() carries on from the same line. A job that does nothing else runs to the end exactly as it would without the interface. Set a flag in interrupted() and test it between units of work, at a point where abandoning the rest is safe.
How do I handle SIGALRM in a Laravel job?
Since Laravel 13.34 the worker calls interrupted(SIGALRM) when a job reaches its timeout, then kills itself with SIGKILL as soon as the method returns. A flag is never read and finally blocks do not run, so save your checkpoint, release locks and stop child processes inside interrupted() itself. Do not call pcntl_signal(SIGALRM) or pcntl_alarm() from a job: a process has one handler and one alarm, and replacing them removes or moves the worker's timeout.
Why was interrupted() called when nothing was being deployed?
Most likely Horizon scaled down. When a queue's ready list is empty, the pool shrinks to minProcesses and sends SIGTERM to its oldest workers, busy or not. That happens with balance set to auto, and with balance set to false as well unless minProcesses equals maxProcesses. A job that releases itself on the signal restarts each time and can run out of tries, so hand over to a new job with a cursor, or run these jobs on a supervisor with equal limits or balance set to simple.
Does queue:restart call interrupted()?
No. queue:restart writes a cache key that workers read between jobs, so no signal is sent and the running job finishes normally. The same goes for --max-time, --max-jobs and --memory. interrupted() only runs for a signal: SIGTERM, SIGINT or SIGQUIT, and SIGALRM on a timeout.
Why does interrupted() not run during a long HTTP request or query?
PHP runs asynchronous signal handlers between VM instructions, so a C function that is blocked holds the handler back until it returns. A SIGTERM sent during an 8-second curl call or MySQL query was handled at eight seconds, and a worker with a two-second timeout was killed at eight seconds too. Give every call its own timeout, such as Http::timeout() or MySQL's MAX_EXECUTION_TIME hint.
Keep reading
13 min read
Laravel Job timeout vs retry_after: The Ordering Rule Nothing Enforces
Why a Laravel job runs twice: the rule for ordering timeout and retry_after, the defaults in each config file,...
24 min read
The Life of a Laravel Queued Job: Every State, Every Transition
How a Laravel queued job moves through Redis: dispatch, reserve, retry, fail. The full lifecycle, traced...
10 min read
Laravel Horizon Queue Balancing: Idle Workers vs. Starved Queues
Horizon's balance modes force a choice: idle workers or starved queues. Why both costs scale with queue count,...
20 min read
Postmortem: A supervisor Package Upgrade Left Two Horizons Running
How a supervisor package upgrade orphaned a set of Laravel Horizon workers, and the systemd drop-in that fixes...