skip to content

When a Laravel app scaled to three servers runs schedule:run on each, why does a report task run three times, and how does onOneServer() prevent it?

level: seniorimportance: must knowfreq 48%

answer

  1. every host evaluates the same schedule
  2. first server to lock wins
  3. key is task plus minute
  4. one central cache for all hosts
  5. name closures and job permutations

basics

~20 s

Each server's cron runs schedule:run, and each sees the same task due, so it runs once per server. onOneServer() makes each server try a lock in a shared cache keyed by task and minute; only the winner runs it.

solid answer

~50 s

The schedule is code, so every host that has the cron entry evaluates the same tasks and runs whatever is due — three servers, three reports. `->onOneServer()` makes `schedule:run` call the scheduling mutex before running the task: it tries to acquire a cache lock named after the task plus the hour and minute that `schedule:run` started (TTL one hour). The first server gets it and runs; the others print `Skipping [...] because the command already ran on another server.` It only works if all servers share one central cache — the docs require `database`, `memcached`, `dynamodb` or `redis` as the default cache driver — or a store chosen with `Schedule::useCache()`. Closures, and several `Schedule::job()` entries of the same class, need distinct `->name()`s. It does not stop a run that is still going from the previous tick, so pair it with `withoutOverlapping()` when runs can be long.

code

php · 16 lines
php
<?php

use App\Jobs\CheckUptime;
use Illuminate\Support\Facades\Schedule;

Schedule::useCache('redis'); // one central store for every host

Schedule::command('reports:generate')
    ->fridays()->at('17:00')
    ->onOneServer()
    ->withoutOverlapping(60);

Schedule::job(new CheckUptime('https://shop.example'))
    ->name('uptime:shop')
    ->everyFiveMinutes()
    ->onOneServer();

go deeper

for a junior

Know that each server with the cron entry runs the schedule, and that onOneServer() limits a task to one of them.

for a middle

Explain the per-minute cache lock, the requirement for one central cache, and why closures and job permutations need names.

for a senior

Configure a shared schedule store, pair onOneServer() with withoutOverlapping() for long runs, keep clocks in sync, and verify with the skip messages.

for a principal

Choose between a single scheduler host and onOneServer() on every host, weighing a single point of failure against dependence on the shared cache.

## Why scaling out duplicates tasks Laravel's schedule lives in `routes/console.php`, not on a particular machine. When an application moves from one server to three identical ones, provisioning usually installs the same `* * * * * php artisan schedule:run` line on each. At 17:00 on Friday all three `schedule:run` processes evaluate the same `reports:generate` task, all three find it due, and all three generate — and email — the report. Nothing in the default scheduler knows the other hosts exist. ## What onOneServer() does ```php Schedule::command('reports:generate') ->fridays() ->at('17:00') ->onOneServer(); ``` When a due event has the `onOneServer` flag, `schedule:run` asks the schedule whether this server should run it. The default `CacheSchedulingMutex` then: 1. Builds a key from the event's mutex name — for a command, a hash of its cron expression and command — plus the **hour and minute** at which this `schedule:run` started (`Hi` format, for example `1700`). 2. Tries to acquire a cache lock with that key and a TTL of **3600 seconds** (or `Cache::add()` on stores without lock support). 3. If it wins, the task runs. If not, the server prints `Skipping [reports:generate] because the command already ran on another server.` The result is cached in the `schedule:run` process, so repeated checks in the same run do not hit the cache again. ## The shared-cache requirement The mechanism is only as good as the cache the servers share: | Scheduler cache store | Protects across servers? | | --- | --- | | `redis`, `memcached`, `dynamodb` on a shared server | Yes | | `database` on a shared database server | Yes | | `database` pointing at a per-server SQLite file | No — each host has its own table | | `file` | No — each host has its own disk | | `array` | No — lives in one process only | The documentation states the rule directly: the default cache driver must be `database`, `memcached`, `dynamodb` or `redis`, and all servers must talk to the **same central cache server**. If the default store is something else, point the scheduler at a shared one with `Schedule::useCache('redis')`; the console kernel also reads a `cache.schedule_store` config value or the `SCHEDULE_CACHE_STORE` env variable. ## Naming what cannot name itself - A `Schedule::call()` closure has no command string, so it must be given `->name('...')` before `onOneServer()`, or a `LogicException` is thrown. - `Schedule::job()` names its event after the job class. Two entries such as `new CheckUptime('https://a.example')` and `new CheckUptime('https://b.example')` therefore share a lock, and one of them would be skipped as "already ran". Give each permutation its own `->name()`. ## Traps that still produce duplicates - **Clock skew.** The key includes the minute the local `schedule:run` started. If one host's clock is a minute off, it builds a different key and wins its own lock. Keep hosts time-synchronised. - **Long runs.** The lock is per tick, not per run. If a run is still going at the next due time on server A, server B can win the next tick's lock and start another copy. Combine `onOneServer()` with `withoutOverlapping()` — both backed by the shared cache — when runs can outlast the interval. - **Manual runs and other entry points.** A developer running the command by hand, or a queued job doing the same work, is outside the scheduler's lock. ## An alternative: one scheduler host Some teams run `schedule:run` on only one host or one dedicated container and leave web servers without the cron entry. That removes duplication without a lock, at the cost of a single point of failure for scheduled work. `onOneServer()` keeps every host eligible, so the schedule survives losing one of them, at the cost of depending on the shared cache. The distributed-systems theory of electing a leader versus racing for a per-tick lock is its own topic; in Laravel the implemented choice is the per-tick cache lock described here.

  • Three Laravel servers use onOneServer() but the skeleton default CACHE_STORE=database with SQLite. Why do reports still triple?
    With SQLite each server's database is a local file, so each host has its own cache table and wins its own lock. The mutex only works when every host reaches the same store. Point the scheduler at a shared Redis or a shared database with `Schedule::useCache()`, or move the app's database to a shared server.
  • Why might two Laravel Schedule::job() entries for the same job class with different constructor arguments interfere under onOneServer()?
    Scheduled job events are named after the job class, and a callback event's lock name comes from that name alone. Both entries therefore compete for the same per-minute lock, and whichever loses is skipped as if it had already run. Give each entry its own `->name()`, as the docs show for uptime checks.

A shared sign-up sheet on the office door for each shift: three colleagues arrive at 17:00, and whoever writes their name on that shift's line first does the job. If each colleague kept a private sheet at home, all three would sign and all three would do the work.

saying these in an interview costs you the question

  • Laravel automatically runs each scheduled task on only one server.
  • onOneServer() works with the file cache driver as long as the servers share a load balancer.
  • onOneServer() also stops a new run while the previous one is still going on another host.
  • Scheduled closures can use onOneServer() without a name.
  • onOneServer() elects one permanent leader host for the whole schedule.