Skip to content

Make Action Scheduler usage optional at runtime (WP-Cron fallback)#1917

Merged
PatelUtkarsh merged 8 commits into
developfrom
feature/1907-optional-action-scheduler
Jul 10, 2026
Merged

Make Action Scheduler usage optional at runtime (WP-Cron fallback)#1917
PatelUtkarsh merged 8 commits into
developfrom
feature/1907-optional-action-scheduler

Conversation

@PatelUtkarsh

@PatelUtkarsh PatelUtkarsh commented Jul 3, 2026

Copy link
Copy Markdown
Member

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 init bootstrap, or a second queue system.

What

  • New Scheduler interface with two backends: AS_Scheduler (default, unchanged behavior) and Cron_Scheduler (WP-Cron fallback).
  • Select the fallback with 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.
  • Switching backends needs no migration: a backend marker option clears the stale recurring purge action on the next request, so only one scheduler ever fires.
  • New wp_stream_enable_auto_purge filter (default true) to disable TTL purge scheduling entirely, for storage drivers that manage retention externally. Honored on both scheduling and executing paths.
  • On the cron backend, a large-table purge/reset surfaces an admin notice / WP_CLI::warning recommending wp 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_Admin suite continues to cover AS. PHPCS clean; PHPUnit passing on single-site and multisite.

Checklist

  • Project documentation has been updated to reflect the changes in this pull request, if applicable.
  • I have tested the changes in the local development environment (see contributing.md).
  • I have added phpunit tests.

Release Changelog

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.
@PatelUtkarsh PatelUtkarsh requested a review from Copilot July 3, 2026 09:03
@PatelUtkarsh PatelUtkarsh marked this pull request as ready for review July 3, 2026 09:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Scheduler interface plus AS_Scheduler and Cron_Scheduler backends, selected at plugin construction via wp_stream_use_action_scheduler.
  • Updates purge/reset and overlap-guard plumbing in Admin/Settings to use the scheduler abstraction and adds wp_stream_enable_auto_purge.
  • Adds PHPUnit coverage for backend selection/handoff and full purge workflow behavior on the cron backend; updates changelog.

Review Findings

  1. Critical issues
  • None found.
  1. 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).
  1. 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_scheduler filter result should be explicitly cast to bool to avoid PHP truthiness surprises ('false' is truthy).
  • AS_Scheduler doesn’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.

Comment thread classes/class-admin.php
Comment on lines 1335 to 1339
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;
Comment thread classes/class-admin.php
Comment on lines +999 to +1003
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' );
}
Comment thread classes/class-plugin.php Outdated
Comment on lines +43 to +47
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 );
}
}
Comment on lines +29 to +31
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 bartoszgadomski left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@PatelUtkarsh Thank you for working on this PR!

Please check inline comments below:

Comment thread classes/class-admin.php Outdated
Comment thread classes/class-admin.php
Comment thread classes/class-plugin.php
…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 bartoszgadomski left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@PatelUtkarsh PatelUtkarsh merged commit d4a05c4 into develop Jul 10, 2026
2 checks passed
@PatelUtkarsh PatelUtkarsh deleted the feature/1907-optional-action-scheduler branch July 10, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reconsider need for Action Scheduler dependency

3 participants