Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- REST routes live in their own package, [`pollora/wp-rest`](https://github.com/Pollora/wp-rest), which the framework requires: `WpRestDiscovery`, the route registrar, the permissions (`IsLoggedIn`, `IsAdmin`, `Can`) and `Pollora\Support\WpGlobals`. Class names do not change. The framework keeps `WpRestAttributeServiceProvider`
- WP-CLI commands live in their own package, [`pollora/wp-cli`](https://github.com/Pollora/wp-cli), which the framework requires: `WpCliDiscovery`, `WpCliService`, the WP-CLI adapters and `WordpressCommand`. Class names do not change. The framework keeps `WpCliServiceProvider` and `pollora:make:wp-cli`
- Scheduling lives in its own package, [`pollora/schedule`](https://github.com/Pollora/schedule), which the framework requires: one engine behind `#[Schedule]` and a fluent API (`Schedule::event()`, `Schedule::once()`, `cancel()`, `next()`, `events()`), WP-Cron as the default driver, and `Schedule::fake()` for tests. `ScheduleDiscovery` keeps its class name and moves there, with the same hook names and schedule keys. The framework gives the engine its container, so the classes passed to `call()` get their dependencies, and keeps the bridge to Laravel's scheduler (`use_laravel_scheduler`)
- `config/schedule.php` (publishable with the `pollora-schedule` tag) chooses the scheduling driver, `wp-cron` (the default) or `action-scheduler`, by `POLLORA_SCHEDULE_DRIVER`, and lists the hooks that stay on WP-Cron whatever the driver. The `cron` option now indexes every driver's events, so `wp_next_scheduled()`, Site Health and WP Crontrol see the ones Action Scheduler keeps, and WP-Cron never runs them. Changing the driver moves nothing by itself
- `pollora:schedule:list` shows the scheduled events with their type, frequency and driver (`--type`, `--driver`, `--site`, `--network`, `--json`), read from the `cron` option with no query to a driver; an event on WP-Cron on purpose reads `(protected)` or `(fallback)`, one a former driver keeps reads `(to migrate)`. `pollora:schedule:migrate` moves the pending events from one driver to another (`--from`, `--to`, `--dry-run`), in both directions and safe to run again
- `pollora:doctor`, Site Health and an administration notice report a driver that does not exist or is not available (events stay on WP-Cron), and the events a former driver still keeps, with the command that moves them

### Changed
- `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"pollora/entity": "^1.2",
"pollora/hook": "^1.5",
"pollora/option": "^1.1",
"pollora/schedule": "^0.1 || ^1.0",
"pollora/schedule": "^0.1.1 || ^1.0",
"pollora/wp-cli": "^0.1 || ^1.0",
"pollora/wp-rest": "^0.1 || ^1.0",
"watson/rememberable": "^7.1",
Expand Down
44 changes: 44 additions & 0 deletions config/schedule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

return [

/*
|--------------------------------------------------------------------------
| Scheduling driver
|--------------------------------------------------------------------------
|
| Where scheduled events are kept and who runs them: #[Schedule],
| Schedule::event(), and every wp_schedule_event() call of WordPress, a
| plugin or a theme.
|
| "wp-cron" is WordPress's own and needs nothing. "action-scheduler" stores
| events in Action Scheduler (a plugin that bundles it, such as WooCommerce,
| must be active); without it, events stay on WP-Cron.
|
| Changing the driver moves nothing: events already scheduled stay where they
| are until php artisan pollora:schedule:migrate moves them, and
| pollora:doctor reminds you.
|
| Supported: "wp-cron", "action-scheduler"
|
*/

'driver' => env('POLLORA_SCHEDULE_DRIVER', 'wp-cron'),

/*
|--------------------------------------------------------------------------
| Hooks that stay on WP-Cron
|--------------------------------------------------------------------------
|
| Action Scheduler's own queue runner always does: handed to Action
| Scheduler, it would never run. Add hooks that must run by WP-Cron
| whatever the driver. The pollora/schedule/protected_hooks filter extends
| the list too.
|
*/

'protected_hooks' => [],

];
106 changes: 106 additions & 0 deletions src/Schedule/Application/Services/ScheduleStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Application\Services;

use Illuminate\Contracts\Config\Repository;
use Pollora\Schedule\Event;
use Pollora\Schedule\Schedule;
use Pollora\Schedule\Support\CronIndex;

/**
* Where the site's scheduled events are, against where the configuration says they should be.
*
* Shared by the commands, the doctor check and the admin notice, so they all
* say the same thing.
*/
final readonly class ScheduleStatus
{
public function __construct(private Repository $config) {}

/**
* The driver config/schedule.php asks for.
*/
public function configured(): string
{
$driver = $this->config->get('schedule.driver', CronIndex::WP_CRON);

return is_string($driver) && $driver !== '' ? $driver : CronIndex::WP_CRON;
}

/**
* The driver new events go to now.
*/
public function active(): string
{
return Schedule::engine()->driverName();
}

/**
* Whether the configured driver is one the package does not know: the site runs on WP-Cron instead.
*/
public function unknown(): bool
{
return ! Schedule::knows($this->configured());
}

/**
* Whether the driver is not there (Action Scheduler without a plugin that bundles it): events stay on WP-Cron.
*/
public function unavailable(): bool
{
return $this->active() !== CronIndex::WP_CRON && ! Schedule::engine()->driver()->available();
}

/**
* Events still kept by a driver other than the active one, by driver.
*
* An event on WP-Cron because its hook is protected, or because the driver
* is not there, is where it is meant to be.
*
* @return array<string, int>
*/
public function toMigrate(): array
{
$counts = [];

foreach (Schedule::engine()->index() as $event) {
if ($this->placement($event) === 'to migrate') {
$counts[(string) $event->driver] = ($counts[(string) $event->driver] ?? 0) + 1;
}
}

return $counts;
}

/**
* The driver of an event, with what sets it apart from the site's: "wp-cron (protected)", "wp-cron (to migrate)".
*/
public function label(Event $event): string
{
$placement = $this->placement($event);

return $event->driver.($placement === 'active' ? '' : sprintf(' (%s)', $placement));
}

/**
* Whether an event is where the site means it to be: "active" (the driver of the site), "protected" or "fallback" (on WP-Cron on purpose), or "to migrate".
*/
private function placement(Event $event): string
{
if ($event->driver === $this->active()) {
return 'active';
}

if ($event->driver === CronIndex::WP_CRON && in_array($event->hook, Schedule::engine()->protectedHooks(), true)) {
return 'protected';
}

if ($event->driver === CronIndex::WP_CRON && $this->unavailable()) {
return 'fallback';
}

return 'to migrate';
}
}
67 changes: 67 additions & 0 deletions src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Infrastructure\Checks;

use Pollora\Doctor\Domain\Contracts\CheckInterface;
use Pollora\Doctor\Domain\Enums\RunContext;
use Pollora\Doctor\Domain\Models\CheckResult;
use Pollora\Schedule\Application\Services\ScheduleStatus;

/**
* Scheduled events are where the configuration says they should be.
*
* Neither failure shows by itself: an unknown or missing driver leaves every
* event on WP-Cron, and events a former driver still keeps are run by that
* driver, or by nobody once it is gone.
*/
final readonly class ScheduleDriverCheck implements CheckInterface
{
public function __construct(private ScheduleStatus $status) {}

public function id(): string
{
return 'schedule-driver';
}

public function label(): string
{
return 'Scheduled events';
}

public function runsIn(): array
{
return [RunContext::Console, RunContext::Http];
}

public function run(RunContext $context): CheckResult
{
if (! function_exists('_get_cron_array') || ! function_exists('did_action') || \did_action('init') === 0) {
return CheckResult::skipped('WordPress is not loaded.');
}

/** @var list<string> $problems */
$problems = [];
$fix = null;

if ($this->status->unknown()) {
$problems[] = sprintf('config/schedule.php asks for the driver "%s", which does not exist: every event goes to WP-Cron', $this->status->configured());
$fix = 'Set POLLORA_SCHEDULE_DRIVER (config/schedule.php driver) to wp-cron or action-scheduler';
} elseif ($this->status->unavailable()) {
$problems[] = sprintf('The "%s" driver is not available, so events stay on WP-Cron', $this->status->active());
$fix = 'Activate a plugin that bundles Action Scheduler (WooCommerce does), or set the driver back to wp-cron';
}

foreach ($this->status->toMigrate() as $driver => $count) {
$problems[] = sprintf('%d event(s) are still kept by "%s", not by the driver of the site ("%s")', $count, $driver, $this->status->active());
$fix ??= sprintf('php artisan pollora:schedule:migrate --from=%s --to=%s (--dry-run shows what would move)', $driver, $this->status->active());
}

if ($problems === []) {
return CheckResult::ok(sprintf('Scheduled events are kept by "%s".', $this->status->active()));
}

return CheckResult::warning(sprintf('%d problem(s) with scheduled events.', count($problems)), $problems, (string) $fix);
}
}
55 changes: 55 additions & 0 deletions src/Schedule/Infrastructure/Services/MigrationNotice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Infrastructure\Services;

use Pollora\Schedule\Application\Services\ScheduleStatus;

/**
* An administration notice for events a former driver still keeps: nothing moves
* when a page loads, so somebody has to be told to run the migration.
*/
final readonly class MigrationNotice
{
public function __construct(private ScheduleStatus $status) {}

/**
* Show the notice on the administration screens, to those who can run the command.
*/
public function register(): void
{
if (function_exists('add_action')) {
add_action('admin_notices', $this->render(...));
add_action('network_admin_notices', $this->render(...));
}
}

public function render(): void
{
if (! current_user_can('manage_options')) {
return;
}

$pending = $this->status->toMigrate();

if ($pending === []) {
return;
}

$driver = array_key_first($pending);

printf(
'<div class="notice notice-warning"><p><strong>%s</strong> %s <code>%s</code></p></div>',
esc_html__('Scheduled events are still kept by another driver.', 'pollora'),
esc_html(sprintf(
/* translators: 1: number of events, 2: driver name, 3: driver of the site */
__('%1$d event(s) wait in "%2$s", while the site uses "%3$s". Move them with', 'pollora'),
array_sum($pending),
$driver,
$this->status->active(),
)),
esc_html(sprintf('php artisan pollora:schedule:migrate --from=%s --to=%s', $driver, $this->status->active())),
);
}
}
42 changes: 42 additions & 0 deletions src/Schedule/SchedulerServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@
namespace Pollora\Schedule;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Database\QueryException;
use Illuminate\Support\ServiceProvider;
use Orchestra\Testbench\TestCase;
use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider;
use Pollora\Hook\Domain\Contract\Filter;
use Pollora\Schedule\Application\Services\ScheduleStatus;
use Pollora\Schedule\Application\UseCases\RegisterSchedulerFiltersUseCase;
use Pollora\Schedule\Contracts\SchedulerInterface;
use Pollora\Schedule\Events\RecurringEvent;
use Pollora\Schedule\Infrastructure\Checks\ScheduleDriverCheck;
use Pollora\Schedule\Infrastructure\Services\MigrationNotice;
use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery;
use Pollora\Schedule\Schedule as ScheduleApi;
use Pollora\Schedule\UI\Console\ScheduleListCommand;
use Pollora\Schedule\UI\Console\ScheduleMigrateCommand;

/**
* Service provider for WordPress cron scheduler functionality.
Expand All @@ -32,23 +39,58 @@ public function register(): void
$this->registerDomainContracts();
$this->registerUseCases();

$this->mergeConfigFrom(__DIR__.'/../../config/schedule.php', 'schedule');
$this->app->singleton(ScheduleStatus::class, fn (Application $app): ScheduleStatus => new ScheduleStatus($app->make(Repository::class)));

ScheduleApi::useContainer($this->app);
$this->chooseDriver($this->app->make(Repository::class));

if ($this->app->runningInConsole()) {
$this->commands([ScheduleListCommand::class, ScheduleMigrateCommand::class]);
}
}

/**
* Bootstrap scheduler services.
*/
public function boot(): void
{
$this->publishes([
__DIR__.'/../../config/schedule.php' => config_path('schedule.php'),
], 'pollora-schedule');

// A check of pollora:doctor and Site Health; tagged on boot, so it comes after the framework's own
$this->app->tag([ScheduleDriverCheck::class], DoctorServiceProvider::CHECKS_TAG);
$this->app->make(MigrationNotice::class)->register();

if (config('wordpress.use_laravel_scheduler', false)) {
$this->app->make(RegisterSchedulerFiltersUseCase::class)->execute();
}

$this->app->booted(function (): void {
// The driver was chosen when the application started; WordPress can take its hooks by now
ScheduleApi::boot();
$this->scheduleRecurringEvents();
});
}

/**
* Choose the driver of the site, WP-Cron when config/schedule.php names one that does not exist (pollora:doctor says so).
*/
private function chooseDriver(Repository $config): void
{
$driver = $config->get('schedule.driver', 'wp-cron');

try {
ScheduleApi::driver(is_string($driver) && $driver !== '' ? $driver : 'wp-cron');
} catch (\InvalidArgumentException) {
ScheduleApi::driver('wp-cron');
}

$protected = $config->get('schedule.protected_hooks', []);
ScheduleApi::engine()->protect(...array_map(strval(...), is_array($protected) ? $protected : []));
}

/**
* Register domain contracts with their infrastructure implementations.
*/
Expand Down
Loading
Loading