From 524f1e1174c1b5261e71ec668f1a6ffa61560692 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Tue, 30 May 2023 14:21:29 +1000 Subject: [PATCH 01/12] Add sub-minute scheduling --- src/Illuminate/Console/Scheduling/Event.php | 38 ++++++++ .../Console/Scheduling/ManagesFrequencies.php | 88 +++++++++++++++++++ .../Scheduling/ScheduleInterruptCommand.php | 58 ++++++++++++ .../Console/Scheduling/ScheduleRunCommand.php | 65 +++++++++++++- .../Providers/ArtisanServiceProvider.php | 2 + 5 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 src/Illuminate/Console/Scheduling/ScheduleInterruptCommand.php diff --git a/src/Illuminate/Console/Scheduling/Event.php b/src/Illuminate/Console/Scheduling/Event.php index 0ff10188b251..cd10564ce502 100644 --- a/src/Illuminate/Console/Scheduling/Event.php +++ b/src/Illuminate/Console/Scheduling/Event.php @@ -37,6 +37,13 @@ class Event */ public $expression = '* * * * *'; + /** + * How often to repeat the event during a minute. + * + * @var int|null + */ + public $repeatSeconds = null; + /** * The timezone the date should be evaluated on. * @@ -156,6 +163,13 @@ class Event */ public $mutexNameResolver; + /** + * The start time of the last event run. + * + * @var \Illuminate\Support\Carbon|null + */ + public $lastRun; + /** * The exit status code of the command. * @@ -204,6 +218,8 @@ public function run(Container $container) return; } + $this->lastRun = Date::now(); + $exitCode = $this->start($container); if (! $this->runInBackground) { @@ -221,6 +237,28 @@ public function shouldSkipDueToOverlapping() return $this->withoutOverlapping && ! $this->mutex->create($this); } + /** + * Determine if the event should repeat. + * + * @return bool + */ + public function shouldRepeat() + { + return ! is_null($this->repeatSeconds); + } + + /** + * Determine if the event is ready to repeat. + * + * @return bool + */ + public function shouldRepeatNow() + { + return $this->shouldRepeat() + && $this->lastRun + && Date::now()->diffInSeconds($this->lastRun) >= $this->repeatSeconds; + } + /** * Run the command process. * diff --git a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php index 020144ebeae0..1a96e85c8c95 100644 --- a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php +++ b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php @@ -3,6 +3,7 @@ namespace Illuminate\Console\Scheduling; use Illuminate\Support\Carbon; +use InvalidArgumentException; trait ManagesFrequencies { @@ -69,6 +70,93 @@ private function inTimeInterval($startTime, $endTime) return fn () => $now->between($startTime, $endTime); } + /** + * Schedule the event to run every second. + * + * @return $this + */ + public function everySecond() + { + return $this->repeatEvery(1); + } + + /** + * Schedule the event to run every two seconds. + * + * @return $this + */ + public function everyTwoSeconds() + { + return $this->repeatEvery(2); + } + + /** + * Schedule the event to run every five seconds. + * + * @return $this + */ + public function everyFiveSeconds() + { + return $this->repeatEvery(5); + } + + /** + * Schedule the event to run every ten seconds. + * + * @return $this + */ + public function everyTenSeconds() + { + return $this->repeatEvery(10); + } + + /** + * Schedule the event to run every fifteen seconds. + * + * @return $this + */ + public function everyFifteenSeconds() + { + return $this->repeatEvery(15); + } + + /** + * Schedule the event to run every twenty seconds. + * + * @return $this + */ + public function everyTwentySeconds() + { + return $this->repeatEvery(20); + } + + /** + * Schedule the event to run every thirty seconds. + * + * @return $this + */ + public function everyThirtySeconds() + { + return $this->repeatEvery(30); + } + + /** + * Schedule the event to run multiple times per minute. + * + * @param int $seconds + * @return $this + */ + public 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(); + } + /** * Schedule the event to run every minute. * diff --git a/src/Illuminate/Console/Scheduling/ScheduleInterruptCommand.php b/src/Illuminate/Console/Scheduling/ScheduleInterruptCommand.php new file mode 100644 index 000000000000..662606a2aee3 --- /dev/null +++ b/src/Illuminate/Console/Scheduling/ScheduleInterruptCommand.php @@ -0,0 +1,58 @@ +cache = $cache; + } + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $this->cache->put('illuminate:schedule:interrupt', true, Date::now()->endOfMinute()); + + $this->components->info('Broadcasting schedule interrupt signal.'); + } +} diff --git a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php index 92c32ec8f0ef..35a1b01812b7 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php @@ -8,6 +8,7 @@ use Illuminate\Console\Events\ScheduledTaskFinished; use Illuminate\Console\Events\ScheduledTaskSkipped; use Illuminate\Console\Events\ScheduledTaskStarting; +use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Carbon; @@ -67,6 +68,13 @@ class ScheduleRunCommand extends Command */ protected $handler; + /** + * The cache store implementation. + * + * @var \Illuminate\Contracts\Cache\Repository + */ + protected $cache; + /** * The PHP binary used by the command. * @@ -92,18 +100,24 @@ public function __construct() * @param \Illuminate\Console\Scheduling\Schedule $schedule * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher * @param \Illuminate\Contracts\Debug\ExceptionHandler $handler + * @param \Illuminate\Contracts\Cache\Repository $cache * @return void */ - public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHandler $handler) + public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHandler $handler, Cache $cache) { $this->schedule = $schedule; $this->dispatcher = $dispatcher; $this->handler = $handler; + $this->cache = $cache; $this->phpBinary = Application::phpBinary(); + $this->clearInterruptSignal(); + $this->newLine(); - foreach ($this->schedule->dueEvents($this->laravel) as $event) { + $events = $this->schedule->dueEvents($this->laravel); + + foreach ($events as $event) { if (! $event->filtersPass($this->laravel)) { $this->dispatcher->dispatch(new ScheduledTaskSkipped($event)); @@ -119,6 +133,10 @@ public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHand $this->eventsRan = true; } + if ($events->contains->shouldRepeat()) { + $this->repeatEvents($events->filter->shouldRepeat()); + } + if (! $this->eventsRan) { $this->components->info('No scheduled commands are ready to run.'); } else { @@ -193,4 +211,47 @@ protected function runEvent($event) ]); } } + + /** + * Run the given repeating events. + * + * @param \Illuminate\Console\Scheduling\Event[] $events + * @return void + */ + protected function repeatEvents($events) + { + while (Date::now()->lte($this->startedAt->endOfMinute())) { + if ($this->shouldInterrupt()) { + return; + } + + foreach ($events as $event) { + if ($event->shouldRepeatNow()) { + $this->runEvent($event); + } + } + + usleep(100000); + } + } + + /** + * Determine if the schedule run should be interrupted. + * + * @return bool + */ + protected function shouldInterrupt() + { + return $this->cache->get('illuminate:schedule:interrupt', false); + } + + /** + * Ensure the interrupt signal is cleared. + * + * @return bool + */ + protected function clearInterruptSignal() + { + $this->cache->forget('illuminate:schedule:interrupt'); + } } diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index bc6ac4ec0b9f..7d756ced01a6 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -9,6 +9,7 @@ use Illuminate\Cache\Console\PruneStaleTagsCommand; use Illuminate\Console\Scheduling\ScheduleClearCacheCommand; use Illuminate\Console\Scheduling\ScheduleFinishCommand; +use Illuminate\Console\Scheduling\ScheduleInterruptCommand; use Illuminate\Console\Scheduling\ScheduleListCommand; use Illuminate\Console\Scheduling\ScheduleRunCommand; use Illuminate\Console\Scheduling\ScheduleTestCommand; @@ -151,6 +152,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ScheduleClearCache' => ScheduleClearCacheCommand::class, 'ScheduleTest' => ScheduleTestCommand::class, 'ScheduleWork' => ScheduleWorkCommand::class, + 'ScheduleInterrupt' => ScheduleInterruptCommand::class, 'ShowModel' => ShowModelCommand::class, 'StorageLink' => StorageLinkCommand::class, 'Up' => UpCommand::class, From 887d9afb83d7d7b4e7afba002bae225bd0113b04 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 29 Jun 2023 16:33:54 -0500 Subject: [PATCH 02/12] formatting --- src/Illuminate/Console/Scheduling/Event.php | 8 +++++--- .../Console/Scheduling/ManagesFrequencies.php | 2 +- .../Console/Scheduling/ScheduleRunCommand.php | 10 +++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/Illuminate/Console/Scheduling/Event.php b/src/Illuminate/Console/Scheduling/Event.php index cd10564ce502..447d99956c0c 100644 --- a/src/Illuminate/Console/Scheduling/Event.php +++ b/src/Illuminate/Console/Scheduling/Event.php @@ -166,6 +166,8 @@ class Event /** * The start time of the last event run. * + * Utilized by sub-minute repeated events. + * * @var \Illuminate\Support\Carbon|null */ public $lastRun; @@ -238,11 +240,11 @@ public function shouldSkipDueToOverlapping() } /** - * Determine if the event should repeat. + * Determine if the event has been configured to repeat multiple times per minute. * * @return bool */ - public function shouldRepeat() + public function isRepeatable() { return ! is_null($this->repeatSeconds); } @@ -254,7 +256,7 @@ public function shouldRepeat() */ public function shouldRepeatNow() { - return $this->shouldRepeat() + return $this->isRepeatable() && $this->lastRun && Date::now()->diffInSeconds($this->lastRun) >= $this->repeatSeconds; } diff --git a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php index 1a96e85c8c95..4ab7d793a2be 100644 --- a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php +++ b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php @@ -146,7 +146,7 @@ public function everyThirtySeconds() * @param int $seconds * @return $this */ - public function repeatEvery($seconds) + protected function repeatEvery($seconds) { if (60 % $seconds !== 0) { throw new InvalidArgumentException("The seconds [$seconds] are not evenly divisible by 60."); diff --git a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php index 35a1b01812b7..3c18297d0d65 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php @@ -99,16 +99,16 @@ public function __construct() * * @param \Illuminate\Console\Scheduling\Schedule $schedule * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher - * @param \Illuminate\Contracts\Debug\ExceptionHandler $handler * @param \Illuminate\Contracts\Cache\Repository $cache + * @param \Illuminate\Contracts\Debug\ExceptionHandler $handler * @return void */ - public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHandler $handler, Cache $cache) + public function handle(Schedule $schedule, Dispatcher $dispatcher, Cache $cache, ExceptionHandler $handler) { $this->schedule = $schedule; $this->dispatcher = $dispatcher; - $this->handler = $handler; $this->cache = $cache; + $this->handler = $handler; $this->phpBinary = Application::phpBinary(); $this->clearInterruptSignal(); @@ -133,8 +133,8 @@ public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHand $this->eventsRan = true; } - if ($events->contains->shouldRepeat()) { - $this->repeatEvents($events->filter->shouldRepeat()); + if ($events->contains->isRepeatable()) { + $this->repeatEvents($events->filter->isRepeatable()); } if (! $this->eventsRan) { From 4e4358e7e990b1c17bf207ad9acf411d4eb79386 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Fri, 30 Jun 2023 16:18:04 +1000 Subject: [PATCH 03/12] Allow sub-minute events to conditionally run throughout the minute --- src/Illuminate/Console/Scheduling/Event.php | 11 +- .../Console/Scheduling/Schedule.php | 9 +- .../Console/Scheduling/ScheduleRunCommand.php | 35 ++- .../Scheduling/SubMinuteSchedulingTest.php | 236 ++++++++++++++++++ 4 files changed, 277 insertions(+), 14 deletions(-) create mode 100644 tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php diff --git a/src/Illuminate/Console/Scheduling/Event.php b/src/Illuminate/Console/Scheduling/Event.php index 447d99956c0c..98fff2fec158 100644 --- a/src/Illuminate/Console/Scheduling/Event.php +++ b/src/Illuminate/Console/Scheduling/Event.php @@ -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. @@ -220,8 +220,6 @@ public function run(Container $container) return; } - $this->lastRun = Date::now(); - $exitCode = $this->start($container); if (! $this->runInBackground) { @@ -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; } /** @@ -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; diff --git a/src/Illuminate/Console/Scheduling/Schedule.php b/src/Illuminate/Console/Scheduling/Schedule.php index 2f62bf9e81d9..684aec58fc4a 100644 --- a/src/Illuminate/Console/Scheduling/Schedule.php +++ b/src/Illuminate/Console/Scheduling/Schedule.php @@ -70,6 +70,13 @@ class Schedule */ protected $dispatcher; + /** + * The cache of mutex results. + * + * @var array + */ + protected $mutexCache = []; + /** * Create a new schedule instance. * @@ -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); } /** diff --git a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php index 3c18297d0d65..97f79c79139d 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php @@ -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; @@ -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); } } diff --git a/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php new file mode 100644 index 000000000000..10470aaecf90 --- /dev/null +++ b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php @@ -0,0 +1,236 @@ +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], + ]; + } +} From 97b4af689424ceafbbf00c1ca22c766f0ebf396b Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Fri, 30 Jun 2023 16:33:13 +1000 Subject: [PATCH 04/12] Fix test failure caused by new mutex cache --- tests/Integration/Console/CommandSchedulingTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Console/CommandSchedulingTest.php b/tests/Integration/Console/CommandSchedulingTest.php index 0550ebcc54ed..1e02c3b3d2fa 100644 --- a/tests/Integration/Console/CommandSchedulingTest.php +++ b/tests/Integration/Console/CommandSchedulingTest.php @@ -67,7 +67,8 @@ protected function tearDown(): void */ public function testExecutionOrder($background, $expected) { - $event = $this->app->make(Schedule::class) + $schedule = $this->app->make(Schedule::class); + $event = $schedule ->command("test:{$this->id}") ->onOneServer() ->after(function () { @@ -82,8 +83,11 @@ public function testExecutionOrder($background, $expected) } // We'll trigger the scheduler three times to simulate multiple servers + $this->app->instance(Schedule::class, clone $schedule); $this->artisan('schedule:run'); + $this->app->instance(Schedule::class, clone $schedule); $this->artisan('schedule:run'); + $this->app->instance(Schedule::class, clone $schedule); $this->artisan('schedule:run'); if ($background) { From c8d8b2c5aca31e81aca6f525706a2ddcb423ef81 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Fri, 30 Jun 2023 19:36:20 +1000 Subject: [PATCH 05/12] Formatting --- .../Scheduling/SubMinuteSchedulingTest.php | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php index 10470aaecf90..ebefc4ff6e43 100644 --- a/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php +++ b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php @@ -90,9 +90,9 @@ public function test_it_runs_multiple_sub_minute_callbacks() public function test_sub_minute_scheduling_can_be_interrupted() { - $everySecondRuns = 0; - $this->schedule->call(function () use (&$everySecondRuns) { - $everySecondRuns++; + $runs = 0; + $this->schedule->call(function () use (&$runs) { + $runs++; })->everySecond(); Carbon::setTestNow(now()->startOfMinute()); @@ -111,15 +111,15 @@ public function test_sub_minute_scheduling_can_be_interrupted() ->expectsOutputToContain('Running [Callback]'); Sleep::assertSleptTimes(300); - $this->assertEquals(30, $everySecondRuns); + $this->assertEquals(30, $runs); $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++; + $runs = 0; + $this->schedule->call(function () use (&$runs) { + $runs++; })->everySecond(); Config::set('app.maintenance.driver', 'cache'); @@ -143,14 +143,14 @@ public function test_sub_minute_events_stop_for_the_rest_of_the_minute_once_main ->expectsOutputToContain('Running [Callback]'); Sleep::assertSleptTimes(600); - $this->assertEquals(30, $everySecondRuns); + $this->assertEquals(30, $runs); } public function test_sub_minute_events_can_be_run_in_maintenance_mode() { - $everySecondRuns = 0; - $this->schedule->call(function () use (&$everySecondRuns) { - $everySecondRuns++; + $runs = 0; + $this->schedule->call(function () use (&$runs) { + $runs++; })->everySecond()->evenInMaintenanceMode(); Config::set('app.maintenance.driver', 'cache'); @@ -170,14 +170,14 @@ public function test_sub_minute_events_can_be_run_in_maintenance_mode() ->expectsOutputToContain('Running [Callback]'); Sleep::assertSleptTimes(600); - $this->assertEquals(60, $everySecondRuns); + $this->assertEquals(60, $runs); } public function test_sub_minute_scheduling_respects_filters() { - $everySecondRuns = 0; - $this->schedule->call(function () use (&$everySecondRuns) { - $everySecondRuns++; + $runs = 0; + $this->schedule->call(function () use (&$runs) { + $runs++; })->everySecond()->when(fn () => now()->second % 2 === 0); Carbon::setTestNow(now()->startOfMinute()); @@ -188,14 +188,14 @@ public function test_sub_minute_scheduling_respects_filters() ->expectsOutputToContain('Running [Callback]'); Sleep::assertSleptTimes(600); - $this->assertEquals(30, $everySecondRuns); + $this->assertEquals(30, $runs); } public function test_sub_minute_scheduling_can_run_on_one_server() { - $everySecondRuns = 0; - $this->schedule->call(function () use (&$everySecondRuns) { - $everySecondRuns++; + $runs = 0; + $this->schedule->call(function () use (&$runs) { + $runs++; })->everySecond()->name('test')->onOneServer(); $startedAt = now()->startOfMinute(); @@ -208,7 +208,7 @@ public function test_sub_minute_scheduling_can_run_on_one_server() ->expectsOutputToContain('Running [test]'); Sleep::assertSleptTimes(600); - $this->assertEquals(60, $everySecondRuns); + $this->assertEquals(60, $runs); // Fake a second server running at the same minute. Carbon::setTestNow($startedAt); @@ -218,7 +218,7 @@ public function test_sub_minute_scheduling_can_run_on_one_server() ->expectsOutputToContain('Skipping [test]'); Sleep::assertSleptTimes(1200); - $this->assertEquals(60, $everySecondRuns); + $this->assertEquals(60, $runs); } public static function frequencyProvider() From a88e2d318b88316329f035120e2b5a9744c16680 Mon Sep 17 00:00:00 2001 From: Tarvo R Date: Fri, 30 Jun 2023 18:28:06 +0300 Subject: [PATCH 06/12] Pass queue from Mailable to SendQueuedMailable job (#47612) * Pass queue from Mailable to SendQueuedMailable job Fixes issue where the unserialized job has the wrong queue. * Pass connection from Mailable to SendQueuedMailable job * Fix property mismatches in SendQueuedMailable * order --------- Co-authored-by: Taylor Otwell --- src/Illuminate/Mail/SendQueuedMailable.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Mail/SendQueuedMailable.php b/src/Illuminate/Mail/SendQueuedMailable.php index 7f2023221d8e..28a72c47c7de 100644 --- a/src/Illuminate/Mail/SendQueuedMailable.php +++ b/src/Illuminate/Mail/SendQueuedMailable.php @@ -56,11 +56,14 @@ class SendQueuedMailable public function __construct(MailableContract $mailable) { $this->mailable = $mailable; - $this->tries = property_exists($mailable, 'tries') ? $mailable->tries : null; - $this->timeout = property_exists($mailable, 'timeout') ? $mailable->timeout : null; - $this->maxExceptions = property_exists($mailable, 'maxExceptions') ? $mailable->maxExceptions : null; + $this->afterCommit = property_exists($mailable, 'afterCommit') ? $mailable->afterCommit : null; + $this->connection = property_exists($mailable, 'connection') ? $mailable->connection : null; + $this->maxExceptions = property_exists($mailable, 'maxExceptions') ? $mailable->maxExceptions : null; + $this->queue = property_exists($mailable, 'queue') ? $mailable->queue : null; $this->shouldBeEncrypted = $mailable instanceof ShouldBeEncrypted; + $this->timeout = property_exists($mailable, 'timeout') ? $mailable->timeout : null; + $this->tries = property_exists($mailable, 'tries') ? $mailable->tries : null; } /** From 51a1a39d3ba9119107bdc3025a0ce55a373c1e91 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Tue, 30 May 2023 14:21:29 +1000 Subject: [PATCH 07/12] Add sub-minute scheduling --- src/Illuminate/Console/Scheduling/Event.php | 38 ++++++++ .../Console/Scheduling/ManagesFrequencies.php | 88 +++++++++++++++++++ .../Scheduling/ScheduleInterruptCommand.php | 58 ++++++++++++ .../Console/Scheduling/ScheduleRunCommand.php | 65 +++++++++++++- .../Providers/ArtisanServiceProvider.php | 2 + 5 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 src/Illuminate/Console/Scheduling/ScheduleInterruptCommand.php diff --git a/src/Illuminate/Console/Scheduling/Event.php b/src/Illuminate/Console/Scheduling/Event.php index 0ff10188b251..cd10564ce502 100644 --- a/src/Illuminate/Console/Scheduling/Event.php +++ b/src/Illuminate/Console/Scheduling/Event.php @@ -37,6 +37,13 @@ class Event */ public $expression = '* * * * *'; + /** + * How often to repeat the event during a minute. + * + * @var int|null + */ + public $repeatSeconds = null; + /** * The timezone the date should be evaluated on. * @@ -156,6 +163,13 @@ class Event */ public $mutexNameResolver; + /** + * The start time of the last event run. + * + * @var \Illuminate\Support\Carbon|null + */ + public $lastRun; + /** * The exit status code of the command. * @@ -204,6 +218,8 @@ public function run(Container $container) return; } + $this->lastRun = Date::now(); + $exitCode = $this->start($container); if (! $this->runInBackground) { @@ -221,6 +237,28 @@ public function shouldSkipDueToOverlapping() return $this->withoutOverlapping && ! $this->mutex->create($this); } + /** + * Determine if the event should repeat. + * + * @return bool + */ + public function shouldRepeat() + { + return ! is_null($this->repeatSeconds); + } + + /** + * Determine if the event is ready to repeat. + * + * @return bool + */ + public function shouldRepeatNow() + { + return $this->shouldRepeat() + && $this->lastRun + && Date::now()->diffInSeconds($this->lastRun) >= $this->repeatSeconds; + } + /** * Run the command process. * diff --git a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php index 020144ebeae0..1a96e85c8c95 100644 --- a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php +++ b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php @@ -3,6 +3,7 @@ namespace Illuminate\Console\Scheduling; use Illuminate\Support\Carbon; +use InvalidArgumentException; trait ManagesFrequencies { @@ -69,6 +70,93 @@ private function inTimeInterval($startTime, $endTime) return fn () => $now->between($startTime, $endTime); } + /** + * Schedule the event to run every second. + * + * @return $this + */ + public function everySecond() + { + return $this->repeatEvery(1); + } + + /** + * Schedule the event to run every two seconds. + * + * @return $this + */ + public function everyTwoSeconds() + { + return $this->repeatEvery(2); + } + + /** + * Schedule the event to run every five seconds. + * + * @return $this + */ + public function everyFiveSeconds() + { + return $this->repeatEvery(5); + } + + /** + * Schedule the event to run every ten seconds. + * + * @return $this + */ + public function everyTenSeconds() + { + return $this->repeatEvery(10); + } + + /** + * Schedule the event to run every fifteen seconds. + * + * @return $this + */ + public function everyFifteenSeconds() + { + return $this->repeatEvery(15); + } + + /** + * Schedule the event to run every twenty seconds. + * + * @return $this + */ + public function everyTwentySeconds() + { + return $this->repeatEvery(20); + } + + /** + * Schedule the event to run every thirty seconds. + * + * @return $this + */ + public function everyThirtySeconds() + { + return $this->repeatEvery(30); + } + + /** + * Schedule the event to run multiple times per minute. + * + * @param int $seconds + * @return $this + */ + public 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(); + } + /** * Schedule the event to run every minute. * diff --git a/src/Illuminate/Console/Scheduling/ScheduleInterruptCommand.php b/src/Illuminate/Console/Scheduling/ScheduleInterruptCommand.php new file mode 100644 index 000000000000..662606a2aee3 --- /dev/null +++ b/src/Illuminate/Console/Scheduling/ScheduleInterruptCommand.php @@ -0,0 +1,58 @@ +cache = $cache; + } + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $this->cache->put('illuminate:schedule:interrupt', true, Date::now()->endOfMinute()); + + $this->components->info('Broadcasting schedule interrupt signal.'); + } +} diff --git a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php index 92c32ec8f0ef..35a1b01812b7 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php @@ -8,6 +8,7 @@ use Illuminate\Console\Events\ScheduledTaskFinished; use Illuminate\Console\Events\ScheduledTaskSkipped; use Illuminate\Console\Events\ScheduledTaskStarting; +use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Carbon; @@ -67,6 +68,13 @@ class ScheduleRunCommand extends Command */ protected $handler; + /** + * The cache store implementation. + * + * @var \Illuminate\Contracts\Cache\Repository + */ + protected $cache; + /** * The PHP binary used by the command. * @@ -92,18 +100,24 @@ public function __construct() * @param \Illuminate\Console\Scheduling\Schedule $schedule * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher * @param \Illuminate\Contracts\Debug\ExceptionHandler $handler + * @param \Illuminate\Contracts\Cache\Repository $cache * @return void */ - public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHandler $handler) + public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHandler $handler, Cache $cache) { $this->schedule = $schedule; $this->dispatcher = $dispatcher; $this->handler = $handler; + $this->cache = $cache; $this->phpBinary = Application::phpBinary(); + $this->clearInterruptSignal(); + $this->newLine(); - foreach ($this->schedule->dueEvents($this->laravel) as $event) { + $events = $this->schedule->dueEvents($this->laravel); + + foreach ($events as $event) { if (! $event->filtersPass($this->laravel)) { $this->dispatcher->dispatch(new ScheduledTaskSkipped($event)); @@ -119,6 +133,10 @@ public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHand $this->eventsRan = true; } + if ($events->contains->shouldRepeat()) { + $this->repeatEvents($events->filter->shouldRepeat()); + } + if (! $this->eventsRan) { $this->components->info('No scheduled commands are ready to run.'); } else { @@ -193,4 +211,47 @@ protected function runEvent($event) ]); } } + + /** + * Run the given repeating events. + * + * @param \Illuminate\Console\Scheduling\Event[] $events + * @return void + */ + protected function repeatEvents($events) + { + while (Date::now()->lte($this->startedAt->endOfMinute())) { + if ($this->shouldInterrupt()) { + return; + } + + foreach ($events as $event) { + if ($event->shouldRepeatNow()) { + $this->runEvent($event); + } + } + + usleep(100000); + } + } + + /** + * Determine if the schedule run should be interrupted. + * + * @return bool + */ + protected function shouldInterrupt() + { + return $this->cache->get('illuminate:schedule:interrupt', false); + } + + /** + * Ensure the interrupt signal is cleared. + * + * @return bool + */ + protected function clearInterruptSignal() + { + $this->cache->forget('illuminate:schedule:interrupt'); + } } diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index bc6ac4ec0b9f..7d756ced01a6 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -9,6 +9,7 @@ use Illuminate\Cache\Console\PruneStaleTagsCommand; use Illuminate\Console\Scheduling\ScheduleClearCacheCommand; use Illuminate\Console\Scheduling\ScheduleFinishCommand; +use Illuminate\Console\Scheduling\ScheduleInterruptCommand; use Illuminate\Console\Scheduling\ScheduleListCommand; use Illuminate\Console\Scheduling\ScheduleRunCommand; use Illuminate\Console\Scheduling\ScheduleTestCommand; @@ -151,6 +152,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ScheduleClearCache' => ScheduleClearCacheCommand::class, 'ScheduleTest' => ScheduleTestCommand::class, 'ScheduleWork' => ScheduleWorkCommand::class, + 'ScheduleInterrupt' => ScheduleInterruptCommand::class, 'ShowModel' => ShowModelCommand::class, 'StorageLink' => StorageLinkCommand::class, 'Up' => UpCommand::class, From 07964ec55351400c150f4ecbe502606b30c782c1 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 29 Jun 2023 16:33:54 -0500 Subject: [PATCH 08/12] formatting --- src/Illuminate/Console/Scheduling/Event.php | 8 +++++--- .../Console/Scheduling/ManagesFrequencies.php | 2 +- .../Console/Scheduling/ScheduleRunCommand.php | 10 +++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/Illuminate/Console/Scheduling/Event.php b/src/Illuminate/Console/Scheduling/Event.php index cd10564ce502..447d99956c0c 100644 --- a/src/Illuminate/Console/Scheduling/Event.php +++ b/src/Illuminate/Console/Scheduling/Event.php @@ -166,6 +166,8 @@ class Event /** * The start time of the last event run. * + * Utilized by sub-minute repeated events. + * * @var \Illuminate\Support\Carbon|null */ public $lastRun; @@ -238,11 +240,11 @@ public function shouldSkipDueToOverlapping() } /** - * Determine if the event should repeat. + * Determine if the event has been configured to repeat multiple times per minute. * * @return bool */ - public function shouldRepeat() + public function isRepeatable() { return ! is_null($this->repeatSeconds); } @@ -254,7 +256,7 @@ public function shouldRepeat() */ public function shouldRepeatNow() { - return $this->shouldRepeat() + return $this->isRepeatable() && $this->lastRun && Date::now()->diffInSeconds($this->lastRun) >= $this->repeatSeconds; } diff --git a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php index 1a96e85c8c95..4ab7d793a2be 100644 --- a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php +++ b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php @@ -146,7 +146,7 @@ public function everyThirtySeconds() * @param int $seconds * @return $this */ - public function repeatEvery($seconds) + protected function repeatEvery($seconds) { if (60 % $seconds !== 0) { throw new InvalidArgumentException("The seconds [$seconds] are not evenly divisible by 60."); diff --git a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php index 35a1b01812b7..3c18297d0d65 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php @@ -99,16 +99,16 @@ public function __construct() * * @param \Illuminate\Console\Scheduling\Schedule $schedule * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher - * @param \Illuminate\Contracts\Debug\ExceptionHandler $handler * @param \Illuminate\Contracts\Cache\Repository $cache + * @param \Illuminate\Contracts\Debug\ExceptionHandler $handler * @return void */ - public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHandler $handler, Cache $cache) + public function handle(Schedule $schedule, Dispatcher $dispatcher, Cache $cache, ExceptionHandler $handler) { $this->schedule = $schedule; $this->dispatcher = $dispatcher; - $this->handler = $handler; $this->cache = $cache; + $this->handler = $handler; $this->phpBinary = Application::phpBinary(); $this->clearInterruptSignal(); @@ -133,8 +133,8 @@ public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHand $this->eventsRan = true; } - if ($events->contains->shouldRepeat()) { - $this->repeatEvents($events->filter->shouldRepeat()); + if ($events->contains->isRepeatable()) { + $this->repeatEvents($events->filter->isRepeatable()); } if (! $this->eventsRan) { From d5e4755af587b0211d92f06bd7a4aa5680b14061 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Fri, 30 Jun 2023 16:18:04 +1000 Subject: [PATCH 09/12] Allow sub-minute events to conditionally run throughout the minute --- src/Illuminate/Console/Scheduling/Event.php | 11 +- .../Console/Scheduling/Schedule.php | 9 +- .../Console/Scheduling/ScheduleRunCommand.php | 35 ++- .../Scheduling/SubMinuteSchedulingTest.php | 236 ++++++++++++++++++ 4 files changed, 277 insertions(+), 14 deletions(-) create mode 100644 tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php diff --git a/src/Illuminate/Console/Scheduling/Event.php b/src/Illuminate/Console/Scheduling/Event.php index 447d99956c0c..98fff2fec158 100644 --- a/src/Illuminate/Console/Scheduling/Event.php +++ b/src/Illuminate/Console/Scheduling/Event.php @@ -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. @@ -220,8 +220,6 @@ public function run(Container $container) return; } - $this->lastRun = Date::now(); - $exitCode = $this->start($container); if (! $this->runInBackground) { @@ -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; } /** @@ -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; diff --git a/src/Illuminate/Console/Scheduling/Schedule.php b/src/Illuminate/Console/Scheduling/Schedule.php index 2f62bf9e81d9..684aec58fc4a 100644 --- a/src/Illuminate/Console/Scheduling/Schedule.php +++ b/src/Illuminate/Console/Scheduling/Schedule.php @@ -70,6 +70,13 @@ class Schedule */ protected $dispatcher; + /** + * The cache of mutex results. + * + * @var array + */ + protected $mutexCache = []; + /** * Create a new schedule instance. * @@ -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); } /** diff --git a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php index 3c18297d0d65..97f79c79139d 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php @@ -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; @@ -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); } } diff --git a/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php new file mode 100644 index 000000000000..10470aaecf90 --- /dev/null +++ b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php @@ -0,0 +1,236 @@ +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], + ]; + } +} From b314202f7035dbe43fc4069cb112a6739fd3d95a Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Fri, 30 Jun 2023 16:33:13 +1000 Subject: [PATCH 10/12] Fix test failure caused by new mutex cache --- tests/Integration/Console/CommandSchedulingTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Console/CommandSchedulingTest.php b/tests/Integration/Console/CommandSchedulingTest.php index 0550ebcc54ed..1e02c3b3d2fa 100644 --- a/tests/Integration/Console/CommandSchedulingTest.php +++ b/tests/Integration/Console/CommandSchedulingTest.php @@ -67,7 +67,8 @@ protected function tearDown(): void */ public function testExecutionOrder($background, $expected) { - $event = $this->app->make(Schedule::class) + $schedule = $this->app->make(Schedule::class); + $event = $schedule ->command("test:{$this->id}") ->onOneServer() ->after(function () { @@ -82,8 +83,11 @@ public function testExecutionOrder($background, $expected) } // We'll trigger the scheduler three times to simulate multiple servers + $this->app->instance(Schedule::class, clone $schedule); $this->artisan('schedule:run'); + $this->app->instance(Schedule::class, clone $schedule); $this->artisan('schedule:run'); + $this->app->instance(Schedule::class, clone $schedule); $this->artisan('schedule:run'); if ($background) { From bc249c68c145f8bd70ed7a9cfb8a3b3ba0d2fe73 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Fri, 30 Jun 2023 19:36:20 +1000 Subject: [PATCH 11/12] Formatting --- .../Scheduling/SubMinuteSchedulingTest.php | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php index 10470aaecf90..ebefc4ff6e43 100644 --- a/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php +++ b/tests/Integration/Console/Scheduling/SubMinuteSchedulingTest.php @@ -90,9 +90,9 @@ public function test_it_runs_multiple_sub_minute_callbacks() public function test_sub_minute_scheduling_can_be_interrupted() { - $everySecondRuns = 0; - $this->schedule->call(function () use (&$everySecondRuns) { - $everySecondRuns++; + $runs = 0; + $this->schedule->call(function () use (&$runs) { + $runs++; })->everySecond(); Carbon::setTestNow(now()->startOfMinute()); @@ -111,15 +111,15 @@ public function test_sub_minute_scheduling_can_be_interrupted() ->expectsOutputToContain('Running [Callback]'); Sleep::assertSleptTimes(300); - $this->assertEquals(30, $everySecondRuns); + $this->assertEquals(30, $runs); $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++; + $runs = 0; + $this->schedule->call(function () use (&$runs) { + $runs++; })->everySecond(); Config::set('app.maintenance.driver', 'cache'); @@ -143,14 +143,14 @@ public function test_sub_minute_events_stop_for_the_rest_of_the_minute_once_main ->expectsOutputToContain('Running [Callback]'); Sleep::assertSleptTimes(600); - $this->assertEquals(30, $everySecondRuns); + $this->assertEquals(30, $runs); } public function test_sub_minute_events_can_be_run_in_maintenance_mode() { - $everySecondRuns = 0; - $this->schedule->call(function () use (&$everySecondRuns) { - $everySecondRuns++; + $runs = 0; + $this->schedule->call(function () use (&$runs) { + $runs++; })->everySecond()->evenInMaintenanceMode(); Config::set('app.maintenance.driver', 'cache'); @@ -170,14 +170,14 @@ public function test_sub_minute_events_can_be_run_in_maintenance_mode() ->expectsOutputToContain('Running [Callback]'); Sleep::assertSleptTimes(600); - $this->assertEquals(60, $everySecondRuns); + $this->assertEquals(60, $runs); } public function test_sub_minute_scheduling_respects_filters() { - $everySecondRuns = 0; - $this->schedule->call(function () use (&$everySecondRuns) { - $everySecondRuns++; + $runs = 0; + $this->schedule->call(function () use (&$runs) { + $runs++; })->everySecond()->when(fn () => now()->second % 2 === 0); Carbon::setTestNow(now()->startOfMinute()); @@ -188,14 +188,14 @@ public function test_sub_minute_scheduling_respects_filters() ->expectsOutputToContain('Running [Callback]'); Sleep::assertSleptTimes(600); - $this->assertEquals(30, $everySecondRuns); + $this->assertEquals(30, $runs); } public function test_sub_minute_scheduling_can_run_on_one_server() { - $everySecondRuns = 0; - $this->schedule->call(function () use (&$everySecondRuns) { - $everySecondRuns++; + $runs = 0; + $this->schedule->call(function () use (&$runs) { + $runs++; })->everySecond()->name('test')->onOneServer(); $startedAt = now()->startOfMinute(); @@ -208,7 +208,7 @@ public function test_sub_minute_scheduling_can_run_on_one_server() ->expectsOutputToContain('Running [test]'); Sleep::assertSleptTimes(600); - $this->assertEquals(60, $everySecondRuns); + $this->assertEquals(60, $runs); // Fake a second server running at the same minute. Carbon::setTestNow($startedAt); @@ -218,7 +218,7 @@ public function test_sub_minute_scheduling_can_run_on_one_server() ->expectsOutputToContain('Skipping [test]'); Sleep::assertSleptTimes(1200); - $this->assertEquals(60, $everySecondRuns); + $this->assertEquals(60, $runs); } public static function frequencyProvider() From 2760f38a2c705da7030d30dcd3a72a55dbc17b6a Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Fri, 30 Jun 2023 16:07:29 -0500 Subject: [PATCH 12/12] bail early --- .../Console/Scheduling/ScheduleRunCommand.php | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php index 97f79c79139d..f6386b89d48b 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php @@ -229,27 +229,29 @@ protected function repeatEvents($events) return; } - if ($event->shouldRepeatNow()) { - $hasEnteredMaintenanceMode = $hasEnteredMaintenanceMode || $this->laravel->isDownForMaintenance(); + if (! $event->shouldRepeatNow()) { + continue; + } - if ($hasEnteredMaintenanceMode && ! $event->runsInMaintenanceMode()) { - continue; - } + $hasEnteredMaintenanceMode = $hasEnteredMaintenanceMode || $this->laravel->isDownForMaintenance(); - if (! $event->filtersPass($this->laravel)) { - $this->dispatcher->dispatch(new ScheduledTaskSkipped($event)); + if ($hasEnteredMaintenanceMode && ! $event->runsInMaintenanceMode()) { + continue; + } - continue; - } + if (! $event->filtersPass($this->laravel)) { + $this->dispatcher->dispatch(new ScheduledTaskSkipped($event)); - if ($event->onOneServer) { - $this->runSingleServerEvent($event); - } else { - $this->runEvent($event); - } + continue; + } - $this->eventsRan = true; + if ($event->onOneServer) { + $this->runSingleServerEvent($event); + } else { + $this->runEvent($event); } + + $this->eventsRan = true; } Sleep::usleep(100000);