Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions config/system.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/Entries/ScheduledEntriesBetween.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Statamic\Entries;

use Carbon\CarbonInterface;
use Statamic\Facades\Collection;
use Statamic\Facades\Entry;

class ScheduledEntriesBetween
{
/**
* Entries in dated collections with a date from $from (inclusive) to $until (exclusive).
*/
public function __construct(
private readonly CarbonInterface $from,
private readonly CarbonInterface $until
) {
}

public function __invoke(): \Illuminate\Support\Collection
{
return Entry::query()
->whereIn('collection', Collection::all()->filter->dated()->map->handle()->all())
->where('date', '>=', $this->from)
->where('date', '<', $this->until)
->get();
}
}
59 changes: 54 additions & 5 deletions src/Jobs/HandleEntrySchedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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());
}
}
3 changes: 2 additions & 1 deletion src/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
169 changes: 169 additions & 0 deletions tests/Data/Entries/ScheduledEntriesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand All @@ -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())
Expand Down
Loading