Scheduling And Background Work
Poor Man's Cron
The Core Idea
Store a timestamp of the last run somewhere durable (a file, a database row, a cache key). On (some or all) incoming requests, check whether enough time has elapsed since that stored timestamp; if so, run the scheduled work and update the timestamp.
<?php
declare(strict_types=1);
function claimScheduledRun(string $stateFile, int $intervalSeconds): bool
{
$handle = fopen($stateFile, 'c+');
if ($handle === false) {
throw new RuntimeException("Cannot open state file: {$stateFile}");
}
if (!flock($handle, LOCK_EX)) {
fclose($handle);
return false;
}
$lastRun = (int) fread($handle, 1024);
$now = time();
$shouldRun = ($now - $lastRun) >= $intervalSeconds;
if ($shouldRun) {
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, (string) $now);
fflush($handle);
}
flock($handle, LOCK_UN);
fclose($handle);
return $shouldRun;
}
$stateFile = tempnam(sys_get_temp_dir(), 'poor-mans-cron-');
file_put_contents($stateFile, (string) (time() - 400)); // pretend it last ran 400s ago
var_dump(claimScheduledRun($stateFile, 300)); // 300s interval already elapsed
var_dump(claimScheduledRun($stateFile, 300)); // just claimed; too soon again
// Prints:
// bool(true)
// bool(false)
The flock(LOCK_EX) around the read-check-write sequence is not optional decoration — it's the difference between a design that works and one that silently double-runs under real traffic, covered next.
The Concurrency Problem Is Immediate, Not An Edge Case
A production site doesn't get one request at a time. If ten requests arrive within the same second and all check "has the interval elapsed?" before any of them has updated the stored timestamp, all ten will see "yes" and all ten will trigger the job — a classic check-then-act race condition, and on a busy site it isn't rare, it's the normal case. This is exactly why the example above wraps the read, check, and write in a single flock()-protected critical section: the lock forces requests to take turns, so only the first one to acquire the lock sees $shouldRun = true; by the time the second request gets the lock, the timestamp has already been updated and it correctly sees false.
Without that lock, a "send a daily digest email" job triggered this way could send the digest ten times in the same second to every user, from ten separate PHP-FPM worker processes that each independently believed they were the one instance running it.
Running The Job Without Blocking The Request
Even after solving the "who gets to run it" race, there's a second design question: should the triggering request wait for the job to finish? For a fast job (a few hundred milliseconds), running it inline before the request finishes might be acceptable, but it directly slows down whichever unlucky visitor's request happened to trigger it — a bad experience for a job that has nothing to do with what they asked for. For anything slower, don't block the request:
- Fire off a background process — PHP can start a detached subprocess (
proc_openwith output redirected away and the request not waiting on it) so the triggering request returns normally while the job runs independently. - Push to a queue — if the application already has a queue worker (covered elsewhere in a full background-jobs setup), the triggering request's job is just to enqueue a message, which is fast and safe to do inline; a separate worker process picks it up on its own schedule.
- Respond first, then work — some setups flush the HTTP response to the client and continue running PHP afterward, though this depends on server configuration (FastCGI's
fastcgi_finish_request()under PHP-FPM is the common mechanism) and ties up a worker process for the job's duration either way, unlike a genuinely separate process or queue.
Blocking a real user's request on background work is the most common practical complaint about poor man's cron setups that skip this design question.
Traffic Dependence Is A Real Limitation
This approach only fires when a request happens to arrive after the interval has elapsed — on a low-traffic site (a few requests an hour), a job meant to run every 5 minutes might actually run every 40 minutes, or not at all during a quiet overnight stretch, and then fire (correctly, but perhaps surprisingly) on the next visitor regardless of how much time has actually passed. If your schedule needs to be reliable independent of traffic, poor man's cron is the wrong tool — real cron, a managed scheduler, or a long-running worker process are the right ones. Poor man's cron is a fallback for environments that give you no other option, not a general-purpose scheduling mechanism to prefer when you have a choice.
Failure Modes
The most common failure is skipping the lock around the check-and-update, assuming a race is unlikely enough not to matter — it isn't; concurrent requests are the normal operating condition of any real web application, not a rare edge case, and the resulting duplicate-trigger bug is intermittent and traffic-dependent, which makes it unusually hard to reproduce and debug after the fact.
The second is blocking the triggering request on slow work, which turns an unrelated visitor's page load into collateral damage for a background job they have nothing to do with.
The third is not accounting for traffic dependence when a schedule needs to be reliable — deploying poor man's cron for a job that genuinely needs to run precisely every 5 minutes regardless of traffic, on a site where that assumption doesn't hold.
What To Check
Before moving on, make sure you can:
- explain the check-then-act race that makes an unlocked "has enough time passed" check unsafe under concurrent requests
- implement a locked claim-and-update sequence that only one concurrent request can win
- explain why a triggering request generally shouldn't block on the scheduled work itself, and name at least one alternative (subprocess, queue, finish-request-then-continue)
- explain the traffic-dependence limitation and when it rules poor man's cron out entirely
What You Should Be Able To Do
After this lesson, you should be able to implement a request-triggered scheduling mechanism that's safe under concurrent traffic, decide how the triggered work should actually run without degrading the triggering request, and recognize when this approach is the wrong tool because a workload genuinely needs traffic-independent scheduling.
Practice
Build a request-triggered scheduler
You're building a session-cleanup job for an app on shared hosting with no cron access. The hosting environment's filesystem is network-mounted (NFS-backed), and flock() is documented as unreliable over NFS in some configurations — you don't want to depend on it. You do have a MySQL database available.
Design a request-triggered scheduler using a database table instead of a locked file, covering:
- a
scheduler_statetable schema (one row is enough) that tracks the last run time; - the PHP/SQL claiming logic — specifically, how a single atomic
UPDATEstatement can serve the same roleflock()served in the article's file-based version, without needing a separate lock at all; - why checking the UPDATE's affected-row count is the part that makes this safe under concurrent requests;
- one sentence on why this database-row approach is actually a better fit here than trying to work around the NFS locking caveat.
Show solution
Solution
Schema:
CREATE TABLE scheduler_state (
job_name VARCHAR(64) PRIMARY KEY,
last_run_at INT UNSIGNED NOT NULL
);
INSERT INTO scheduler_state (job_name, last_run_at) VALUES ('session_cleanup', 0);
One row per named job is enough; job_name as the primary key lets the same table serve multiple scheduled jobs later without a schema change.
Claiming logic:
<?php
declare(strict_types=1);
// no-execute: requires a live MySQL connection via PDO.
function claimScheduledRun(PDO $pdo, string $jobName, int $intervalSeconds): bool
{
$now = time();
$stmt = $pdo->prepare(
'UPDATE scheduler_state
SET last_run_at = :now
WHERE job_name = :job_name
AND last_run_at <= :threshold'
);
$stmt->execute([
'now' => $now,
'job_name' => $jobName,
'threshold' => $now - $intervalSeconds,
]);
return $stmt->rowCount() === 1;
}
The UPDATE ... WHERE last_run_at <= :threshold is the entire locking mechanism — there's no separate lock acquired and released, because the database's own row-level locking during the UPDATE already serializes concurrent attempts to modify the same row. Two requests arriving at the same instant both issue this UPDATE; the database processes them one at a time internally, and only the first one finds a row that still satisfies last_run_at <= :threshold (the second one's UPDATE runs after the first has already advanced last_run_at, so its WHERE clause matches zero rows).
Why the affected-row count matters: rowCount() === 1 is the signal that this specific request was the one that successfully updated the timestamp — the equivalent of "acquired the lock" in the file-based version. A request whose UPDATE matches zero rows (because another request already updated last_run_at past the threshold microseconds earlier) gets rowCount() === 0 and correctly knows it lost the race and should not run the job. Without checking this, every request would believe it successfully claimed the run, since the UPDATE statement itself doesn't error just because it matched no rows.
Why this fits better than working around NFS: an atomic conditional UPDATE pushes the concurrency-safety requirement onto the database, which is specifically designed to serialize concurrent writes to the same row correctly — regardless of the filesystem underneath the web servers. This sidesteps the NFS flock() reliability question entirely rather than trying to verify or work around it, which is the safer choice when the underlying locking primitive's reliability is already in doubt.
Diagnose a duplicate trigger
<?php
declare(strict_types=1);
function shouldSendDigest(string $stateFile, int $intervalSeconds): bool
{
$lastRun = file_exists($stateFile) ? (int) file_get_contents($stateFile) : 0;
$now = time();
if (($now - $lastRun) < $intervalSeconds) {
return false;
}
file_put_contents($stateFile, (string) $now);
return true;
}
Users report receiving the weekly digest email 3-6 times within the same minute, once a week, always around the same time (a period when the site reliably gets a burst of simultaneous traffic from a link shared on social media).
Diagnose the bug and write a plan covering:
- exactly what sequence of events produces 3-6 sends instead of 1 (walk through it with concrete overlapping requests);
- why this bug is specifically tied to simultaneous traffic, and why it wouldn't show up in normal manual testing;
- how you'd reproduce it deliberately rather than waiting for next week's traffic spike;
- the fix, and why it closes the gap this specific code leaves open.
Show solution
Diagnosis
The sequence that produces multiple sends: shouldSendDigest() does a read (file_get_contents), a check, and a write (file_put_contents) as three separate, unsynchronized steps. When a burst of simultaneous requests arrives (say 5 of them, all within the same second, right after the weekly interval has elapsed): all 5 call file_get_contents($stateFile) before any of them has called file_put_contents() — because nothing forces them to wait for each other. All 5 read the same old $lastRun value, all 5 compute ($now - $lastRun) >= $intervalSeconds as true, and all 5 return true and separately trigger a digest send. Each one also calls file_put_contents() afterward, but by then the damage is done — the digest has already gone out multiple times. This is the check-then-act race the article names directly: nothing serializes the read-check-write sequence across concurrent requests.
Why it's tied to simultaneous traffic specifically: the bug requires multiple requests to be between the read and the write at the same time — with low, spread-out traffic, requests arrive far enough apart that the first one's write always completes before the second one's read, and the code behaves correctly by accident. A social-media traffic spike is exactly the condition that reliably produces several requests within the same instant, which is why the bug shows up specifically then and not during ordinary browsing traffic or during a developer's manual single-request testing — manual testing essentially never has two requests hit the check-then-act window concurrently.
Reproducing deliberately: rather than waiting for next week's traffic, simulate concurrent requests directly — a small script that forks several child processes (or uses curl with parallel requests, e.g. for i in {1..5}; do curl -s https://example.com/digest-trigger & done; wait) all hitting the triggering endpoint at effectively the same instant, with the state file pre-set so the interval has already elapsed. This should reproduce multiple true returns from a single logical "should run" decision, confirming the diagnosis without needing production-scale coordinated traffic.
The fix: wrap the read-check-write sequence in a flock()-protected critical section, exactly as in the article's claimScheduledRun() example — open the state file, acquire an exclusive lock, then do the read/check/write while holding it, then release. This closes the gap because it forces the five concurrent requests to take turns rather than all observing the same pre-update state: the first to acquire the lock sees the elapsed interval and claims it (updating the timestamp before releasing the lock); every subsequent request, once it gets the lock, reads the already-updated timestamp and correctly sees the interval hasn't elapsed since the successful claim, returning false.
Review a poor man's cron design
On every single page request, check a
last_cleanuptimestamp stored in the session. If more than an hour has passed, run the cleanup function inline — scan the uploads directory, delete anything older than 24 hours, then update the timestamp — before continuing to render the page the visitor actually asked for.
Review this design and identify the problems, covering:
- what's wrong with storing the timestamp in the session specifically (as opposed to a file or database row shared across all visitors);
- what happens to the unlucky visitor whose request happens to trigger the cleanup, and why that's a problem even if it's rare;
- whether this design has the concurrency race from this lesson's diagnose exercise, and why storing the state per-session changes the answer;
- a revised design that fixes the real problems here.
Show solution
Review
Storing the timestamp in the session: this is a fundamental design error, not just a missing safeguard. A session is per-visitor — each visitor gets their own session and therefore their own independent last_cleanup value. This means the job doesn't run "once per hour across the whole site," it runs "once per hour, separately, for every single visitor who happens to have an old-enough session timestamp" — so on a site with regular traffic, cleanup could fire dozens of times an hour, once per visitor whose personal session clock happens to have elapsed, all of them redundantly scanning and deleting from the same shared uploads directory. The state needs to live somewhere shared across all requests from all visitors — a file or database row, as in the article and the other exercises in this lesson — not somewhere scoped to one visitor's session.
The unlucky visitor: whichever visitor's request happens to trigger the inline cleanup pays the cost directly — their page load blocks on a full directory scan and a batch of file deletions before they see anything, turning what should be an unrelated background maintenance task into unpredictable latency on a random visitor's experience. This is a problem even if it's rare precisely because it's unpredictable: most visitors get a fast response, and occasionally, with no visible cause from their side, someone's request takes several seconds longer for a reason that has nothing to do with what they asked for — a hard bug to even notice, let alone diagnose, from user reports alone.
The concurrency race: because the state is per-session rather than shared, the check-then-act race from the diagnose exercise doesn't apply in the same way — two different visitors' concurrent requests read and write two different session values, so they don't race over the same stored timestamp. But this isn't a point in the design's favor: it's a symptom of the same root problem (per-session state), and it doesn't make the design safe — it just replaces "sometimes runs multiple times concurrently" with "runs far too often, independently, once per visitor," which is arguably worse for a filesystem-scanning job.
Revised design: move the timestamp to a shared file (or database row, if available) exactly as shown elsewhere in this lesson, protected with flock() (or an atomic UPDATE ... WHERE if using a database) so only one request across the entire site claims each cleanup window. For the blocking problem, don't run the scan-and-delete inline before rendering the visitor's page — trigger a detached background process (proc_open) or enqueue the work if a queue exists, so the triggering request returns normally and the actual cleanup runs independently of any specific visitor's page load.