From f7247f136227d39455a04f3b44a065e9bed5b893 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 29 Jun 2026 21:21:26 +0530 Subject: [PATCH 1/7] feat(scheduler): make Action Scheduler usage optional at runtime (#1907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- changelog.md | 7 + classes/class-admin.php | 114 ++++--- classes/class-as-scheduler.php | 133 ++++++++ classes/class-cron-scheduler.php | 202 ++++++++++++ classes/class-plugin.php | 62 +++- classes/class-scheduler.php | 108 ++++++ classes/class-settings.php | 12 +- tests/phpunit/test-class-admin-cron-purge.php | 311 ++++++++++++++++++ tests/phpunit/test-class-admin.php | 2 + tests/phpunit/test-class-cron-scheduler.php | 108 ++++++ .../phpunit/test-class-scheduler-handoff.php | 104 ++++++ .../test-class-scheduler-selection.php | 58 ++++ 12 files changed, 1169 insertions(+), 52 deletions(-) create mode 100644 classes/class-as-scheduler.php create mode 100644 classes/class-cron-scheduler.php create mode 100644 classes/class-scheduler.php create mode 100644 tests/phpunit/test-class-admin-cron-purge.php create mode 100644 tests/phpunit/test-class-cron-scheduler.php create mode 100644 tests/phpunit/test-class-scheduler-handoff.php create mode 100644 tests/phpunit/test-class-scheduler-selection.php diff --git a/changelog.md b/changelog.md index db8d85766..355c4334f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # 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' )`. 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. + ## 4.2.0 - May 28, 2026 ### New Features diff --git a/classes/class-admin.php b/classes/class-admin.php index 5ed808f2b..ec1d6b142 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -53,6 +53,16 @@ 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'). Used to detect a + * backend switch so the inactive backend's 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'; + /** * Holds Instance of plugin object * @@ -757,7 +767,7 @@ 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 ); } /** @@ -766,7 +776,11 @@ private function schedule_erase_large_records( int $log_size ) { * @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->has_scheduled( self::ASYNC_DELETION_ACTION ); } /** @@ -788,28 +802,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 ) + ); } /** @@ -871,7 +871,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, @@ -910,27 +910,45 @@ 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. - return; + $scheduler = $this->plugin->scheduler; + $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 ) { + if ( 'action_scheduler' === $backend ) { + // Drop any leftover WP-Cron recurring event. + wp_unschedule_hook( self::AUTO_PURGE_ACTION ); + } else { + // Drop any leftover Action Scheduler recurring action. Routed + // through AS_Scheduler so the as_*() call stays contained + // there; it no-ops when Action Scheduler is not loaded. + ( new AS_Scheduler() )->unschedule_all( self::AUTO_PURGE_ACTION ); + } + update_option( self::SCHEDULER_BACKEND_OPTION, $backend ); } - 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 - ); - } + // 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 + ); } /** @@ -1077,12 +1095,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, @@ -1135,6 +1153,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. * @@ -1193,7 +1218,8 @@ 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 ); + $this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP ); + $this->plugin->scheduler->mark_done( 'auto_purge' ); return; } @@ -1240,7 +1266,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, @@ -1284,8 +1310,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 @@ -1294,7 +1320,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..dcec51d0c --- /dev/null +++ b/classes/class-as-scheduler.php @@ -0,0 +1,133 @@ + $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..c1c67bccc --- /dev/null +++ b/classes/class-cron-scheduler.php @@ -0,0 +1,202 @@ + 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 "purge running" marker. + * + * 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. + * + * @param string $context Short identifier for the running work. + * @return void + */ + public function mark_running( $context ) { + set_transient( self::RUNNING_TRANSIENT, 1, 10 * MINUTE_IN_SECONDS ); + } + + /** + * Clear the "purge running" marker. + * + * @param string $context Short identifier for the running work. + * @return void + */ + public function mark_done( $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 44e5c81ce..5ec6c29d5 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,25 @@ public function __construct() { spl_autoload_register( array( $this, 'autoload' ) ); - // Load Action Scheduler. - require_once $this->locations['dir'] . '/vendor/woocommerce/action-scheduler/action-scheduler.php'; + // Load Action Scheduler. Guarded with file_exists() so an environment + // that already provides or deliberately omits AS does not fatal here; + // AS's own ActionScheduler_Versions arbitration handles multiple + // bundled copies. AS remains a hard, bundled dependency for the + // WordPress.org build (see issue #1907). + // + // Track availability from the bundled file load rather than + // function_exists(): AS only declares its as_*() API on `init`, but + // this constructor runs on `plugins_loaded`, 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 ); + if ( $this->action_scheduler_available ) { + require_once $action_scheduler; + } + + // Select the scheduler backend for Stream's deferred work. + $this->scheduler = $this->create_scheduler(); // Load helper functions. require_once $this->locations['inc_dir'] . 'functions.php'; @@ -205,6 +244,25 @@ public function __construct() { } } + /** + * Build the scheduler backend for Stream's deferred work (purge / reset). + * + * Defaults to Action Scheduler when its API is loaded. 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`. + * See issue #1907. + * + * @return Scheduler + */ + public function create_scheduler() { + $use_action_scheduler = apply_filters( + 'wp_stream_use_action_scheduler', + $this->action_scheduler_available + ); + + return $use_action_scheduler ? new AS_Scheduler() : new Cron_Scheduler(); + } + /** * Autoloader for classes * diff --git a/classes/class-scheduler.php b/classes/class-scheduler.php new file mode 100644 index 000000000..ecea2c398 --- /dev/null +++ b/classes/class-scheduler.php @@ -0,0 +1,108 @@ +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..87f9dd8ba --- /dev/null +++ b/tests/phpunit/test-class-admin-cron-purge.php @@ -0,0 +1,311 @@ +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 ); + } + + /** + * 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', + ) + ); + } + 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->assertFalse( + (bool) get_transient( Cron_Scheduler::RUNNING_TRANSIENT ), + 'Running marker must be cleared once the chain reaches the reaper' + ); + } + + /** + * 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..66505380c --- /dev/null +++ b/tests/phpunit/test-class-cron-scheduler.php @@ -0,0 +1,108 @@ +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' ) ) ); + } + + /** + * 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..e686c9886 --- /dev/null +++ b/tests/phpunit/test-class-scheduler-handoff.php @@ -0,0 +1,104 @@ +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' + ); + } +} diff --git a/tests/phpunit/test-class-scheduler-selection.php b/tests/phpunit/test-class-scheduler-selection.php new file mode 100644 index 000000000..8776d1f7c --- /dev/null +++ b/tests/phpunit/test-class-scheduler-selection.php @@ -0,0 +1,58 @@ +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() ); + } +} From ce9904ed640a47a06f559c8884e6156f81745cb6 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 30 Jun 2026 21:03:59 +0530 Subject: [PATCH 2/7] feat(scheduler): gate AS load, add purge master-switch, large-table notice (#1907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- classes/class-admin.php | 70 +++++++++- classes/class-plugin.php | 42 +++--- tests/phpunit/test-class-admin-cron-purge.php | 122 ++++++++++++++++++ 3 files changed, 218 insertions(+), 16 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index ec1d6b142..bea9b34bd 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -768,6 +768,53 @@ private function schedule_erase_large_records( int $log_size ) { ); $this->plugin->scheduler->enqueue_async( self::ASYNC_DELETION_ACTION, $args ); + + $this->maybe_warn_large_table_without_action_scheduler( (int) $log_size ); + } + + /** + * 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. + * + * Surfaces in both contexts via {@see Admin::notice()}: an admin notice in + * the dashboard, a WP_CLI::warning under WP-CLI. The WP-CLI surface still + * matters here — scheduling the batch chain onto WP-Cron does not drain it, + * so a headless / low-traffic site is exactly where the chain can stall. + * + * No-op when Action Scheduler is the active backend (built to drain long + * chains) or when auto-purge is disabled (no purge to warn about). + * + * @param int $record_count Number of rows the operation will touch. + * @return void + */ + private function maybe_warn_large_table_without_action_scheduler( int $record_count ) { + if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) { + return; + } + + if ( $this->plugin->scheduler instanceof AS_Scheduler ) { + return; + } + + if ( ! $this->plugin->is_large_records_table( $record_count ) ) { + return; + } + + $this->notice( + sprintf( + /* translators: 1: number of records, 2: WP-CLI command. */ + __( 'Stream queued a large batch operation (%1$s records) to WP-Cron because Action Scheduler is disabled. On low-traffic sites WP-Cron may stall before the batch completes. To run it to completion deterministically, use WP-CLI: %2$s', 'stream' ), + number_format_i18n( $record_count ), + 'wp cron event run --due-now' + ) + ); } /** @@ -916,7 +963,26 @@ public function purge_schedule_setup() { } $scheduler = $this->plugin->scheduler; - $backend = $scheduler instanceof AS_Scheduler ? 'action_scheduler' : 'wp_cron'; + + /** + * 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 ) ) { + $scheduler->unschedule_all( self::AUTO_PURGE_ACTION ); + wp_unschedule_hook( self::AUTO_PURGE_ACTION ); + delete_option( self::SCHEDULER_BACKEND_OPTION ); + return; + } + + $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 @@ -1108,6 +1174,8 @@ public function purge_scheduled_action() { ), self::AUTO_PURGE_GROUP ); + + $this->maybe_warn_large_table_without_action_scheduler( $record_count ); } /** diff --git a/classes/class-plugin.php b/classes/class-plugin.php index 5ec6c29d5..a7bbb8f54 100755 --- a/classes/class-plugin.php +++ b/classes/class-plugin.php @@ -166,24 +166,22 @@ public function __construct() { spl_autoload_register( array( $this, 'autoload' ) ); - // Load Action Scheduler. Guarded with file_exists() so an environment - // that already provides or deliberately omits AS does not fatal here; - // AS's own ActionScheduler_Versions arbitration handles multiple - // bundled copies. AS remains a hard, bundled dependency for the - // WordPress.org build (see issue #1907). + // 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. // - // Track availability from the bundled file load rather than + // Availability is tracked from a file_exists() check rather than // function_exists(): AS only declares its as_*() API on `init`, but // this constructor runs on `plugins_loaded`, 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 ); - if ( $this->action_scheduler_available ) { - require_once $action_scheduler; - } - // Select the scheduler backend for Stream's deferred work. + // 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. @@ -247,10 +245,12 @@ public function __construct() { /** * Build the scheduler backend for Stream's deferred work (purge / reset). * - * Defaults to Action Scheduler when its API is loaded. 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`. - * See issue #1907. + * 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 */ @@ -260,7 +260,19 @@ public function create_scheduler() { $this->action_scheduler_available ); - return $use_action_scheduler ? new AS_Scheduler() : new Cron_Scheduler(); + if ( ! $use_action_scheduler ) { + return new Cron_Scheduler(); + } + + // Load the bundled AS library before instantiating its scheduler. + // file_exists() guards an environment that omits AS; AS's own + // ActionScheduler_Versions arbitration handles a host that already + // provides its own copy. + if ( $this->action_scheduler_available ) { + require_once $this->locations['dir'] . '/vendor/woocommerce/action-scheduler/action-scheduler.php'; + } + + return new AS_Scheduler(); } /** diff --git a/tests/phpunit/test-class-admin-cron-purge.php b/tests/phpunit/test-class-admin-cron-purge.php index 87f9dd8ba..a20fb1611 100644 --- a/tests/phpunit/test-class-admin-cron-purge.php +++ b/tests/phpunit/test-class-admin-cron-purge.php @@ -280,6 +280,128 @@ public function test_batch_enqueues_reaper_and_clears_marker_when_done() { ); } + /** + * 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->assertFalse( + get_option( Admin::SCHEDULER_BACKEND_OPTION ), + 'Backend marker must be cleared when auto-purge is disabled' + ); + + remove_all_filters( 'wp_stream_enable_auto_purge' ); + } + + /** + * On the WP-Cron fallback, queueing a large-table batch surfaces an admin + * notice pointing the operator at a deterministic WP-CLI drain. + */ + public function test_large_table_on_cron_surfaces_admin_notice() { + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + $this->admin->notices = array(); + + $this->seed_aged_records( 2, 5 ); + $this->set_records_ttl( 1 ); + + $this->admin->purge_scheduled_action(); + + $messages = wp_list_pluck( $this->admin->notices, 'message' ); + $matched = array_filter( + $messages, + function ( $message ) { + return false !== strpos( $message, 'wp cron event run' ); + } + ); + $this->assertNotEmpty( + $matched, + 'A WP-CLI guidance notice must be queued for large tables on the cron backend' + ); + + remove_all_filters( 'wp_stream_is_large_records_table' ); + } + + /** + * 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' ); + + $this->admin->notices = array(); + + $this->seed_aged_records( 2, 5 ); + $this->set_records_ttl( 1 ); + + $this->admin->purge_scheduled_action(); + + $messages = wp_list_pluck( $this->admin->notices, 'message' ); + $matched = array_filter( + $messages, + function ( $message ) { + return false !== strpos( $message, 'wp cron event run' ); + } + ); + $this->assertEmpty( + $matched, + 'No notice must be queued when auto-purge is disabled' + ); + + 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' ); + + $this->admin->notices = array(); + + $this->seed_aged_records( 2, 5 ); + $this->set_records_ttl( 1 ); + + $this->admin->purge_scheduled_action(); + + $messages = wp_list_pluck( $this->admin->notices, 'message' ); + $matched = array_filter( + $messages, + function ( $message ) { + return false !== strpos( $message, 'wp cron event run' ); + } + ); + $this->assertEmpty( + $matched, + 'No WP-CLI guidance notice must be queued when Action Scheduler is the backend' + ); + + remove_all_filters( 'wp_stream_is_large_records_table' ); + } + /** * A running batch chain marks the overlap guard as busy via the cron * scheduler, so is_running_auto_purge() reports true even mid-chain. From 474ad96102595f00267dfb5b609ca6b85930791f Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Fri, 3 Jul 2026 14:08:56 +0530 Subject: [PATCH 3/7] fix(scheduler): honor auto-purge switch on execute path; clarify cron notice (#1907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- changelog.md | 6 +- classes/class-admin.php | 92 ++++++++++++++----- classes/class-plugin.php | 19 +++- tests/phpunit/test-class-admin-cron-purge.php | 72 ++++++++++++++- 4 files changed, 161 insertions(+), 28 deletions(-) diff --git a/changelog.md b/changelog.md index 355c4334f..8015893a1 100644 --- a/changelog.md +++ b/changelog.md @@ -4,8 +4,10 @@ ### 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' )`. 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. +- 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 an admin notice (and a `WP_CLI::warning` under WP-CLI) when a large-table purge or reset is queued, recommending `wp cron event run` to drain the batch chain deterministically ([#1907](https://github.com/xwp/stream/issues/1907)). ## 4.2.0 - May 28, 2026 diff --git a/classes/class-admin.php b/classes/class-admin.php index bea9b34bd..8239f0a67 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -55,9 +55,11 @@ class Admin { /** * Option storing which scheduler backend last registered the recurring - * auto-purge action ('action_scheduler' | 'wp_cron'). Used to detect a - * backend switch so the inactive backend's recurring action is cleared - * exactly once, instead of probing for it on every page load. + * 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 */ @@ -769,7 +771,10 @@ private function schedule_erase_large_records( int $log_size ) { $this->plugin->scheduler->enqueue_async( self::ASYNC_DELETION_ACTION, $args ); - $this->maybe_warn_large_table_without_action_scheduler( (int) $log_size ); + $this->maybe_warn_large_table_without_action_scheduler( + (int) $log_size, + __( 'reset the Stream database (delete all records for this site)', 'stream' ) + ); } /** @@ -789,16 +794,20 @@ private function schedule_erase_large_records( int $log_size ) { * so a headless / low-traffic site is exactly where the chain can stall. * * No-op when Action Scheduler is the active backend (built to drain long - * chains) or when auto-purge is disabled (no purge to warn about). - * - * @param int $record_count Number of rows the operation will touch. + * 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 ) { - if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) { - return; - } - + private function maybe_warn_large_table_without_action_scheduler( int $record_count, string $operation ) { if ( $this->plugin->scheduler instanceof AS_Scheduler ) { return; } @@ -809,8 +818,9 @@ private function maybe_warn_large_table_without_action_scheduler( int $record_co $this->notice( sprintf( - /* translators: 1: number of records, 2: WP-CLI command. */ - __( 'Stream queued a large batch operation (%1$s records) to WP-Cron because Action Scheduler is disabled. On low-traffic sites WP-Cron may stall before the batch completes. To run it to completion deterministically, use WP-CLI: %2$s', 'stream' ), + /* 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' ) @@ -976,9 +986,21 @@ public function purge_schedule_setup() { * @param bool $enabled Whether auto-purge scheduling is enabled. */ if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) { - $scheduler->unschedule_all( self::AUTO_PURGE_ACTION ); - wp_unschedule_hook( self::AUTO_PURGE_ACTION ); - delete_option( self::SCHEDULER_BACKEND_OPTION ); + // 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 ); + update_option( self::SCHEDULER_BACKEND_OPTION, 'disabled' ); + } return; } @@ -994,16 +1016,30 @@ public function purge_schedule_setup() { // 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 ); - } else { + } 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; it no-ops when Action Scheduler is not loaded. + // 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 ); } - update_option( self::SCHEDULER_BACKEND_OPTION, $backend ); } // 12 hours == old `twicedaily` interval. The scheduler only schedules @@ -1039,6 +1075,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() @@ -1175,7 +1220,10 @@ public function purge_scheduled_action() { self::AUTO_PURGE_GROUP ); - $this->maybe_warn_large_table_without_action_scheduler( $record_count ); + $this->maybe_warn_large_table_without_action_scheduler( + $record_count, + __( 'delete records older than the retention period', 'stream' ) + ); } /** diff --git a/classes/class-plugin.php b/classes/class-plugin.php index a7bbb8f54..6c673228e 100755 --- a/classes/class-plugin.php +++ b/classes/class-plugin.php @@ -174,8 +174,9 @@ public function __construct() { // // Availability is tracked from a file_exists() check rather than // function_exists(): AS only declares its as_*() API on `init`, but - // this constructor runs on `plugins_loaded`, so the functions are not - // defined yet. Scheduler methods are only ever called on/after `init` + // 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 ); @@ -255,6 +256,20 @@ public function __construct() { * @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. + */ $use_action_scheduler = apply_filters( 'wp_stream_use_action_scheduler', $this->action_scheduler_available diff --git a/tests/phpunit/test-class-admin-cron-purge.php b/tests/phpunit/test-class-admin-cron-purge.php index a20fb1611..a0641773e 100644 --- a/tests/phpunit/test-class-admin-cron-purge.php +++ b/tests/phpunit/test-class-admin-cron-purge.php @@ -300,12 +300,48 @@ public function test_enable_auto_purge_filter_disables_scheduling() { wp_next_scheduled( Admin::AUTO_PURGE_ACTION ), 'Disabling auto-purge must unschedule the recurring event' ); - $this->assertFalse( + $this->assertSame( + 'disabled', get_option( Admin::SCHEDULER_BACKEND_OPTION ), - 'Backend marker must be cleared when auto-purge is disabled' + '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' + ); } /** @@ -368,6 +404,38 @@ function ( $message ) { 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' ); + + $this->admin->notices = array(); + + $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)' ); + + $messages = wp_list_pluck( $this->admin->notices, 'message' ); + $matched = array_filter( + $messages, + function ( $message ) { + return false !== strpos( $message, 'wp cron event run' ); + } + ); + $this->assertNotEmpty( + $matched, + 'The reset stall warning must fire even when auto-purge is disabled' + ); + + 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. From 30a3a8e0d76a014dc99a464fb531a47489500857 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Fri, 3 Jul 2026 14:26:48 +0530 Subject: [PATCH 4/7] style(tests): fix phpcs violations in scheduler test suites (#1907) --- tests/phpunit/test-class-admin-cron-purge.php | 5 ++-- tests/phpunit/test-class-cron-scheduler.php | 24 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/phpunit/test-class-admin-cron-purge.php b/tests/phpunit/test-class-admin-cron-purge.php index a0641773e..a74704dc9 100644 --- a/tests/phpunit/test-class-admin-cron-purge.php +++ b/tests/phpunit/test-class-admin-cron-purge.php @@ -107,7 +107,8 @@ private function seed_aged_records( int $count, int $days_old ): array { 'meta_value' => 'false', ) ); - } + }//end for + return $ids; } @@ -140,7 +141,7 @@ private function cutoff_one_day_ago(): string { } /** - * purge_schedule_setup() registers the recurring event on WP-Cron using + * 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() { diff --git a/tests/phpunit/test-class-cron-scheduler.php b/tests/phpunit/test-class-cron-scheduler.php index 66505380c..a1185a240 100644 --- a/tests/phpunit/test-class-cron-scheduler.php +++ b/tests/phpunit/test-class-cron-scheduler.php @@ -40,10 +40,16 @@ public function test_registers_custom_recurrence() { } /** - * enqueue_async() schedules a one-off event detectable by next_scheduled(). + * 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 ) ); + $this->scheduler->enqueue_async( + 'wp_stream_test_async', + array( + 'a' => 1, + 'b' => 2, + ) + ); // Args are passed positionally (array_values), mirroring AS. $this->assertNotFalse( @@ -53,7 +59,7 @@ public function test_enqueue_async_schedules_single_event() { } /** - * schedule_recurring() registers a recurring event and is idempotent. + * 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 ); @@ -69,10 +75,16 @@ public function test_schedule_recurring_is_idempotent() { } /** - * any_pending_or_running() reports true for a pending hook regardless of args. + * 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->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' ) ) @@ -96,7 +108,7 @@ public function test_running_marker_toggles_guard() { } /** - * unschedule_all() clears every pending instance of a 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 ) ); From bad1d0aca5d3e2f942885a919b4f851a8eb8d8c5 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Fri, 3 Jul 2026 14:50:12 +0530 Subject: [PATCH 5/7] fix(scheduler): guard reaper execution window; clear AS store on disable (#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. --- classes/class-admin.php | 29 ++++++++++++++- classes/class-as-scheduler.php | 13 +++++++ classes/class-scheduler.php | 11 ++++++ tests/phpunit/test-class-admin-cron-purge.php | 9 ++++- .../phpunit/test-class-scheduler-handoff.php | 37 +++++++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index 8239f0a67..f43377d5a 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -999,6 +999,19 @@ public function purge_schedule_setup() { 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; @@ -1334,8 +1347,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. + // 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 ); - $this->plugin->scheduler->mark_done( 'auto_purge' ); return; } @@ -1404,7 +1421,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' ); } /** diff --git a/classes/class-as-scheduler.php b/classes/class-as-scheduler.php index dcec51d0c..ff54273ae 100644 --- a/classes/class-as-scheduler.php +++ b/classes/class-as-scheduler.php @@ -21,6 +21,14 @@ class AS_Scheduler implements Scheduler { /** * Enqueue a one-off asynchronous action. * + * The args array is passed to Action Scheduler as-is (keys preserved), + * intentionally NOT normalized to array_values(): this matches Stream's + * pre-abstraction behavior exactly, keeps the keyed args display in + * Tools → Scheduled Actions, and avoids breaking third-party code that + * matches Stream's actions by keyed args. AS executes callbacks + * positionally regardless, so callback behavior is identical to the + * cron backend. + * * @param string $hook Action hook name. * @param array $args Arguments passed positionally to the callback. * @param string $group Optional grouping label. @@ -33,6 +41,11 @@ public function enqueue_async( $hook, $args = array(), $group = '' ) { /** * Schedule a recurring action if one is not already scheduled. * + * The "already scheduled" probe is deliberately hook-scoped (args and + * group ignored), preserving Stream's pre-abstraction behavior: one + * recurring action per hook, and no stacking of recurrences that differ + * only in args. See the {@see Scheduler::schedule_recurring()} contract. + * * @param int $timestamp First run, as a Unix timestamp. * @param int $interval Recurrence interval in seconds. * @param string $hook Action hook name. diff --git a/classes/class-scheduler.php b/classes/class-scheduler.php index ecea2c398..c42836753 100644 --- a/classes/class-scheduler.php +++ b/classes/class-scheduler.php @@ -29,6 +29,11 @@ interface Scheduler { * * Values in $args are passed positionally to the hook callback, in the * order they appear in the array, mirroring Action Scheduler semantics. + * How the args are *stored* is backend-specific: AS keeps the array as + * given (preserving Stream's historical behavior and the keyed display + * in Tools → Scheduled Actions), while cron stores array_values(). Args + * therefore only round-trip through next_scheduled() on the backend + * that scheduled them — which is the only supported usage. * * @param string $hook Action hook name. * @param array $args Arguments passed positionally to the callback. @@ -40,6 +45,12 @@ public function enqueue_async( $hook, $args = array(), $group = '' ); /** * Schedule a recurring action if one is not already scheduled. * + * The "already scheduled" probe may be hook-scoped (ignoring args and + * group): the AS backend intentionally checks the hook only, preserving + * Stream's historical behavior and preventing recurrences with differing + * args from stacking. Callers must treat one recurring action per hook + * as the contract; the sole caller schedules with empty args. + * * @param int $timestamp First run, as a Unix timestamp. * @param int $interval Recurrence interval in seconds. * @param string $hook Action hook name. diff --git a/tests/phpunit/test-class-admin-cron-purge.php b/tests/phpunit/test-class-admin-cron-purge.php index a74704dc9..83689e0e6 100644 --- a/tests/phpunit/test-class-admin-cron-purge.php +++ b/tests/phpunit/test-class-admin-cron-purge.php @@ -275,9 +275,16 @@ public function test_batch_enqueues_reaper_and_clears_marker_when_done() { $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 chain reaches the reaper' + 'Running marker must be cleared once the reaper completes' ); } diff --git a/tests/phpunit/test-class-scheduler-handoff.php b/tests/phpunit/test-class-scheduler-handoff.php index e686c9886..34ed55e7e 100644 --- a/tests/phpunit/test-class-scheduler-handoff.php +++ b/tests/phpunit/test-class-scheduler-handoff.php @@ -101,4 +101,41 @@ public function test_action_scheduler_active_clears_stray_wp_cron_recurring() { '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' + ); + } } From 5c27ffb1046a96602c9a816bf7bde5340bb5f080 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 7 Jul 2026 16:26:46 +0530 Subject: [PATCH 6/7] =?UTF-8?q?fix(scheduler):=20address=20review=20feedba?= =?UTF-8?q?ck=20=E2=80=94=20persist=20cron=20notice,=20guard=20reset=20cha?= =?UTF-8?q?in,=20AS-absent=20fallback=20(#1907)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- changelog.md | 2 +- classes/class-admin.php | 112 ++++++++++-- classes/class-cron-scheduler.php | 23 ++- classes/class-plugin.php | 18 +- tests/phpunit/test-class-admin-cron-purge.php | 162 +++++++++++++----- tests/phpunit/test-class-cron-scheduler.php | 23 +++ .../test-class-scheduler-selection.php | 21 +++ 7 files changed, 295 insertions(+), 66 deletions(-) diff --git a/changelog.md b/changelog.md index 8015893a1..012bc3bce 100644 --- a/changelog.md +++ b/changelog.md @@ -7,7 +7,7 @@ - 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 an admin notice (and a `WP_CLI::warning` under WP-CLI) when a large-table purge or reset is queued, recommending `wp cron event run` to drain the batch chain deterministically ([#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.0 - May 28, 2026 diff --git a/classes/class-admin.php b/classes/class-admin.php index f43377d5a..610a7210f 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -65,6 +65,17 @@ class Admin { */ 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 * @@ -252,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( @@ -788,10 +805,15 @@ private function schedule_erase_large_records( int $log_size ) { * table over the large-table threshold, surface a notice pointing the * operator at a deterministic WP-CLI drain instead of failing silently. * - * Surfaces in both contexts via {@see Admin::notice()}: an admin notice in - * the dashboard, a WP_CLI::warning under WP-CLI. The WP-CLI surface still - * matters here — scheduling the batch chain onto WP-Cron does not drain it, - * so a headless / low-traffic site is exactly where the chain can stall. + * 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 @@ -816,20 +838,72 @@ private function maybe_warn_large_table_without_action_scheduler( int $record_co return; } - $this->notice( - 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' - ) + $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() { @@ -837,7 +911,7 @@ public static function is_running_async_deletion() { if ( empty( $plugin->scheduler ) ) { return false; } - return $plugin->scheduler->has_scheduled( self::ASYNC_DELETION_ACTION ); + return $plugin->scheduler->any_pending_or_running( array( self::ASYNC_DELETION_ACTION ) ); } /** @@ -886,6 +960,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", @@ -895,6 +976,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; } diff --git a/classes/class-cron-scheduler.php b/classes/class-cron-scheduler.php index c1c67bccc..9ba3afece 100644 --- a/classes/class-cron-scheduler.php +++ b/classes/class-cron-scheduler.php @@ -151,28 +151,43 @@ public function unschedule_all( $hook ) { } /** - * Set the best-effort "purge running" marker. + * 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, 1, 10 * MINUTE_IN_SECONDS ); + set_transient( self::RUNNING_TRANSIENT, (string) $context, 10 * MINUTE_IN_SECONDS ); } /** - * Clear the "purge running" marker. + * 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 ) { - delete_transient( self::RUNNING_TRANSIENT ); + if ( get_transient( self::RUNNING_TRANSIENT ) === (string) $context ) { + delete_transient( self::RUNNING_TRANSIENT ); + } } /** diff --git a/classes/class-plugin.php b/classes/class-plugin.php index 6c673228e..cdc44160b 100755 --- a/classes/class-plugin.php +++ b/classes/class-plugin.php @@ -279,14 +279,20 @@ public function create_scheduler() { return new Cron_Scheduler(); } - // Load the bundled AS library before instantiating its scheduler. - // file_exists() guards an environment that omits AS; AS's own - // ActionScheduler_Versions arbitration handles a host that already - // provides its own copy. - if ( $this->action_scheduler_available ) { - require_once $this->locations['dir'] . '/vendor/woocommerce/action-scheduler/action-scheduler.php'; + // 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(); } diff --git a/tests/phpunit/test-class-admin-cron-purge.php b/tests/phpunit/test-class-admin-cron-purge.php index 83689e0e6..73c2198cf 100644 --- a/tests/phpunit/test-class-admin-cron-purge.php +++ b/tests/phpunit/test-class-admin-cron-purge.php @@ -68,6 +68,7 @@ private function clear_purge_events() { 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 ); } /** @@ -353,34 +354,82 @@ public function test_enable_auto_purge_filter_blocks_executing_purge() { } /** - * On the WP-Cron fallback, queueing a large-table batch surfaces an admin - * notice pointing the operator at a deterministic WP-CLI drain. + * 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_surfaces_admin_notice() { + public function test_large_table_on_cron_persists_and_renders_admin_notice() { add_filter( 'wp_stream_is_large_records_table', '__return_true' ); - $this->admin->notices = array(); + delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ); $this->seed_aged_records( 2, 5 ); $this->set_records_ttl( 1 ); $this->admin->purge_scheduled_action(); - $messages = wp_list_pluck( $this->admin->notices, 'message' ); - $matched = array_filter( - $messages, - function ( $message ) { - return false !== strpos( $message, 'wp cron event run' ); - } - ); + $stored = get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ); $this->assertNotEmpty( - $matched, - 'A WP-CLI guidance notice must be queued for large tables on the cron backend' + $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. @@ -389,23 +438,16 @@ 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' ); - $this->admin->notices = array(); + delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ); $this->seed_aged_records( 2, 5 ); $this->set_records_ttl( 1 ); $this->admin->purge_scheduled_action(); - $messages = wp_list_pluck( $this->admin->notices, 'message' ); - $matched = array_filter( - $messages, - function ( $message ) { - return false !== strpos( $message, 'wp cron event run' ); - } - ); $this->assertEmpty( - $matched, - 'No notice must be queued when auto-purge is disabled' + 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' ); @@ -422,24 +464,20 @@ 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' ); - $this->admin->notices = array(); + 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)' ); - $messages = wp_list_pluck( $this->admin->notices, 'message' ); - $matched = array_filter( - $messages, - function ( $message ) { - return false !== strpos( $message, 'wp cron event run' ); - } - ); + $stored = get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ); $this->assertNotEmpty( - $matched, + $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' ); } @@ -456,28 +494,68 @@ public function test_large_table_on_action_scheduler_does_not_warn() { $this->plugin->scheduler = new AS_Scheduler(); add_filter( 'wp_stream_is_large_records_table', '__return_true' ); - $this->admin->notices = array(); + delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ); $this->seed_aged_records( 2, 5 ); $this->set_records_ttl( 1 ); $this->admin->purge_scheduled_action(); - $messages = wp_list_pluck( $this->admin->notices, 'message' ); - $matched = array_filter( - $messages, - function ( $message ) { - return false !== strpos( $message, 'wp cron event run' ); - } - ); $this->assertEmpty( - $matched, - 'No WP-CLI guidance notice must be queued when Action Scheduler is the backend' + 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. diff --git a/tests/phpunit/test-class-cron-scheduler.php b/tests/phpunit/test-class-cron-scheduler.php index a1185a240..19772eb70 100644 --- a/tests/phpunit/test-class-cron-scheduler.php +++ b/tests/phpunit/test-class-cron-scheduler.php @@ -107,6 +107,29 @@ public function test_running_marker_toggles_guard() { $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. */ diff --git a/tests/phpunit/test-class-scheduler-selection.php b/tests/phpunit/test-class-scheduler-selection.php index 8776d1f7c..e265a8c77 100644 --- a/tests/phpunit/test-class-scheduler-selection.php +++ b/tests/phpunit/test-class-scheduler-selection.php @@ -55,4 +55,25 @@ public function test_filter_true_forces_action_scheduler() { $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 ); + } + } } From 3075328bcca3b90cc177d465f6e908dcff0964f4 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Thu, 9 Jul 2026 19:49:24 +0530 Subject: [PATCH 7/7] fix(scheduler): cast wp_stream_use_action_scheduler filter result to bool (#1907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- classes/class-plugin.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/classes/class-plugin.php b/classes/class-plugin.php index 4c902784c..b08baff56 100755 --- a/classes/class-plugin.php +++ b/classes/class-plugin.php @@ -268,9 +268,13 @@ public function create_scheduler() { * * @param bool $use_action_scheduler Whether to use Action Scheduler. * Defaults to true when the bundled - * AS library is present. + * 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 = apply_filters( + $use_action_scheduler = (bool) apply_filters( 'wp_stream_use_action_scheduler', $this->action_scheduler_available );