Without lease renewal, a job that runs longer than retry_after goes to a second worker while the
first one still runs it. Here every run outlives its lease, so no ACK is accepted: the job runs
until Laravel’s tries are spent and ends in the dead-letter queue, unfinished. With lease renewal
the same job runs once.
Run it
php artisan example:lease # in examples/apps/laravelWhat it printed against a 2.0.1 node (setup):
broker http://localhost:6632
a) lease of 6 s, not renewed, a job of 8 s, --tries=2
0.1 s w1 started attempt 1
6.1 s w2 started attempt 2
8.1 s w1 ended, ACK refused: invalid or expired lease
12.2 s w1 failed: BuildReport has been attempted too many times.
14.2 s w2 ended, ACK refused: invalid or expired lease
ok: the job ran twice
ok: the second run was on the other worker
ok: the second run began when the first lease expired, 6.0 s in
ok: both ACKs were refused: each run outlived its lease
ok: the third delivery failed the job without running it
ok: the job is in the dead-letter queue, never completed
b) the same lease, renewed every second, the same job
0.2 s w2 started attempt 1
8.2 s w2 ended, ACK accepted
ok: the job ran once
ok: no ACK was refused
ok: its ACK was accepted
ok: nothing is left in the queue
PASS: 10 checksexamples/apps/laravel, php artisan example:lease- Two runs at once. From 6.1 s to 8.1 s both workers ran the job. Anything the job did, such as an email or a charge, happened twice.
- No run counted. Both runs did all their work, and the broker refused both ACKs.
- The end. The third delivery was past
--tries=2, so Laravel failed it without running it, and the driver filed it in the dead-letter queue.
The program
The job runs for 8 seconds and records each start:
final class BuildReport implements ShouldQueue
{
use Queueable;
// No $timeout here: the worker's --timeout applies. Without lease
// renewal, the connection refuses to run a job whose own $timeout
// is not shorter than retry_after.
public function __construct(public int $seconds) {}
public function handle(): void
{
Journal::record($this->job, [
'event' => 'started',
'attempt' => $this->attempts(),
]);
sleep($this->seconds); // longer than the lease
}
}The command runs one job per phase on two workers and checks what happened to it. The ACK
outcomes come from Laravel’s queue events, which the application records in
app/Providers/AppServiceProvider.php:
final class LeaseExample extends ExampleCommand
{
protected $signature = 'example:lease';
protected $description = 'A job longer than its lease, without and with lease renewal';
private const JOB_SECONDS = 8;
private const LEASE_SECONDS = 6; // retry_after of both connections
protected function example(): void
{
$this->line(sprintf(
"\na) lease of %d s, not renewed, a job of %d s, --tries=2",
self::LEASE_SECONDS,
self::JOB_SECONDS,
));
$events = $this->runOneJob('queen-short-lease', 'expiring');
$started = $events['started'] ?? [];
$this->check(count($started) === 2, 'the job ran twice');
$this->check(
$started[0]['pid'] !== $started[1]['pid'],
'the second run was on the other worker',
);
$gap = $started[1]['at'] - $started[0]['at'];
$this->check(
$gap >= self::LEASE_SECONDS - 0.5,
sprintf('the second run began when the first lease expired, %.1f s in', $gap),
);
$refused = array_filter(
$events['exception'] ?? [],
fn ($e) => str_contains($e['error'], 'expired lease'),
);
$this->check(count($refused) === 2, 'both ACKs were refused: each run outlived its lease');
$this->check(
count($events['failed'] ?? []) === 1,
'the third delivery failed the job without running it',
);
$dead = app(Queen::class)->queue($events['queue'])->dlq()->limit(10)->get();
$this->check(
count($dead['messages'] ?? []) === 1,
'the job is in the dead-letter queue, never completed',
);
$this->line("\nb) the same lease, renewed every second, the same job");
$events = $this->runOneJob('queen-renewed-lease', 'renewed');
$this->check(count($events['started'] ?? []) === 1, 'the job ran once');
$this->check(count($events['exception'] ?? []) === 0, 'no ACK was refused');
$this->check(count($events['acked'] ?? []) === 1, 'its ACK was accepted');
$this->check(
Queue::connection('queen-renewed-lease')->size($events['queue']) === 0,
'nothing is left in the queue',
);
}
/**
* One job, two workers, a fresh queue. Waits until the job is either
* acknowledged or failed, and every run of it has ended.
*
* @return array<string, mixed> the job's events by kind, and the queue
*/
private function runOneJob(string $connection, string $name): array
{
$queue = $this->freshQueue("lease-{$name}");
BuildReport::dispatch(self::JOB_SECONDS)->onConnection($connection)->onQueue($queue);
$t0 = microtime(true);
// --timeout=20: Laravel would stop the job after 20 s, long after it
// ends. --tries=2: a third delivery fails the job instead of running it.
[$one, $two] = $this->startWorkers(2, $connection, $queue, ['timeout' => 20, 'tries' => 2]);
$this->waitFor(
'the job to be acknowledged or failed',
40,
fn () => Journal::read($queue, 'acked') ?: Journal::read($queue, 'failed'),
);
// A run ends with its ACK, accepted or refused.
$ended = fn () => count(Journal::read($queue, 'acked')) + count(Journal::read($queue, 'exception'));
$this->waitFor('every run to end', 20, fn () => $ended() === count(Journal::read($queue, 'started')));
$this->stopProcesses();
$events = ['queue' => $queue];
$workers = [$one => 'w1', $two => 'w2'];
foreach (Journal::read($queue) as $row) {
$events[$row['event']][] = $row;
$what = match ($row['event']) {
'started' => "started attempt {$row['attempt']}",
'acked' => 'ended, ACK accepted',
// The driver says "Unable to acknowledge job: <the broker's reason>".
'exception' => 'ended, ACK refused: '
. preg_replace('/^Unable to acknowledge job: /', '', $row['error']),
'failed' => 'failed: ' . str_replace(BuildReport::class, 'BuildReport', $row['error']),
};
$this->line(sprintf(' %5.1f s %s %s', $row['at'] - $t0, $workers[$row['pid']], $what));
}
return $events;
}
}Phase a uses queen-short-lease, phase b queen-renewed-lease, both in
config/queue.php.
How it works
The lease and the ACK
- The pop takes a lease. It lasts
retry_afterseconds from the pop. Until it ends, no other worker of the consumer group receives the job (the lease). - An expired lease frees the job. The broker delivers it again to the next pop, with a
delivery count one higher, which Laravel’s
attempts()reads. - The ACK carries the lease. The broker refuses an ACK under a lease that has expired. The
driver throws
Unable to acknowledge job: invalid or expired lease, Laravel reports it, and the worker goes on. The job is already deleted on the worker’s side, so it is not released again.
What renewal changes
With lease_renewal, each queue:work starts a small PHP helper process that extends the lease
every lease_renewal_interval while the job runs. If a renewal cannot finish in time, the helper
stops the worker before the lease ends, so two runs never overlap
(lease renewal and fencing).
The connection refuses a renewal timing that may not fit in the lease:
| Term | Phase b |
|---|---|
lease_renewal_interval |
1 s |
two request budgets, lease_renewal_timeout each |
2 s |
| one second for a retry | 1 s |
lease_renewal_kill_grace |
0 s |
lease_renewal_safety_margin |
1 s |
the sum, which must be below retry_after (6 s) |
5 s |
Every term has a minimum, and the minimums add up to 5 s, so no timing passes with a
retry_after of 5 s. That is why both phases use 6 s.
What the checks at start allow
queen:superviserefuses a pool whosetimeoutis not shorter thanretry_after, with renewal or without. With renewal, a job that runs longer declares a longer$timeoutin its class.- The connection refuses to run a job whose class sets a
$timeoutthat is not shorter thanretry_after, while renewal is off. - A plain
queue:workdoes not check its--timeout. Both phases run with--timeout=20and a job class without$timeout, so phase a is the setup the two rules exist to stop (refused at start).
Limits
- Renewal is not exactly once. A worker that crashes mid-job still leaves the job to run again
after
retry_after(at-least-once delivery). - Laravel’s timeout still applies. Laravel stops a job that runs longer than the worker’s
--timeout, or than its own$timeout, renewal or not. - Renewal costs a helper process per worker. Under the Rust supervisor on Linux the master renews the leases instead (lease renewal in the master).
Next
Lease renewal lists the keys, and the card charger shows how to make a repeated run harmless.