Checking Out Sub-Minute Scheduling

reading up on laravel tricks
Image by Annie Ruygt

Fly.io can build and run your Laravel apps globally, including your scheduled tasks. Deploy your Laravel application on Fly.io, you’ll be up and running in minutes!

One of Laravel’s new, fun enhancements is the ability to run scheduled commands (or jobs) more than once per minute!

Notoriously, CRON can only run tasks every 1 minute or slower. Laravel’s scheduler, typically driven by CRON, therefore only ran once per minute.

Well, that hasn’t changed! It’s still powered by CRON, but now it can run tasks more than once per minute. That fancy thing powering that?

A while loop.

How?

When the scheduler runs once per minute, it finds and runs tasks that are due. With sub-minute tasks, it now has an additional while() loop that runs for that entire minute.

# First, run all due tasks
foreach ($events as $event) {
  // run events as normal
}

# Directly after, find tasks that should
# repeat within the current minute
while (Date::now()->lte($this->startedAt->endOfMinute())) {
  foreach ($repeatableEvents as $event) {
    // Run repeatable events
  }
}

Each while-loop iteration, it checks to see if there are “events” (also called “tasks” - these are scheduled commands or jobs) that are due to run. So, if we have a command that should run every 30 seconds, the scheduler will run that twice within the given minute.

If there are no more things it might possibly run, it is smart enough to exit out of that new loop.

The PR for this feature is here.

The top-level code is in the schedule:run command’s handle() method. It finds events that are “due now” and then runs them. That’s the normal thing the scheduler always did.

However, after that, it now also checks for any events that should be repeated. That’s where our new while() loop comes in.

Here’s a selection of code from the scheduler’s handle() method, with some added comments:

// Illuminate/Console/Scheduling/ScheduleRunCommand

public function handle(...)
{
    $events = $this->schedule->dueEvents($this->laravel);

    // Run regular events due now (at the top of the minute)
    foreach ($events as $event) {
        $this->runEvent($event);
    }

    // Repeatable events are ones that can happen 
    // more than once per minute, and thus repeat within
    // this run of `artisan schedule:run`
    if ($events->contains->isRepeatable()) {
        // Note the filter
        $this->repeatEvents($events->filter->isRepeatable());
    }
}

You’ll note that Laravel runs “normal” tasks BEFORE doing any repeatable ones. Everything is, by default, run in sequence - so any commands (or jobs, if not sent to the queue) that take a long time to run will delay the running of sub-minute tasks.

You can push some tasks to the background if they take too long.

In any case, if repeatable events are found, then we call repeatEvents(), which is the home of our new while() loop.

// Illuminate/Console/Scheduling/ScheduleRunCommand

protected function repeatEvents($events)
{
    // Only run within the current given minute
    while (Date::now()->lte($this->startedAt->endOfMinute())) 
    {
        // These events have been pre-filtered to only include
        // sub-minute tasks
        foreach ($events as $event) {
            // Only run events ready to be repeated
            if (! $event->shouldRepeatNow()) {
                continue;
            }

            $this->runEvent($event)
        }
    }
}

Keep in mind I’m simplifying the code there, which means there are important checks / features I’m not showing (running or not running in maintenance mode, signals to interrupt the loop, etc).

The gist is that this just runs within the current minute, and any “repeatable” task gets run when they’re due. Events that run every 30 seconds get run twice - once in the first foreach() loop of the handle() method, and again in this while loop that is looking for repeatable events.

Timing (checking if a job needs to be run) logic hasn’t really changed here - Laravel was always comparing an “event” that’s scheduled versus the current time. If an event’s timing is less-than-or-equal-to-now, then it’s ready to be run.

Sub-minute events are the same, we just needed the schedule:run command to run for the entire minute and continuously check to see if any repeatable events are due.

Interrupt

There’s also a new command artisan schedule:interrupt, which will gracefully stop the while() loop if the interrupt is flagged.

This is useful for deployments, so we don’t stop/shut off a running task when it’s mid-process. If you use sub-minute tasks, then your deploy process should include a call to artisan schedule:interrupt.

New Options

The new options available when scheduling tasks are:

$schedule->job(new ExampleJob)->everySecond();
$schedule->job(new ExampleJob)->everyTwoSeconds();
$schedule->job(new ExampleJob)->everyFiveSeconds();
$schedule->job(new ExampleJob)->everyTenSeconds();
$schedule->job(new ExampleJob)->everyFifteenSeconds();
$schedule->job(new ExampleJob)->everyTwentySeconds();
$schedule->job(new ExampleJob)->everyThirtySeconds();

Fly.io ❤️ Laravel

Fly your servers close to your users—and marvel at the speed of close proximity. Deploy globally on Fly in minutes!

Deploy your Laravel app!

Time Cop

You can’t currently set an arbitrary number of seconds, as certain intervals are weird to process when dividing how many times per minute the task should run.

Imagine running a task every 7 seconds! You’d end up with a task run ~8.5 times per minute (which would essentially be 8 times). Laravel skips over this possibility by disallowing times that might result in this situation like so:

protected function repeatEvery($seconds)
{
    if (60 % $seconds !== 0) {
        throw new InvalidArgumentException("The seconds [$seconds] are not evenly divisible by 60.");
    }

    $this->repeatSeconds = $seconds;

    return $this->everyMinute();
}

You can see the exception thrown if there’s a remainder when dividing into 60.

Another fun thing: The repeatEvery() method sets an attribute “repeatSeconds” with the seconds value, and then sets the task to run every minute!

This gets the task to run at the top of the minute. Afterwards, the while() loop takes the repeated task and runs it again, if the repeatSeconds attribute is set (and when the task is due to run).

This is a neat feature! Like anything that seems super powerful, the execution has a lot of details, but usually isn’t as complex as you might think on the surface.