Scheduling And Background Work
Distributed Scheduled Jobs
The N-Times Problem
Cron has no concept of "other servers." If a deployment process installs the same crontab on every server in an autoscaling group, and that group runs 4 instances, a job scheduled for 0 2 * * * fires 4 times at 2:00 AM — once per server, all believing they're the only one running it. For an idempotent, side-effect-free job (read-only reporting, for instance) this might be merely wasteful. For a job that charges a customer, sends a notification, or increments a counter, it's a production incident.
Option 1: A Designated Cron Server
The simplest fix: only one server in the fleet has the scheduling crontab installed at all; the rest don't. This works, and many teams run this way successfully, but it has real weaknesses — that one server is a single point of failure for scheduling (if it's down or being replaced during a deploy, jobs silently don't run, with no automatic failover), and "which server is the cron server" is a piece of tribal knowledge or manual configuration that has to survive infrastructure changes, autoscaling events, and team turnover. It's a reasonable starting point for a small fleet, not a robust long-term answer for anything that scales or needs reliability guarantees.
Option 2: A Distributed Lock
Every server keeps its own crontab (simple, uniform, no special-casing any one server), but each job acquires a shared lease before actually running — exactly the Redis-based lease from the previous lesson, applied here for a different reason. There, the lease prevented one server's job from overlapping itself across runs; here, it prevents the same scheduled moment from running on multiple servers at once.
<?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:nightly-charge-batch', $token, ['NX', 'EX' => 300]);
if (!$acquired) {
exit(0); // a different server already claimed this run
}
try {
// ... run the job; comfortably within 300 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:nightly-charge-batch', $token], 1);
}
All 4 servers' crontabs fire at 2:00 AM and all 4 attempt the SET ... NX; exactly one succeeds, runs the job, and the other 3 exit immediately. Uniform configuration across the fleet, and it survives any single server going down — whichever servers are up at 2:00 AM race for the lease, and one of them wins.
Option 3: A Dedicated Scheduler
Rather than distributing the scheduling decision across every application server, delegate it to infrastructure built specifically for this: Kubernetes CronJob resources, a managed scheduler service from your cloud provider, or a dedicated internal scheduling service that calls an application endpoint or enqueues a message on schedule. This removes the "N servers, N crontabs" problem at the root — there's exactly one thing deciding when the job runs, external to the fleet of servers doing application work, and it typically comes with built-in retry, failure alerting, and history that you'd otherwise have to build yourself. The tradeoff is infrastructure complexity and, for platform-specific schedulers, a dependency on that platform's own reliability — worth it once a team has enough scheduled jobs and enough scale that the distributed-lock approach's operational overhead (Redis dependency, lease tuning, monitoring the lock mechanism itself) exceeds the cost of adopting dedicated scheduling infrastructure.
Idempotency: The Backstop Under All Three
None of the above mechanisms are perfect — a lease can theoretically still allow a double-run under network partitions or the stale-write scenario the previous lesson's fencing-token section describes; a dedicated scheduler can retry a job that actually succeeded but whose success acknowledgment was lost. The property that makes all of this safe even when the scheduling mechanism itself has a rare failure is idempotency: designing the job so running it twice produces the same result as running it once.
<?php
declare(strict_types=1);
final class OrderLedger
{
/** @var array<string, float> */
private array $processed = [];
public function applyPayment(string $idempotencyKey, float $amount): void
{
if (isset($this->processed[$idempotencyKey])) {
return; // already applied; a duplicate delivery has no additional effect
}
$this->processed[$idempotencyKey] = $amount;
}
public function total(): float
{
return array_sum($this->processed);
}
}
$ledger = new OrderLedger();
$ledger->applyPayment('order-42-charge', 19.99);
$ledger->applyPayment('order-42-charge', 19.99); // duplicate, e.g. two servers raced
$ledger->applyPayment('order-43-charge', 5.00);
echo $ledger->total();
// Prints:
// 24.99
An idempotency key — a stable identifier for "this specific unit of work," not just "this job ran" — lets the receiving side (a database with a unique constraint, a payment processor's idempotency-key API, an in-memory set as shown here) recognize and discard a duplicate regardless of why it happened: a lease race, a retry after a lost acknowledgment, a human re-running something by hand. Locks and leases reduce how often duplicates happen; idempotency is what makes an occasional duplicate harmless instead of a real incident. Both matter — idempotency alone without any locking means constantly relying on the backstop for things that a lock would have prevented cheaply in the first place, and locking alone without idempotency means a rare lock failure becomes a real incident instead of a shrug.
Missed-Run Monitoring: The Dead Man's Switch
Every mechanism above prevents a job from running too often. None of them tell you when a job didn't run at all — a crashed scheduler, a Redis outage that made every lease attempt fail, a deploy that accidentally removed the crontab entry from every server. The pattern for catching this is a dead man's switch: the job pings an external monitoring endpoint on success, and that monitoring service alerts specifically when it doesn't receive a ping within the expected window.
<?php
declare(strict_types=1);
function reportSuccess(string $healthcheckUrl): void
{
$context = stream_context_create(['http' => ['timeout' => 5]]);
@file_get_contents($healthcheckUrl, false, $context);
}
// After the job's real work completes successfully:
// reportSuccess('https://hc-ping.com/your-check-id-here');
echo "job completed; would report success to monitoring\n";
// Prints:
// job completed; would report success to monitoring
This inverts the usual alerting model — instead of "alert me when something bad happens," it's "alert me when something expected stops happening," which is exactly the shape of failure none of the locking mechanisms above can detect on their own, since a job that never runs never has the chance to report an error about itself.
Failure Modes
The most common failure is installing the same crontab on every server with no coordination at all, discovered only when a job's side effects (duplicate emails, duplicate charges) make the N-times problem visible in a way that can't be ignored.
The second is relying on locking alone with no idempotency, treating the lock as a perfect guarantee rather than a strong reduction — when the rare double-run does happen (and over a long enough timeline, it will), there's nothing absorbing it.
The third is no missed-run monitoring, where a job silently stops running — Redis outage, a bad deploy, an expired credential — and nobody notices until the absence of its output becomes a problem days or weeks later, by which point diagnosing exactly when it stopped is much harder than it would have been with an immediate alert.
What To Check
Before moving on, make sure you can:
- explain why horizontally-scaled servers with identical crontabs cause a job to run once per server
- compare a designated cron server, a distributed lock, and a dedicated scheduler, and give a reason to choose each
- implement an idempotent operation using a stable idempotency key, and explain why it's a backstop rather than a replacement for locking
- implement a dead man's switch pattern and explain why it catches a failure mode locks and leases cannot
What You Should Be Able To Do
After this lesson, you should be able to design a scheduling setup for a horizontally-scaled PHP application that runs each job exactly once across the fleet, survives individual server failures, treats idempotency as the safety net under an imperfect locking mechanism, and gets alerted when a job silently stops running rather than discovering the gap by accident.
Practice
Design a distributed lock
Your application runs on two worker servers, each with an identical crontab that fires a "send subscription renewal reminders" job every hour. You don't have Redis available, but you do have the application's existing MySQL database. Both workers must never send the same batch of reminders in the same hour.
Design a database-backed lease (not a Redis one) covering:
- a schema for the lease, including what's needed to detect a worker that died mid-run versus one that's cleanly finished (an expiry, the same idea as the Redis lease's TTL);
- the PHP/SQL for acquiring the lease atomically — two workers' cron jobs firing in the same second must never both believe they acquired it;
- the release step, including why it must check ownership the same way the article's token-checked Redis release does;
- what happens if a worker crashes mid-send and never releases — walk through what the other worker sees, and when.
Show solution
Solution
Schema:
CREATE TABLE scheduler_leases (
lease_name VARCHAR(64) PRIMARY KEY,
holder_token VARCHAR(64) NULL,
expires_at INT UNSIGNED NOT NULL DEFAULT 0
);
INSERT INTO scheduler_leases (lease_name, holder_token, expires_at)
VALUES ('renewal-reminders', NULL, 0);
expires_at plays the same role as the Redis lease's EX TTL — a worker that dies mid-run leaves a row with a holder_token set, but once expires_at is in the past, that token is treated as stale and available to reclaim, exactly like Redis's automatic key expiry.
Acquiring atomically:
<?php
declare(strict_types=1);
// no-execute: requires a live MySQL connection via PDO.
function acquireLease(PDO $pdo, string $leaseName, int $ttlSeconds): ?string
{
$token = bin2hex(random_bytes(16));
$now = time();
$stmt = $pdo->prepare(
'UPDATE scheduler_leases
SET holder_token = :token, expires_at = :expires
WHERE lease_name = :lease_name
AND expires_at < :now'
);
$stmt->execute([
'token' => $token,
'expires' => $now + $ttlSeconds,
'lease_name' => $leaseName,
'now' => $now,
]);
return $stmt->rowCount() === 1 ? $token : null;
}
This is the same atomic-UPDATE-with-a-WHERE-condition pattern from the poor man's cron lesson's database exercise, applied to a lease instead of a plain timestamp: WHERE expires_at < :now only matches a row whose current lease has genuinely expired (or never been held). Two workers' cron jobs firing in the same second both issue this UPDATE; the database serializes the two writes to the same row, and only the first one finds the row still matching the WHERE clause — the second one's UPDATE runs after the first has already advanced expires_at into the future, so it matches zero rows and correctly gets null back.
Releasing, with ownership check:
<?php
declare(strict_types=1);
// no-execute: requires a live MySQL connection via PDO.
function releaseLease(PDO $pdo, string $leaseName, string $token): void
{
$stmt = $pdo->prepare(
'UPDATE scheduler_leases
SET holder_token = NULL, expires_at = 0
WHERE lease_name = :lease_name
AND holder_token = :token'
);
$stmt->execute(['lease_name' => $leaseName, 'token' => $token]);
}
This checks holder_token = :token in the same statement as the release, for the same reason the article's Redis release does a token comparison before deleting: if this worker's lease already expired and a second worker has since acquired a new lease (with a different token), this worker's release must not clear that new lease. Checking the token as part of the WHERE clause means the release only succeeds (only matches a row) if this worker still actually holds the current lease — a stale release attempt from an expired holder simply matches zero rows and does nothing, leaving the current legitimate holder's lease untouched.
Crash scenario, walked through: worker A acquires the lease at 2:00:00 with expires_at set to 2:00:00 + TTL (say a 10-minute TTL, so 2:10:00), then crashes mid-send at 2:03:00 without ever calling releaseLease(). The row sits with A's stale holder_token and expires_at = 2:10:00. Any acquire attempt from worker B between 2:00:00 and 2:10:00 fails (rowCount() === 0, since expires_at is still in the future) — B correctly believes A might still be legitimately working. Only after 2:10:00 does B's next scheduled attempt see expires_at < now, succeed in acquiring a fresh lease, and run the job. The TTL is what bounds how long A's crash can block the job — chosen the same way the article describes for the Redis case: comfortably longer than the job's realistic runtime, but not so long that a genuine crash blocks reminders for an excessive stretch.
Diagnose a duplicate charge
Diagnose the failure and write a plan covering:
- the direct cause, stated precisely (not just "distributed systems are hard" — the specific mechanical reason 2-3 charges happened, tied to the 3-server detail);
- why this didn't happen before the scale-out, and what specifically changed;
- the immediate question to answer before writing any fix: is this job currently idempotent at all, and why that determines whether locking alone is a sufficient fix;
- the two-part durable fix, and which part is more urgent given customers have already been double-charged.
Show solution
Diagnosis
The direct cause: the crontab entry for the billing job is installed identically on all 3 servers, and cron on each server has no awareness of the other two — this is precisely the N-times problem the article opens with. At the scheduled time, all 3 servers independently fire the job, and each one processes the full set of pending invoices and charges each one, with nothing coordinating between them. A customer with one pending invoice gets charged by however many of the 3 servers' job instances happen to reach that invoice before anything (if anything) stops them — which explains the reported 2-3 charges directly: it's bounded by the server count, because that's exactly how many independent, uncoordinated instances of the job ran.
Why this didn't happen before scale-out: with one server, there was only ever one crontab, so the job only ever ran once per scheduled time — there was no N-times problem to have, not because the job was ever protected against it, but because there was only ever one instance in existence. Scaling to 3 servers didn't introduce a new bug into the billing job's logic — it exposed a coordination assumption ("there's only ever one instance of this cron entry running") that was implicitly true before and silently became false the moment the crontab was replicated across servers without anyone revisiting the job's scheduling design.
The immediate question — is the job idempotent: this determines everything about how urgent and how sufficient a locking fix is. If charging is not idempotent — if "charge this invoice" doesn't first check whether it was already charged — then a distributed lock alone is necessary but not sufficient: even a well-implemented lease can theoretically still allow a rare double-run (a lease expiring mid-run under load, a network partition), and with no idempotency, that rare event is still a real customer charge, not a harmless no-op. Given customers have already been charged multiple times, this also needs a data-correctness fix (refunding or reversing the extra charges) independent of whatever prevents recurrence.
Two-part durable fix, in priority order:
- Immediate and most urgent: make the charge operation idempotent, keyed on something stable per invoice per billing period (an idempotency key like
invoice-{id}-{billing_period}), so a database unique constraint or an explicit "already charged" check makes a duplicate charge attempt a no-op rather than a second real charge — regardless of whether it came from a lock failure, a retry, or (as here) three completely uncoordinated instances all running at once. This is the single fix that would have prevented every customer complaint already received, and it protects against every future coordination failure too, not just this specific one. - Also needed, but secondary in urgency: add a distributed lock (Redis lease or database lease, per this lesson) so the job only actually runs once across the fleet, rather than running 3 times and relying entirely on idempotency to absorb it. Idempotency alone technically prevents the customer-facing harm, but running the full billing job 3 times unnecessarily still wastes resources and increases load on whatever the job touches — the lock is what makes the system efficient, not just correct, and the article's framing (locking reduces frequency, idempotency makes the remainder harmless) applies exactly here: you want both, not either.
Review a distributed scheduling design
Each server's crontab fires the job at 1:00 AM. The job acquires a Redis lease (
NX, 10-minute TTL) before running, so only one server actually performs the sync. The lease is released in afinallyblock after the sync completes. The sync writes directly to the inventory table; each row is a plainUPDATEbased on the latest counts from the supplier's API.
Review this design and identify the gaps, covering:
- whether the locking approach itself (Redis lease with TTL) is sound, based on what this lesson covers;
- what's missing for the scenario where the job successfully acquires the lease but the server itself loses power or network connectivity mid-sync, before Redis's own TTL-based expiry would kick in — specifically, what happens to visibility into "did last night's sync actually happen";
- whether "each row is a plain UPDATE" creates any risk if the sync job's lease is somehow lost and a second instance starts before the first is truly done (tie this to a concept from the previous lesson, not just this one);
- what you'd add to this design before approving it.
Show solution
Review
Is the locking approach sound? Yes, as far as it goes — a Redis lease with NX and a TTL, released in a finally block, is exactly the pattern this lesson and the previous one describe, and it correctly solves the N-times problem across the 5-server fleet: only one server's job instance will successfully acquire the lease and run the sync. Nothing about the locking mechanism itself, as described, is wrong.
What's missing for total-failure scenarios: the design has no missed-run monitoring — no dead man's switch. If the server that acquired the lease loses power or network connectivity mid-sync, the lease will still correctly expire after its 10-minute TTL (Redis doesn't need the crashed server to do anything for that), so locking isn't actually broken here. But nothing in this design tells anyone that the sync didn't complete — the job never reported success anywhere external, so a night where the sync silently fails partway looks, from outside, identical to a night where it succeeded: nobody gets an alert, and the gap is only discovered later, likely when someone notices stale inventory data and has to work backward to figure out when it stopped being accurate. This is precisely the failure mode the article's dead-man's-switch section describes as invisible to locking mechanisms by design — locks control how often a job runs, not whether it ran to completion at all.
The plain-UPDATE-per-row risk: this connects directly to the previous lesson's fencing-token discussion, not just this lesson's lease mechanics. If the lease is somehow lost mid-run — the TTL is shorter than the sync actually takes under some conditions, or a Redis blip causes an unexpected loss — and a second server's instance starts before the first has truly stopped writing, both instances can end up issuing UPDATE statements against the same inventory rows around the same time, with no way for the database to know one of them is operating on a stale, no-longer-authoritative view of "the latest sync." Plain UPDATEs have no built-in protection against this; the previous lesson's fencing-token idea (rejecting writes carrying an older token than the highest one seen) is the mechanism that would close this specific gap, at the cost of the inventory-writing code needing to understand and check tokens — worth doing if a stale double-write to inventory counts is a real correctness risk for this business, and worth skipping (accepting the small residual risk) if it isn't, but the design document should say which was decided rather than leaving it unconsidered.
What to add before approving:
- A dead man's switch — the job pings an external monitoring endpoint on successful completion, with alerting configured for a missed nightly ping, per this lesson's pattern.
- An explicit decision on whether the TTL (10 minutes) has been validated against the sync's actual worst-case runtime, not just its typical one — the previous lesson's TTL-mismatch failure mode applies here exactly as it did for the invoice-export example.
- A stated decision on the fencing-token question above — either "not needed, because [reason]" or a plan to add it — rather than the current document's silence on what happens to the underlying data during a rare lease-loss-and-overlap event.