Skip to content
Merged
9 changes: 9 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Stream Changelog

## Unreleased

### Enhancements

- Make Action Scheduler usage optional at runtime: Stream now dispatches its deferred purge / reset work through a scheduler abstraction that defaults to Action Scheduler but can fall back to WP-Cron. Hosts that bundle Stream and run reliable cron (e.g. Cavalcade) can force the WP-Cron path with `add_filter( 'wp_stream_use_action_scheduler', '__return_false' )`. Note the filter is applied when the Stream plugin file loads — before `plugins_loaded` — so it must be registered from an mu-plugin, `wp-config.php`, or a plugin that loads before Stream; a regular plugin hooking `plugins_loaded` is too late. Switching backends needs no migration — the recurring purge action left by the previous backend is cleared automatically on the next page load, so only one scheduler ever fires. Action Scheduler remains a bundled dependency for the WordPress.org build ([#1907](https://github.com/xwp/stream/issues/1907)).
- Guard the Action Scheduler `require_once` so an environment that already provides or deliberately omits Action Scheduler no longer fatals on load. When the WP-Cron fallback is selected, Action Scheduler is no longer loaded at all.
- Add the `wp_stream_enable_auto_purge` filter (default `true`). Returning `false` disables all TTL record auto-purge scheduling regardless of backend — for storage drivers that manage retention externally — and clears any already-registered recurring purge ([#1907](https://github.com/xwp/stream/issues/1907)).
- On the WP-Cron fallback, surface 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

### New Features
Expand Down
226 changes: 184 additions & 42 deletions classes/class-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ class Admin {
*/
const AUTO_PURGE_GROUP = 'stream-auto-purge';

/**
* Option storing which scheduler backend last registered the recurring
* auto-purge action ('action_scheduler' | 'wp_cron'), or 'disabled' when
* the `wp_stream_enable_auto_purge` filter has torn scheduling down. Used
* to detect a backend switch (or a disable/re-enable cycle) so the stale
* recurring action is cleared exactly once, instead of probing for it on
* every page load.
*
* @const string
*/
const SCHEDULER_BACKEND_OPTION = 'wp_stream_scheduler_backend';

/**
* Holds Instance of plugin object
*
Expand Down Expand Up @@ -757,7 +769,62 @@ 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 );
Comment thread
bartoszgadomski marked this conversation as resolved.

$this->maybe_warn_large_table_without_action_scheduler(
(int) $log_size,
__( 'reset the Stream database (delete all records for this site)', 'stream' )
);
}

/**
* Warn when a large-table batched operation has to lean on WP-Cron.
*
* Action Scheduler is purpose-built to drain long self-chaining batch
* jobs reliably; default WP-Cron fires opportunistically on traffic and
* can stall a multi-hour chain on a low-traffic site. When Stream is
* running the WP-Cron fallback (the `wp_stream_use_action_scheduler`
* filter returned false, or the bundled AS library is absent) against a
* table over the large-table threshold, surface a notice pointing the
* operator at a deterministic WP-CLI drain instead of failing silently.
*
* 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). The `wp_stream_enable_auto_purge` filter deliberately does NOT
* gate this helper: it governs TTL retention purging only, while this
* warning also covers the manual database reset — an operator who manages
* retention externally can still click "Reset Stream Database" and needs
* the stall warning. The auto-purge call site is already gated by the
* filter's early return in {@see Admin::purge_scheduled_action()}.
*
* @param int $record_count Number of rows the operation will touch.
* @param string $operation Human-readable, translated description of what the
* batched work does (e.g. "delete records older than
* the retention period"), interpolated into the notice.
* @return void
*/
private function maybe_warn_large_table_without_action_scheduler( int $record_count, string $operation ) {
if ( $this->plugin->scheduler instanceof AS_Scheduler ) {
return;
}

if ( ! $this->plugin->is_large_records_table( $record_count ) ) {
return;
}

$this->notice(
Comment thread
bartoszgadomski marked this conversation as resolved.
Outdated
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 ),
'<code>wp cron event run --due-now</code>'
)
);
}

/**
Expand All @@ -766,7 +833,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 );
}

/**
Expand All @@ -788,28 +859,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 )
);
}

/**
Expand Down Expand Up @@ -871,7 +928,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,
Expand Down Expand Up @@ -910,27 +967,90 @@ public static function get_blog_record_table_size( $blog_id = null ): int {
*/
public function purge_schedule_setup() {
// Clear the legacy WP-Cron event scheduled by Stream <= 4.1.x so it
// cannot double-fire alongside the new AS recurring action.
// cannot double-fire alongside the new recurring action.
if ( wp_next_scheduled( 'wp_stream_auto_purge' ) ) {
wp_clear_scheduled_hook( 'wp_stream_auto_purge' );
}

if ( ! function_exists( 'as_schedule_recurring_action' ) ) {
// Action Scheduler not yet loaded (e.g. very early hook); bail.
// Plugin::__construct() loads it before init, so this should be unreachable.
$scheduler = $this->plugin->scheduler;

/**
* Filter whether Stream schedules its TTL record auto-purge at all.
*
* Custom storage drivers that manage retention externally (TTL
* indexes, partition rotation, a warehouse job, etc.) can return
* false to disable all TTL purge scheduling regardless of the
* scheduler backend. Any already-registered recurring purge is
* unscheduled from both backends so it cannot keep firing.
*
* @param bool $enabled Whether auto-purge scheduling is enabled.
*/
if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) {
// Tear down only once, then record the 'disabled' sentinel in the
// backend marker. This runs on every wp_loaded, so without the
// guard a permanently-disabled site would pay the unschedule
// probes on every request; with it, steady state is a single
// in-memory compare (the marker is autoloaded). The sentinel also
// covers a site upgrading with the filter already active (no
// marker yet, but a recurring action left by a previous version).
// The executing path is independently gated by the same filter in
// purge_scheduled_action(), so a stray entry that somehow survives
// cannot purge anything anyway.
if ( 'disabled' !== get_option( self::SCHEDULER_BACKEND_OPTION ) ) {
$scheduler->unschedule_all( self::AUTO_PURGE_ACTION );
wp_unschedule_hook( self::AUTO_PURGE_ACTION );
update_option( self::SCHEDULER_BACKEND_OPTION, 'disabled' );
}
Comment on lines +1092 to +1109
return;
}

if ( false === as_next_scheduled_action( self::AUTO_PURGE_ACTION ) ) {
// 12 hours == old `twicedaily` interval.
as_schedule_recurring_action(
time(),
12 * HOUR_IN_SECONDS,
self::AUTO_PURGE_ACTION,
array(),
self::AUTO_PURGE_GROUP
);
$backend = $scheduler instanceof AS_Scheduler ? 'action_scheduler' : 'wp_cron';

// Detect a backend switch and clear the inactive backend's copy of the
// recurring action exactly once. A site that switched schedulers (via
// the wp_stream_use_action_scheduler filter) would otherwise keep
// firing the purge from BOTH backends — the two stores are independent
// and neither overlap guard can see the other. The marker is an
// autoloaded option, so the steady-state cost on every wp_loaded is a
// single in-memory compare; the cleanup query runs only on the first
// page load after a switch. Idempotent and self-healing. No data is
// affected — only the redundant schedule entry.
if ( get_option( self::SCHEDULER_BACKEND_OPTION ) !== $backend ) {
$cleanup_done = true;

if ( 'action_scheduler' === $backend ) {
// Drop any leftover WP-Cron recurring event.
wp_unschedule_hook( self::AUTO_PURGE_ACTION );
} elseif ( function_exists( 'as_unschedule_all_actions' ) ) {
// Drop any leftover Action Scheduler recurring action. Routed
// through AS_Scheduler so the as_*() call stays contained there.
( new AS_Scheduler() )->unschedule_all( self::AUTO_PURGE_ACTION );
} else {
// Action Scheduler is not loaded (cron backend selected and no
// other plugin provides AS), so its store cannot be cleaned
// right now. Do NOT write the marker: if an AS-providing
// plugin (e.g. WooCommerce) is installed later, the stray
// Stream recurring action in the AS store would resume firing
// alongside the cron one — and the cron overlap guard cannot
// see it. Leaving the marker stale retries this cleanup on a
// later request once as_unschedule_all_actions() exists.
$cleanup_done = false;
}

if ( $cleanup_done ) {
update_option( self::SCHEDULER_BACKEND_OPTION, $backend );
}
}

// 12 hours == old `twicedaily` interval. The scheduler only schedules
// a fresh recurring action when one is not already registered.
$scheduler->schedule_recurring(
time(),
12 * HOUR_IN_SECONDS,
self::AUTO_PURGE_ACTION,
array(),
self::AUTO_PURGE_GROUP
);
}

/**
Expand All @@ -955,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()
Expand Down Expand Up @@ -1077,19 +1206,24 @@ 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,
'blog_id' => $blog_id,
),
self::AUTO_PURGE_GROUP
);

$this->maybe_warn_large_table_without_action_scheduler(
$record_count,
__( 'delete records older than the retention period', 'stream' )
);
}

/**
Expand Down Expand Up @@ -1135,6 +1269,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.
*
Expand Down Expand Up @@ -1193,7 +1334,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;
Comment on lines 1441 to 1449
}

Expand Down Expand Up @@ -1240,7 +1382,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,
Expand Down Expand Up @@ -1284,8 +1426,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
Expand All @@ -1294,7 +1436,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 ) {
Expand Down
Loading
Loading