skip to content

In Laravel, what goes wrong when a Cache::lock() expires before a statement job finishes, and how do owner tokens and refresh() limit the damage?

level: seniorimportance: should knowfreq 40%

answer

  1. the lifetime is a crash safety net
  2. expired lock lets a second worker in
  3. release() checks the owner, returns false
  4. refresh(): extend while still owned
  5. 0 seconds on Redis: never expires

basics

~20 s

If the lock's lifetime ends mid-work, a second worker can acquire it and the statement is generated twice. Owner tokens stop the first worker's release() deleting the second's lock; refresh() extends a lock this process still owns.

solid answer

~40 s

The seconds passed to `Cache::lock($name, 120)` are a lease: if the holder crashes, the lock frees itself. But if rendering takes 200 seconds, the lock vanishes at 120, a second worker's `get()` succeeds, and two statements are produced. When the first worker finishes, its `release()` compares its owner token with the stored one; they differ, so it returns `false` and leaves the second worker's lock alone instead of letting a third worker in. `refresh()` (in laravel/framework 13.17 and later) extends the lock by its original duration or a given number of seconds, and returns `false` if this process no longer owns it — the cue to stop. The fix is a short lease refreshed during the work, plus an idempotent write as the last line of defence.

code

php · 16 lines
php
<?php

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

$lock = Cache::lock("statement:{$accountId}:{$month}", 60);

if ($lock->get()) {
    try {
        $this->renderInChunks($lock); // calls $lock->refresh() per chunk
    } finally {
        if (! $lock->release()) {
            Log::warning('Statement lock expired before release', compact('accountId', 'month'));
        }
    }
}

go deeper

for a junior

Recall that the seconds on a lock are its lifetime, and that the lock can disappear while your code is still running.

for a middle

Explain the owner token, why release() returns false after expiry, and what refresh() and forceRelease() do differently.

for a senior

Design a short lease with refresh-per-chunk, abort on a false refresh, log failed releases, and back the lock with a unique constraint on the statement.

for a principal

Weigh lease length against crash-recovery time for the statement pipeline, and decide which guarantees must come from the database rather than the cache.

## The lifetime is a lease, not a timeout on the work `Cache::lock('statement:42:2026-08', 120)` asks the store to keep the lock for **120 seconds**. That number protects you from a worker that dies holding the lock: after 120 seconds the store drops it and someone else can proceed. Laravel does not stop your code when the lifetime ends — the job keeps running with no lock at all. ## The failure, step by step 1. Worker A acquires the lock at t=0 and starts rendering a large statement. 2. At t=120 the store expires the lock. Worker A does not notice. 3. At t=130 a retried or duplicate job on worker B calls `get()`, which succeeds. 4. Both render; both write a statement row and both email the customer. 5. At t=200 worker A calls `release()`. Step 5 is where the **owner token** matters. Every lock object carries an owner, a random 16-character string unless you pass one. `release()` deletes the stored lock only when the stored owner equals this object's owner: - the Redis lock runs a small script that compares the value and deletes in one step; - the database lock deletes `WHERE key = ? AND owner = ?`. So A's `release()` returns `false` and B keeps its lock. Without the owner check, A would delete B's lock and worker C could start a third render. `forceRelease()` is the method that ignores the owner, which is why it belongs in emergency tooling rather than in the normal path. ## `refresh()`: keep a short lease alive `refresh(?int $seconds = null)` extends the lock **only if this process still owns it**: - With no argument it re-applies the lock's original duration; pass seconds to choose another. - It returns `true` when the extension happened and `false` when the lock expired or now belongs to someone else. - It arrived in laravel/framework 13.17.0; a lock class that does not implement it throws `RuntimeException` (*This lock driver does not support refreshing locks.*). The array, database, DynamoDB, file, Memcached and Redis locks all implement it. The resulting pattern is a **heartbeat**: acquire a short lock (60 s), refresh it after each chunk of work, and abort if refresh reports `false`. ```php $lock = Cache::lock($name, 60); if (! $lock->get()) { return; } try { foreach ($transactions->chunk(500) as $chunk) { $pdf->addPage($chunk); if (! $lock->refresh()) { throw new RuntimeException('Lost statement lock'); } } $statements->store($pdf); } finally { $lock->release(); } ``` ## Choosing the lifetime | Choice | Crash recovery | Risk while healthy | |---|---|---| | Long lifetime (1 hour) | a crashed worker blocks the statement for up to an hour | low overlap risk | | Short lifetime, no refresh | fast | overlap whenever work runs long | | Short lifetime plus `refresh()` | fast | overlap only if a refresh is missed | | `0` seconds | Redis: **never** expires; database: falls back to `lock_timeout` (86400 s) | a crash leaves the lock until `forceRelease()` | ## Defence in depth - Check `isOwnedByCurrentProcess()` or the result of `refresh()` before the **final write**, so a worker that lost its lease does not commit. - Make the write itself safe to repeat, for example a unique index on `(account_id, month)` so the second insert fails instead of creating a duplicate statement. - Emit a log line or metric when `release()` returns `false`; it means the lease was too short for real workloads. A cache lock narrows the window for duplicates; only an idempotent write closes it. ## Sizing the lease Questions worth answering before picking the number: - How long does the slowest realistic statement take — the largest account at month end, not the median? - How long can a crashed worker block a customer before someone notices? - Can the work be split into chunks, so a short lease plus `refresh()` replaces a long lease? - What happens downstream if two runs overlap: a duplicate email, a duplicate row, a double charge? The worse the overlap, the more the design should lean on refresh-and-abort plus a database constraint rather than on a generous lifetime alone.

  • In Laravel, why is Cache::lock('statement:42')->forceRelease() dangerous in a job's normal code path?
    `forceRelease()` deletes the lock regardless of owner. If this worker's lease already expired and another worker acquired the lock, a force release removes the other worker's lock and lets a third process start the same statement. Use `release()`, which checks the owner, and keep `forceRelease()` for operator tooling after a crash.
  • In Laravel, what does Cache::lock($name) with no seconds argument mean on the Redis and database stores?
    The lifetime defaults to 0. The Redis lock then uses a plain set-if-not-exists with no expiry, so a crashed holder blocks the name until someone force-releases it. The database lock substitutes the store's `lock_timeout`, 86400 seconds unless configured. Always pass an explicit lifetime.

saying these in an interview costs you the question

  • Laravel stops the job when its lock's lifetime runs out
  • release() deletes the lock even if another worker now owns it
  • refresh() can re-acquire a lock that another worker has taken
  • A lock with 0 seconds expires immediately
  • A cache lock alone guarantees a statement is never generated twice