Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
49 changes: 49 additions & 0 deletions classes/Visualizer/ActionScheduler/Store.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
/**
* Action Scheduler store that tolerates a lost race when marking an action failed.
*
* `ActionScheduler_DBStore::mark_failure()` throws when its UPDATE changes no
* row. That happens when another process deleted the action, or already marked
* it failed: the queue cleaner and the queue runner can both reach the same
* action, and WP-Cron's queue run takes no lock. Nothing catches the exception,
* so the whole queue run ends with a fatal error (#1369, upstream
* woocommerce/action-scheduler#970).
*
* Registered through the `action_scheduler_store_class` filter in `index.php`.
*
* @category Visualizer
* @package ActionScheduler
*
* @since 4.0.9
*/
class Visualizer_ActionScheduler_Store extends ActionScheduler_DBStore {

/**
* Mark an action failed, and accept that another process got there first.
*
* A database error still throws, so real failures stay visible.
*
* @param int $action_id Action ID.
*
* @throws InvalidArgumentException When the UPDATE itself failed.
*
* @return void
*/
public function mark_failure( $action_id ) {
global $wpdb;

// Same UPDATE as the parent. Zero rows means the row was deleted or
// already failed; only `false` is a database error.
$updated = $wpdb->update(
$wpdb->actionscheduler_actions,
array( 'status' => self::STATUS_FAILED ),
array( 'action_id' => $action_id ),
array( '%s' ),
array( '%d' )
);
Comment on lines +32 to +43
Comment on lines +37 to +43

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not needed. The parent store has no table property; its own mark_failure() uses $wpdb->actionscheduler_actions, so this matches it.

if ( false === $updated ) {
/* translators: %d is the action ID */
throw new InvalidArgumentException( sprintf( __( 'Unable to mark action %d as failed.', 'visualizer' ), $action_id ) );
}
}
}
17 changes: 17 additions & 0 deletions index.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ function () {
require_once $action_scheduler_file;
}

// After Action Scheduler's own data controller, which sets the class at 100.
add_filter( 'action_scheduler_store_class', 'visualizer_action_scheduler_store_class', 200 );
Comment thread
Alexia-Soare marked this conversation as resolved.

add_filter( 'themeisle_sdk_products', 'visualizer_register_sdk', 10, 1 );
add_filter( 'pirate_parrot_log', 'visualizer_register_parrot', 10, 1 );
add_filter(
Expand Down Expand Up @@ -232,6 +235,20 @@ function visualizer_can_use_action_scheduler() {
return isset( $wpdb ) && is_callable( array( $wpdb, 'db_server_info' ) );
}

/**
* Use a store that survives a lost race when marking an action failed.
*
* Only replaces Action Scheduler's own database store. Another plugin's store
* and the legacy post store, which does not have the problem, are left alone.
*
* @param string $class_name Store class Action Scheduler resolved.
*
* @return string
*/
function visualizer_action_scheduler_store_class( $class_name ) {
return 'ActionScheduler_DBStore' === $class_name ? 'Visualizer_ActionScheduler_Store' : $class_name;
}

/**
* Registers with the SDK
*
Expand Down
1 change: 1 addition & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ parameters:
- %currentWorkingDirectory%/vendor/neitanod/forceutf8
- %currentWorkingDirectory%/vendor/openspout/openspout
- %currentWorkingDirectory%/vendor/codeinwp/themeisle-sdk
- %currentWorkingDirectory%/vendor/woocommerce/action-scheduler

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changing this. scanDirectories only loads the class names for discovery; PHPStan does not analyze or report errors in that code. The other vendor packages are listed the same way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changing this, same as the earlier thread: scanDirectories only loads the class names, PHPStan does not analyze that code.

excludePaths:
- classes/Visualizer/Gutenberg/build (?)
- classes/Visualizer/GutenChartBuilder/build (?)
Expand Down
8 changes: 8 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ function _manually_load_plugin() {
tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' );
// Start up the WP testing environment.
require $_tests_dir . '/includes/bootstrap.php';

// The framework snapshots hooks at the first test and restores that snapshot
// after every test. WP_Ajax_UnitTestCase removes these once per class, which
// only holds when an AJAX class runs first. Remove them here so no test file
// order makes AJAX tests call api.wordpress.org.
remove_action( 'admin_init', '_maybe_update_core' );
remove_action( 'admin_init', '_maybe_update_plugins' );
remove_action( 'admin_init', '_maybe_update_themes' );
Comment thread
Alexia-Soare marked this conversation as resolved.
activate_plugin( 'visualizer/index.php' );
global $current_user;
$current_user = new WP_User( 1 );
Expand Down
241 changes: 241 additions & 0 deletions tests/test-action-scheduler-mark-failure.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
<?php
/**
* Marking an action failed must survive another process getting there first.
*
* Regression tests for #1369; see Visualizer_ActionScheduler_Store.
*
* @package visualizer
* @subpackage Tests
* @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
*/

/**
* The replacement store, and the two callers that mark actions failed.
*/
class Test_Visualizer_Action_Scheduler_Mark_Failure extends WP_UnitTestCase {

/**
* Store under test.
*
* @var Visualizer_ActionScheduler_Store
*/
private $store;

/**
* Query filters added during a test.
*
* @var list<Closure(string): string>
*/
private $filters_to_remove = array();

/**
* Skip when Action Scheduler is not loaded.
*/
public function set_up() {
parent::set_up();

if ( ! class_exists( 'ActionScheduler_DBStore' ) ) {
$this->markTestSkipped( 'Action Scheduler is not loaded.' );
}

$this->store = new Visualizer_ActionScheduler_Store();
$this->store->init();
}

/**
* Remove the query filters even when a test throws.
*/
public function tear_down() {
foreach ( $this->filters_to_remove as $filter ) {
remove_filter( 'query', $filter );
}
$this->filters_to_remove = array();
parent::tear_down();
}

/**
* Save an action, then make it a stale in-progress one (last attempt two hours ago).
*
* @return int Action id.
*/
private function seed_stale_running_action() {
global $wpdb;
$action_id = $this->store->save_action( new ActionScheduler_Action( 'visualizer_schedule_refresh_db', array(), new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-2 hours' ) ) ) );
$gmt = gmdate( 'Y-m-d H:i:s', time() - 2 * HOUR_IN_SECONDS );
$wpdb->update(
$wpdb->actionscheduler_actions,
array(
'status' => ActionScheduler_Store::STATUS_RUNNING,
'last_attempt_gmt' => $gmt,
'last_attempt_local' => $gmt,
Comment on lines +63 to +70

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not needed. The queue cleaner finds stale actions by last_attempt_gmt only (ActionScheduler_DBStore filters modified on that column), and the tests run in UTC.

),
array( 'action_id' => $action_id )
);
return (int) $action_id;
}

/**
* Status column of one action, or null when the row is gone.
*
* @param int $action_id Action id.
* @return string|null
*/
private function status_of( $action_id ) {
global $wpdb;
return $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->actionscheduler_actions} WHERE action_id = %d", $action_id ) );
}

/**
* Run `$intercept` once, on the UPDATE that marks `$action_id` failed, and
* use its return value as the SQL to execute.
*
* @param int $action_id Action whose UPDATE is intercepted.
* @param callable(string): string $intercept Receives the SQL, returns the SQL to run.
*/
private function intercept_mark_failure_update( $action_id, callable $intercept ) {
global $wpdb;
$table = $wpdb->actionscheduler_actions;
$done = false;
// Queries issued inside $intercept re-enter this filter: run it once only.
$filter = function ( $sql ) use ( $action_id, $table, $intercept, &$done ) {
if ( $done || ! $this->is_mark_failed_update( $sql, $table, $action_id ) ) {
return $sql;
}
$done = true;
return $intercept( $sql );
};
add_filter( 'query', $filter );
$this->filters_to_remove[] = $filter;
Comment on lines +100 to +108

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not needed. The $done flag already makes the filter a no-op after the first match, and tear_down() removes it.

}

/**
* Whether `$sql` is the UPDATE that marks `$action_id` in `$table` failed.
* Matches the SQL `wpdb::update()` builds, with or without backticks, and a bare or quoted action id.
*
* @param string $sql SQL about to run.
* @param string $table Actions table name.
* @param int $action_id Action id.
* @return bool
*/
private function is_mark_failed_update( $sql, $table, $action_id ) {
if ( 0 !== stripos( ltrim( $sql ), 'UPDATE' ) || false === strpos( $sql, $table ) ) {
return false;
}
if ( ! preg_match( '/status`?\s*=\s*\'' . preg_quote( ActionScheduler_Store::STATUS_FAILED, '/' ) . '\'/', $sql ) ) {
return false;
}
Comment on lines +111 to +126

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in fb00b81: the docstring now says only the action id can be bare or quoted.

return preg_match( '/action_id`?\s*=\s*\'?(\d+)/', $sql, $m ) && (int) $m[1] === $action_id;
}

/**
* Visualizer replaces Action Scheduler's own database store, and nothing else.
*/
public function test_filter_replaces_only_the_default_database_store() {
$this->assertSame( 'Visualizer_ActionScheduler_Store', visualizer_action_scheduler_store_class( 'ActionScheduler_DBStore' ) );
$this->assertSame( 'Another_Plugin_Store', visualizer_action_scheduler_store_class( 'Another_Plugin_Store' ) );
$this->assertSame( 'ActionScheduler_HybridStore', visualizer_action_scheduler_store_class( 'ActionScheduler_HybridStore' ) );
}

/**
* The filter runs after Action Scheduler's data controller, so its class wins.
*/
public function test_filter_runs_after_the_data_controller() {
update_option( 'action_scheduler_migration_status', 'complete' );

$this->assertSame( 'Visualizer_ActionScheduler_Store', apply_filters( 'action_scheduler_store_class', ActionScheduler_Store::DEFAULT_CLASS ) );
Comment on lines +143 to +145
Comment on lines +143 to +145
}

/**
* Another process deleted the action: nothing left to mark.
*/
public function test_mark_failure_tolerates_a_deleted_action() {
global $wpdb;
$action_id = $this->seed_stale_running_action();
$wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $action_id ) );

$this->store->mark_failure( $action_id );

$this->assertNull( $this->status_of( $action_id ) );
}

/**
* An overlapping cleaner already marked it failed: the UPDATE changes nothing.
*/
public function test_mark_failure_tolerates_an_already_failed_action() {
global $wpdb;
$action_id = $this->seed_stale_running_action();
$wpdb->update( $wpdb->actionscheduler_actions, array( 'status' => ActionScheduler_Store::STATUS_FAILED ), array( 'action_id' => $action_id ) );

$this->store->mark_failure( $action_id );

$this->assertSame( ActionScheduler_Store::STATUS_FAILED, $this->status_of( $action_id ) );
}

/**
* A real database error still surfaces.
*/
public function test_mark_failure_still_throws_on_a_database_error() {
global $wpdb;
$action_id = $this->seed_stale_running_action();

// Break the UPDATE itself: the store gets `false`, not zero rows.
$this->intercept_mark_failure_update(
$action_id,
static function ( $sql ) use ( $wpdb ) {
return str_replace( $wpdb->actionscheduler_actions, 'no_such_table', $sql );
}
);
$suppressed = $wpdb->suppress_errors( true );

$this->expectException( InvalidArgumentException::class );
try {
$this->store->mark_failure( $action_id );
} finally {
$wpdb->suppress_errors( $suppressed );
}
}

/**
* Queue cleanup keeps going when an action vanishes between its query and its update.
*/
public function test_mark_failures_continues_past_an_action_deleted_by_another_process() {
global $wpdb;
$vanishing = $this->seed_stale_running_action();
$survivor = $this->seed_stale_running_action();

$this->intercept_mark_failure_update(
$vanishing,
static function ( $sql ) use ( $wpdb, $vanishing ) {
$wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $vanishing ) );
return $sql;
}
);

( new ActionScheduler_QueueCleaner( $this->store ) )->mark_failures( 60 );

$this->assertNull( $this->status_of( $vanishing ), 'the concurrently deleted action stays gone' );
$this->assertSame( ActionScheduler_Store::STATUS_FAILED, $this->status_of( $survivor ), 'cleanup continues and marks the remaining stale action failed' );
}

/**
* Runner path (the trace in upstream #970): the action is deleted while it
* runs, then it throws, and the runner marks it failed.
*/
public function test_process_action_survives_marking_a_deleted_action() {
global $wpdb;
$hook = 'visualizer_test_throwing_action';
$action_id = $this->store->save_action( new ActionScheduler_Action( $hook, array(), new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-1 minute' ) ) ) );

add_action(
$hook,
static function () use ( $wpdb, $action_id ) {
$wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $action_id ) );
throw new RuntimeException( 'refresh failed' );
}
);

( new ActionScheduler_QueueRunner( $this->store ) )->process_action( $action_id, 'test' );
Comment on lines +228 to +237

Comment on lines +229 to +238

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not needed. The comment in tests/bootstrap.php says the framework restores the hook snapshot after every test, so the callback is removed as soon as this test ends.

$this->assertNull( $this->status_of( $action_id ), 'the deleted action stays gone and the run survives' );
}
}
Loading