Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
594871c
fix: keep the DB refresh scheduled when Action Scheduler creation fails
Alexia-Soare Sep 21, 2026
38786b1
fix: clear a WP-Cron event left beside the Action Scheduler action
Alexia-Soare Sep 21, 2026
22ccbba
test: skip instead of fatal when Action Scheduler is not loaded
Alexia-Soare Sep 21, 2026
afa7686
fix: recover the refresh chain a killed run ends, and throttle the check
Alexia-Soare Sep 21, 2026
c8cf512
fix: start a recovered run now, and stop rewriting an autoloaded option
Alexia-Soare Sep 21, 2026
3ea7339
test: prove the filter argument order instead of assuming it
Alexia-Soare Sep 21, 2026
d69c338
test: do not depend on plugin_basename() for the lifecycle hook name
Alexia-Soare Sep 21, 2026
29adb15
test: say why the Action Scheduler precondition fails instead of skip…
Alexia-Soare Sep 21, 2026
fb1f58d
fix: do not drop the refresh on an unknown interval or a fractional o…
Alexia-Soare Sep 21, 2026
091ffcf
docs: note that tear_down restores hooks, so test filters need no rem…
Alexia-Soare Sep 21, 2026
8b32e01
fix: record the check window only when a trigger exists
Alexia-Soare Sep 21, 2026
842a76c
fix: treat a live WP-Cron fallback as scheduled for the check window
Alexia-Soare Sep 22, 2026
f814207
refactor: read the cron schedules once when scheduling the refresh
Alexia-Soare Sep 22, 2026
dbd3176
fix: keep the old WP-Cron event until its replacement is scheduled
Alexia-Soare Sep 22, 2026
ede25f0
fix: unschedule the old refresh event without passing its arguments
Alexia-Soare Sep 22, 2026
da6fa8f
docs: trim the comments
Alexia-Soare Sep 22, 2026
02d9630
test: type the pending action IDs as numeric strings
Alexia-Soare Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 79 additions & 18 deletions classes/Visualizer/Module/Setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ class Visualizer_Module_Setup extends Visualizer_Module {

const NAME = __CLASS__;

/**
* Hook that refreshes database charts.
*/
const REFRESH_DB_HOOK = 'visualizer_schedule_refresh_db';

/**
* Action Scheduler group that owns the refresh.
*/
const REFRESH_DB_GROUP = 'visualizer';

/**
* When the refresh trigger was last checked.
*/
const REFRESH_DB_CHECK_OPTION = 'visualizer-refresh-db-checked';

/**
* How long a check stays good for. Action Scheduler needs the same 300s to mark a
* killed run failed, so a shorter window cannot recover one any sooner.
*/
const REFRESH_DB_CHECK_WINDOW = 300;

/**
* Constructor.
*
Expand All @@ -44,8 +65,9 @@ public function __construct( Visualizer_Plugin $plugin ) {

register_activation_hook( VISUALIZER_BASEFILE, array( $this, 'activate' ) );
register_deactivation_hook( VISUALIZER_BASEFILE, array( $this, 'deactivate' ) );
$this->_addAction( 'visualizer_schedule_refresh_db', 'refreshDbChart' );
$this->_addAction( self::REFRESH_DB_HOOK, 'refreshDbChart' );
$this->_addAction( 'init', 'maybe_reschedule_refresh_db' );
$this->_addAction( 'action_scheduler_ensure_recurring_actions', 'ensure_refresh_db_action' );
$this->_addFilter( 'visualizer_schedule_refresh_chart', 'refresh_db_for_chart', 10, 3 );

$this->_addAction( 'admin_init', 'adminInit' );
Expand Down Expand Up @@ -485,8 +507,8 @@ public function custom_cron_schedules( $schedules ) {
* Schedule the recurring DB refresh action.
*/
private function schedule_refresh_db_action(): void {
$hook = 'visualizer_schedule_refresh_db';
$group = 'visualizer';
$hook = self::REFRESH_DB_HOOK;
$group = self::REFRESH_DB_GROUP;
$interval_key = apply_filters( 'visualizer_chart_schedule_interval', 'visualizer_ten_minutes' );
$interval = $this->get_schedule_interval_seconds( $interval_key );
$timestamp = strtotime( 'midnight' ) - get_option( 'gmt_offset' ) * HOUR_IN_SECONDS;
Comment thread
Alexia-Soare marked this conversation as resolved.
Outdated
Comment thread
Alexia-Soare marked this conversation as resolved.
Outdated
Comment thread
Alexia-Soare marked this conversation as resolved.
Outdated
Expand All @@ -498,45 +520,84 @@ private function schedule_refresh_db_action(): void {
) {
$next = as_next_scheduled_action( $hook, array(), $group );
if ( false === $next ) {
as_schedule_recurring_action( $timestamp, $interval, $hook, array(), $group );
// Unique: this runs on every request, and Action Scheduler only creates the next
// recurrence once the current one completes, so a concurrent request can arrive
// while nothing is pending. Uniqueness is enforced in the insert itself.
as_schedule_recurring_action( $timestamp, $interval, $hook, array(), $group, true );

// The call returns 0 and stores nothing when creation fails, so ask the store.
$next = as_next_scheduled_action( $hook, array(), $group );
}

// Drop the WP-Cron fallback only once the action is there to replace it.
if ( false !== $next ) {
wp_clear_scheduled_hook( $hook );
return;
}
}

// Re-arm only when the event is missing or set to a different interval. This runs on
// every request while Action Scheduler keeps refusing, and re-arming a live event
// would pin it to a past timestamp and make the refresh due on every cron spawn.
$event = wp_get_scheduled_event( $hook );
if ( ! $event || $event->schedule !== $interval_key ) {
wp_clear_scheduled_hook( $hook );
wp_schedule_event( $timestamp, $interval_key, $hook );
Comment thread
Alexia-Soare marked this conversation as resolved.
Outdated
}
Comment thread
Alexia-Soare marked this conversation as resolved.
}

/**
* Check once per window that something still fires the refresh.
*
* Hooked to `init`, so it runs on every request. The timestamp is autoloaded and costs
* no query, and the daily `action_scheduler_ensure_recurring_actions` hook is the floor
* under it on a site that serves few requests.
*/
public function maybe_reschedule_refresh_db(): void {
$checked = (int) get_option( self::REFRESH_DB_CHECK_OPTION, 0 );
if ( time() - $checked < self::REFRESH_DB_CHECK_WINDOW ) {
return;
}

wp_clear_scheduled_hook( $hook );
wp_schedule_event( $timestamp, $interval_key, $hook );
update_option( self::REFRESH_DB_CHECK_OPTION, time(), true );
$this->ensure_refresh_db_action();
}
Comment thread
Alexia-Soare marked this conversation as resolved.
Comment thread
Alexia-Soare marked this conversation as resolved.

/**
* Keep the DB refresh scheduled when Action Scheduler is not available.
* Keep the DB refresh scheduled on whichever scheduler the site can use.
*
* The migration to Action Scheduler clears the WP-Cron event, so a site that
* already migrated and then lost the library would have nothing left running
* the refresh. Re-arms WP-Cron in that case; no-op whenever the library is up.
* This is the only way back. Action Scheduler creates the next occurrence of a recurring
* action inside schedule_next_instance(), which a killed run never reaches, so the chain
* ends there and nothing but a reactivation used to restore it. Also covers a site that
* lost Action Scheduler, and a legacy WP-Cron event that never migrated.
*/
public function maybe_reschedule_refresh_db(): void {
public function ensure_refresh_db_action(): void {
$hook = self::REFRESH_DB_HOOK;

if (
visualizer_can_use_action_scheduler()
&& function_exists( 'as_next_scheduled_action' )
&& function_exists( 'as_schedule_recurring_action' )
) {
return;
// Settled only once Action Scheduler holds the action and no WP-Cron event fires
// the same hook beside it; a site keeping both refreshes twice per interval.
$scheduled = false !== as_next_scheduled_action( $hook, array(), self::REFRESH_DB_GROUP )
&& ! wp_next_scheduled( $hook );
} else {
$scheduled = (bool) wp_next_scheduled( $hook );
}

Comment thread
Alexia-Soare marked this conversation as resolved.
Outdated
if ( wp_next_scheduled( 'visualizer_schedule_refresh_db' ) ) {
return;
if ( ! $scheduled ) {
$this->schedule_refresh_db_action();
}

$this->schedule_refresh_db_action();
}

/**
* Unschedule the recurring DB refresh action.
*/
private function unschedule_refresh_db_action(): void {
$hook = 'visualizer_schedule_refresh_db';
$group = 'visualizer';
$hook = self::REFRESH_DB_HOOK;
$group = self::REFRESH_DB_GROUP;
if ( function_exists( 'as_unschedule_all_actions' ) ) {
as_unschedule_all_actions( $hook, array(), $group );
}
Expand Down
42 changes: 0 additions & 42 deletions classes/Visualizer/Module/Upgrade.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@ public static function upgrade() {
$upgraded = true;
}

if ( wp_next_scheduled( 'visualizer_schedule_refresh_db' ) ) {
self::migrate_action_scheduler();
$upgraded = true;
}

if ( ! $upgraded ) {
return;
}
Expand Down Expand Up @@ -79,41 +74,4 @@ private static function makeAllTableChartsTabular() {
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}

/**
* Migrate recurring WP-Cron jobs to Action Scheduler.
*/
private static function migrate_action_scheduler(): void {
if ( ! function_exists( 'as_schedule_recurring_action' ) || ! function_exists( 'as_next_scheduled_action' ) ) {
return;
}

$hook = 'visualizer_schedule_refresh_db';
$group = 'visualizer';
$interval_key = apply_filters( 'visualizer_chart_schedule_interval', 'visualizer_ten_minutes' );
$interval = self::get_schedule_interval_seconds( $interval_key );
$timestamp = strtotime( 'midnight' ) - get_option( 'gmt_offset' ) * HOUR_IN_SECONDS;

$next = as_next_scheduled_action( $hook, array(), $group );
if ( false === $next ) {
as_schedule_recurring_action( $timestamp, $interval, $hook, array(), $group );
}

wp_clear_scheduled_hook( $hook );
}

/**
* Resolve a cron schedule key to seconds.
*
* @param string $interval_key Cron schedule key.
* @return int Interval in seconds.
*/
private static function get_schedule_interval_seconds( $interval_key ) {
$schedules = wp_get_schedules();
if ( isset( $schedules[ $interval_key ]['interval'] ) ) {
return (int) $schedules[ $interval_key ]['interval'];
}

return 600;
}
}
Loading
Loading