Skip to content

fix: recover the database refresh chain a killed run ends - #1385

Open
Alexia-Soare wants to merge 16 commits into
developmentfrom
fix/1384
Open

Alexia-Soare wants to merge 16 commits into
developmentfrom
fix/1384

Conversation

@Alexia-Soare

@Alexia-Soare Alexia-Soare commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

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_QueueCleaner then 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 development the 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() returns 0 instead 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:458 computes $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 3
Loading

QA

Steps 1 to 4 cover the confirmed defect. Steps 5 to 8 cover a refused Action Scheduler creation.

  1. Go to WP Admin → Tools → Scheduled Actions. Select the Pending tab. Search for visualizer_schedule_refresh_db. Write down the action ID.

    Expect: exactly one pending action.

  2. Kill that run the way a host kill does. Replace <ID> with the ID from step 1.

    wp eval '$s = ActionScheduler::store(); $s->log_execution( <ID> ); $s->mark_failure( <ID> );'

    Expect: the command completes with no output.

  3. Go to WP Admin → Tools → Scheduled Actions. Select the Pending tab. Search for visualizer_schedule_refresh_db.

    Expect: no pending action. Before this PR the chart never refreshes again.

  4. Run wp eval 'delete_transient( "visualizer-refresh-db-checked" );' to close the throttle window. Open any WP Admin page. Return to the Pending tab.

    Expect: one pending action again, with a new ID.

  5. Create the file wp-content/mu-plugins/vis-1384.php with this content. It makes every Action Scheduler recurring creation fail.

    <?php
    add_filter( 'pre_as_schedule_recurring_action', '__return_zero' );

    Expect: the next admin page loads with no error.

  6. Go to WP Admin → Plugins → Installed Plugins. Deactivate Visualizer. Activate Visualizer again.

    Expect: the plugin activates with no error.

  7. Run this command from the WordPress root.

    wp cron event list --fields=hook,next_run_relative,recurrence | grep visualizer_schedule_refresh_db

    Expect: one WP-Cron event every 10 minutes. Before this PR the output is empty, and nothing fires the refresh.

  8. Delete wp-content/mu-plugins/vis-1384.php. Run wp eval 'delete_transient( "visualizer-refresh-db-checked" );'. Open any WP Admin page. Run the command from step 7 again.

    Expect: empty output, and WP Admin → Tools → Scheduled Actions → Pending lists visualizer_schedule_refresh_db instead.

🤖 Generated with Claude Code

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>
@Alexia-Soare Alexia-Soare self-assigned this Sep 21, 2026
@Alexia-Soare
Alexia-Soare requested a balanced review from Copilot September 21, 2026 11:49
@pirate-bot

pirate-bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Plugin build for da6fa8f is ready 🛎️!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Copilot was unable to run its full agentic suite in this review.

Copilot review overview

Review effort: Lite
Findings: 2 Medium severity · 2 Low severity

Open (4)
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.

Comment thread classes/Visualizer/Module/Setup.php Outdated
Comment thread tests/test-schedule-refresh-db.php Outdated
Comment thread classes/Visualizer/Module/Setup.php Outdated
Comment thread classes/Visualizer/Module/Setup.php Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Comment thread tests/test-schedule-refresh-db.php
Comment thread tests/test-schedule-refresh-db.php
Comment thread classes/Visualizer/Module/Setup.php Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@Alexia-Soare Alexia-Soare changed the title fix: keep the DB refresh scheduled when Action Scheduler creation fails fix: recover the database refresh chain a killed run ends Sep 21, 2026
@Alexia-Soare
Alexia-Soare requested a balanced review from Copilot September 21, 2026 12:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread classes/Visualizer/Module/Setup.php Outdated
Comment thread classes/Visualizer/Module/Setup.php
Comment thread tests/test-schedule-refresh-db.php
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Comment thread tests/test-schedule-refresh-db.php Outdated
Comment thread tests/test-schedule-refresh-db.php Outdated
Comment thread tests/test-schedule-refresh-db.php Outdated
Comment thread tests/test-schedule-refresh-db.php Outdated
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>
@Alexia-Soare
Alexia-Soare requested a balanced review from Copilot September 21, 2026 13:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Comment thread classes/Visualizer/Module/Setup.php
Comment thread tests/test-schedule-refresh-db.php
Comment thread classes/Visualizer/Module/Setup.php Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread tests/test-schedule-refresh-db.php
Comment thread tests/test-schedule-refresh-db.php
Comment thread classes/Visualizer/Module/Setup.php Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread tests/test-schedule-refresh-db.php
Comment thread tests/test-schedule-refresh-db.php
Comment thread classes/Visualizer/Module/Setup.php Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread classes/Visualizer/Module/Setup.php Outdated
Comment thread tests/test-schedule-refresh-db.php
Alexia-Soare and others added 2 commits September 22, 2026 10:16
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>

@pirate-bot pirate-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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-base worktree. 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_action passes $priority before $unique. The unique insert checks pending and in-progress rows by hook and group. action_scheduler_ensure_recurring_actions exists 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 init hook 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 Soare-Robert-Daniel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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>
@Alexia-Soare

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants