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?
answer
- the lifetime is a crash safety net
- expired lock lets a second worker in
- release() checks the owner, returns false
- refresh(): extend while still owned
- 0 seconds on Redis: never expires
basics
~20 sIf 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 sThe 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
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
Recall that the seconds on a lock are its lifetime, and that the lock can disappear while your code is still running.
Explain the owner token, why release() returns false after expiry, and what refresh() and forceRelease() do differently.
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.
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