diff --git a/changelog.md b/changelog.md
index d2071a263..011d69e21 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,14 @@
# Stream Changelog
+## Unreleased
+
+### Enhancements
+
+- Make Action Scheduler usage optional at runtime: Stream now dispatches its deferred purge / reset work through a scheduler abstraction that defaults to Action Scheduler but can fall back to WP-Cron. Hosts that bundle Stream and run reliable cron (e.g. Cavalcade) can force the WP-Cron path with `add_filter( 'wp_stream_use_action_scheduler', '__return_false' )`. Note the filter is applied when the Stream plugin file loads — before `plugins_loaded` — so it must be registered from an mu-plugin, `wp-config.php`, or a plugin that loads before Stream; a regular plugin hooking `plugins_loaded` is too late. Switching backends needs no migration — the recurring purge action left by the previous backend is cleared automatically on the next page load, so only one scheduler ever fires. Action Scheduler remains a bundled dependency for the WordPress.org build ([#1907](https://github.com/xwp/stream/issues/1907)).
+- Guard the Action Scheduler `require_once` so an environment that already provides or deliberately omits Action Scheduler no longer fatals on load. When the WP-Cron fallback is selected, Action Scheduler is no longer loaded at all.
+- Add the `wp_stream_enable_auto_purge` filter (default `true`). Returning `false` disables all TTL record auto-purge scheduling regardless of backend — for storage drivers that manage retention externally — and clears any already-registered recurring purge ([#1907](https://github.com/xwp/stream/issues/1907)).
+- On the WP-Cron fallback, surface a warning when a large-table purge or reset is queued, recommending `wp cron event run` to drain the batch chain deterministically. Under WP-CLI this is an immediate `WP_CLI::warning`; in the dashboard the warning is persisted and rendered as an admin notice on the next admin page load, since the queueing contexts (cron callback, reset redirect) produce no visible output themselves ([#1907](https://github.com/xwp/stream/issues/1907)).
+
## 4.2.2 - July 6, 2026
### Security
diff --git a/classes/class-admin.php b/classes/class-admin.php
index 55165f5f2..51848a38f 100644
--- a/classes/class-admin.php
+++ b/classes/class-admin.php
@@ -53,6 +53,29 @@ class Admin {
*/
const AUTO_PURGE_GROUP = 'stream-auto-purge';
+ /**
+ * Option storing which scheduler backend last registered the recurring
+ * auto-purge action ('action_scheduler' | 'wp_cron'), or 'disabled' when
+ * the `wp_stream_enable_auto_purge` filter has torn scheduling down. Used
+ * to detect a backend switch (or a disable/re-enable cycle) so the stale
+ * recurring action is cleared exactly once, instead of probing for it on
+ * every page load.
+ *
+ * @const string
+ */
+ const SCHEDULER_BACKEND_OPTION = 'wp_stream_scheduler_backend';
+
+ /**
+ * Option persisting the "large batched operation queued to WP-Cron"
+ * warning between requests. The contexts that queue the warning (the
+ * recurring purge under DOING_CRON, the reset handler just before its
+ * redirect) never render their own output, so the message is stored here
+ * and displayed on the next admin page load instead. Deleted on render.
+ *
+ * @const string
+ */
+ const LARGE_TABLE_CRON_NOTICE_OPTION = 'wp_stream_large_table_cron_notice';
+
/**
* Holds Instance of plugin object
*
@@ -240,6 +263,12 @@ public function __construct( $plugin ) {
add_action( 'admin_notices', array( $this, 'maybe_display_message' ) );
add_action( 'network_admin_notices', array( $this, 'maybe_display_message' ) );
+ // Render the persisted "large batched operation queued to WP-Cron"
+ // warning on the next admin page load (see
+ // maybe_warn_large_table_without_action_scheduler()).
+ add_action( 'admin_notices', array( $this, 'display_large_table_cron_notice' ) );
+ add_action( 'network_admin_notices', array( $this, 'display_large_table_cron_notice' ) );
+
// Auto purge setup (Action Scheduler).
add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) );
add_action(
@@ -764,16 +793,132 @@ private function schedule_erase_large_records( int $log_size ) {
'blog_id' => (int) get_current_blog_id(),
);
- as_enqueue_async_action( self::ASYNC_DELETION_ACTION, $args );
+ $this->plugin->scheduler->enqueue_async( self::ASYNC_DELETION_ACTION, $args );
+
+ $this->maybe_warn_large_table_without_action_scheduler(
+ (int) $log_size,
+ __( 'reset the Stream database (delete all records for this site)', 'stream' )
+ );
+ }
+
+ /**
+ * Warn when a large-table batched operation has to lean on WP-Cron.
+ *
+ * Action Scheduler is purpose-built to drain long self-chaining batch
+ * jobs reliably; default WP-Cron fires opportunistically on traffic and
+ * can stall a multi-hour chain on a low-traffic site. When Stream is
+ * running the WP-Cron fallback (the `wp_stream_use_action_scheduler`
+ * filter returned false, or the bundled AS library is absent) against a
+ * table over the large-table threshold, surface a notice pointing the
+ * operator at a deterministic WP-CLI drain instead of failing silently.
+ *
+ * Delivery depends on context. Under WP-CLI the warning is emitted
+ * immediately via {@see Admin::notice()} (WP_CLI::warning) — scheduling
+ * the batch chain onto WP-Cron does not drain it, so a headless /
+ * low-traffic site is exactly where the chain can stall. Outside WP-CLI
+ * neither call site renders its own output (the recurring purge runs
+ * under DOING_CRON; the manual reset redirects and exits before its
+ * shutdown hook output reaches the browser), so the message is persisted
+ * to {@see Admin::LARGE_TABLE_CRON_NOTICE_OPTION} and rendered on the
+ * next admin page load by {@see Admin::display_large_table_cron_notice()}.
+ *
+ * No-op when Action Scheduler is the active backend (built to drain long
+ * chains). The `wp_stream_enable_auto_purge` filter deliberately does NOT
+ * gate this helper: it governs TTL retention purging only, while this
+ * warning also covers the manual database reset — an operator who manages
+ * retention externally can still click "Reset Stream Database" and needs
+ * the stall warning. The auto-purge call site is already gated by the
+ * filter's early return in {@see Admin::purge_scheduled_action()}.
+ *
+ * @param int $record_count Number of rows the operation will touch.
+ * @param string $operation Human-readable, translated description of what the
+ * batched work does (e.g. "delete records older than
+ * the retention period"), interpolated into the notice.
+ * @return void
+ */
+ private function maybe_warn_large_table_without_action_scheduler( int $record_count, string $operation ) {
+ if ( $this->plugin->scheduler instanceof AS_Scheduler ) {
+ return;
+ }
+
+ if ( ! $this->plugin->is_large_records_table( $record_count ) ) {
+ return;
+ }
+
+ $message = sprintf(
+ /* translators: 1: operation description (e.g. "delete records older than the retention period"), 2: number of records, 3: WP-CLI command. */
+ __( 'Stream queued a large batched operation to %1$s (%2$s records) to WP-Cron because Action Scheduler is disabled. The records are removed in chained batches as WP-Cron runs. This completes on its own where reliable cron is configured (a Linux crontab or third-party cron service triggering wp-cron.php on a fixed interval, without an execution timeout). On sites relying on default traffic-triggered WP-Cron the chain may stall before it finishes, leaving records only partly removed; to run it to completion deterministically, use WP-CLI: %3$s', 'stream' ),
+ $operation,
+ number_format_i18n( $record_count ),
+ 'wp cron event run --due-now'
+ );
+
+ if ( defined( 'WP_CLI' ) && WP_CLI ) {
+ // Immediate WP_CLI::warning — the operator is watching the terminal.
+ $this->notice( $message );
+ return;
+ }
+
+ // Persist for the next admin page load. Neither call site can render
+ // output itself: the recurring purge runs under DOING_CRON (response
+ // discarded) and the manual reset redirects + exits before shutdown
+ // output reaches the browser. No autoload — this is set rarely and
+ // read only in the admin.
+ update_option( self::LARGE_TABLE_CRON_NOTICE_OPTION, $message, false );
+ }
+
+ /**
+ * Render (and clear) the persisted large-table WP-Cron warning.
+ *
+ * Counterpart to {@see Admin::maybe_warn_large_table_without_action_scheduler()}:
+ * displays the stored warning on the first admin page an operator with
+ * the Stream settings capability loads after a large batched operation
+ * was queued onto WP-Cron.
+ *
+ * @action admin_notices
+ * @action network_admin_notices
+ *
+ * @return void
+ */
+ public function display_large_table_cron_notice() {
+ if ( ! current_user_can( $this->settings_cap ) ) {
+ return;
+ }
+
+ $message = get_option( self::LARGE_TABLE_CRON_NOTICE_OPTION );
+ if ( empty( $message ) ) {
+ return;
+ }
+
+ delete_option( self::LARGE_TABLE_CRON_NOTICE_OPTION );
+
+ printf(
+ '
%s
',
+ wp_kses_post( wpautop( $message ) )
+ );
}
/**
* Checks if the async deletion process is running.
*
+ * Checks pending AND in-flight state, mirroring
+ * {@see Admin::is_running_auto_purge()}. Under WP-Cron the event is
+ * removed from the cron array before its callback runs, so a
+ * pending-only probe would momentarily read idle mid-chain and briefly
+ * re-expose the reset link in Settings. The batch worker keeps the
+ * best-effort running marker set for that window (see
+ * {@see Admin::erase_large_records()}). The marker transient is shared
+ * with the auto-purge chain, which only makes both guards more
+ * conservative — never less safe.
+ *
* @return bool True if the async deletion process is running, false otherwise.
*/
public static function is_running_async_deletion() {
- return as_has_scheduled_action( self::ASYNC_DELETION_ACTION );
+ $plugin = wp_stream_get_instance();
+ if ( empty( $plugin->scheduler ) ) {
+ return false;
+ }
+ return $plugin->scheduler->any_pending_or_running( array( self::ASYNC_DELETION_ACTION ) );
}
/**
@@ -795,28 +940,14 @@ public static function is_running_async_deletion() {
* @return bool
*/
public static function is_running_auto_purge() {
- if ( ! function_exists( 'as_get_scheduled_actions' ) ) {
+ $plugin = wp_stream_get_instance();
+ if ( empty( $plugin->scheduler ) ) {
return false;
}
- foreach ( array( self::AUTO_PURGE_BATCH_ACTION, self::AUTO_PURGE_REAPER_ACTION ) as $hook ) {
- $found = as_get_scheduled_actions(
- array(
- 'hook' => $hook,
- 'status' => array(
- \ActionScheduler_Store::STATUS_PENDING,
- \ActionScheduler_Store::STATUS_RUNNING,
- ),
- 'per_page' => 1,
- ),
- 'ids'
- );
- if ( ! empty( $found ) ) {
- return true;
- }
- }
-
- return false;
+ return $plugin->scheduler->any_pending_or_running(
+ array( self::AUTO_PURGE_BATCH_ACTION, self::AUTO_PURGE_REAPER_ACTION )
+ );
}
/**
@@ -836,6 +967,13 @@ public static function is_running_auto_purge() {
public function erase_large_records( int $total, int $done, int $last_entry, int $blog_id ) {
global $wpdb;
+ // Best-effort "running" marker, mirroring auto_purge_batch(). Under
+ // WP-Cron the event is dequeued before this callback runs, so without
+ // the marker is_running_async_deletion() would momentarily read idle
+ // between batches and briefly re-expose the reset link in Settings.
+ // No-op under Action Scheduler; self-expires on a fatal.
+ $this->plugin->scheduler->mark_running( 'async_deletion' );
+
$start_from = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->stream} WHERE ID < %d AND `blog_id`=%d ORDER BY ID DESC LIMIT 1",
@@ -845,6 +983,11 @@ public function erase_large_records( int $total, int $done, int $last_entry, int
);
if ( empty( $start_from ) ) {
+ // Terminal batch: nothing left to delete, no further event will
+ // be chained, and no work follows within this callback — safe to
+ // clear the marker immediately (unlike the auto-purge chain,
+ // whose terminal batch hands off to the reaper).
+ $this->plugin->scheduler->mark_done( 'async_deletion' );
return;
}
@@ -878,7 +1021,7 @@ public function erase_large_records( int $total, int $done, int $last_entry, int
$done = $total - $remaining;
- as_enqueue_async_action(
+ $this->plugin->scheduler->enqueue_async(
self::ASYNC_DELETION_ACTION,
array(
'total' => (int) $total,
@@ -917,27 +1060,103 @@ public static function get_blog_record_table_size( $blog_id = null ): int {
*/
public function purge_schedule_setup() {
// Clear the legacy WP-Cron event scheduled by Stream <= 4.1.x so it
- // cannot double-fire alongside the new AS recurring action.
+ // cannot double-fire alongside the new recurring action.
if ( wp_next_scheduled( 'wp_stream_auto_purge' ) ) {
wp_clear_scheduled_hook( 'wp_stream_auto_purge' );
}
- if ( ! function_exists( 'as_schedule_recurring_action' ) ) {
- // Action Scheduler not yet loaded (e.g. very early hook); bail.
- // Plugin::__construct() loads it before init, so this should be unreachable.
+ $scheduler = $this->plugin->scheduler;
+
+ /**
+ * Filter whether Stream schedules its TTL record auto-purge at all.
+ *
+ * Custom storage drivers that manage retention externally (TTL
+ * indexes, partition rotation, a warehouse job, etc.) can return
+ * false to disable all TTL purge scheduling regardless of the
+ * scheduler backend. Any already-registered recurring purge is
+ * unscheduled from both backends so it cannot keep firing.
+ *
+ * @param bool $enabled Whether auto-purge scheduling is enabled.
+ */
+ if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) {
+ // Tear down only once, then record the 'disabled' sentinel in the
+ // backend marker. This runs on every wp_loaded, so without the
+ // guard a permanently-disabled site would pay the unschedule
+ // probes on every request; with it, steady state is a single
+ // in-memory compare (the marker is autoloaded). The sentinel also
+ // covers a site upgrading with the filter already active (no
+ // marker yet, but a recurring action left by a previous version).
+ // The executing path is independently gated by the same filter in
+ // purge_scheduled_action(), so a stray entry that somehow survives
+ // cannot purge anything anyway.
+ if ( 'disabled' !== get_option( self::SCHEDULER_BACKEND_OPTION ) ) {
+ $scheduler->unschedule_all( self::AUTO_PURGE_ACTION );
+ wp_unschedule_hook( self::AUTO_PURGE_ACTION );
+
+ // Also clear the Action Scheduler store when its API is
+ // available but AS is not the active backend (e.g. the cron
+ // backend is selected while WooCommerce provides AS). The
+ // active-backend unschedule above cannot see AS's store, and
+ // this filter promises teardown from BOTH backends. When AS
+ // is entirely absent this is skipped — a stray AS entry
+ // cannot execute (no AS runner), and if AS appears later the
+ // action fires as a no-op thanks to the execute-path gate.
+ if ( ! $scheduler instanceof AS_Scheduler && function_exists( 'as_unschedule_all_actions' ) ) {
+ ( new AS_Scheduler() )->unschedule_all( self::AUTO_PURGE_ACTION );
+ }
+
+ update_option( self::SCHEDULER_BACKEND_OPTION, 'disabled' );
+ }
return;
}
- if ( false === as_next_scheduled_action( self::AUTO_PURGE_ACTION ) ) {
- // 12 hours == old `twicedaily` interval.
- as_schedule_recurring_action(
- time(),
- 12 * HOUR_IN_SECONDS,
- self::AUTO_PURGE_ACTION,
- array(),
- self::AUTO_PURGE_GROUP
- );
+ $backend = $scheduler instanceof AS_Scheduler ? 'action_scheduler' : 'wp_cron';
+
+ // Detect a backend switch and clear the inactive backend's copy of the
+ // recurring action exactly once. A site that switched schedulers (via
+ // the wp_stream_use_action_scheduler filter) would otherwise keep
+ // firing the purge from BOTH backends — the two stores are independent
+ // and neither overlap guard can see the other. The marker is an
+ // autoloaded option, so the steady-state cost on every wp_loaded is a
+ // single in-memory compare; the cleanup query runs only on the first
+ // page load after a switch. Idempotent and self-healing. No data is
+ // affected — only the redundant schedule entry.
+ if ( get_option( self::SCHEDULER_BACKEND_OPTION ) !== $backend ) {
+ $cleanup_done = true;
+
+ if ( 'action_scheduler' === $backend ) {
+ // Drop any leftover WP-Cron recurring event.
+ wp_unschedule_hook( self::AUTO_PURGE_ACTION );
+ } elseif ( function_exists( 'as_unschedule_all_actions' ) ) {
+ // Drop any leftover Action Scheduler recurring action. Routed
+ // through AS_Scheduler so the as_*() call stays contained there.
+ ( new AS_Scheduler() )->unschedule_all( self::AUTO_PURGE_ACTION );
+ } else {
+ // Action Scheduler is not loaded (cron backend selected and no
+ // other plugin provides AS), so its store cannot be cleaned
+ // right now. Do NOT write the marker: if an AS-providing
+ // plugin (e.g. WooCommerce) is installed later, the stray
+ // Stream recurring action in the AS store would resume firing
+ // alongside the cron one — and the cron overlap guard cannot
+ // see it. Leaving the marker stale retries this cleanup on a
+ // later request once as_unschedule_all_actions() exists.
+ $cleanup_done = false;
+ }
+
+ if ( $cleanup_done ) {
+ update_option( self::SCHEDULER_BACKEND_OPTION, $backend );
+ }
}
+
+ // 12 hours == old `twicedaily` interval. The scheduler only schedules
+ // a fresh recurring action when one is not already registered.
+ $scheduler->schedule_recurring(
+ time(),
+ 12 * HOUR_IN_SECONDS,
+ self::AUTO_PURGE_ACTION,
+ array(),
+ self::AUTO_PURGE_GROUP
+ );
}
/**
@@ -962,6 +1181,15 @@ protected function delete_orphaned_meta() {
* @return void
*/
public function purge_scheduled_action() {
+ // Respect the auto-purge master switch on the executing path too, not
+ // just at scheduling time. A recurring action already in flight when
+ // the filter flips to false (or an args-specific entry the unschedule
+ // missed) would otherwise still run a purge cycle the operator opted
+ // out of. This filter is documented in Admin::purge_schedule_setup().
+ if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) {
+ return;
+ }
+
// Don't purge when in Network Admin unless Stream is network activated.
if (
$this->plugin->is_multisite_not_network_activated()
@@ -1084,12 +1312,12 @@ public function purge_scheduled_action() {
);
}
- 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 );
return;
}
// Large-table path: batched chain.
- as_enqueue_async_action(
+ $this->plugin->scheduler->enqueue_async(
self::AUTO_PURGE_BATCH_ACTION,
array(
'cutoff' => $cutoff,
@@ -1097,6 +1325,11 @@ public function purge_scheduled_action() {
),
self::AUTO_PURGE_GROUP
);
+
+ $this->maybe_warn_large_table_without_action_scheduler(
+ $record_count,
+ __( 'delete records older than the retention period', 'stream' )
+ );
}
/**
@@ -1142,6 +1375,13 @@ public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) {
throw new \InvalidArgumentException( 'auto_purge_batch requires a non-empty cutoff.' );
}
+ // Best-effort "running" marker for schedulers without a native RUNNING
+ // store (cron). Bridges the gap between this batch starting and the
+ // next chained event being enqueued; self-expires on a fatal. No-op
+ // under Action Scheduler. Cleared when the chain reaches its terminal
+ // reaper (see the empty-$start_from branch below).
+ $this->plugin->scheduler->mark_running( 'auto_purge' );
+
/**
* Filters the number of records to delete per batch.
*
@@ -1200,7 +1440,12 @@ public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) {
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 );
+ // The running marker is NOT cleared here: under WP-Cron the reaper
+ // event is removed from the cron array before its callback runs,
+ // so clearing now would let the overlap guard read "idle" while
+ // the reaper's orphan-meta DELETE is still executing. The reaper
+ // clears the marker itself when it finishes.
+ $this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP );
return;
}
@@ -1247,7 +1492,7 @@ public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) {
// Chain the next batch. Pass $window_low as the new upper bound so the
// next SELECT cannot pick up rows in or above the window we just touched.
- as_enqueue_async_action(
+ $this->plugin->scheduler->enqueue_async(
self::AUTO_PURGE_BATCH_ACTION,
array(
'cutoff' => $cutoff,
@@ -1269,7 +1514,17 @@ public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) {
* @return void
*/
public function auto_purge_reaper() {
+ // Keep the overlap guard reading "busy" while the orphan-meta DELETE
+ // runs. Under WP-Cron the event is removed from the cron array before
+ // this callback executes, so without the marker a recurring purge
+ // tick or a manual "clean orphaned meta" click could stack parallel
+ // work against the same rows. No-op under Action Scheduler, which
+ // tracks RUNNING state natively. Self-expires on a fatal.
+ $this->plugin->scheduler->mark_running( 'auto_purge' );
+
$this->delete_orphaned_meta();
+
+ $this->plugin->scheduler->mark_done( 'auto_purge' );
}
/**
@@ -1291,8 +1546,8 @@ public function wp_ajax_clean_orphan_meta() {
check_ajax_referer( 'stream_nonce_clean_orphan_meta', 'wp_stream_nonce_clean_orphan_meta' );
- if ( ! function_exists( 'as_get_scheduled_actions' ) || ! function_exists( 'as_enqueue_async_action' ) ) {
- wp_die( esc_html__( 'Action Scheduler is not available.', 'stream' ), 500 );
+ if ( empty( $this->plugin->scheduler ) ) {
+ wp_die( esc_html__( 'No scheduler is available.', 'stream' ), 500 );
}
// Idempotency: skip enqueue when any auto-purge action is already
@@ -1301,7 +1556,7 @@ public function wp_ajax_clean_orphan_meta() {
// its own terminal reaper is not duplicated by a manual click landing
// in the small CSRF/stale-URL window where the UI link is hidden.
if ( ! self::is_running_auto_purge() ) {
- 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 );
}
if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) {
diff --git a/classes/class-as-scheduler.php b/classes/class-as-scheduler.php
new file mode 100644
index 000000000..ff54273ae
--- /dev/null
+++ b/classes/class-as-scheduler.php
@@ -0,0 +1,146 @@
+ $hook,
+ 'status' => array(
+ \ActionScheduler_Store::STATUS_PENDING,
+ \ActionScheduler_Store::STATUS_RUNNING,
+ ),
+ 'per_page' => 1,
+ ),
+ 'ids'
+ );
+ if ( ! empty( $found ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Unschedule every pending instance of a hook.
+ *
+ * @param string $hook Action hook name.
+ * @return void
+ */
+ public function unschedule_all( $hook ) {
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ as_unschedule_all_actions( $hook );
+ }
+ }
+
+ /**
+ * No-op: Action Scheduler tracks RUNNING state in its own store.
+ *
+ * @param string $context Short identifier for the running work.
+ * @return void
+ */
+ public function mark_running( $context ) {}
+
+ /**
+ * No-op counterpart to {@see AS_Scheduler::mark_running()}.
+ *
+ * @param string $context Short identifier for the running work.
+ * @return void
+ */
+ public function mark_done( $context ) {}
+}
diff --git a/classes/class-cron-scheduler.php b/classes/class-cron-scheduler.php
new file mode 100644
index 000000000..9ba3afece
--- /dev/null
+++ b/classes/class-cron-scheduler.php
@@ -0,0 +1,217 @@
+ 12 * HOUR_IN_SECONDS,
+ 'display' => __( 'Twice Daily (Stream auto-purge)', 'stream' ),
+ );
+
+ return $schedules;
+ }
+
+ /**
+ * Enqueue a one-off action to run on the next cron tick.
+ *
+ * @param string $hook Action hook name.
+ * @param array $args Arguments passed positionally to the callback.
+ * @param string $group Unused (Action Scheduler concept).
+ * @return void
+ */
+ public function enqueue_async( $hook, $args = array(), $group = '' ) {
+ wp_schedule_single_event( time(), $hook, array_values( (array) $args ) );
+ }
+
+ /**
+ * Schedule a recurring event if one is not already scheduled.
+ *
+ * The recurrence interval is fixed to {@see Cron_Scheduler::RECURRENCE}
+ * (12 hours); the $interval argument is accepted for interface parity but
+ * not used, since WP-Cron recurrences are named schedules registered up
+ * front. The sole caller schedules at the 12-hour cadence.
+ *
+ * @param int $timestamp First run, as a Unix timestamp.
+ * @param int $interval Recurrence interval in seconds (unused; see above).
+ * @param string $hook Action hook name.
+ * @param array $args Arguments passed positionally to the callback.
+ * @param string $group Unused (Action Scheduler concept).
+ * @return void
+ */
+ public function schedule_recurring( $timestamp, $interval, $hook, $args = array(), $group = '' ) {
+ $args = array_values( (array) $args );
+
+ if ( false === wp_next_scheduled( $hook, $args ) ) {
+ wp_schedule_event( $timestamp, self::RECURRENCE, $hook, $args );
+ }
+ }
+
+ /**
+ * Get the next scheduled timestamp for a hook (with matching args).
+ *
+ * @param string $hook Action hook name.
+ * @param array $args Arguments the event was scheduled with.
+ * @return int|false
+ */
+ public function next_scheduled( $hook, $args = array() ) {
+ return wp_next_scheduled( $hook, array_values( (array) $args ) );
+ }
+
+ /**
+ * Whether any instance of a hook is scheduled, regardless of its args.
+ *
+ * @param string $hook Action hook name.
+ * @return bool
+ */
+ public function has_scheduled( $hook ) {
+ return $this->cron_has_any( $hook );
+ }
+
+ /**
+ * Whether any of the given hooks is pending, or a callback is currently
+ * running (best-effort, via the running transient).
+ *
+ * @param array $hooks Action hook names to probe.
+ * @return bool
+ */
+ public function any_pending_or_running( $hooks ) {
+ foreach ( (array) $hooks as $hook ) {
+ if ( $this->cron_has_any( $hook ) ) {
+ return true;
+ }
+ }
+
+ return (bool) get_transient( self::RUNNING_TRANSIENT );
+ }
+
+ /**
+ * Unschedule every pending instance of a hook.
+ *
+ * @param string $hook Action hook name.
+ * @return void
+ */
+ public function unschedule_all( $hook ) {
+ wp_unschedule_hook( $hook );
+ }
+
+ /**
+ * Set the best-effort "running" marker, claiming it for $context.
+ *
+ * Bridges the window between a chained callback starting and the next
+ * event being scheduled, so the overlap guard does not momentarily read
+ * as idle mid-chain. Self-expires so a fatal mid-callback cannot wedge
+ * the guard permanently.
+ *
+ * The marker is a single shared transient claimed by the last caller
+ * (the context string is stored as its value). Multiple Stream chains
+ * (auto-purge, manual reset) share it, which only makes the guards more
+ * conservative: a chain can be reported as running while a sibling chain
+ * is. {@see Cron_Scheduler::mark_done()} clears it only when the caller
+ * is the current claimant, so one chain finishing cannot un-mark another
+ * that is still mid-flight.
+ *
+ * @param string $context Short identifier for the running work.
+ * @return void
+ */
+ public function mark_running( $context ) {
+ set_transient( self::RUNNING_TRANSIENT, (string) $context, 10 * MINUTE_IN_SECONDS );
+ }
+
+ /**
+ * Clear the "running" marker, if $context is the current claimant.
+ *
+ * When another context has since claimed the marker, it is left alone
+ * and self-expires — the conservative failure mode (guards read busy for
+ * up to the transient TTL) rather than the unsafe one (a running chain
+ * reported as idle).
+ *
+ * @param string $context Short identifier for the running work.
+ * @return void
+ */
+ public function mark_done( $context ) {
+ if ( get_transient( self::RUNNING_TRANSIENT ) === (string) $context ) {
+ delete_transient( self::RUNNING_TRANSIENT );
+ }
+ }
+
+ /**
+ * Scan the cron array for any pending event matching a hook, ignoring args.
+ *
+ * `wp_next_scheduled()` matches on a specific args signature; this detects
+ * a hook scheduled with any args (e.g. successive batch windows).
+ *
+ * @param string $hook Action hook name.
+ * @return bool
+ */
+ protected function cron_has_any( $hook ) {
+ $crons = _get_cron_array();
+
+ if ( empty( $crons ) ) {
+ return false;
+ }
+
+ foreach ( $crons as $events ) {
+ if ( isset( $events[ $hook ] ) && ! empty( $events[ $hook ] ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/classes/class-plugin.php b/classes/class-plugin.php
index c6a652869..b08baff56 100755
--- a/classes/class-plugin.php
+++ b/classes/class-plugin.php
@@ -114,6 +114,28 @@ class Plugin {
*/
public $install;
+ /**
+ * Backend used to schedule Stream's deferred work (purge / reset).
+ *
+ * Either an {@see AS_Scheduler} (Action Scheduler, default) or a
+ * {@see Cron_Scheduler} (WP-Cron fallback), selected at construction via
+ * the `wp_stream_use_action_scheduler` filter.
+ *
+ * @var Scheduler
+ */
+ public $scheduler;
+
+ /**
+ * Whether the bundled Action Scheduler library was loaded.
+ *
+ * Set from a file_exists() check at construction (see __construct), so it
+ * is reliable on `plugins_loaded` even though AS only declares its as_*()
+ * API later on `init`.
+ *
+ * @var bool
+ */
+ protected $action_scheduler_available = false;
+
/**
* URLs and Paths used by the plugin
*
@@ -144,8 +166,24 @@ public function __construct() {
spl_autoload_register( array( $this, 'autoload' ) );
- // Load Action Scheduler.
- require_once $this->locations['dir'] . '/vendor/woocommerce/action-scheduler/action-scheduler.php';
+ // Determine the scheduler backend, then load Action Scheduler only if
+ // it is the selected backend. AS remains a hard, bundled dependency
+ // for the WordPress.org build (see issue #1907), but integrators who
+ // opt into the WP-Cron fallback via `wp_stream_use_action_scheduler`
+ // pay neither the require_once nor AS's own `init` bootstrap.
+ //
+ // Availability is tracked from a file_exists() check rather than
+ // function_exists(): AS only declares its as_*() API on `init`, but
+ // this constructor runs at plugin-file inclusion time (before the
+ // `plugins_loaded` action fires), so the functions are not defined
+ // yet. Scheduler methods are only ever called on/after `init`
+ // (wp_loaded, AJAX, cron), by which point the API is loaded.
+ $action_scheduler = $this->locations['dir'] . '/vendor/woocommerce/action-scheduler/action-scheduler.php';
+ $this->action_scheduler_available = file_exists( $action_scheduler );
+
+ // Select the scheduler backend for Stream's deferred work, loading the
+ // bundled AS library only if it is the chosen backend.
+ $this->scheduler = $this->create_scheduler();
// Load helper functions.
require_once $this->locations['inc_dir'] . 'functions.php';
@@ -205,6 +243,63 @@ public function __construct() {
}
}
+ /**
+ * Build the scheduler backend for Stream's deferred work (purge / reset).
+ *
+ * Defaults to Action Scheduler when its bundled library is present.
+ * Integrators that bundle Stream and run reliable cron (e.g. Cavalcade)
+ * can force the WP-Cron fallback by returning false from
+ * `wp_stream_use_action_scheduler`. When the fallback is chosen, the
+ * bundled AS library is not loaded at all (no require_once, no AS `init`
+ * bootstrap). See issue #1907.
+ *
+ * @return Scheduler
+ */
+ public function create_scheduler() {
+ /**
+ * Filter whether Stream uses Action Scheduler for its deferred work.
+ *
+ * IMPORTANT — timing: this filter is applied in Plugin::__construct(),
+ * which runs when the Stream plugin file is included, i.e. BEFORE the
+ * `plugins_loaded` action. Callbacks must therefore be registered from
+ * code that loads before Stream: an mu-plugin, wp-config.php, or a
+ * plugin guaranteed to load earlier. Registering it from a regular
+ * plugin's `plugins_loaded` hook is too late and will be ignored.
+ *
+ * @param bool $use_action_scheduler Whether to use Action Scheduler.
+ * Defaults to true when the bundled
+ * AS library is present. Return a
+ * real boolean: the value is cast
+ * with (bool), so PHP string
+ * truthiness applies to strings
+ * (e.g. 'false' is truthy).
+ */
+ $use_action_scheduler = (bool) apply_filters(
+ 'wp_stream_use_action_scheduler',
+ $this->action_scheduler_available
+ );
+
+ if ( ! $use_action_scheduler ) {
+ return new Cron_Scheduler();
+ }
+
+ // Guard a forced `__return_true` override when the bundled AS library
+ // is absent: returning AS_Scheduler without loading AS would fatal on
+ // the first unguarded as_*() call. Fall back to the cron scheduler
+ // instead. The default path never hits this — the filter defaults to
+ // $this->action_scheduler_available.
+ if ( ! $this->action_scheduler_available ) {
+ return new Cron_Scheduler();
+ }
+
+ // Load the bundled AS library before instantiating its scheduler.
+ // AS's own ActionScheduler_Versions arbitration handles a host that
+ // already provides its own copy.
+ require_once $this->locations['dir'] . '/vendor/woocommerce/action-scheduler/action-scheduler.php';
+
+ return new AS_Scheduler();
+ }
+
/**
* Autoloader for classes
*
diff --git a/classes/class-scheduler.php b/classes/class-scheduler.php
new file mode 100644
index 000000000..c42836753
--- /dev/null
+++ b/classes/class-scheduler.php
@@ -0,0 +1,119 @@
+plugin->scheduler ) ) {
if ( ! \WP_Stream\Admin::is_running_auto_purge() ) {
- as_enqueue_async_action(
+ $this->plugin->scheduler->enqueue_async(
\WP_Stream\Admin::AUTO_PURGE_ACTION,
array(),
\WP_Stream\Admin::AUTO_PURGE_GROUP
diff --git a/tests/phpunit/test-class-admin-cron-purge.php b/tests/phpunit/test-class-admin-cron-purge.php
new file mode 100644
index 000000000..73c2198cf
--- /dev/null
+++ b/tests/phpunit/test-class-admin-cron-purge.php
@@ -0,0 +1,587 @@
+admin = $this->plugin->admin;
+ $this->assertNotEmpty( $this->admin );
+
+ // Force the WP-Cron fallback for the duration of each test. Because
+ // $this->plugin is the global instance, this also routes the static
+ // is_running_* probes through the cron scheduler.
+ $this->original_scheduler = $this->plugin->scheduler;
+ $this->plugin->scheduler = new Cron_Scheduler();
+
+ $this->clear_purge_events();
+ }
+
+ public function tearDown(): void {
+ $this->clear_purge_events();
+ $this->plugin->scheduler = $this->original_scheduler;
+
+ global $wpdb;
+ $wpdb->query( "DELETE FROM {$wpdb->stream}" );
+ $wpdb->query( "DELETE FROM {$wpdb->streammeta}" );
+
+ delete_option( 'wp_stream' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Remove any scheduled purge events / markers left by a test.
+ */
+ private function clear_purge_events() {
+ wp_unschedule_hook( Admin::AUTO_PURGE_ACTION );
+ wp_unschedule_hook( Admin::AUTO_PURGE_BATCH_ACTION );
+ wp_unschedule_hook( Admin::AUTO_PURGE_REAPER_ACTION );
+ wp_unschedule_hook( Admin::ASYNC_DELETION_ACTION );
+ wp_clear_scheduled_hook( 'wp_stream_auto_purge' );
+ delete_transient( Cron_Scheduler::RUNNING_TRANSIENT );
+ delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION );
+ }
+
+ /**
+ * Insert N stream rows aged $days_old days.
+ *
+ * @param int $count Number of rows.
+ * @param int $days_old Age of each row's `created` column, in days.
+ * @return int[] Inserted stream IDs.
+ */
+ private function seed_aged_records( int $count, int $days_old ): array {
+ global $wpdb;
+ $ids = array();
+ for ( $i = 0; $i < $count; $i++ ) {
+ $wpdb->insert(
+ $wpdb->stream,
+ array(
+ 'object_id' => null,
+ 'site_id' => '1',
+ 'blog_id' => get_current_blog_id(),
+ 'user_id' => '1',
+ 'user_role' => 'administrator',
+ 'created' => gmdate( 'Y-m-d H:i:s', strtotime( $days_old . ' days ago' ) ),
+ 'summary' => 'cron purge test row',
+ 'ip' => '192.168.0.1',
+ 'connector' => 'installer',
+ 'context' => 'plugins',
+ 'action' => 'activated',
+ )
+ );
+ $stream_id = (int) $wpdb->insert_id;
+ $ids[] = $stream_id;
+ $wpdb->insert(
+ $wpdb->streammeta,
+ array(
+ 'record_id' => $stream_id,
+ 'meta_key' => 'space_helmet',
+ 'meta_value' => 'false',
+ )
+ );
+ }//end for
+
+ return $ids;
+ }
+
+ /**
+ * Set the records TTL in whichever option applies on this install.
+ *
+ * @param int $days Retention window in days.
+ */
+ private function set_records_ttl( int $days ) {
+ if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) {
+ $options = (array) get_site_option( 'wp_stream_network', array() );
+ $options['general_records_ttl'] = (string) $days;
+ unset( $options['general_keep_records_indefinitely'] );
+ update_site_option( 'wp_stream_network', $options );
+ } else {
+ $options = (array) get_option( 'wp_stream', array() );
+ $options['general_records_ttl'] = (string) $days;
+ unset( $options['general_keep_records_indefinitely'] );
+ update_option( 'wp_stream', $options );
+ }
+ }
+
+ /**
+ * A UTC cutoff one day in the past, matching purge_scheduled_action().
+ */
+ private function cutoff_one_day_ago(): string {
+ return ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) )
+ ->sub( \DateInterval::createFromDateString( '1 days' ) )
+ ->format( 'Y-m-d H:i:s' );
+ }
+
+ /**
+ * Purge_schedule_setup() registers the recurring event on WP-Cron using
+ * the custom 12-hour recurrence, clears the legacy event, and is idempotent.
+ */
+ public function test_schedule_setup_registers_recurring_cron_event() {
+ // Simulate a legacy WP-Cron event from older Stream versions.
+ wp_schedule_event( time(), 'twicedaily', 'wp_stream_auto_purge' );
+ $this->assertNotFalse( wp_next_scheduled( 'wp_stream_auto_purge' ) );
+
+ $this->admin->purge_schedule_setup();
+
+ $this->assertFalse(
+ wp_next_scheduled( 'wp_stream_auto_purge' ),
+ 'Legacy WP-Cron event should be cleared'
+ );
+ $this->assertNotFalse(
+ wp_next_scheduled( Admin::AUTO_PURGE_ACTION ),
+ 'Recurring purge event must be scheduled via WP-Cron'
+ );
+ $this->assertSame(
+ Cron_Scheduler::RECURRENCE,
+ wp_get_schedule( Admin::AUTO_PURGE_ACTION ),
+ 'Recurring event must use the custom 12-hour recurrence'
+ );
+
+ // Idempotent: a second call must not stack a duplicate.
+ $first = wp_next_scheduled( Admin::AUTO_PURGE_ACTION );
+ $this->admin->purge_schedule_setup();
+ $this->assertSame( $first, wp_next_scheduled( Admin::AUTO_PURGE_ACTION ) );
+ }
+
+ /**
+ * Small-table fast path: an inline DELETE removes eligible rows, then the
+ * reaper is enqueued on WP-Cron and no batch chain is scheduled.
+ */
+ public function test_small_table_fast_path_deletes_inline_and_enqueues_reaper() {
+ global $wpdb;
+
+ $ids = $this->seed_aged_records( 2, 5 );
+ $this->set_records_ttl( 1 );
+
+ $this->admin->purge_scheduled_action();
+
+ $remaining = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM ' . $wpdb->stream . ' WHERE ID IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')',
+ ...$ids
+ )
+ );
+ $this->assertSame( 0, $remaining, 'Eligible rows must be deleted inline' );
+
+ $this->assertFalse(
+ $this->plugin->scheduler->has_scheduled( Admin::AUTO_PURGE_BATCH_ACTION ),
+ 'Small-table path must not schedule a batch chain'
+ );
+ $this->assertTrue(
+ $this->plugin->scheduler->has_scheduled( Admin::AUTO_PURGE_REAPER_ACTION ),
+ 'Small-table path must enqueue the orphan reaper on WP-Cron'
+ );
+ }
+
+ /**
+ * Large-table path: a batch chain is scheduled on WP-Cron and the reaper
+ * is left to the terminal batch.
+ */
+ public function test_large_table_schedules_batch_chain() {
+ add_filter( 'wp_stream_is_large_records_table', '__return_true' );
+
+ $this->seed_aged_records( 2, 5 );
+ $this->set_records_ttl( 1 );
+
+ $this->admin->purge_scheduled_action();
+
+ $this->assertTrue(
+ $this->plugin->scheduler->has_scheduled( Admin::AUTO_PURGE_BATCH_ACTION ),
+ 'Large table must schedule the batched chain on WP-Cron'
+ );
+ $this->assertFalse(
+ $this->plugin->scheduler->has_scheduled( Admin::AUTO_PURGE_REAPER_ACTION ),
+ 'Reaper is scheduled by the terminal batch, not the recurring callback'
+ );
+
+ remove_all_filters( 'wp_stream_is_large_records_table' );
+ }
+
+ /**
+ * The batch worker deletes a window and chains the next batch on WP-Cron
+ * while rows remain.
+ */
+ public function test_batch_deletes_window_and_chains_next_via_cron() {
+ global $wpdb;
+
+ add_filter(
+ 'wp_stream_batch_size',
+ function () {
+ return 2;
+ }
+ );
+
+ $this->seed_aged_records( 5, 5 );
+ $before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" );
+
+ $this->admin->auto_purge_batch( $this->cutoff_one_day_ago(), 0 );
+
+ $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" );
+ $this->assertLessThan( $before, $remaining, 'Batch must delete at least one row' );
+ $this->assertGreaterThan( 0, $remaining, 'Batch must not exceed one window' );
+
+ $this->assertTrue(
+ $this->plugin->scheduler->has_scheduled( Admin::AUTO_PURGE_BATCH_ACTION ),
+ 'Next batch must be chained on WP-Cron while rows remain'
+ );
+
+ remove_all_filters( 'wp_stream_batch_size' );
+ }
+
+ /**
+ * When nothing is eligible, the batch worker schedules the terminal reaper
+ * (and not another batch) and clears the running marker.
+ */
+ public function test_batch_enqueues_reaper_and_clears_marker_when_done() {
+ global $wpdb;
+ $wpdb->query( "DELETE FROM {$wpdb->stream}" );
+ $wpdb->query( "DELETE FROM {$wpdb->streammeta}" );
+
+ $this->admin->auto_purge_batch( $this->cutoff_one_day_ago(), 0 );
+
+ $this->assertFalse(
+ $this->plugin->scheduler->has_scheduled( Admin::AUTO_PURGE_BATCH_ACTION ),
+ 'No further batch must be chained when nothing is eligible'
+ );
+ $this->assertTrue(
+ $this->plugin->scheduler->has_scheduled( Admin::AUTO_PURGE_REAPER_ACTION ),
+ 'Terminal reaper must be scheduled on WP-Cron'
+ );
+ $this->assertTrue(
+ (bool) get_transient( Cron_Scheduler::RUNNING_TRANSIENT ),
+ 'Running marker must remain set until the reaper has executed'
+ );
+
+ // The reaper clears the marker when it finishes.
+ $this->admin->auto_purge_reaper();
+ $this->assertFalse(
+ (bool) get_transient( Cron_Scheduler::RUNNING_TRANSIENT ),
+ 'Running marker must be cleared once the reaper completes'
+ );
+ }
+
+ /**
+ * The `wp_stream_enable_auto_purge` filter, returning false, unschedules
+ * any recurring purge and skips re-registering it — for storage drivers
+ * that manage retention externally.
+ */
+ public function test_enable_auto_purge_filter_disables_scheduling() {
+ // Establish a recurring purge first.
+ $this->admin->purge_schedule_setup();
+ $this->assertNotFalse(
+ wp_next_scheduled( Admin::AUTO_PURGE_ACTION ),
+ 'Recurring purge must be scheduled before the disable filter is applied'
+ );
+
+ add_filter( 'wp_stream_enable_auto_purge', '__return_false' );
+ $this->admin->purge_schedule_setup();
+
+ $this->assertFalse(
+ wp_next_scheduled( Admin::AUTO_PURGE_ACTION ),
+ 'Disabling auto-purge must unschedule the recurring event'
+ );
+ $this->assertSame(
+ 'disabled',
+ get_option( Admin::SCHEDULER_BACKEND_OPTION ),
+ 'Backend marker must record the disabled sentinel so the teardown runs only once'
+ );
+
+ // Re-enabling must recover: the sentinel differs from the active
+ // backend, so the switch cleanup re-registers the recurring purge.
+ remove_all_filters( 'wp_stream_enable_auto_purge' );
+ $this->admin->purge_schedule_setup();
+ $this->assertNotFalse(
+ wp_next_scheduled( Admin::AUTO_PURGE_ACTION ),
+ 'Re-enabling auto-purge must re-register the recurring event'
+ );
+ }
+
+ /**
+ * The executing path honors the master switch too: a purge callback that
+ * fires while `wp_stream_enable_auto_purge` is false must be a no-op, so a
+ * stale in-flight event cannot delete records the operator opted out of.
+ */
+ public function test_enable_auto_purge_filter_blocks_executing_purge() {
+ global $wpdb;
+
+ $ids = $this->seed_aged_records( 2, 5 );
+ $this->set_records_ttl( 1 );
+
+ add_filter( 'wp_stream_enable_auto_purge', '__return_false' );
+ $this->admin->purge_scheduled_action();
+ remove_all_filters( 'wp_stream_enable_auto_purge' );
+
+ $remaining = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM ' . $wpdb->stream . ' WHERE ID IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')',
+ ...$ids
+ )
+ );
+ $this->assertSame(
+ count( $ids ),
+ $remaining,
+ 'No records may be purged while auto-purge is disabled'
+ );
+ }
+
+ /**
+ * On the WP-Cron fallback, queueing a large-table batch persists a warning
+ * pointing the operator at a deterministic WP-CLI drain, and the persisted
+ * warning is rendered (once) on the next admin page load.
+ */
+ public function test_large_table_on_cron_persists_and_renders_admin_notice() {
+ add_filter( 'wp_stream_is_large_records_table', '__return_true' );
+
+ delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION );
+
+ $this->seed_aged_records( 2, 5 );
+ $this->set_records_ttl( 1 );
+
+ $this->admin->purge_scheduled_action();
+
+ $stored = get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION );
+ $this->assertNotEmpty(
+ $stored,
+ 'A WP-CLI guidance warning must be persisted for large tables on the cron backend'
+ );
+ $this->assertStringContainsString(
+ 'wp cron event run',
+ $stored,
+ 'The persisted warning must include the WP-CLI drain command'
+ );
+
+ // The persisted warning renders on the next admin page load for a
+ // capable user, then clears so it does not repeat.
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ if ( is_multisite() ) {
+ grant_super_admin( get_current_user_id() );
+ }
+
+ ob_start();
+ $this->admin->display_large_table_cron_notice();
+ $rendered = ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'wp cron event run',
+ $rendered,
+ 'The persisted warning must render as an admin notice'
+ );
+ $this->assertEmpty(
+ get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ),
+ 'The persisted warning must be cleared after rendering'
+ );
+
+ ob_start();
+ $this->admin->display_large_table_cron_notice();
+ $second = ob_get_clean();
+ $this->assertEmpty( $second, 'The warning must render only once' );
+
+ remove_all_filters( 'wp_stream_is_large_records_table' );
+ }
+
+ /**
+ * The persisted warning must not render for users without the Stream
+ * settings capability.
+ */
+ public function test_large_table_cron_notice_requires_capability() {
+ update_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION, 'run wp cron event run --due-now', false );
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );
+
+ ob_start();
+ $this->admin->display_large_table_cron_notice();
+ $rendered = ob_get_clean();
+
+ $this->assertEmpty( $rendered, 'Users without the settings capability must not see the warning' );
+ $this->assertNotEmpty(
+ get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ),
+ 'The persisted warning must remain stored for a capable user'
+ );
+
+ delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION );
+ }
+
+ /**
+ * The large-table notice must NOT fire when auto-purge is disabled via
+ * `wp_stream_enable_auto_purge` — there is no purge to warn about.
+ */
+ public function test_large_table_notice_suppressed_when_auto_purge_disabled() {
+ add_filter( 'wp_stream_is_large_records_table', '__return_true' );
+ add_filter( 'wp_stream_enable_auto_purge', '__return_false' );
+
+ delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION );
+
+ $this->seed_aged_records( 2, 5 );
+ $this->set_records_ttl( 1 );
+
+ $this->admin->purge_scheduled_action();
+
+ $this->assertEmpty(
+ get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ),
+ 'No warning must be persisted when auto-purge is disabled'
+ );
+
+ remove_all_filters( 'wp_stream_enable_auto_purge' );
+ remove_all_filters( 'wp_stream_is_large_records_table' );
+ }
+
+ /**
+ * The `wp_stream_enable_auto_purge` filter must NOT suppress the stall
+ * warning for the manual database reset: it governs TTL retention purging
+ * only, and an operator managing retention externally can still trigger a
+ * large reset that needs the WP-Cron stall guidance.
+ */
+ public function test_reset_warning_not_suppressed_by_auto_purge_filter() {
+ add_filter( 'wp_stream_is_large_records_table', '__return_true' );
+ add_filter( 'wp_stream_enable_auto_purge', '__return_false' );
+
+ delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION );
+
+ $method = new \ReflectionMethod( Admin::class, 'maybe_warn_large_table_without_action_scheduler' );
+ $method->setAccessible( true );
+ $method->invoke( $this->admin, 2000000, 'reset the Stream database (delete all records for this site)' );
+
+ $stored = get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION );
+ $this->assertNotEmpty(
+ $stored,
+ 'The reset stall warning must fire even when auto-purge is disabled'
+ );
+ $this->assertStringContainsString( 'wp cron event run', $stored );
+
+ delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION );
+ remove_all_filters( 'wp_stream_enable_auto_purge' );
+ remove_all_filters( 'wp_stream_is_large_records_table' );
+ }
+
+ /**
+ * The large-table notice must NOT fire on the Action Scheduler backend,
+ * which is built to drain long batch chains reliably.
+ */
+ public function test_large_table_on_action_scheduler_does_not_warn() {
+ if ( ! class_exists( AS_Scheduler::class ) ) {
+ $this->markTestSkipped( 'Action Scheduler is not loaded in this environment.' );
+ }
+
+ $this->plugin->scheduler = new AS_Scheduler();
+ add_filter( 'wp_stream_is_large_records_table', '__return_true' );
+
+ delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION );
+
+ $this->seed_aged_records( 2, 5 );
+ $this->set_records_ttl( 1 );
+
+ $this->admin->purge_scheduled_action();
+
+ $this->assertEmpty(
+ get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ),
+ 'No WP-CLI guidance warning must be persisted when Action Scheduler is the backend'
+ );
+
+ remove_all_filters( 'wp_stream_is_large_records_table' );
+ }
+
+ /**
+ * The manual-reset chain sets the running marker while a batch executes
+ * (mirroring auto_purge_batch), so is_running_async_deletion() cannot
+ * momentarily read idle mid-chain, and clears it on the terminal batch.
+ */
+ public function test_erase_large_records_marks_running_and_clears_when_done() {
+ global $wpdb;
+
+ add_filter(
+ 'wp_stream_batch_size',
+ function () {
+ return 2;
+ }
+ );
+
+ $ids = $this->seed_aged_records( 5, 5 );
+ $last = max( $ids );
+
+ // Non-terminal batch: marker set, next batch chained.
+ $this->admin->erase_large_records( 5, 0, $last, get_current_blog_id() );
+
+ $this->assertTrue(
+ (bool) get_transient( Cron_Scheduler::RUNNING_TRANSIENT ),
+ 'Running marker must be set while the reset chain is mid-flight'
+ );
+ $this->assertTrue(
+ Admin::is_running_async_deletion(),
+ 'is_running_async_deletion() must read busy while the chain is pending'
+ );
+
+ // Drain: run remaining batches directly until the terminal one.
+ $wpdb->query( "DELETE FROM {$wpdb->stream}" );
+ wp_unschedule_hook( Admin::ASYNC_DELETION_ACTION );
+ $this->admin->erase_large_records( 5, 5, $last, get_current_blog_id() );
+
+ $this->assertFalse(
+ (bool) get_transient( Cron_Scheduler::RUNNING_TRANSIENT ),
+ 'Terminal batch must clear the running marker'
+ );
+ $this->assertFalse(
+ Admin::is_running_async_deletion(),
+ 'is_running_async_deletion() must read idle after the chain completes'
+ );
+
+ remove_all_filters( 'wp_stream_batch_size' );
+ }
+
+ /**
+ * A running batch chain marks the overlap guard as busy via the cron
+ * scheduler, so is_running_auto_purge() reports true even mid-chain.
+ */
+ public function test_is_running_auto_purge_reflects_cron_state() {
+ $this->assertFalse(
+ Admin::is_running_auto_purge(),
+ 'Guard must read idle when nothing is scheduled or running'
+ );
+
+ add_filter(
+ 'wp_stream_batch_size',
+ function () {
+ return 2;
+ }
+ );
+ $this->seed_aged_records( 5, 5 );
+
+ // First batch deletes a window and chains the next batch.
+ $this->admin->auto_purge_batch( $this->cutoff_one_day_ago(), 0 );
+
+ $this->assertTrue(
+ Admin::is_running_auto_purge(),
+ 'Guard must read busy while a batch chain is pending on WP-Cron'
+ );
+
+ remove_all_filters( 'wp_stream_batch_size' );
+ }
+}
diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php
index 98bb28707..ba29f6869 100644
--- a/tests/phpunit/test-class-admin.php
+++ b/tests/phpunit/test-class-admin.php
@@ -1244,6 +1244,7 @@ public function test_purge_scheduled_action_scopes_to_current_blog_when_not_netw
public $admin;
public $connectors;
public $network;
+ public $scheduler;
public function __construct( $real ) {
$this->settings = $real->settings;
$this->db = $real->db;
@@ -1251,6 +1252,7 @@ public function __construct( $real ) {
$this->admin = $real->admin;
$this->connectors = $real->connectors;
$this->network = $real->network;
+ $this->scheduler = $real->scheduler;
}
public function is_multisite_not_network_activated() {
return true;
diff --git a/tests/phpunit/test-class-cron-scheduler.php b/tests/phpunit/test-class-cron-scheduler.php
new file mode 100644
index 000000000..19772eb70
--- /dev/null
+++ b/tests/phpunit/test-class-cron-scheduler.php
@@ -0,0 +1,143 @@
+scheduler = new Cron_Scheduler();
+ }
+
+ public function tearDown(): void {
+ // Clear anything the scheduler may have left behind.
+ wp_unschedule_hook( 'wp_stream_test_async' );
+ wp_unschedule_hook( Admin::AUTO_PURGE_ACTION );
+ delete_transient( Cron_Scheduler::RUNNING_TRANSIENT );
+ parent::tearDown();
+ }
+
+ /**
+ * The custom 12-hour recurrence is registered with WP-Cron.
+ */
+ public function test_registers_custom_recurrence() {
+ $schedules = wp_get_schedules();
+
+ $this->assertArrayHasKey( Cron_Scheduler::RECURRENCE, $schedules );
+ $this->assertSame( 12 * HOUR_IN_SECONDS, $schedules[ Cron_Scheduler::RECURRENCE ]['interval'] );
+ }
+
+ /**
+ * Enqueue_async() schedules a one-off event detectable by next_scheduled().
+ */
+ public function test_enqueue_async_schedules_single_event() {
+ $this->scheduler->enqueue_async(
+ 'wp_stream_test_async',
+ array(
+ 'a' => 1,
+ 'b' => 2,
+ )
+ );
+
+ // Args are passed positionally (array_values), mirroring AS.
+ $this->assertNotFalse(
+ $this->scheduler->next_scheduled( 'wp_stream_test_async', array( 1, 2 ) )
+ );
+ $this->assertTrue( $this->scheduler->has_scheduled( 'wp_stream_test_async' ) );
+ }
+
+ /**
+ * Schedule_recurring() registers a recurring event and is idempotent.
+ */
+ public function test_schedule_recurring_is_idempotent() {
+ $this->scheduler->schedule_recurring( time(), 12 * HOUR_IN_SECONDS, Admin::AUTO_PURGE_ACTION );
+ $first = $this->scheduler->next_scheduled( Admin::AUTO_PURGE_ACTION );
+ $this->assertNotFalse( $first );
+
+ // A second call must not stack a duplicate.
+ $this->scheduler->schedule_recurring( time() + 100, 12 * HOUR_IN_SECONDS, Admin::AUTO_PURGE_ACTION );
+ $this->assertSame( $first, $this->scheduler->next_scheduled( Admin::AUTO_PURGE_ACTION ) );
+
+ $schedule = wp_get_schedule( Admin::AUTO_PURGE_ACTION );
+ $this->assertSame( Cron_Scheduler::RECURRENCE, $schedule );
+ }
+
+ /**
+ * Any_pending_or_running() reports true for a pending hook regardless of args.
+ */
+ public function test_any_pending_or_running_detects_pending_with_any_args() {
+ $this->scheduler->enqueue_async(
+ 'wp_stream_test_async',
+ array(
+ 'cutoff' => '2026-01-01',
+ 'blog_id' => 0,
+ )
+ );
+
+ $this->assertTrue(
+ $this->scheduler->any_pending_or_running( array( 'wp_stream_test_async' ) )
+ );
+ $this->assertFalse(
+ $this->scheduler->any_pending_or_running( array( 'wp_stream_some_other_hook' ) )
+ );
+ }
+
+ /**
+ * The running marker bridges the gap when nothing is pending.
+ */
+ public function test_running_marker_toggles_guard() {
+ $this->assertFalse( $this->scheduler->any_pending_or_running( array( 'wp_stream_idle_hook' ) ) );
+
+ $this->scheduler->mark_running( 'auto_purge' );
+ $this->assertTrue( $this->scheduler->any_pending_or_running( array( 'wp_stream_idle_hook' ) ) );
+
+ $this->scheduler->mark_done( 'auto_purge' );
+ $this->assertFalse( $this->scheduler->any_pending_or_running( array( 'wp_stream_idle_hook' ) ) );
+ }
+
+ /**
+ * Mark_done() only clears the marker when the caller is the current
+ * claimant, so one chain finishing cannot un-mark a sibling chain that
+ * claimed the marker after it.
+ */
+ public function test_mark_done_respects_claimant_context() {
+ $this->scheduler->mark_running( 'auto_purge' );
+ $this->scheduler->mark_running( 'async_deletion' );
+
+ // auto_purge no longer owns the marker; its mark_done must be a no-op.
+ $this->scheduler->mark_done( 'auto_purge' );
+ $this->assertTrue(
+ $this->scheduler->any_pending_or_running( array( 'wp_stream_idle_hook' ) ),
+ 'A stale mark_done from a sibling context must not clear the marker'
+ );
+
+ // The current claimant can clear it.
+ $this->scheduler->mark_done( 'async_deletion' );
+ $this->assertFalse(
+ $this->scheduler->any_pending_or_running( array( 'wp_stream_idle_hook' ) )
+ );
+ }
+
+ /**
+ * Unschedule_all() clears every pending instance of a hook.
+ */
+ public function test_unschedule_all_clears_hook() {
+ $this->scheduler->enqueue_async( 'wp_stream_test_async', array( 1 ) );
+ $this->assertTrue( $this->scheduler->has_scheduled( 'wp_stream_test_async' ) );
+
+ $this->scheduler->unschedule_all( 'wp_stream_test_async' );
+ $this->assertFalse( $this->scheduler->has_scheduled( 'wp_stream_test_async' ) );
+ }
+}
diff --git a/tests/phpunit/test-class-scheduler-handoff.php b/tests/phpunit/test-class-scheduler-handoff.php
new file mode 100644
index 000000000..34ed55e7e
--- /dev/null
+++ b/tests/phpunit/test-class-scheduler-handoff.php
@@ -0,0 +1,141 @@
+admin = $this->plugin->admin;
+ $this->original_scheduler = $this->plugin->scheduler;
+ $this->clear();
+ }
+
+ public function tearDown(): void {
+ $this->clear();
+ $this->plugin->scheduler = $this->original_scheduler;
+ parent::tearDown();
+ }
+
+ private function clear() {
+ wp_unschedule_hook( Admin::AUTO_PURGE_ACTION );
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ as_unschedule_all_actions( Admin::AUTO_PURGE_ACTION );
+ }
+ // Reset the backend marker so the next purge_schedule_setup() sees a
+ // switch and performs the one-time cleanup.
+ delete_option( Admin::SCHEDULER_BACKEND_OPTION );
+ }
+
+ /**
+ * Switching to WP-Cron must clear a leftover Action Scheduler recurring
+ * action and register the WP-Cron one in its place.
+ */
+ public function test_cron_active_clears_stray_action_scheduler_recurring() {
+ // Pre-existing AS recurring action (as if the site previously ran AS).
+ as_schedule_recurring_action(
+ time(),
+ 12 * HOUR_IN_SECONDS,
+ Admin::AUTO_PURGE_ACTION,
+ array(),
+ Admin::AUTO_PURGE_GROUP
+ );
+ $this->assertNotFalse( as_next_scheduled_action( Admin::AUTO_PURGE_ACTION ) );
+
+ $this->plugin->scheduler = new Cron_Scheduler();
+ $this->admin->purge_schedule_setup();
+
+ $this->assertFalse(
+ as_next_scheduled_action( Admin::AUTO_PURGE_ACTION ),
+ 'Switching to WP-Cron must remove the stray Action Scheduler recurring action'
+ );
+ $this->assertNotFalse(
+ wp_next_scheduled( Admin::AUTO_PURGE_ACTION ),
+ 'WP-Cron recurring event must be registered after the switch'
+ );
+ }
+
+ /**
+ * Switching to Action Scheduler must clear a leftover WP-Cron recurring
+ * event and register the AS one in its place.
+ */
+ public function test_action_scheduler_active_clears_stray_wp_cron_recurring() {
+ // Pre-existing WP-Cron recurring event (as if the site previously ran cron).
+ wp_schedule_event( time(), 'twicedaily', Admin::AUTO_PURGE_ACTION );
+ $this->assertNotFalse( wp_next_scheduled( Admin::AUTO_PURGE_ACTION ) );
+
+ $this->plugin->scheduler = new AS_Scheduler();
+ $this->admin->purge_schedule_setup();
+
+ $this->assertFalse(
+ wp_next_scheduled( Admin::AUTO_PURGE_ACTION ),
+ 'Switching to Action Scheduler must remove the stray WP-Cron recurring event'
+ );
+ $this->assertNotFalse(
+ as_next_scheduled_action( Admin::AUTO_PURGE_ACTION ),
+ 'Action Scheduler recurring action must be registered after the switch'
+ );
+ }
+
+ /**
+ * Disabling auto-purge while the cron backend is active must also clear
+ * a leftover Action Scheduler recurring action when the AS API is loaded
+ * (e.g. provided by WooCommerce) — the filter promises teardown from
+ * BOTH backends, and the active-backend unschedule cannot see AS's store.
+ */
+ public function test_disable_clears_action_scheduler_store_when_cron_active() {
+ // Pre-existing AS recurring action (as if the site previously ran AS).
+ as_schedule_recurring_action(
+ time(),
+ 12 * HOUR_IN_SECONDS,
+ Admin::AUTO_PURGE_ACTION,
+ array(),
+ Admin::AUTO_PURGE_GROUP
+ );
+ $this->assertNotFalse( as_next_scheduled_action( Admin::AUTO_PURGE_ACTION ) );
+
+ $this->plugin->scheduler = new Cron_Scheduler();
+ add_filter( 'wp_stream_enable_auto_purge', '__return_false' );
+ $this->admin->purge_schedule_setup();
+ remove_all_filters( 'wp_stream_enable_auto_purge' );
+
+ $this->assertFalse(
+ as_next_scheduled_action( Admin::AUTO_PURGE_ACTION ),
+ 'Disabling auto-purge must clear the AS store even when cron is the active backend'
+ );
+ $this->assertFalse(
+ wp_next_scheduled( Admin::AUTO_PURGE_ACTION ),
+ 'Disabling auto-purge must clear the WP-Cron store'
+ );
+ $this->assertSame(
+ 'disabled',
+ get_option( Admin::SCHEDULER_BACKEND_OPTION ),
+ 'Backend marker must record the disabled sentinel'
+ );
+ }
+}
diff --git a/tests/phpunit/test-class-scheduler-selection.php b/tests/phpunit/test-class-scheduler-selection.php
new file mode 100644
index 000000000..e265a8c77
--- /dev/null
+++ b/tests/phpunit/test-class-scheduler-selection.php
@@ -0,0 +1,79 @@
+assertInstanceOf( Scheduler::class, $this->plugin->scheduler );
+ }
+
+ /**
+ * With Action Scheduler loaded (as it is in the test bootstrap) and no
+ * filter override, the default selection is the AS-backed scheduler.
+ */
+ public function test_default_selects_action_scheduler_when_available() {
+ if ( ! function_exists( 'as_enqueue_async_action' ) ) {
+ $this->markTestSkipped( 'Action Scheduler is not loaded in this environment.' );
+ }
+
+ $this->assertInstanceOf( AS_Scheduler::class, $this->plugin->create_scheduler() );
+ }
+
+ /**
+ * Returning false from the filter forces the WP-Cron fallback even when
+ * Action Scheduler is present — the Altis / Cavalcade use case.
+ */
+ public function test_filter_false_forces_cron_scheduler() {
+ add_filter( 'wp_stream_use_action_scheduler', '__return_false' );
+
+ $this->assertInstanceOf( Cron_Scheduler::class, $this->plugin->create_scheduler() );
+ }
+
+ /**
+ * Returning true from the filter forces the AS scheduler.
+ */
+ public function test_filter_true_forces_action_scheduler() {
+ add_filter( 'wp_stream_use_action_scheduler', '__return_true' );
+
+ $this->assertInstanceOf( AS_Scheduler::class, $this->plugin->create_scheduler() );
+ }
+
+ /**
+ * A forced `__return_true` override must NOT return the AS scheduler when
+ * the bundled AS library is absent — AS_Scheduler's unguarded as_*()
+ * calls would fatal. The guard falls back to the cron scheduler instead.
+ */
+ public function test_filter_true_with_as_unavailable_falls_back_to_cron() {
+ add_filter( 'wp_stream_use_action_scheduler', '__return_true' );
+
+ // Simulate an environment that omits the bundled AS library.
+ $property = new \ReflectionProperty( Plugin::class, 'action_scheduler_available' );
+ $property->setAccessible( true );
+ $original = $property->getValue( $this->plugin );
+ $property->setValue( $this->plugin, false );
+
+ try {
+ $this->assertInstanceOf( Cron_Scheduler::class, $this->plugin->create_scheduler() );
+ } finally {
+ $property->setValue( $this->plugin, $original );
+ }
+ }
+}