turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

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.

PHP
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.

Also in Admin and Operations