From 357c6e59ae58a04ff4bf963d6d9730c4cf197a0d Mon Sep 17 00:00:00 2001 From: Joshua Blum Date: Wed, 21 Oct 2026 14:13:29 +0200 Subject: [PATCH] Configurable scheduled entries frequency with catch-up Co-Authored-By: Claude Opus 5.5 --- config/system.php | 13 ++ src/Entries/ScheduledEntriesBetween.php | 28 ++++ src/Jobs/HandleEntrySchedule.php | 59 ++++++- src/Providers/AppServiceProvider.php | 3 +- tests/Data/Entries/ScheduledEntriesTest.php | 169 ++++++++++++++++++++ 5 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 src/Entries/ScheduledEntriesBetween.php diff --git a/config/system.php b/config/system.php index 58b05476185..03dd8065ef5 100644 --- a/config/system.php +++ b/config/system.php @@ -163,6 +163,19 @@ 'handle_scheduled_entries' => env('STATAMIC_HANDLE_SCHEDULED_ENTRIES', true), + /* + |-------------------------------------------------------------------------- + | Scheduled Entries Frequency + |-------------------------------------------------------------------------- + | + | How often to check for entries that have reached their scheduled date, + | as a cron expression. Each check covers everything that has become + | due since the last one, so a lower frequency only delays updates. + | + */ + + 'scheduled_entries_frequency' => env('STATAMIC_SCHEDULED_ENTRIES_FREQUENCY', '* * * * *'), + /* |-------------------------------------------------------------------------- | Enable Cache Tags diff --git a/src/Entries/ScheduledEntriesBetween.php b/src/Entries/ScheduledEntriesBetween.php new file mode 100644 index 00000000000..55a6e54c056 --- /dev/null +++ b/src/Entries/ScheduledEntriesBetween.php @@ -0,0 +1,28 @@ +whereIn('collection', Collection::all()->filter->dated()->map->handle()->all()) + ->where('date', '>=', $this->from) + ->where('date', '<', $this->until) + ->get(); + } +} diff --git a/src/Jobs/HandleEntrySchedule.php b/src/Jobs/HandleEntrySchedule.php index c59e68f16bb..6aa814b4b33 100644 --- a/src/Jobs/HandleEntrySchedule.php +++ b/src/Jobs/HandleEntrySchedule.php @@ -3,23 +3,28 @@ namespace Statamic\Jobs; use Carbon\CarbonInterface; +use Cron\CronExpression; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use Statamic\Entries\MinuteEntries; +use Illuminate\Support\Facades\Cache; +use Statamic\Entries\ScheduledEntriesBetween; use Statamic\Events\EntryScheduleReached; class HandleEntrySchedule implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable; + const CACHE_KEY = 'statamic.scheduled-entries.checked-until'; + protected CarbonInterface $minute; public function __construct() { - // We want to target the PREVIOUS minute because we can be sure that any entries that + // We want to target up to the PREVIOUS minute because we can be sure that any entries that // were scheduled for then would now be considered published. If we were targeting // the current minute and the entry has defined a time with seconds later in the // same minute, it may still be considered scheduled when it gets dispatched. @@ -28,11 +33,55 @@ public function __construct() public function handle() { - $this->entries()->each(fn ($entry) => EntryScheduleReached::dispatch($entry)); + $until = $this->minute->copy()->addMinute()->startOfMinute(); + $from = $this->from($until); + + if ($from->gte($until)) { + return; + } + + $this->entries($from, $until)->each(fn ($entry) => EntryScheduleReached::dispatch($entry)); + + $this->markCheckedUntil($until); } - private function entries(): Collection + private function entries(CarbonInterface $from, CarbonInterface $until): Collection { - return (new MinuteEntries($this->minute))(); + return (new ScheduledEntriesBetween($from, $until))(); + } + + private function from(CarbonInterface $until): CarbonInterface + { + $previousRun = $this->previousRunDate($until); + + // Without a record of the last check (first run, cache cleared), assume the + // previous scheduled run happened. With the default frequency, that's the + // previous minute. + if (! $checkedUntil = Cache::get(self::CACHE_KEY)) { + return $previousRun; + } + + // Catch up on everything since the last check, but don't replay more than a + // day (or one interval, if that's longer) when the job hasn't run for a while. + return Carbon::createFromTimestamp($checkedUntil, $until->getTimezone()) + ->max($previousRun->min($until->copy()->subDay())); + } + + private function previousRunDate(CarbonInterface $until): CarbonInterface + { + $expression = new CronExpression(config('statamic.system.scheduled_entries_frequency', '* * * * *')); + $timezone = config('app.schedule_timezone') ?? $until->getTimezone()->getName(); + + return Carbon::instance($expression->getPreviousRunDate($until, 0, false, $timezone)); + } + + private function markCheckedUntil(CarbonInterface $until): void + { + // Overlapping or out of order jobs should never move the marker backwards. + if (Cache::get(self::CACHE_KEY, 0) >= $until->getTimestamp()) { + return; + } + + Cache::forever(self::CACHE_KEY, $until->getTimestamp()); } } diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 768cf89d890..982783e9ff9 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -151,7 +151,8 @@ public function boot() $this->registerElevatedSessionMacros(); if (config('statamic.system.handle_scheduled_entries')) { - $this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute(); + $this->app->make(Schedule::class)->job(HandleEntrySchedule::class) + ->cron(config('statamic.system.scheduled_entries_frequency', '* * * * *')); } $this->app->make(Schedule::class) diff --git a/tests/Data/Entries/ScheduledEntriesTest.php b/tests/Data/Entries/ScheduledEntriesTest.php index 77eb5c21c8e..04749a141cb 100644 --- a/tests/Data/Entries/ScheduledEntriesTest.php +++ b/tests/Data/Entries/ScheduledEntriesTest.php @@ -5,9 +5,13 @@ use Carbon\Carbon; use Facades\Tests\Factories\EntryFactory; use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Event; use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\Test; use Statamic\Entries\MinuteEntries; +use Statamic\Entries\ScheduledEntriesBetween; +use Statamic\Events\EntryScheduleReached; use Statamic\Facades\Blueprint; use Statamic\Facades\Collection; use Statamic\Jobs\HandleEntrySchedule; @@ -95,6 +99,156 @@ public function it_gets_entries_scheduled_for_given_minute() $this->assertEquals(['22'], $this->getEntryIdsForMinute('2023-09-13 00:00:00')); } + #[Test] + public function it_gets_entries_scheduled_between_two_times() + { + $this->createScheduledEntries([ + '01' => '2023-09-13-115959', // before window + '02' => '2023-09-13-120000', // start of window (inclusive) + '03' => '2023-09-13-123030', // within window + '04' => '2023-09-13-125959', // end of window + '05' => '2023-09-13-130000', // after window (exclusive) + ]); + + $ids = (new ScheduledEntriesBetween( + Carbon::parse('2023-09-13 12:00:00'), + Carbon::parse('2023-09-13 13:00:00'), + ))()->map->id->sort()->values()->all(); + + $this->assertEquals(['02', '03', '04'], $ids); + } + + #[Test] + public function it_handles_entries_that_became_due_since_the_last_check() + { + $this->createScheduledEntries([ + '01' => '2023-09-13-115959', // already handled + '02' => '2023-09-13-120000', + '03' => '2023-09-13-120730', + '04' => '2023-09-13-121459', + '05' => '2023-09-13-121500', // current minute, next run + ]); + + Cache::forever(HandleEntrySchedule::CACHE_KEY, Carbon::parse('2023-09-13 12:00:00')->getTimestamp()); + + $this->runJobAt('2023-09-13 12:15:20'); + + $this->assertEquals(['02', '03', '04'], $this->handledEntryIds()); + $this->assertEquals(Carbon::parse('2023-09-13 12:15:00')->getTimestamp(), Cache::get(HandleEntrySchedule::CACHE_KEY)); + } + + #[Test] + public function it_handles_the_previous_minute_when_nothing_has_been_checked_yet() + { + $this->createScheduledEntries([ + '01' => '2023-09-13-121359', + '02' => '2023-09-13-121400', + '03' => '2023-09-13-121459', + '04' => '2023-09-13-121500', + ]); + + $this->runJobAt('2023-09-13 12:15:20'); + + $this->assertEquals(['02', '03'], $this->handledEntryIds()); + } + + #[Test] + public function it_handles_the_previous_interval_when_nothing_has_been_checked_yet() + { + config(['statamic.system.scheduled_entries_frequency' => '0 * * * *']); + + $this->createScheduledEntries([ + '01' => '2023-09-13-115959', + '02' => '2023-09-13-120000', + '03' => '2023-09-13-125959', + '04' => '2023-09-13-130000', + ]); + + $this->runJobAt('2023-09-13 13:00:10'); + + $this->assertEquals(['02', '03'], $this->handledEntryIds()); + } + + #[Test] + public function it_doesnt_look_back_further_than_a_day() + { + $this->createScheduledEntries([ + '01' => '2023-09-12-121459', // more than a day ago + '02' => '2023-09-12-121500', + '03' => '2023-09-13-111400', + ]); + + Cache::forever(HandleEntrySchedule::CACHE_KEY, Carbon::parse('2023-09-10 12:00:00')->getTimestamp()); + + $this->runJobAt('2023-09-13 12:15:20'); + + $this->assertEquals(['02', '03'], $this->handledEntryIds()); + } + + #[Test] + public function it_looks_back_a_full_interval_when_the_frequency_is_longer_than_a_day() + { + config(['statamic.system.scheduled_entries_frequency' => '0 0 * * 0']); + + $this->createScheduledEntries([ + '01' => '2023-09-09-235959', // before the previous run + '02' => '2023-09-10-000000', + '03' => '2023-09-14-120000', + '04' => '2023-09-16-235959', + ]); + + Cache::forever(HandleEntrySchedule::CACHE_KEY, Carbon::parse('2023-09-03 00:00:00')->getTimestamp()); + + $this->runJobAt('2023-09-17 00:00:10'); + + $this->assertEquals(['02', '03', '04'], $this->handledEntryIds()); + } + + #[Test] + public function it_doesnt_move_the_checked_marker_backwards() + { + $this->createScheduledEntries(['01' => '2023-09-13-121400']); + + $marker = Carbon::parse('2023-09-13 12:30:00')->getTimestamp(); + Cache::forever(HandleEntrySchedule::CACHE_KEY, $marker); + + $this->runJobAt('2023-09-13 12:15:20'); + + $this->assertEquals([], $this->handledEntryIds()); + $this->assertEquals($marker, Cache::get(HandleEntrySchedule::CACHE_KEY)); + } + + private function createScheduledEntries(array $entries) + { + $this->makeCollectionsWithBlueprints([ + 'scheduled' => [ + 'date' => ['type' => 'date', 'time_enabled' => true, 'time_seconds_enabled' => true], + ], + 'undated' => [], + ]); + + Collection::find('undated')->dated(false)->save(); + EntryFactory::id('undated')->collection('undated')->create(); + + collect($entries)->each(fn ($date, $id) => EntryFactory::id($id)->collection('scheduled')->date($date)->create()); + } + + private function runJobAt($time) + { + $this->travelTo(Carbon::parse($time)); + + Event::fake([EntryScheduleReached::class]); + + (new HandleEntrySchedule)->handle(); + } + + private function handledEntryIds() + { + return Event::dispatched(EntryScheduleReached::class) + ->map(fn ($args) => $args[0]->entry->id()) + ->sort()->values()->all(); + } + #[Test] public function it_schedules_the_job_every_minute() { @@ -116,6 +270,21 @@ public function disableHandlingOfScheduledEntries($app) $app['config']->set('statamic.system.handle_scheduled_entries', false); } + #[Test] + #[DefineEnvironment('scheduleEntriesEveryFifteenMinutes')] + public function it_schedules_the_job_at_the_configured_frequency() + { + $events = $this->scheduledJobEvents(); + + $this->assertCount(1, $events); + $this->assertEquals('*/15 * * * *', $events->first()->expression); + } + + public function scheduleEntriesEveryFifteenMinutes($app) + { + $app['config']->set('statamic.system.scheduled_entries_frequency', '*/15 * * * *'); + } + private function scheduledJobEvents() { return collect($this->app->make(Schedule::class)->events())