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 a warning when a large-table purge or reset is queued, recommending `wp cron event run` to drain the batch chain deterministically. Under WP-CLI this is an immediate `WP_CLI::warning`; in the dashboard the warning is persisted and rendered as an admin notice on the next admin page load, since the queueing contexts (cron callback, reset redirect) produce no visible output themselves ([#1907](https://github.com/xwp/stream/issues/1907)).

## 4.2.2 - July 6, 2026

### Security
Expand Down
339 changes: 297 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 ) {}
}
217 changes: 217 additions & 0 deletions classes/class-cron-scheduler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
<?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 "running" marker, claiming it for $context.
*
* Bridges the window between a chained callback starting and the next
* event being scheduled, so the overlap guard does not momentarily read
* as idle mid-chain. Self-expires so a fatal mid-callback cannot wedge
* the guard permanently.
*
* The marker is a single shared transient claimed by the last caller
* (the context string is stored as its value). Multiple Stream chains
* (auto-purge, manual reset) share it, which only makes the guards more
* conservative: a chain can be reported as running while a sibling chain
* is. {@see Cron_Scheduler::mark_done()} clears it only when the caller
* is the current claimant, so one chain finishing cannot un-mark another
* that is still mid-flight.
*
* @param string $context Short identifier for the running work.
* @return void
*/
public function mark_running( $context ) {
set_transient( self::RUNNING_TRANSIENT, (string) $context, 10 * MINUTE_IN_SECONDS );
}

/**
* Clear the "running" marker, if $context is the current claimant.
*
* When another context has since claimed the marker, it is left alone
* and self-expires — the conservative failure mode (guards read busy for
* up to the transient TTL) rather than the unsafe one (a running chain
* reported as idle).
*
* @param string $context Short identifier for the running work.
* @return void
*/
public function mark_done( $context ) {
if ( get_transient( self::RUNNING_TRANSIENT ) === (string) $context ) {
delete_transient( self::RUNNING_TRANSIENT );
}
}

/**
* Scan the cron array for any pending event matching a hook, ignoring args.
*
* `wp_next_scheduled()` matches on a specific args signature; this detects
* a hook scheduled with any args (e.g. successive batch windows).
*
* @param string $hook Action hook name.
* @return bool
*/
protected function cron_has_any( $hook ) {
$crons = _get_cron_array();

if ( empty( $crons ) ) {
return false;
}

foreach ( $crons as $events ) {
if ( isset( $events[ $hook ] ) && ! empty( $events[ $hook ] ) ) {
return true;
}
}

return false;
}
}
Loading
Loading