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
253 changes: 211 additions & 42 deletions classes/class-admin.php

Large diffs are not rendered by default.

146 changes: 146 additions & 0 deletions classes/class-as-scheduler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php
/**
* Action Scheduler implementation of the {@see Scheduler} interface.
*
* Thin wrappers over the `as_*()` API. This is the default scheduler and
* preserves Stream's historical behavior exactly: batched purge chains run
* through Action Scheduler, are grouped, and remain visible under
* Tools → Scheduled Actions. The in-progress markers are no-ops because
* Action Scheduler tracks RUNNING state in its own store.
*
* @package WP_Stream
*/

namespace WP_Stream;

/**
* Class - AS_Scheduler
*/
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.
* @return void
*/
public function enqueue_async( $hook, $args = array(), $group = '' ) {
as_enqueue_async_action( $hook, $args, $group );
}
Comment on lines +37 to +39

/**
* 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.
* @param array $args Arguments passed positionally to the callback.
* @param string $group Optional grouping label.
* @return void
*/
public function schedule_recurring( $timestamp, $interval, $hook, $args = array(), $group = '' ) {
if ( false === as_next_scheduled_action( $hook ) ) {
as_schedule_recurring_action( $timestamp, $interval, $hook, $args, $group );
}
}
Comment on lines +56 to +60

/**
* Get the next scheduled timestamp for a hook.
*
* @param string $hook Action hook name.
* @param array $args Arguments the action was scheduled with.
* @return int|false
*/
public function next_scheduled( $hook, $args = array() ) {
return as_next_scheduled_action( $hook, empty( $args ) ? null : $args );
}

/**
* Whether any instance of a hook is scheduled.
*
* @param string $hook Action hook name.
* @return bool
*/
public function has_scheduled( $hook ) {
return as_has_scheduled_action( $hook );
}

/**
* Whether any of the given hooks is pending or in progress.
*
* Checks both PENDING and RUNNING statuses so a chain that is mid-flight
* (the batch worker is executing and has not yet enqueued the next batch)
* still reports as running, preventing a second parallel chain from
* stacking against the same rows.
*
* @param array $hooks Action hook names to probe.
* @return bool
*/
public function any_pending_or_running( $hooks ) {
if ( ! function_exists( 'as_get_scheduled_actions' ) ) {
return false;
}

foreach ( (array) $hooks 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;
}

/**
* 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 ) {}
}
202 changes: 202 additions & 0 deletions classes/class-cron-scheduler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
<?php
/**
* WP-Cron implementation of the {@see Scheduler} interface.
*
* Fallback scheduler for environments that bundle Stream, run reliable cron
* (e.g. Cavalcade), and prefer not to use Action Scheduler — selected by
* returning false from the `wp_stream_use_action_scheduler` filter. See
* issue #1907.
*
* Behavioral notes vs. {@see AS_Scheduler}:
* - Deferred work is not visible under Tools → Scheduled Actions; inspect
* via WP-Cron tooling (e.g. WP Crontrol) instead.
* - WP-Cron has no native "running" state store. The overlap guard combines
* a scan of pending events with a short-lived transient set by
* {@see Cron_Scheduler::mark_running()} while a callback executes, so a
* chain that is mid-flight (between batches) still reports as running.
* This is a best-effort approximation, not a hard lock.
*
* @package WP_Stream
*/

namespace WP_Stream;

/**
* Class - Cron_Scheduler
*/
class Cron_Scheduler implements Scheduler {

/**
* Custom cron recurrence name used for the recurring auto-purge.
*
* @const string
*/
const RECURRENCE = 'wp_stream_auto_purge_recurrence';

/**
* Transient key for the best-effort "purge running" marker.
*
* @const string
*/
const RUNNING_TRANSIENT = 'wp_stream_scheduler_running';

/**
* Register the custom cron recurrence used by the recurring auto-purge.
*
* The schedule must be registered on every request: WP-Cron re-reads the
* interval from `wp_get_schedules()` each time it reschedules a recurring
* event, so an unregistered name would break rescheduling.
*/
public function __construct() {
add_filter( 'cron_schedules', array( $this, 'register_recurrence' ) );
}

/**
* Inject Stream's custom cron recurrence (12 hours, matching the legacy
* `twicedaily` interval used before the Action Scheduler migration).
*
* @param array $schedules Existing cron schedules.
* @return array
*/
public function register_recurrence( $schedules ) {
$schedules[ self::RECURRENCE ] = array(
'interval' => 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;
}
}
Loading
Loading