Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

resident-scheduler-laravel

Run the Laravel scheduler on a resident dispatcher and a warm worker pool. No cold framework boot per task.

Status

Experimental / early prototype. The API is unstable and may change without notice before a 1.0 release. See docs/design.md for the full design rationale, architecture, and open questions — this README is the short version.

The problem

In a stock Laravel deployment, a cron sidecar execs php artisan schedule:run once a minute, and every Schedule::command() entry due at that minute spawns another child process. Each of those is a full framework cold boot — autoloading, service-provider registration, command resolution — typically hundreds of milliseconds, even with Laravel's optimize caches, before the task does any real work. Run this across a few dozen scheduled tasks, or across many services doing the same thing side by side, and steady-state CPU ends up dominated by process boots rather than by the work the tasks actually do.

A secondary problem: schedule:run discards a task's stdout/stderr unless the schedule entry explicitly opts in with sendOutputTo or a sibling hook. In practice that means scheduled-task output either disappears, or services route around it by writing to their own log files instead of whatever container-stdout-based log pipeline the rest of the application already relies on.

How it works

one container
├── octane:frankenphp                 warm worker pool (task server)
│     workers ×N, sandbox per run, --max-requests recycling, no ingress
│     only registers a task route when RESIDENT_SCHEDULER_SERVER=true
│     each task's live output → this worker's own STDERR
└── resident-scheduler:run            resident dispatcher (main process)
      1s tick → due events → TaskExecutor → hooks (before/after/onSuccess/…)

Both processes live in the same container. The dispatcher boots the framework once and loops on a 1-second tick, reusing Laravel's own isDue()/filtersPass() and mutex logic to decide what's due — only the execution path changes. Instead of spawning a process per task, it hands each due event to a TaskExecutor, an interface with one implementation today: OctaneHttpExecutor, a loopback HTTP call into the same container's octane:frankenphp worker pool. That's the same model FrankenPHP/Octane already use for HTTP requests — pre-booted workers, a fresh sandbox per run — applied to schedules instead. Because the worker that actually runs the task shares the container's log stream, a task's live output goes straight to that worker's own STDERR — no relaying, no output-only-in-a-file.

Installation

The consuming application needs PHP ^8.3, Laravel 11 or 12, and laravel/octane already running the app on the FrankenPHP driver — this package doesn't install or manage Octane itself, it only talks to the worker pool it already provides.

composer require cego/resident-scheduler-laravel
php artisan vendor:publish --tag=resident-scheduler-config

Three environment variables control it:

Variable Default Purpose
RESIDENT_SCHEDULER_SERVER false Registers the task-execution HTTP endpoint on this process. Only enable it on the resident-scheduler container — never on a process with public ingress.
RESIDENT_SCHEDULER_TOKEN Shared secret between the dispatcher and the pool. Both read it from the same container's environment.
RESIDENT_SCHEDULER_ENDPOINT http://127.0.0.1 Where the dispatcher reaches the pool.

Both the dispatcher and the worker pool run in the same container, as two processes. A generic entrypoint:

#!/bin/sh
set -e

# Warm worker pool — the task route only exists because RESIDENT_SCHEDULER_SERVER=true.
php artisan octane:start --server=frankenphp --workers=4 --max-requests=500 &

# Dispatcher — the container's main process; the container exits if this does.
exec php artisan resident-scheduler:run
# docker-compose.yml
services:
  scheduler:
    image: your-app:latest
    entrypoint: ["/entrypoint.sh"]
    environment:
      RESIDENT_SCHEDULER_SERVER: "true"
      RESIDENT_SCHEDULER_TOKEN: "${RESIDENT_SCHEDULER_TOKEN}"
      RESIDENT_SCHEDULER_ENDPOINT: "http://127.0.0.1"

No changes to any existing Schedule:: definitions are required.

Drop-in compatibility

Feature Status Notes
->cron() and frequency helpers (daily(), hourly(), ...) DueEvaluator calls Laravel's own Event::isDue() unmodified.
Sub-minute repeats (everyFiveSeconds(), etc.) Own catch-up/modulo logic; tested for cold start mid-minute, delayed-tick catch-up, and steady-state repeat firing.
withoutOverlapping() (+ custom expiry) Reuses the event's own EventMutex binding; tested.
onOneServer() Via Schedule::serverShouldRun(), unmodified; tested.
runInBackground() Dispatched without waiting, driven forward by TaskExecutor::progress(); concurrent dispatch (not real OS backgrounding, since execution happens on the worker pool) is tested.
environments() / evenInMaintenanceMode() / timezone() All three ride Laravel's own Event::isDue(), evaluated dispatcher-side; the call site is tested, the individual filters aren't each covered by a dedicated test.
when() / skip() / between() / unlessBetween() All four ride Laravel's own Event::filtersPass(), evaluated dispatcher-side (between/unlessBetween are themselves built on when/skip by Laravel); when() is tested directly.
user() (exec events) ⚠️ Not implemented exec() events run via Symfony\Process::fromShellCommandline($event->command) directly, not $event->buildCommand() — the sudo -u wrapper stock Laravel adds there is never applied.
before() / after() Tested.
onSuccess() / onFailure() Tested for both branches of the exit code.
pingBefore() / thenPing() (+ ...If, pingOnSuccess, pingOnFailure) Built by Laravel on top of before()/then()/onSuccess()/onFailure(), which are tested directly; the ping wrappers themselves aren't separately tested.
sendOutputTo() / appendOutputTo() Tested.
emailOutputTo() / emailWrittenOutputTo() / emailOutputOnFailure() The output file is written before after-callbacks run, so Laravel's own file-read-then-mail logic sees it; not covered by a dedicated test.
Schedule::command() / ->call() / ->exec() Each runs through a distinct path in the task runner; all three are tested.
Schedule::job() Laravel itself reduces job() to a CallbackEvent closure — the same path as ->call(); not covered by a dedicated test.
Named events (->name()) Plain Laravel state; exercised throughout the test suite via mutex/cache keys.
schedule:list ⚠️ Untouched Still the stock command; this package doesn't register a replacement or interact with it, and it isn't exercised by this package's test suite.
schedule:test parity Planned No resident-scheduler:test exists yet. Stock schedule:test execs the task directly, bypassing the executor and worker pool entirely.
Adding/removing/rescheduling a Schedule:: entry at runtime v0.1 limitation The dispatcher materializes its schedule once at boot; changing which entries exist requires restarting the dispatcher process. when()/skip() filters are unaffected — they still evaluate live, every tick. See docs/design.md for why.

Semantic differences vs schedule:run

  • exit() inside a scheduled command kills the pool worker that ran it, not "its own process". The pool auto-respawns the worker, but the task is reported as failed instead of exiting with whatever code it chose. Worth auditing scheduled-task code for exit() calls before adopting this package.
  • memory_limit is per worker, not per task process — usage accumulates across every task that happens to land on a given worker, until --max-requests recycling restarts it.
  • Tasks now run inside the same sandbox isolation that already applies to ordinary Octane/FrankenPHP HTTP requests: state doesn't leak between runs by design, but scheduled-task code has never previously run under that guarantee. A static property mutated during one task's run persists until the worker recycles, and is visible to whatever task lands on that same worker next.
  • Task output now lands live on the container's own stdout/stderr instead of being discarded or written to a file — that's the point, but it does mean the volume that used to go nowhere now flows into whatever log pipeline already reads the container's stdout/stderr.
  • A foreground task (anything not marked runInBackground()) blocks the dispatcher's tick loop until it finishes, so no other due event fires while it runs — stock cron doesn't have this limitation, since every due entry there gets its own OS process. Mark long-running tasks runInBackground() to avoid blocking the loop. If the loop is blocked long enough to skip a minute boundary entirely, only the latest missed minute is caught up, not every one that was skipped.

Roadmap

The HTTP round-trip in OctaneHttpExecutor exists because FrankenPHP doesn't yet have native, non-HTTP background/task workers. Once that support lands, a FrankenPhpTaskExecutor implementing the same TaskExecutor contract against the native API — swapping the executor binding and deleting the HTTP route, the shared-secret header, and the request/response envelope — should be the entire migration. The dispatcher, EventRefs, hooks, and output handling don't change. Tracking:

A ForkExecutor (pcntl fork-per-task) is a plausible cheaper alternative to the HTTP pool for deployments that don't need per-run sandbox isolation — it isn't built yet. If it is, the natural next step is a throughput/latency benchmark against OctaneHttpExecutor to quantify what that isolation actually costs.

License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages