Make Action Scheduler usage optional at runtime (WP-Cron fallback)#1917
Conversation
Stream hard-required Action Scheduler (AS) for all deferred work — the
recurring TTL auto-purge, the batched purge chain, the orphan reaper, and
the large-table reset. Hosts that bundle Stream and run reliable cron
(e.g. Cavalcade on Altis) had no way to opt out, even though AS is less
efficient for that workload and bypasses custom DB drivers.
Introduce a Scheduler abstraction with two implementations:
- AS_Scheduler (default): thin wrappers over the existing as_*() calls;
behavior is unchanged on the default path.
- Cron_Scheduler: WP-Cron fallback implementing the same contract —
self-chaining batches via one-off events, a custom 12h recurrence, a
cron-array scan for the overlap guard, and a short-lived transient
"running" marker to bridge mid-chain gaps that AS tracks natively.
Selection happens once in Plugin::create_scheduler() via the new
`wp_stream_use_action_scheduler` filter. The default is based on whether
the bundled AS file loaded — not function_exists() — because AS only
declares its as_*() API on `init`, while the plugin is constructed on
`plugins_loaded`; scheduler methods are only ever called on/after `init`.
All as_*() / ActionScheduler_Store references now live solely in
AS_Scheduler; Admin and Settings talk only to the interface.
No migration is needed when switching backends. purge_schedule_setup()
detects a switch via the autoloaded wp_stream_scheduler_backend marker and
clears the inactive backend's recurring action exactly once (so the purge
never fires from both AS and WP-Cron). Steady-state cost is a single
in-memory option compare; the cleanup query runs only on the first load
after a switch. Self-healing in both directions.
AS stays a hard, bundled dependency in composer.json — the WordPress.org
build ships it via `composer install --no-dev`, so it is NOT moved to
`suggest` (that would break the common shared-hosting case). The AS
require_once is now file_exists-guarded so an environment that omits or
already provides AS does not fatal.
Tests: Cron_Scheduler unit tests, scheduler-selection tests, a cron-mode
auto-purge workflow suite (batch chaining, terminal reaper, recurring
registration, overlap guard) mirroring the AS suite, and a backend-handoff
suite covering both switch directions. The per-site-scoping Admin test
stub gains the new scheduler property. Single-site and multisite PHPUnit
lanes pass. The DB_Driver purge_storage() rework is tracked separately.
…otice (#1907) Three refinements to the optional-Action-Scheduler work: 1. Don't load Action Scheduler when the WP-Cron fallback is chosen. The `wp_stream_use_action_scheduler` filter is now evaluated before the bundled require_once; Plugin::create_scheduler() loads action-scheduler.php only on the AS branch. Opting into cron pays neither the require nor AS's own `init` bootstrap — only the unused file on disk. AS stays a hard bundled dependency. 2. Add the `wp_stream_enable_auto_purge` filter (default true). Returning false from purge_schedule_setup() unschedules any recurring purge from both backends and skips re-registration, for storage drivers that manage retention externally. The backend marker option is cleared so re-enabling heals cleanly. 3. Add a large-table notice on the cron backend. When a large-table batch operation (manual reset or auto-purge) is queued while the cron fallback is active, maybe_warn_large_table_without_action_scheduler() surfaces guidance to drain it deterministically via WP-CLI. No-op under AS and when auto-purge is disabled. Routed through Admin::notice(), so it renders as an admin notice in the dashboard and a WP_CLI::warning under WP-CLI — the WP-CLI surface is kept (scheduling the chain onto cron does not drain it, so headless/low-traffic sites are exactly where it can stall). Tests: cron-purge suite gains coverage for the disable switch and the notice firing/suppression across backends.
… notice (#1907) purge_schedule_setup() already skips registering the recurring purge when wp_stream_enable_auto_purge returns false, but the callback itself did not re-check the filter. A recurring action already in flight when the filter flips to false (or an args-specific entry the unschedule missed) would still run a purge cycle the operator opted out of. Re-check the filter at the top of purge_scheduled_action() so the off switch is honored on both the scheduling and executing paths. Clarify the large-table WP-Cron notice: - State what the batched work does — either "delete records older than the retention period" (auto-purge) or "reset the Stream database" (manual reset) — via a new $operation argument, instead of a generic "large batch operation". - Explain the cleanup mechanics (records removed in chained batches as WP-Cron runs) and the partial-completion risk (chain may stall, leaving records only partly removed) on default traffic-triggered WP-Cron. - Describe "reliable cron" accurately as a Linux crontab or third-party cron service hitting wp-cron.php on a fixed interval without an execution timeout (not Cavalcade). Document the wp_stream_enable_auto_purge filter, the large-table WP-Cron / WP-CLI notice, and the cron-fallback "AS not loaded" behavior in the changelog. Test: a purge callback firing while the filter is false deletes nothing.
There was a problem hiding this comment.
Pull request overview
This PR makes Stream’s deferred purge/reset work dispatch through a new Scheduler abstraction so Action Scheduler can be disabled at runtime (WP-Cron fallback) for environments that already have reliable cron, while also adding a master switch to disable TTL auto-purge entirely.
Changes:
- Introduces
Schedulerinterface plusAS_SchedulerandCron_Schedulerbackends, selected at plugin construction viawp_stream_use_action_scheduler. - Updates purge/reset and overlap-guard plumbing in
Admin/Settingsto use the scheduler abstraction and addswp_stream_enable_auto_purge. - Adds PHPUnit coverage for backend selection/handoff and full purge workflow behavior on the cron backend; updates changelog.
Review Findings
- Critical issues
- None found.
- High issues
- Cron overlap-guard “running” marker is cleared before the terminal reaper executes, making it more likely the guard reports idle while reaper work is running on WP-Cron (see stored comment in
classes/class-admin.php).
- Medium/Low issues
- Auto-purge disable teardown does not reliably unschedule Action Scheduler’s recurring purge when Cron is the active backend but AS is available (e.g., via WooCommerce), contradicting the intent to clear schedules “from both backends”.
wp_stream_use_action_schedulerfilter result should be explicitly cast toboolto avoid PHP truthiness surprises ('false'is truthy).AS_Schedulerdoesn’t consistently implement the interface’s positional-args contract and its recurring “already scheduled” check ignores args/group, diverging from the cron backend and from the interface doc.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/phpunit/test-class-scheduler-selection.php | Verifies backend selection behavior driven by wp_stream_use_action_scheduler. |
| tests/phpunit/test-class-scheduler-handoff.php | Ensures backend switching removes the stale recurring purge entry from the inactive scheduler. |
| tests/phpunit/test-class-cron-scheduler.php | Unit tests for cron backend primitives (enqueue, recurring schedule, overlap guard). |
| tests/phpunit/test-class-admin.php | Test harness updated to expose the plugin scheduler on the proxy. |
| tests/phpunit/test-class-admin-cron-purge.php | End-to-end purge/reset workflow coverage when using the WP-Cron backend. |
| classes/class-settings.php | Routes “TTL shortened” immediate purge kick through the scheduler abstraction. |
| classes/class-scheduler.php | Adds the scheduler interface contract for deferred work. |
| classes/class-plugin.php | Selects backend at construction time and conditionally loads bundled Action Scheduler. |
| classes/class-cron-scheduler.php | Implements scheduler operations on WP-Cron, including best-effort running marker. |
| classes/class-as-scheduler.php | Implements scheduler operations via Action Scheduler as_*() APIs. |
| classes/class-admin.php | Migrates purge/reset scheduling + overlap guard to scheduler abstraction; adds backend marker + large-table warning + auto-purge master switch. |
| changelog.md | Documents the new optional AS runtime usage and the new auto-purge enable switch. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if ( empty( $start_from ) ) { | ||
| // Chain is done. Schedule the orphan reaper as the terminal step. | ||
| as_enqueue_async_action( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP ); | ||
| $this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP ); | ||
| $this->plugin->scheduler->mark_done( 'auto_purge' ); | ||
| return; |
| if ( 'disabled' !== get_option( self::SCHEDULER_BACKEND_OPTION ) ) { | ||
| $scheduler->unschedule_all( self::AUTO_PURGE_ACTION ); | ||
| wp_unschedule_hook( self::AUTO_PURGE_ACTION ); | ||
| update_option( self::SCHEDULER_BACKEND_OPTION, 'disabled' ); | ||
| } |
| public function schedule_recurring( $timestamp, $interval, $hook, $args = array(), $group = '' ) { | ||
| if ( false === as_next_scheduled_action( $hook ) ) { | ||
| as_schedule_recurring_action( $timestamp, $interval, $hook, $args, $group ); | ||
| } | ||
| } |
| public function enqueue_async( $hook, $args = array(), $group = '' ) { | ||
| as_enqueue_async_action( $hook, $args, $group ); | ||
| } |
…ble (#1907) Address PR review feedback: - Move the cron running-marker lifecycle into auto_purge_reaper() so the overlap guard stays busy while the orphan-meta DELETE executes (WP-Cron removes the event before the callback runs). - Disabled teardown now also clears the Action Scheduler store when cron is the active backend but the AS API is loaded (e.g. via WooCommerce), honoring the wp_stream_enable_auto_purge 'both backends' promise. - Document intentional AS backend behaviors: keyed args preserved on enqueue, hook-scoped recurring probe.
bartoszgadomski
left a comment
There was a problem hiding this comment.
@PatelUtkarsh Thank you for working on this PR!
Please check inline comments below:
…reset chain, AS-absent fallback (#1907) Addresses bartoszgadomski's review on PR #1917: - Persist the large-table WP-Cron warning to an option and render it on the next admin page load. Neither queueing context could display it: the recurring purge runs under DOING_CRON (response discarded) and the manual reset redirects + exits before shutdown output reaches the browser. WP-CLI keeps the immediate WP_CLI::warning. - Apply the running-marker treatment to the manual reset chain (erase_large_records), mirroring auto_purge_batch, and route is_running_async_deletion() through any_pending_or_running() so the reset link is not briefly re-exposed mid-callback on the cron backend. The Cron_Scheduler marker is now claimed per context so one chain finishing cannot un-mark a sibling chain. - Fall back to Cron_Scheduler when wp_stream_use_action_scheduler is forced true but the bundled AS library is absent, instead of returning an AS_Scheduler whose unguarded as_*() calls would fatal.
…nal-action-scheduler # Conflicts: # changelog.md
bartoszgadomski
left a comment
There was a problem hiding this comment.
Thanks @PatelUtkarsh — commit 5c27ffb resolves all three points from my earlier review.
One optional, non-blocking nit (also raised by Copilot): wp_stream_use_action_scheduler isn't cast to bool in create_scheduler(), so a filter returning a truthy string (e.g. 'false') would select AS. The documented __return_false / __return_true usage returns real booleans, so this is robustness rather than a real bug — a (bool) cast would make selection deterministic.
Approving, nice work!
…bool (#1907) A filter callback returning a truthy string (e.g. 'false') would otherwise select the Action Scheduler backend. The documented __return_false / __return_true usage returns real booleans, so this is robustness rather than a live bug — the explicit (bool) cast makes backend selection deterministic. Raised by Copilot and seconded in review.
Fixes #1907.
Why
Stream 4.2.0 made Action Scheduler a hard dependency for deferred purge / reset work. That's a problem for hosts that bundle Stream and already run reliable cron (e.g. Altis / Cavalcade) — they don't want AS's tables, its
initbootstrap, or a second queue system.What
Schedulerinterface with two backends:AS_Scheduler(default, unchanged behavior) andCron_Scheduler(WP-Cron fallback).add_filter( 'wp_stream_use_action_scheduler', '__return_false' )— must be registered before Stream loads (mu-plugin). When selected, the AS library is not loaded at all.wp_stream_enable_auto_purgefilter (defaulttrue) to disable TTL purge scheduling entirely, for storage drivers that manage retention externally. Honored on both scheduling and executing paths.WP_CLI::warningrecommendingwp cron event run --due-now, since traffic-triggered WP-Cron can stall long batch chains.Action Scheduler remains a bundled dependency for the WordPress.org build.
Testing
New suites cover backend selection, backend handoff (both directions), the cron scheduler primitives, and the full purge workflow (batch chaining, reaper, overlap guard, master switch, notice matrix) on the cron backend. Existing
Test_Adminsuite continues to cover AS. PHPCS clean; PHPUnit passing on single-site and multisite.Checklist
contributing.md).Release Changelog
wp_stream_use_action_schedulerfilter, with a WP-Cron fallback; switching backends needs no migration (Reconsider need for Action Scheduler dependency #1907).wp_stream_enable_auto_purgefilter (defaulttrue) to disable TTL auto-purge scheduling for storage drivers that manage retention externally (Reconsider need for Action Scheduler dependency #1907).require_onceso environments that omit or provide their own copy no longer fatal on load (Reconsider need for Action Scheduler dependency #1907).