Skip to content

Commit

Permalink
Allow sub-minute events to conditionally run throughout the minute
Browse files Browse the repository at this point in the history
  • Loading branch information
jessarcher committed Jun 30, 2023
1 parent 887d9af commit 4e4358e
Show file tree
Hide file tree
Showing 4 changed files with 277 additions and 14 deletions.
11 changes: 5 additions & 6 deletions src/Illuminate/Console/Scheduling/Event.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,13 @@ class Event
public $mutexNameResolver;

/**
* The start time of the last event run.
* The last time the event was checked for eligibility to run.
*
* Utilized by sub-minute repeated events.
*
* @var \Illuminate\Support\Carbon|null
*/
public $lastRun;
protected $lastChecked;

/**
* The exit status code of the command.
Expand Down Expand Up @@ -220,8 +220,6 @@ public function run(Container $container)
return;
}

$this->lastRun = Date::now();

$exitCode = $this->start($container);

if (! $this->runInBackground) {
Expand Down Expand Up @@ -257,8 +255,7 @@ public function isRepeatable()
public function shouldRepeatNow()
{
return $this->isRepeatable()
&& $this->lastRun
&& Date::now()->diffInSeconds($this->lastRun) >= $this->repeatSeconds;
&& $this->lastChecked?->diffInSeconds() >= $this->repeatSeconds;
}

/**
Expand Down Expand Up @@ -410,6 +407,8 @@ public function runsInEnvironment($environment)
*/
public function filtersPass($app)
{
$this->lastChecked = Date::now();

foreach ($this->filters as $callback) {
if (! $app->call($callback)) {
return false;
Expand Down
9 changes: 8 additions & 1 deletion src/Illuminate/Console/Scheduling/Schedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ class Schedule
*/
protected $dispatcher;

/**
* The cache of mutex results.
*
* @var array<string, bool>
*/
protected $mutexCache = [];

/**
* Create a new schedule instance.
*
Expand Down Expand Up @@ -299,7 +306,7 @@ public function compileArrayInput($key, $value)
*/
public function serverShouldRun(Event $event, DateTimeInterface $time)
{
return $this->schedulingMutex->create($event, $time);
return $this->mutexCache[$event->mutexName()] ??= $this->schedulingMutex->create($event, $time);
}

/**
Expand Down
35 changes: 28 additions & 7 deletions src/Illuminate/Console/Scheduling/ScheduleRunCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Sleep;
use Symfony\Component\Console\Attribute\AsCommand;
use Throwable;

Expand Down Expand Up @@ -215,23 +216,43 @@ protected function runEvent($event)
/**
* Run the given repeating events.
*
* @param \Illuminate\Console\Scheduling\Event[] $events
* @param \Illuminate\Support\Collection<\Illuminate\Console\Scheduling\Event> $events
* @return void
*/
protected function repeatEvents($events)
{
while (Date::now()->lte($this->startedAt->endOfMinute())) {
if ($this->shouldInterrupt()) {
return;
}
$hasEnteredMaintenanceMode = false;

while (Date::now()->lte($this->startedAt->endOfMinute())) {
foreach ($events as $event) {
if ($this->shouldInterrupt()) {
return;
}

if ($event->shouldRepeatNow()) {
$this->runEvent($event);
$hasEnteredMaintenanceMode = $hasEnteredMaintenanceMode || $this->laravel->isDownForMaintenance();

if ($hasEnteredMaintenanceMode && ! $event->runsInMaintenanceMode()) {
continue;
}

if (! $event->filtersPass($this->laravel)) {
$this->dispatcher->dispatch(new ScheduledTaskSkipped($event));

continue;
}

if ($event->onOneServer) {
$this->runSingleServerEvent($event);
} else {
$this->runEvent($event);
}

$this->eventsRan = true;
}
}

usleep(100000);
Sleep::usleep(100000);
}
}

Expand Down
236 changes: 236 additions & 0 deletions tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
<?php

namespace Illuminate\Tests\Integration\Console\Scheduling;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Sleep;
use Orchestra\Testbench\TestCase;

class SubMinuteSchedulingTest extends TestCase
{
protected Schedule $schedule;

public function setUp(): void
{
parent::setUp();

$this->schedule = $this->app->make(Schedule::class);
}

public function test_it_doesnt_wait_for_sub_minute_events_when_nothing_is_scheduled()
{
Carbon::setTestNow(now()->startOfMinute());
Sleep::fake();

$this->artisan('schedule:run')
->expectsOutputToContain('No scheduled commands are ready to run.');

Sleep::assertNeverSlept();
}

public function test_it_doesnt_wait_for_sub_minute_events_when_none_are_scheduled()
{
$this->schedule
->call(fn () => true)
->everyMinute();

Carbon::setTestNow(now()->startOfMinute());
Sleep::fake();

$this->artisan('schedule:run')
->expectsOutputToContain('Running [Callback]');

Sleep::assertNeverSlept();
}

/** @dataProvider frequencyProvider */
public function test_it_runs_sub_minute_callbacks($frequency, $expectedRuns)
{
$runs = 0;
$this->schedule->call(function () use (&$runs) {
$runs++;
})->{$frequency}();

Carbon::setTestNow(now()->startOfMinute());
Sleep::fake();
Sleep::whenFakingSleep(fn ($duration) => Carbon::setTestNow(now()->add($duration)));

$this->artisan('schedule:run')
->expectsOutputToContain('Running [Callback]');

Sleep::assertSleptTimes(600);
$this->assertEquals($expectedRuns, $runs);
}

public function test_it_runs_multiple_sub_minute_callbacks()
{
$everySecondRuns = 0;
$this->schedule->call(function () use (&$everySecondRuns) {
$everySecondRuns++;
})->everySecond();

$everyThirtySecondsRuns = 0;
$this->schedule->call(function () use (&$everyThirtySecondsRuns) {
$everyThirtySecondsRuns++;
})->everyThirtySeconds();

Carbon::setTestNow(now()->startOfMinute());
Sleep::fake();
Sleep::whenFakingSleep(fn ($duration) => Carbon::setTestNow(now()->add($duration)));

$this->artisan('schedule:run')
->expectsOutputToContain('Running [Callback]');

Sleep::assertSleptTimes(600);
$this->assertEquals(60, $everySecondRuns);
$this->assertEquals(2, $everyThirtySecondsRuns);
}

public function test_sub_minute_scheduling_can_be_interrupted()
{
$everySecondRuns = 0;
$this->schedule->call(function () use (&$everySecondRuns) {
$everySecondRuns++;
})->everySecond();

Carbon::setTestNow(now()->startOfMinute());
$startedAt = now();
Sleep::fake();
Sleep::whenFakingSleep(function ($duration) use ($startedAt) {
Carbon::setTestNow(now()->add($duration));

if (now()->diffInSeconds($startedAt) >= 30) {
$this->artisan('schedule:interrupt')
->expectsOutputToContain('Broadcasting schedule interrupt signal.');
}
});

$this->artisan('schedule:run')
->expectsOutputToContain('Running [Callback]');

Sleep::assertSleptTimes(300);
$this->assertEquals(30, $everySecondRuns);
$this->assertEquals(30, $startedAt->diffInSeconds(now()));
}

public function test_sub_minute_events_stop_for_the_rest_of_the_minute_once_maintenance_mode_is_enabled()
{
$everySecondRuns = 0;
$this->schedule->call(function () use (&$everySecondRuns) {
$everySecondRuns++;
})->everySecond();

Config::set('app.maintenance.driver', 'cache');
Config::set('app.maintenance.store', 'array');
Carbon::setTestNow(now()->startOfMinute());
$startedAt = now();
Sleep::fake();
Sleep::whenFakingSleep(function ($duration) use ($startedAt) {
Carbon::setTestNow(now()->add($duration));

if (now()->diffInSeconds($startedAt) >= 30 && ! $this->app->isDownForMaintenance()) {
$this->artisan('down');
}

if (now()->diffInSeconds($startedAt) >= 40 && $this->app->isDownForMaintenance()) {
$this->artisan('up');
}
});

$this->artisan('schedule:run')
->expectsOutputToContain('Running [Callback]');

Sleep::assertSleptTimes(600);
$this->assertEquals(30, $everySecondRuns);
}

public function test_sub_minute_events_can_be_run_in_maintenance_mode()
{
$everySecondRuns = 0;
$this->schedule->call(function () use (&$everySecondRuns) {
$everySecondRuns++;
})->everySecond()->evenInMaintenanceMode();

Config::set('app.maintenance.driver', 'cache');
Config::set('app.maintenance.store', 'array');
Carbon::setTestNow(now()->startOfMinute());
$startedAt = now();
Sleep::fake();
Sleep::whenFakingSleep(function ($duration) use ($startedAt) {
Carbon::setTestNow(now()->add($duration));

if (now()->diffInSeconds($startedAt) >= 30 && ! $this->app->isDownForMaintenance()) {
$this->artisan('down');
}
});

$this->artisan('schedule:run')
->expectsOutputToContain('Running [Callback]');

Sleep::assertSleptTimes(600);
$this->assertEquals(60, $everySecondRuns);
}

public function test_sub_minute_scheduling_respects_filters()
{
$everySecondRuns = 0;
$this->schedule->call(function () use (&$everySecondRuns) {
$everySecondRuns++;
})->everySecond()->when(fn () => now()->second % 2 === 0);

Carbon::setTestNow(now()->startOfMinute());
Sleep::fake();
Sleep::whenFakingSleep(fn ($duration) => Carbon::setTestNow(now()->add($duration)));

$this->artisan('schedule:run')
->expectsOutputToContain('Running [Callback]');

Sleep::assertSleptTimes(600);
$this->assertEquals(30, $everySecondRuns);
}

public function test_sub_minute_scheduling_can_run_on_one_server()
{
$everySecondRuns = 0;
$this->schedule->call(function () use (&$everySecondRuns) {
$everySecondRuns++;
})->everySecond()->name('test')->onOneServer();

$startedAt = now()->startOfMinute();
Carbon::setTestNow($startedAt);
Sleep::fake();
Sleep::whenFakingSleep(fn ($duration) => Carbon::setTestNow(now()->add($duration)));

$this->app->instance(Schedule::class, clone $this->schedule);
$this->artisan('schedule:run')
->expectsOutputToContain('Running [test]');

Sleep::assertSleptTimes(600);
$this->assertEquals(60, $everySecondRuns);

// Fake a second server running at the same minute.
Carbon::setTestNow($startedAt);

$this->app->instance(Schedule::class, clone $this->schedule);
$this->artisan('schedule:run')
->expectsOutputToContain('Skipping [test]');

Sleep::assertSleptTimes(1200);
$this->assertEquals(60, $everySecondRuns);
}

public static function frequencyProvider()
{
return [
'everySecond' => ['everySecond', 60],
'everyTwoSeconds' => ['everyTwoSeconds', 30],
'everyFiveSeconds' => ['everyFiveSeconds', 12],
'everyTenSeconds' => ['everyTenSeconds', 6],
'everyFifteenSeconds' => ['everyFifteenSeconds', 4],
'everyTwentySeconds' => ['everyTwentySeconds', 3],
'everyThirtySeconds' => ['everyThirtySeconds', 2],
];
}
}

0 comments on commit 4e4358e

Please sign in to comment.