OPS-005
Stop two cron runs from overlapping
A non-blocking file lock in three lines, and why a database flag is the wrong tool for this particular job.
A job scheduled every five minutes that sometimes takes six will eventually run twice at once. Two copies pruning the same table, retrying the same payouts, sending the same emails.
A lock file with a non-blocking flock is the right primitive. The second run tries the lock, fails immediately, and exits. When the first process dies — cleanly, crashed, or killed — the operating system releases the lock, which is the property a database flag does not have.
That is the trap worth naming: a running = 1 column looks equivalent until a run dies without clearing it, and then the job never runs again until someone notices and resets it by hand.
function with_lock(string $name, callable $work)
{
$path = sys_get_temp_dir() . '/' . preg_replace('~[^a-z0-9_\-]~i', '_', $name) . '.lock';
$fh = fopen($path, 'c');
if ($fh === false) {
throw new RuntimeException('cannot open lock file: ' . $path);
}
// LOCK_NB: fail immediately rather than queueing up behind the running copy.
if (!flock($fh, LOCK_EX | LOCK_NB)) {
fclose($fh);
return null; // already running
}
// Useful when something is stuck and you need to know which process holds it.
ftruncate($fh, 0);
fwrite($fh, (string) getmypid() . ' ' . date('c') . "\n");
fflush($fh);
try {
return $work();
} finally {
flock($fh, LOCK_UN);
fclose($fh); // released even if $work() threw
}
}
// $result = with_lock('payout_retry', fn () => retry_failed_payouts($pdo));
// if ($result === null) { echo "already running\n"; }
Using it
One lock per job, not one for the whole cron. A slow report should not block a fast payout retry.
Write the pid and the start time into the file. When a job appears stuck, that tells you whether a process really holds it.
Put the lock file somewhere that survives between runs but is not web-readable. The system temp directory is fine on shared hosting.
What bites people
flock does not work reliably on some network filesystems. On NFS or similar, use a database-level lock such as MySQL's GET_LOCK instead, which is also released automatically when the connection drops.
Never use a plain "running" column with no timeout. The first crash disables your scheduler permanently and silently.
Release in a finally block. A thrown exception must not leave the lock held for the life of the process.