---
title: "Laravel job longer than its lease: renewal example"
description: "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."
---

> Queen MQ documentation, for AI agents
> Complete self-contained summary of Queen MQ: https://queenmq.com/llms-brief.txt
> Fetch that first when the question is about the product rather than about this page.
> Index of all pages: https://queenmq.com/llms.txt

# Laravel job longer than its lease: renewal example

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

```bash
php artisan example:lease   # in examples/apps/laravel
```

What it printed against a 2.0.1 node ([setup](/examples/laravel/#run-them)):

```text
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
```

**Figure.** 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.

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.

*a) lease of 6 s, not renewed*

1. w1 → Queen: pop
2. Queen → w1: job, lease 6 s
   (Queen: 6 s: the lease expires)
3. w2 → Queen: pop
4. Queen → w2: same job, attempt 2
5. w1 → Queen: 8 s: ACK, old lease
6. Queen → w1: refused
   (Queen: 12 s: it expires again)
7. w1 → Queen: pop
8. Queen → w1: attempt 3
9. w1 → Queen: past --tries=2: dead letter
10. w2 → Queen: 14 s: ACK, old lease
11. Queen → w2: refused

*b) the same lease, renewed every second*

12. w1 → Queen: pop
13. Queen → w1: job, lease 6 s
14. w1 → Queen: renew, every second (the worker's helper process)
15. w1 → Queen: 8 s: ACK
16. Queen → w1: accepted

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:

```php title="examples/apps/laravel/app/Jobs/BuildReport.php"
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`:

```php title="examples/apps/laravel/app/Console/Commands/Examples/LeaseExample.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](/examples/laravel/#what-the-application-holds).

## 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](/guides/laravel/concepts/#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](/guides/laravel/concepts/#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](/guides/laravel/configuration/#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](/guides/laravel/concepts/#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](/guides/laravel/supervisors/#lease-renewal-in-the-master)).

## Next

[Lease renewal](/guides/laravel/configuration/#lease-renewal) lists the keys, and the
[card charger](/examples/exactly-once/) shows how to make a repeated run harmless.

Source: https://queenmq.com/examples/laravel/lease/index.mdx
