Skip to content

Laravel job longer than its lease: renewal example

One 8-second Laravel job on a 6-second lease, two workers. Without lease renewal the other worker runs it again, every ACK is refused and the job ends in the dead-letter queue unfinished. With lease renewal it runs once.

Updated View as Markdown

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/laravel

What 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 checks
Without renewal: worker w1 pops the job under a 6 s lease and runs it for 8 s. At 6 s the lease expires and worker w2 pops the same job, attempt 2. At 8 s w1 sends its ACK under the expired lease and the broker refuses it. At 12 s the second lease expires too; w1 pops attempt 3, which is past --tries=2, so Laravel fails it and the driver files it in the dead-letter queue. At 14 s w2's ACK is refused as well. With renewal: a worker pops the job, its renewal helper extends the lease every second, and at 8 s the broker accepts the ACK.w1queue:workQueenw2queue:worka) lease of 6 s, not renewedpopjob, lease 6 s6 s: the lease expirespopsame job, attempt 28 s: ACK, old leaserefused12 s: it expires againpopattempt 3past --tries=2: dead letter14 s: ACK, old leaserefusedb) the same lease, renewed every secondpopjob, lease 6 srenew, every secondthe worker's helper process8 s: ACKaccepted
The ACK carries the lease, so a run that outlives its lease cannot complete the job. Without renewal every run of this job outlives it; with renewal the first one completes. Source: examples/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:

examples/apps/laravel/app/Jobs/BuildReport.phpphp
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:

examples/apps/laravel/app/Console/Commands/Examples/LeaseExample.phpphp
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_after seconds 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:supervise refuses a pool whose timeout is not shorter than retry_after, with renewal or without. With renewal, a job that runs longer declares a longer $timeout in its class.
  • The connection refuses to run a job whose class sets a $timeout that is not shorter than retry_after, while renewal is off.
  • A plain queue:work does not check its --timeout. Both phases run with --timeout=20 and 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close