fix: recover the database refresh chain a killed run ends - #1385
Alexia-Soare wants to merge 16 commits into
Conversation
as_schedule_recurring_action() returns 0 instead of throwing when it cannot store an action. The activation path ignored that return, cleared the WP-Cron fallback anyway, and left the refresh hook with no trigger at all. Nothing recovered it: the recovery hook stood down whenever Action Scheduler was usable, and the migration was gated on the WP-Cron event that had just been deleted, so even a reactivation could fail the same way. Clear the fallback only after re-querying Action Scheduler for the action. Turn the recovery hook into a plain "is anything going to fire this hook" check, which also covers the legacy WP-Cron migration, so the duplicate scheduling code in Visualizer_Module_Upgrade is no longer needed. Two consequences of running that check on every request: - Re-arm WP-Cron only when the event is missing or its interval changed. Re-arming unconditionally pinned it to a past timestamp on every request, so the refresh became due on every cron spawn. - Schedule the action as unique. Action Scheduler creates the next recurrence only after the current one completes, so a concurrent request can arrive while nothing is pending and add a duplicate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Copilot was unable to run its full agentic suite in this review.
Copilot review overview
Review effort: Lite
Findings: 2
Open (4)
When Action Scheduler is available and already has a scheduled action, this method will skip… · New The callback parameter order andaccepted_argsdon’t match Action Scheduler’s… · New This does two Action Scheduler lookups in the common success path (one before scheduling, one… · New The Action Scheduler group is hard-coded as'visualizer'here, while other code paths use a… · New
What changed in this PR
Adds regression coverage and adjusts refresh scheduling logic to ensure the DB refresh hook always retains a valid trigger and avoids duplicate scheduling when Action Scheduler is unavailable or racing.
Changes:
- Added unit tests covering Action Scheduler failures, recovery behavior, WP-Cron migration, and race/uniqueness behavior.
- Updated scheduling logic to (a) use unique Action Scheduler recurring actions, (b) only clear WP-Cron after a confirmed Action Scheduler schedule, and (c) avoid re-arming live WP-Cron events.
- Removed Upgrade-module migration helpers in favor of runtime recovery/migration via the Setup module.
| File | Description |
|---|---|
| tests/test-schedule-refresh-db.php | New regression tests for refresh scheduling, fallback behavior, migration, and race conditions. |
| classes/Visualizer/Module/Upgrade.php | Removes upgrade-time WP-Cron→Action Scheduler migration code now handled elsewhere. |
| classes/Visualizer/Module/Setup.php | Improves scheduling reliability (uniqueness, confirmed migration, safe fallback re-arming) and adds recovery logic. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Both schedulers fire visualizer_schedule_refresh_db, so a site holding an action and an event refreshes twice per interval. The recovery check counted the action alone as settled and left the event in place. Treat the refresh as settled only when Action Scheduler holds the action and no event fires the same hook beside it. Also from review: reuse the first lookup instead of querying Action Scheduler again when the action already exists, and name the hook and the group once instead of repeating the literals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Copilot was unable to run its full agentic suite in this review.
Copilot review overview
Review effort: Lite
Findings: 2
Open (3)
Resolved since last review (4)
The callback parameter order andaccepted_argsdon’t match Action Scheduler’s… When Action Scheduler is available and already has a scheduled action, this method will skip… The Action Scheduler group is hard-coded as'visualizer'here, while other code paths use a… This does two Action Scheduler lookups in the common success path (one before scheduling, one…
index.php loads Action Scheduler only when visualizer_can_use_action_scheduler() passes, so a host without wpdb::db_server_info() has none. set_up() called the library unconditionally, which ended the whole suite with a fatal before any test could report. Skip the file instead. Also corrects the WP-Cron comment: the check is missing or different interval, not a stale timestamp. A timestamp criterion would pin the event to the past again, which is what the check exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hands-on investigation on the reporting site corrected the premise of #1384. Reactivation did restore the action; the customer was reading WP Crontrol, where the hook correctly no longer appears after the Action Scheduler migration. The real defect is that a run killed mid flight ends the recurring chain for good: Action Scheduler creates the next occurrence inside schedule_next_instance(), which a host kill, fatal or timeout never reaches, and the queue cleaner then marks the action failed with no successor. The per-request check already recovers this. Add the two pieces it was missing: - Hook action_scheduler_ensure_recurring_actions, Action Scheduler's own daily assurance hook, as a floor under the per-request check for sites that serve few requests. - Throttle the per-request check to one run per 300s, recorded in an autoloaded option so a settled site spends no query on it. Action Scheduler needs the same 300s to mark a killed run failed, so a shorter window cannot recover one any sooner. The daily hook ignores the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Copilot was unable to run its full agentic suite in this review.
Copilot review overview
Review effort: Lite
Findings: 3
Open (3)
Computing local midnight viagmt_offsetis incorrect across DST transitions (and can be wrong for… · New Updating an autoloaded option on a timer (every 300s per site) can causealloptionscache churn… · New Tests that add global filters should ensure cleanup even if an assertion fails or an exception is… · New
Two findings from review. The start time is local midnight derived from gmt_offset. West of UTC that midnight has not arrived yet, so a recovered run was parked up to twelve hours ahead and the charts stayed stale for the rest of the day. Fall back to the previous midnight when the computed one is still in the future. The throttle recorded its timestamp in an autoloaded option, so every window rewrote the alloptions blob and invalidated it for every process. A transient with the window as its expiry is not autoloaded, expires on its own, and keeps the read cached. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Copilot was unable to run its full agentic suite in this review.
Copilot review overview
Review effort: Lite
Findings: 1
Open (4)
The callback parameter order for thepre_as_schedule_recurring_actionfilter is likely incorrect:… · New The skip condition in set_up() checks only for a function, but later tests reference the… · New The skip condition in set_up() checks only for a function, but later tests reference the… · New This test mutates the globalgmt_offsetoption but doesn’t restore it afterward. That can leak… · New
Resolved since last review (3)
Review has now questioned the pre_as_schedule_recurring_action argument order twice. The racer records the two arguments it receives and the test asserts their types, so a swap fails loudly instead of silently forwarding the wrong value. Types separate them whatever value the code under test passes. Also from review: guard set_up() on the ActionScheduler classes the tests call statics on, not only the functions, and scope the gmt_offset change to a filter the framework restores. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Copilot was unable to run its full agentic suite in this review.
Copilot review overview
Review effort: Lite
Findings: 2
Open (3)
The transient is set before attempting repair. If ensure_refresh_db_action() fails to schedule a… · New The custom error handler returns true for all E_DEPRECATED warnings in the protected block, which… · New wp_get_schedules() is consulted here and (based on the existing helper pattern) is likely consulted… · New
Resolved since last review (4)
The WP-Cron fallback path can permanently drop the refresh trigger if$interval_keyis not a… The WP-Cron fallback path can permanently drop the refresh trigger if$interval_keyis not a…get_option('gmt_offset')can be a float (e.g., 5.5), which makes$timestampa float. Both…get_option('gmt_offset')can be a float (e.g., 5.5), which makes$timestampa float. Both…
The window skips work that is already done, so recording it after an attempt that established no trigger left the site without one until the window expired, with the daily assurance hook a day away. ensure_refresh_db_action() now reports whether the refresh ended up scheduled, and the caller caches only that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Where Action Scheduler is present but refuses, the fallback is the trigger, and the previous commit reported that as unscheduled. The window was then never recorded, so every request repeated the whole check and the refused insert. Split the two questions: settled means the refresh sits on the scheduler this site should use, which is what decides whether to act; having a trigger means something will fire the hook at all, which is what decides whether to cache. ensure_refresh_db_action() goes back to returning nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The key check and the interval lookup each called wp_get_schedules(). Read it once and derive both from the same array, which also makes the one-line helper that did the second lookup unnecessary. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Changing interval cleared the old event and then scheduled the new one, so a refused schedule left the refresh with nothing. That is the bug this PR fixes on the Action Scheduler path, repeated on the WP-Cron one. Schedule first and remove the old event by its own timestamp afterwards. wp_clear_scheduled_hook() would remove the new event too, since WP-Cron does not dedupe recurring events for a hook. A matching timestamp means the write already replaced the old entry in place, so nothing is removed in that case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The refresh is scheduled without arguments, so the old event has none to match. Keeps this in step with the Pro plugin, where PHPStan objected to passing the typed-as-array field where a list is expected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approved
The recovery logic matches the Action Scheduler 3.9.3 and WP-Cron contracts, and the shipped test class passes at HEAD and fails on the base tree.
Validation details
- Files reviewed: 4/4 changed files.
- Shipped tests at HEAD:
vendor/bin/phpunit --filter 'Test_Visualizer_Schedule_Refresh_Db::'on PHP 8.3.33, WordPress 7.1.1 test library, MariaDB. Result:OK (16 tests, 35 assertions). - Shipped tests on base: the same file on a
pr-baseworktree. Result: 16 errors,Undefined constant Visualizer_Module_Setup::REFRESH_DB_CHECK_TRANSIENT. The tests do not pass without the change. - Existing test:
Test_Visualizer_Schedule::still passes (1 test). - Action Scheduler 3.9.3 source:
pre_as_schedule_recurring_actionpasses$prioritybefore$unique. The unique insert checks pending and in-progress rows by hook and group.action_scheduler_ensure_recurring_actionsexists since 3.9.3. - WordPress core:
wp_schedule_event()has no duplicate check, so unscheduling the old event by its own timestamp is correct. - Security: no trust boundary changed. The
inithook already ran on every request at base.
Untested areas
- The PR QA steps on a live site (killed action recovery, WP Crontrol, Scheduled Actions screen) were not run.
- Multisite network activation was not run.
- Sites where another plugin loads an Action Scheduler older than 3.9.3 were not run. There the daily hook never fires and only the per-request check recovers.
- PHP 7.4 was not run. The added syntax is 7.4 compatible by inspection.
🤖 Automated review · run code-review-agent_6ab22fa3ebd623.03983434
🤖 Review agent — review posted ✅ on ede25f05 · approved · 0 findings · 23 min
Run code-review-agent_6ab22fa3ebd623.03983434 · trail
Soare-Robert-Daniel
left a comment
There was a problem hiding this comment.
@Alexia-Soare you can trim down the comments to be shorter.
Requested in review: keep the one line that says why, drop the paragraphs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Trimmed in da6fa8f: every multi-sentence comment and docblock is down to the one line that says why, and the ones that restated the code are gone. |



A database refresh run that is killed mid flight ends the recurring chain for good, and the charts go stale in silence. The refresh now re-registers itself when nothing is left to fire it.
Related: #1384. Customer report: HelpScout conversation 3456322130.
Note
The bug was added in bc14d4b on version v4.0.0.
The hands-on investigation corrected the premise of the issue. Reactivation did restore the action. The customer was reading WP Crontrol, where the hook correctly no longer appears after the Action Scheduler migration.
Why a killed run is permanent
Action Scheduler creates the next occurrence of a recurring action inside
schedule_next_instance(), which runs only after the current occurrence finishes. A host kill, a fatal or a timeout never reaches it.ActionScheduler_QueueCleanerthen marks the action failed after 300s, and nothing succeeds it. Under the WP-Cron implementation the recurrence was stored up front, so the same kill only cost one run.What changed
Refresh trigger — re-registers when nothing is left to fire it. A killed run gets a successor on the next request. On
developmentthe chain stays dead: I killed an action and ran 50 requests against both branches, and only this one recovered.Daily assurance — hooks
action_scheduler_ensure_recurring_actions, Action Scheduler's own hook for this case. It is the floor under the per-request check on a site that serves few requests.Per-request cost — the check runs once per 300s, held by a transient, so a settled site spends one cached read instead of two Action Scheduler queries. Action Scheduler needs the same 300s to mark a killed run failed, so a shorter window cannot recover one any sooner. The daily hook ignores the window.
Start time — a recovered run is due now. The start time is local midnight derived from
gmt_offset, and west of UTC that midnight has not arrived yet, so a recovered run was parked up to twelve hours ahead and the charts stayed stale for the rest of the day.Activation — clears the WP-Cron event only after Action Scheduler confirms the action.
as_schedule_recurring_action()returns0instead of throwing when it cannot store an action, so a refused creation used to leave both schedulers empty.Both schedulers at once — a WP-Cron event left beside an Action Scheduler action is cleared. Both fire the same hook, so a site holding each refreshed twice per interval.
Upgrade module — loses
migrate_action_scheduler()and its helper. The per-request check covers the legacy WP-Cron migration, and the old gate needed the WP-Cron event that activation had just deleted.Note
Recovery is not instant. While a killed run sits in progress, Action Scheduler still reports it as scheduled, so the check correctly stands down until the queue cleaner marks it failed at 300s. Worst case recovery is about 5 minutes, or 24 hours on a site that serves no requests at all.
Pro has the same defect on
visualizer_schedule_import, which Pro registers and owns. It needs the same two hooks there.Setup.php:458computes$current_time + $scheduled_hours * HOUR_IN_SECONDS. The One-time option stores-1, which puts the next run an hour in the past and makes the chart due on every run. Free never writes that meta, Pro does. This is a separate bug and needs its own issue.How the refresh gets a trigger
flowchart LR A["Any request, on init"] --> B{"New:<br/>checked in<br/>the last 300s?"}:::added B -- yes --> C["Do nothing"] B -- no --> D{"Anything going<br/>to fire the hook?"} E["New:<br/>daily assurance hook"]:::added --> D D -- yes --> F["Clear any<br/>WP-Cron event beside it"] D -- no --> G["Changed:<br/>create unique<br/>recurring action"]:::changed G --> H{"New:<br/>action stored?"}:::added H -- yes --> F H -- no --> I["Arm WP-Cron fallback"] classDef added fill:#1a7f37,color:#fff,stroke:#116329,stroke-width:3px classDef changed fill:#9a6700,color:#fff,stroke:#5c3d00,stroke-width:3px,stroke-dasharray:6 3QA
Steps 1 to 4 cover the confirmed defect. Steps 5 to 8 cover a refused Action Scheduler creation.
Go to
WP Admin → Tools → Scheduled Actions. Select the Pending tab. Search forvisualizer_schedule_refresh_db. Write down the action ID.Expect: exactly one pending action.
Kill that run the way a host kill does. Replace
<ID>with the ID from step 1.Expect: the command completes with no output.
Go to
WP Admin → Tools → Scheduled Actions. Select the Pending tab. Search forvisualizer_schedule_refresh_db.Expect: no pending action. Before this PR the chart never refreshes again.
Run
wp eval 'delete_transient( "visualizer-refresh-db-checked" );'to close the throttle window. Open anyWP Adminpage. Return to the Pending tab.Expect: one pending action again, with a new ID.
Create the file
wp-content/mu-plugins/vis-1384.phpwith this content. It makes every Action Scheduler recurring creation fail.Expect: the next admin page loads with no error.
Go to
WP Admin → Plugins → Installed Plugins. Deactivate Visualizer. Activate Visualizer again.Expect: the plugin activates with no error.
Run this command from the WordPress root.
wp cron event list --fields=hook,next_run_relative,recurrence | grep visualizer_schedule_refresh_dbExpect: one WP-Cron event every 10 minutes. Before this PR the output is empty, and nothing fires the refresh.
Delete
wp-content/mu-plugins/vis-1384.php. Runwp eval 'delete_transient( "visualizer-refresh-db-checked" );'. Open anyWP Adminpage. Run the command from step 7 again.Expect: empty output, and
WP Admin → Tools → Scheduled Actions → Pendinglistsvisualizer_schedule_refresh_dbinstead.🤖 Generated with Claude Code