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?
answer
- every host evaluates the same schedule
- first server to lock wins
- key is task plus minute
- one central cache for all hosts
- name closures and job permutations
basics
~20 sEach 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 sThe 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
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
Know that each server with the cron entry runs the schedule, and that onOneServer() limits a task to one of them.
Explain the per-minute cache lock, the requirement for one central cache, and why closures and job permutations need names.
Configure a shared schedule store, pair onOneServer() with withoutOverlapping() for long runs, keep clocks in sync, and verify with the skip messages.
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.