artisanplaybook .dev
ARTICLES / DEPLOYMENT

Your workers are still running last week's code

A worker holds a booted application in memory, so a deploy never reaches it. What actually delivers the restart, and the deploy steps that quietly throw it away.

ON THIS PAGE

The fix shipped everywhere except the queue

You patched the invoice job to round to two decimal places, deployed, refreshed the app, and the web tier was clearly on the new build. Six hours later support forwarded another invoice with four decimal places on it. Nothing in your logs says the deploy failed, because the deploy did not fail. It simply never reached the process that generates invoices.

A queue:work process boots the framework once and then loops. Container bindings, your service classes, every autoloaded class — all resolved at start, all still in memory. Swapping the files on disk changes nothing for a process that stopped reading them hours ago. That much is documented and most people know it. What surprises them is the delivery mechanism for the fix, and how easy it is to break.

queue:restart does not talk to your workers

There is no signal, no PID file, no socket. The entire command is one write:

public function handle()
{
    $this->cache->forever('illuminate:queue:restart', $this->currentTime());

    $this->components->info('Broadcasting queue restart signal.');
}

That is Illuminate\Queue\Console\RestartCommand in full. It puts a Unix timestamp in your default cache store under illuminate:queue:restart and prints a confident sentence. Whether any worker ever reads it is not its problem.

On the other side, the worker reads that key once when it boots and keeps the value in a local variable. At the bottom of each pass through the daemon loop, it reads the key again and compares:

protected function queueShouldRestart($lastRestart)
{
    if (! static::$restartable) {
        return false;
    }

    return $this->getTimestampOfLastQueueRestart() != $lastRestart;
}

Two things follow from those five lines, and both matter more than they look.

The comparison is != against the value captured at boot — not "is this newer than what I started with". Any change counts as a restart, including a change to something older or to nothing at all. And the check happens between jobs. A worker halfway through a forty-minute report finishes that report on the old code. That is the graceful part of a graceful restart, and it is the right trade, but it means "restart the workers" is not a deploy step that completes; it is a request with an unbounded latency.

When a worker does decide to stop, it exits with status 0. That is deliberate — a clean exit tells your process monitor this was expected and it should start a fresh one. Without Supervisor, systemd or equivalent in front of queue:work, queue:restart is a command that stops your queue.

The deploy step that eats the signal

Here is the one that costs an afternoon. Take an ordinary deploy script:

php artisan migrate --force
php artisan queue:restart
php artisan optimize:clear
php artisan optimize

optimize:clear runs cache:clear, which calls flush() on the default cache store. The restart timestamp lives in the default cache store. You just deleted the message before anyone read it.

Whether that costs you anything depends on what the worker was holding, and the answer is not intuitive. Running the sequence against this application, with the key read back at each step:

// Worker booted while the key existed (value: 1790103019)
Cache::get('illuminate:queue:restart');   // null, after the flush
// null != 1790103019  ->  true, the worker restarts anyway

The flush itself changed the value, so the worker notices and exits. You get away with it. Now the case where the worker booted on a cache with no restart key in it at all — a fresh Redis, a cache flushed by the previous deploy, the first worker ever started:

// Worker booted with no key at all (captured: null)
// queue:restart writes 1790103019
// cache:clear deletes it
Cache::get('illuminate:queue:restart');   // null
// null != null  ->  false, the worker does not restart

Nothing restarts. Nothing logs. The command already printed Broadcasting queue restart signal, your deploy exits 0, and a worker keeps serving last week's code until something else kills it. It is also a race: if the worker happens to finish a job in the gap between the two commands, it exits and you never see the bug. Same script, same servers, different outcome depending on job timing — which is exactly why this gets diagnosed as "flaky" rather than as a bug in the deploy order.

The fix is ordering, and it is free. Clear and rebuild caches first, broadcast the restart last:

php artisan migrate --force
php artisan optimize:clear
php artisan optimize
php artisan reload

php artisan reload is the wider version of the same idea: it runs queue:restart, schedule:interrupt, and any reload commands packages have registered, so Reverb and Octane get terminated too. It is a better front door, but it is the same cache-backed mechanism underneath and it inherits the same trap. Putting it after the flush is what fixes this, not the change of command.

The performance tweak that turns it off

Polling the cache on every loop iteration costs something, so there is a documented way to stop:

use Illuminate\Support\Facades\Queue;

public function boot(): void
{
    Queue::withoutInterruptionPolling();
}

Look again at queueShouldRestart: with Worker::$restartable false it returns false before reading anything. queue:restart still writes the key. It still prints its message. No worker will ever look. If that call is in your AppServiceProvider — added by someone optimising a high-throughput queue a year ago — every restart in your deploy script has been a no-op since, and the only symptom is workers that seem to run for a suspiciously long time.

Bound the staleness instead of trusting the signal

The robust answer is to stop depending on a message arriving at all. Give the worker a lifetime:

php artisan queue:work --max-time=3600 --max-jobs=1000

Both limits are checked in the same place as the restart signal, and both exit 0, so your process monitor starts a fresh worker that boots the current code. --max-time=3600 means no worker is ever more than an hour behind a deploy, whatever your deploy script does or forgets to do. --max-jobs does the same in the other unit and doubles as a cap on leaked memory from jobs that hold resources.

This is a backstop, not a replacement. An hour of stale workers after a security fix is still an hour. But it converts an unbounded, silent failure into a bounded one, and it keeps working when the cache is misconfigured, flushed at the wrong moment or pointed at a store the worker's environment cannot see.

Make the worker tell you why it stopped

You do not have to guess which of these is happening. Every worker exit dispatches WorkerStopping, and it carries the reason as an enum:

use Illuminate\Queue\Events\WorkerStopping;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;

Event::listen(function (WorkerStopping $event) {
    Log::info('worker stopped', [
        'reason' => $event->reason?->value,
        'jobs' => $event->jobsProcessed,
        'memory' => $event->memoryUsage,
    ]);
});

The cases are restart_signal, max_time, max_jobs, memory, timed_out, interrupted, lost_connection, empty and empty_for. Deploy, then read the log. If you see restart_signal for each worker within a few minutes, the mechanism works. If you only ever see max_time, your restart signal is not landing and the limits are the only thing saving you. If you see nothing at all, your workers are not exiting, and that is the finding.

What to change

Open your deploy script and find queue:restart or reload. If any cache-clearing command runs after it, move it. That is the whole fix for the common version of this.

Then grep your service providers for withoutInterruptionPolling and Worker::$restartable, and confirm queue:work sits behind a process monitor that restarts it on a zero exit. Add --max-time and --max-jobs to the worker command so the worst case is bounded rather than open-ended.

Finally, add the WorkerStopping listener and deploy once with it in place. Five minutes of log output will tell you which of these three failures you have, and none of the other checks can.

Related reading

ARCHIVE →