WP Webhooks / Blog / Architecture

Fixing ActionScheduler_DBStore MySQL Deadlocks in WooCommerce

ActionScheduler_DBStore::claim_actions throws MySQL deadlocks under concurrent WooCommerce queue runners — why it happens and the exact fix.

8 min 2026-06-25 Updated 2026-10-14
#action-scheduler#woocommerce#wordpress

TL;DR: Action Scheduler deadlocks come from the UPDATE inside claim_actions() colliding when two or more queue runners reserve overlapping rows in wp_actionscheduler_actions at the same instant — mostly on older Action Scheduler or database versions.

  • Run Action Scheduler 3.9.3 or newer (any current WooCommerce ships it) on MySQL 8.0.1+ or MariaDB 10.6+ — the claim then uses SKIP LOCKED, so overlapping runners skip each other's rows instead of deadlocking on them.
  • Check that nothing raised action_scheduler_queue_runner_concurrent_batches above its default of 1, and use a smaller batch size to hold the lock for less time. This reduces collisions; it does not remove them.
  • A MySQL deadlock is transient, not data loss: InnoDB rolls back one transaction and expects a retry. Do not treat error 1213 as fatal.

/ Root cause

Why does Action Scheduler throw MySQL deadlocks?

Because two or more queue runners try to claim overlapping rows in wp_actionscheduler_actions in the same moment. Before Action Scheduler 3.9.3, each runner issued a single statement like UPDATE wp_actionscheduler_actions SET claim_id = N, last_attempt_gmt = ... WHERE claim_id = 0 AND status = 'pending' ... ORDER BY ... LIMIT 25. InnoDB takes row and gap locks as it walks the rows the query matches. When two runners — or a claim and a claim release — wait on each other's locks, InnoDB detects the cycle and kills one transaction with Deadlock found when trying to get lock; try restarting transaction — MySQL error 1213.

Since 3.9.3 the claim is an UPDATE ... JOIN (SELECT action_id ... LIMIT 25 FOR UPDATE SKIP LOCKED): a second runner skips rows the first has already locked instead of waiting on them. That needs MySQL 8.0.1+ or MariaDB 10.6+; on older servers Action Scheduler drops SKIP LOCKED and the old locking behaviour stays.

On a busy WooCommerce store the pending table is large and runners can overlap — WP-Cron starts one queue pass a minute, while the admin-triggered async runner, WP-CLI or custom code can start more — so the window where two claim queries overlap is wide. This is a documented, intermittent symptom rather than a corruption bug — see the long-running "Diagnose intermittent deadlocks" issue on the Action Scheduler tracker. The claim mechanism itself — the claim_id column and the dedicated claims table those runners write to — is detailed in the Action Scheduler database schema.

/ The mechanism

What are claim_actions() and release_claim()?

The claim is Action Scheduler's concurrency primitive. ActionScheduler_DBStore::claim_actions() reserves a batch: it stamps a unique claim_id (plus last-attempt timestamps) onto up to batch_size pending rows in one UPDATE. The rows stay pending; each action is switched to in-progress on its own, just before it runs. That stamp is what lets several runners work the same queue without ever executing the same action twice.

When the batch finishes, release_claim() clears that claim_id on any still-pending actions so they return to the pool for another runner. If the runner process dies instead, the queue cleaner resets the stale claim after 5 minutes. The important detail for debugging: the deadlock lives in the claim UPDATE, not in your action callback. The callback never ran; the runner could not even reserve its work. You can read both methods in the ActionScheduler_DBStore source.

FIG 01 — Two runners colliding on the same claim window

/ Multipliers

What makes concurrent runners collide?

Three things widen the collision window. First, action_scheduler_queue_runner_concurrent_batches greater than 1 lets several runners — the WP-Cron pass, the async runner chain, WP-CLI — hold claims at the same time, and each one issues its own claim UPDATE. Second, double-driven cron: a traffic-triggered WP-Cron and a system cron can both spawn runners at the same time. Third, a slow claim query holds its locks longer.

That last one is the quiet killer. When the pending table is large or missing the right index, the claim UPDATE scans more rows and keeps gap locks open longer, so overlapping runners are far more likely to deadlock or hit lock-wait timeouts — the exact behaviour tracked in issue #1104, "Slow claim actions update query causing lock wait timeouts". More pending rows plus a longer UPDATE equals more overlap.

A MySQL deadlock is not data loss. InnoDB rolls back one transaction and expects you to retry it. The bug is treating error 1213 as fatal. — InnoDB locking model

/ The fix

How do I stop the deadlocks safely?

Work the cheapest, highest-leverage fixes first. Most stores stop seeing 1213 after the first two.

  1. Upgrade Action Scheduler to 3.9.3 or newer, on MySQL 8.0.1+ or MariaDB 10.6+. 3.9.3 claims rows with SKIP LOCKED and only releases claims on pending actions. Earlier releases chipped at the same problem: 3.2.0 added an index to the actions table to reduce deadlocks (see the Action Scheduler 3.2.0 release notes), and 3.5.4 reworked release_claim() to avoid a claim/release deadlock. WooCommerce bundles its own copy (WooCommerce 11.0 ships Action Scheduler 4.0.0), so update WooCommerce — and check your database version, because older servers still get the old locking path.
  2. Keep concurrent batches at 1. One is already the default, so look for a plugin or host setting that raised it. It reduces overlapping claims but does not remove them: the limit is checked without a lock before claiming, wp action-scheduler run --force ignores it, and claim releases and WooCommerce's own writes to the table can still collide.
  3. Use one cron source. Disable visitor-triggered WP-Cron and drive the queue from a single system cron, so you never have two schedulers spawning runners on top of each other.
  4. Treat a stray 1213 as transient. An occasional deadlock under heavy load is expected. Action Scheduler re-claims rolled-back rows on the next tick; alert on sustained spikes, not single events.

mu-plugins/as-deadlock-guard.php — serialize the queue runner

// wp-content/mu-plugins/as-deadlock-guard.php
// Pin concurrency at the default of 1 — late priority wins over plugins that raise it.
// Reduces overlapping claims; it does not remove every deadlock.
add_filter( 'action_scheduler_queue_runner_concurrent_batches', function () {
    return 1;
}, 999 );

// Smaller batches hold the claim lock for less time.
add_filter( 'action_scheduler_queue_runner_batch_size', function () {
    return 10;
} );

/ Diagnose first

How do I confirm it is a claim deadlock and not something else?

Check the MySQL error log (or your host's slow/error query view) for Deadlock found when trying to get lock on an UPDATE against wp_actionscheduler_actions containing SET claim_id (on 3.9.3+ it joins a SELECT ... FOR UPDATE subquery). If the failing statement stamps claim_id on that table, it is the claim — and if you see it on 3.9.3 or newer, check whether your database is too old for SKIP LOCKED. A deadlock inside one of your own action callbacks is a different problem — that points at the work the action does, not at Action Scheduler.

For live queue pressure, open WooCommerce → Status → Scheduled Actions or run wp action-scheduler status as documented in the WP-CLI reference. A pending count in the thousands that keeps growing tells you the claim is contended and the table is big enough for the slow-query path to bite.

ConcernHand-tuned Action SchedulerWebhook Actions
ConcurrencyOverlapping runners claim the same rows, triggering 1213 deadlocksSingle persistent delivery queue with a serialized claim
On failureDeadlocked claim rolls back; the runner throws and the rows stay pending for the next runFailed HTTP deliveries retried with exponential backoff, fully logged
Visibilitygrep the MySQL error log to find the failing UPDATEPer-attempt delivery log in wp-admin with response codes
try_it

Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.

/ The deeper fix

Should you move webhook delivery out of the shared table?

If the deadlocks started after you began enqueuing an outbound HTTP call per order or per form entry, the contention is self-inflicted. You are mixing your delivery workload into WooCommerce's own scheduled-action table, so your webhook claims and WooCommerce's order, email, and sync claims all fight for the same rows.

A purpose-built delivery queue with its own tables and a single serialized runner removes most of that cross-contention — your deliveries stop competing row for row with WooCommerce's housekeeping, and the queue runner adds one Action Scheduler row per minute instead of one per delivery. That is exactly the boundary the Webhook Actions plugin draws. For the wider failure modes of running delivery on shared cron infrastructure, see why WordPress webhooks silently fail in production.

→

Webhook Actions keeps outbound delivery in its own queue with smart retry and a full attempt log — so a busy WooCommerce store's scheduled-action table carries one queue-runner row per minute instead of one row per webhook delivery.

/Footnotes
¹ Action Scheduler intermittent deadlock discussion: github.com/woocommerce/action-scheduler/issues/530.
² Slow claim query / lock-wait timeouts: github.com/woocommerce/action-scheduler/issues/1104.
³ Index added to reduce deadlocks in Action Scheduler 3.2.0.
FAQ

Common questions always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

What causes the "Deadlock found when trying to get lock" error in WooCommerce? +
It is almost always Action Scheduler. Two or more queue runners issue an UPDATE on wp_actionscheduler_actions to claim pending rows at the same instant, and InnoDB kills one transaction with error 1213 to break the lock cycle. The failing statement stamps a claim_id on pending rows (SET claim_id = ...). Since Action Scheduler 3.9.3 the claim uses SKIP LOCKED on MySQL 8.0.1+ or MariaDB 10.6+, so these deadlocks are mostly seen on older Action Scheduler or database versions.
Is an Action Scheduler deadlock dangerous or does it lose data? +
No. A MySQL deadlock rolls back one transaction cleanly; the rows are untouched and Action Scheduler re-claims them on the next tick. An occasional deadlock under heavy load is expected. Only sustained spikes indicate a real problem worth tuning for.
How do I reduce Action Scheduler deadlocks? +
Upgrade to Action Scheduler 3.9.3 or newer (any current WooCommerce) on MySQL 8.0.1+ or MariaDB 10.6+ so claims use SKIP LOCKED, make sure nothing raised action_scheduler_queue_runner_concurrent_batches above its default of 1, lower the batch size so the claim lock is held briefly, and drive the queue from a single cron source instead of both WP-Cron and a system cron.
What is the claim_id column in wp_actionscheduler_actions? +
claim_id is how a queue runner reserves a batch of actions. claim_actions() stamps a unique claim_id on up to batch_size pending rows so other runners skip them; the rows stay pending and each action is switched to in-progress only when it runs. release_claim() clears the claim on still-pending actions when the batch finishes; if the runner dies, the queue cleaner resets the stale claim after 5 minutes.
Does upgrading Action Scheduler fix deadlocks? +
It helps significantly. Action Scheduler 3.2.0 added an index to reduce deadlocks, 3.5.4 reworked release_claim() to avoid a claim/release deadlock, and 3.9.3 switched claiming to SELECT ... FOR UPDATE SKIP LOCKED, so overlapping runners skip each other's rows. SKIP LOCKED needs MySQL 8.0.1+ or MariaDB 10.6+; on older servers the old locking path remains. Keeping concurrent batches at the default of 1 reduces collisions further but does not remove them entirely.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate