Scheduling And Background Work
Overlapping Jobs, Locks, And Leases
Cron does not check whether the previous run of a job has finished before starting the next one. A job scheduled every minute that occasionally takes 90 seconds will, sooner or later, have two instances running at once — reading and writing the same data, sending the same notifications twice, or racing each other in ways that are hard to reproduce because they depend on exact timing. This lesson covers preventing overlap with locks, why a plain lock isn't quite enough for distributed systems (which is where "lease" comes in), and fencing tokens for the failure mode leases alone don't cover.
Why Overlap Happens
A job's runtime is not fixed — it depends on data volume, network latency to a dependency, database load, and a dozen other things that vary run to run. A job that takes 20 seconds on a normal day can take 3 minutes when a downstream API is slow or the table it scans has grown. If that job is scheduled every minute, cron doesn't know or care that the previous instance is still running — it starts a new one on schedule regardless. Now two instances are executing the same logic against the same data concurrently, and whatever that logic assumed about running alone (a "read current balance, then update it" sequence, for example) can produce wrong results neither instance would produce alone.
File Locks: The Local Solution
For a job running on a single server, a file lock is the simplest fix — acquire an exclusive, non-blocking lock at the start; if you can't get it, another instance is already running, so exit immediately instead of waiting or duplicating work.
<?php
declare(strict_types=1);
function tryRunExclusively(string $lockFile, callable $job): bool
{
$handle = fopen($lockFile, 'c');
if ($handle === false) {
throw new RuntimeException("Cannot open lock file: {$lockFile}");
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return false; // another instance already holds the lock
}
try {
$job();
return true;
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
$lockFile = tempnam(sys_get_temp_dir(), 'cron-lock-');
// Simulate a first instance holding the lock open for the whole "run"
$firstHandle = fopen($lockFile, 'c');
flock($firstHandle, LOCK_EX);
// A second instance tries to start while the first is still running
$secondRan = tryRunExclusively($lockFile, function () {
echo "second instance ran\n";
});
var_dump($secondRan);
// The first instance finishes and releases its lock
flock($firstHandle, LOCK_UN);
fclose($firstHandle);
// Now a third attempt succeeds
$thirdRan = tryRunExclusively($lockFile, function () {
echo "third instance ran\n";
});
var_dump($thirdRan);
// Prints:
// bool(false)
// third instance ran
// bool(true)
LOCK_EX | LOCK_NB requests an exclusive lock without blocking — if another process already holds it, flock() returns false immediately instead of waiting, which is exactly what a scheduled job wants: skip this run rather than queue up behind a slow one, which would only make the overlap worse as delayed runs pile up.
A key property of OS-level file locks: if the process holding one crashes or is killed, the operating system releases the lock automatically when the process's file descriptors are cleaned up. This means a plain flock() doesn't have the "stale lock held forever by a dead process" problem — which is not true of the distributed locks covered next, and is exactly why they need an extra idea.
Why Distributed Locks Need An Expiry: Leases
A file lock only protects a single machine. Once a job runs on multiple servers — several web servers each with the same crontab, or a horizontally-scaled worker fleet — you need a lock that all of them can see, typically implemented with a shared store like Redis or a database.
The problem: unlike an OS file lock, a distributed lock isn't automatically released just because the process that acquired it crashed — nothing about Redis knows or cares that the PHP process on server A that called SET lock:daily-report ... NX just died. Without an expiry, a crashed holder leaves the lock held forever, and every future run is blocked indefinitely by a lock nobody will ever release.
The fix is a lease: a lock with a built-in expiry (TTL), so even if the holder crashes without releasing it, the lock frees itself automatically after the TTL passes.
<?php
declare(strict_types=1);
// no-execute: requires a live Redis server via the redis extension.
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$token = bin2hex(random_bytes(16));
$acquired = $redis->set('lock:daily-report', $token, ['NX', 'EX' => 30]);
if (!$acquired) {
exit(0); // another node already holds the lease
}
try {
// ... run the job; must finish comfortably within 30 seconds ...
} finally {
$release = <<<'LUA'
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
LUA;
$redis->eval($release, ['lock:daily-report', $token], 1);
}
NX means "set only if the key doesn't already exist" (the acquire step), and EX 30 sets a 30-second expiry — the lease. If this process dies mid-job, Redis expires the key on its own after 30 seconds, and the next scheduled attempt can acquire it. The TTL must be chosen deliberately: too short, and a legitimately-still-running job can lose its lease to another instance while it's mid-work; too long, and a genuine crash leaves the resource blocked for longer than necessary.
The Release Must Check Ownership: Why The Token Exists
Notice the release step doesn't just DEL the key — it checks that the stored value still matches the unique $token this specific process generated when it acquired the lease. This matters because of a specific race: if a job runs slightly longer than its TTL, the lease can expire and be acquired by a second instance while the first instance is still finishing up. If the first instance's cleanup code then blindly ran DEL lock:daily-report, it would delete the second instance's currently-active lease — not its own, which already expired — leaving the resource unprotected while the second instance still believes it holds an exclusive lease. Comparing the token before deleting (done atomically via the Lua script, so the check-then-delete can't itself race) ensures a process only ever releases a lease it still actually owns.
Fencing Tokens: The Failure The Token Check Alone Doesn't Solve
The token check prevents deleting someone else's lease, but there's a subtler problem it doesn't solve: what if the first instance, after its lease has already expired and been reacquired by a second instance, is still slow enough to make one more write to the actual protected resource (the database, the report file) before it notices its lease is gone? Both instances can end up writing to the shared resource around the same time, even though only one of them currently "holds the lease" — the lease only ever protected the right to start, not a guarantee that the protected write always happens while still valid.
The fix is a fencing token: a number that increases every time the lease is acquired (e.g., a Redis INCR alongside the lock), passed along with every write to the protected resource itself. The resource (a database update, a storage API) is told to reject any write carrying a fencing token lower than the highest one it's already seen. If instance A acquired the lease with token 7, lost it, and instance B acquired it next with token 8, any late write from A still carrying token 7 is rejected by the resource — even though A doesn't know it's no longer the legitimate holder, the resource does. This requires the protected resource itself to understand and check fencing tokens, which is more machinery than most scheduled PHP jobs need — reserve it for the cases where a stale write actually causes serious harm (financial transactions, anything non-idempotent), not as a default for every locked job.
Failure Modes
The most common failure is not locking at all, on the assumption that overlap "probably won't happen" — it will, the moment the job's runtime occasionally exceeds its interval, which for most real workloads is eventually.
The second is a distributed lock with no expiry, which converts a single crashed process into a permanently stuck job — every future scheduled run blocked by a lock nobody will ever release, discovered only when someone notices the job hasn't run in days.
The third is releasing a lease without checking ownership, which can silently delete a different instance's active lease during exactly the overlap scenario the lease was meant to prevent.
What To Check
Before moving on, make sure you can:
- explain why a job's runtime occasionally exceeding its schedule interval makes overlap inevitable, not just theoretically possible
- implement a non-blocking
flock()-based exclusive run for a single-server scheduled job - explain why OS file locks release automatically on crash, and why distributed locks don't
- implement a lease (lock + TTL) and explain what TTL value tradeoff it represents
- explain why a lease's release step must check ownership (via a token) before deleting
- describe what a fencing token adds beyond a token-checked release, and why it requires cooperation from the protected resource itself
What You Should Be Able To Do
After this lesson, you should be able to add overlap protection to a scheduled PHP job: a non-blocking file lock for single-server jobs, or a TTL'd, token-verified lease for distributed ones — and reason about when the added complexity of fencing tokens is actually warranted versus unnecessary for a given job's risk profile.
Practice
Add a lock to a cron job
<?php
declare(strict_types=1);
function reconcile(): void
{
// ... reads pending transactions, updates balances, marks them reconciled ...
echo "reconciliation complete\n";
}
reconcile();
Add overlap protection using a non-blocking file lock, covering:
- the modified script, wrapping
reconcile()so a second instance starting while the first is still running exits immediately instead of running; - what should happen (concretely — log line, exit code) when a run is skipped because another instance holds the lock, and why that matters for monitoring;
- whether this lock needs a TTL/expiry the way the article's Redis lease example does, and why the answer is different for a single-server file lock.
Show solution
Solution
<?php
declare(strict_types=1);
function reconcile(): void
{
// ... reads pending transactions, updates balances, marks them reconciled ...
echo "reconciliation complete\n";
}
function tryRunExclusively(string $lockFile, callable $job): bool
{
$handle = fopen($lockFile, 'c');
if ($handle === false) {
throw new RuntimeException("Cannot open lock file: {$lockFile}");
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return false;
}
try {
$job();
return true;
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
$lockFile = sys_get_temp_dir() . '/reconcile.lock';
$ran = tryRunExclusively($lockFile, 'reconcile');
if (!$ran) {
fwrite(STDERR, "reconcile skipped: previous run still in progress\n");
exit(75); // EX_TEMPFAIL: a conventional "temporary failure, try again later" exit code
}
// Prints:
// reconciliation complete
What happens on a skipped run: a message to STDERR ("previous run still in progress") and a distinct, non-zero exit code — deliberately not the same exit code a genuine failure would use, since "skipped because still running" and "crashed" mean very different things to whoever is monitoring this job. This matters for monitoring because an occasional skip is expected and benign (this job is explicitly scheduled every 5 minutes specifically as a retry safety net, so some runs finding the lock held is normal), but frequent skips are a real signal — they mean the job is regularly taking longer than 5 minutes and the interval or the job's performance needs attention. Logging skips distinguishably (rather than not logging them at all, or logging them identically to a crash) is what makes that pattern visible over time instead of invisible.
TTL/expiry: this lock does not need a TTL the way the article's Redis lease does. The article explains why: an OS-level flock() is automatically released by the kernel when the holding process's file descriptor is closed — including when the process crashes or is killed, since the OS reclaims all of a dead process's file descriptors as part of cleanup. There's no scenario where a crashed PHP process leaves this flock() held forever, because the lock isn't a fact about the file's content (which would persist independent of the process) — it's a fact tracked by the kernel about a specific process's open file descriptor, and the kernel cleans that up unconditionally when the process ends. A TTL is what compensates for exactly the guarantee this local mechanism already provides for free.
Diagnose an overlap bug
<?php
declare(strict_types=1);
$lockFile = sys_get_temp_dir() . '/import.lock';
if (file_exists($lockFile)) {
exit(0); // another instance must be running
}
touch($lockFile);
try {
// ... do the import ...
} finally {
unlink($lockFile);
}
This worked fine in testing. In production, the import job hung once (an upstream API stopped responding and the process was eventually killed by the server's out-of-memory killer, without ever reaching the finally block). Since then, the job has not run a single time — every scheduled attempt exits immediately at the file_exists() check, for days, with no errors anywhere.
Diagnose the failure and write a plan covering:
- exactly why this "lock" behaves differently from the article's
flock()-based one in the crash scenario; - what evidence would confirm this diagnosis (what you'd actually go check);
- why this bug was invisible in testing but guaranteed to eventually happen in production;
- the fix, and whether it needs anything beyond switching to
flock().
Show solution
Diagnosis
Why this behaves differently from a real flock(): this "lock" is really just a marker file whose existence is being treated as the lock state — touch() creates it, unlink() removes it, and the check is a plain file_exists(). That existence is a fact about the filesystem, completely independent of whether the process that created the file is still alive. The article explains that OS-level flock() locks are tied to a process's open file descriptor and get released automatically by the kernel when the process dies for any reason, including a crash or being killed. A marker file has no such relationship to the process — when the OOM killer terminated the import process mid-run, the kernel cleaned up that process's resources, but the marker file itself, sitting on disk as an ordinary file, was completely unaffected. Nothing ever runs unlink() for it again, because the code that would have run it (the finally block) belongs to a process that no longer exists. The file sits there forever, and every subsequent run's file_exists() check correctly (from the code's perspective) reports "yes, locked" — forever.
Evidence to confirm this: check whether /tmp/import.lock currently exists on the server — if it does, and its modification time lines up with roughly when the reported hang happened (days ago), that's direct, conclusive confirmation. Cross-reference with the server's OOM killer logs (dmesg or /var/log/kern.log, depending on distribution) around that timestamp to confirm the earlier process was in fact killed rather than exiting normally — connecting "process was killed, not exited" to "marker file was never cleaned up."
Why testing missed it and production was guaranteed to hit it eventually: in normal testing, the import job runs, finishes, and reaches its finally block, which calls unlink() — the marker file always gets cleaned up because nothing ever interrupts execution abnormally. The bug only manifests when the process is terminated in a way that skips the finally block entirely: an OOM kill, a kill -9, a server crash. That's not a code path anyone exercises by running the job normally, however many times — it requires a genuine abnormal termination, which is rare in testing and effectively guaranteed to happen eventually in production, where OOM conditions, deploys that restart PHP mid-run, and other abrupt terminations are a normal (if infrequent) occurrence over a long enough timeline.
The fix: replace the marker-file check with an actual flock()-based lock, as in the article and the previous exercise — the lock's release becomes the kernel's responsibility on process termination, for any reason, rather than depending on the script's own cleanup code successfully running. This needs nothing beyond switching to flock(); no TTL or extra machinery is required for a single-server lock, per the article's explanation of why OS locks already have crash-safety built in. The immediate remediation is also worth naming separately: delete the stuck /tmp/import.lock file by hand once, to unblock the job today, while the code fix (switching to flock()) is what prevents this from ever needing a manual fix again.
Review a locking strategy
<?php
declare(strict_types=1);
// no-execute: requires a live Redis server via the redis extension.
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$acquired = $redis->set('lock:invoice-export', '1', ['NX', 'EX' => 5]);
if (!$acquired) {
exit(0);
}
try {
exportInvoices(); // typically takes 10-15 seconds
} finally {
$redis->del('lock:invoice-export');
}
Review this and identify every problem, covering:
- the TTL choice relative to the job's actual runtime, and what specifically goes wrong because of the mismatch;
- the release step, and what's missing compared to the article's example;
- whether the two problems above are related or independent — walk through a concrete sequence of events where both bite at once;
- a corrected version.
Show solution
Review
The TTL problem: EX 5 sets a 5-second lease, but exportInvoices() typically takes 10-15 seconds — meaning the lease is expected to expire while the job is still legitimately running, every single time, not as a rare edge case. Per the article, a TTL that's too short relative to actual runtime means a legitimately-still-running instance routinely loses its lease to another instance, defeating the entire purpose of the lock: two instances end up running exportInvoices() concurrently on nearly every execution, exactly the overlap this mechanism exists to prevent. The TTL needs to comfortably exceed the job's worst-case realistic runtime, with margin — something like 60 seconds for a job that normally takes 10-15, not a value shorter than even the typical case.
The release problem: $redis->del('lock:invoice-export') unconditionally deletes the key, with no check that this process still owns the lease. There's no unique token being set or compared — every instance is racing to set and delete the same fixed string '1'. Per the article, this means a process can delete a lease it no longer owns: if this process's lease expired and a second instance acquired a new one, this process's finally block will still delete it — the second instance's lease — leaving the resource completely unprotected while the second instance believes it's still exclusively holding it.
Why these two are related, not independent: the token-less release problem is largely masked when the TTL is generous enough that leases essentially never expire mid-run — there's rarely a second instance to accidentally delete the lease of. But combined with the too-short TTL here, the two compound directly. Concrete sequence: instance A acquires the lease at T+0 (5-second TTL, expires T+5). At T+5, the lease auto-expires while A is still exporting (it takes 10-15s). At T+6, instance B's scheduled attempt acquires a new lease successfully. At T+12, instance A finishes and its finally block runs DEL, deleting instance B's still-active lease — even though B is still exporting invoices, believing it holds the lock alone. At T+13, a third scheduled attempt (instance C) sees no lease and acquires one, and now B and C are both exporting simultaneously, with nothing left tracking any of this. The short TTL creates the expired-lease-during-active-run scenario, and the missing token-check turns that scenario into a second overlap on top of the first.
Corrected version:
<?php
declare(strict_types=1);
// no-execute: requires a live Redis server via the redis extension.
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$token = bin2hex(random_bytes(16));
$acquired = $redis->set('lock:invoice-export', $token, ['NX', 'EX' => 60]);
if (!$acquired) {
exit(0);
}
try {
exportInvoices();
} finally {
$release = <<<'LUA'
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
LUA;
$redis->eval($release, ['lock:invoice-export', $token], 1);
}
EX 60 gives comfortable margin over the 10-15 second typical runtime, and the unique $token plus the compare-and-delete Lua script ensure this process only ever releases a lease it still actually holds.