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.
- Fastest fix: set
action_scheduler_queue_runner_concurrent_batchesto 1 so one runner claims at a time, and use a smaller batch size to hold the lock for less time. - Make sure you are on Action Scheduler 3.2.0 or newer — it added an index that cut claim-query lock contention.
- 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. Each runner issues a single statement like UPDATE wp_actionscheduler_actions SET status = 'in-progress', claim_id = N ... ORDER BY ... LIMIT 25. InnoDB takes row and gap locks in the order rows match the query. When two runners lock the same rows in opposite order, InnoDB detects the cycle and kills one transaction with Deadlock found when trying to get lock; try restarting transaction — MySQL error 1213.
On a busy WooCommerce store the pending table is large and several runners fire every minute, 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 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 onto up to batch_size pending rows and flips them to in-progress in one UPDATE. That stamp is what lets several runners work the same queue without ever executing the same action twice.
When the batch finishes — or the runner process dies — release_claim() clears that claim_id so any unprocessed actions return to the pool for another runner. 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.
/ Multipliers
What makes concurrent runners collide?
Three things widen the collision window. First, action_scheduler_queue_runner_concurrent_batches greater than 1 dispatches several loopback runners per cron tick — 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.
- Upgrade Action Scheduler to 3.2.0 or newer. That release added an index to the actions table specifically to reduce claim-query contention — see the Action Scheduler 3.2.0 release notes. WooCommerce bundles its own copy, so update WooCommerce too.
- Drop concurrent batches to 1. If you do not need the parallel throughput, serialize the claim so only one runner reserves rows at a time. This removes the collision entirely.
- 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.
- 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 // One runner claims at a time — removes the claim-overlap deadlock. add_filter( 'action_scheduler_queue_runner_concurrent_batches', function () { return 1; } ); // 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 that sets status and claim_id. If the failing statement targets that table and column pair, it is the claim. 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 queue-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.
| Concern | Hand-tuned Action Scheduler | Webhook Actions |
|---|---|---|
| Concurrency | Multiple loopback runners claim the same rows, triggering 1213 deadlocks | Single persistent delivery queue with a serialized claim |
| On deadlock | Action left in-progress until the claim times out | Automatic retry with exponential backoff, fully logged |
| Visibility | grep the MySQL error log to find the failing UPDATE | Per-attempt delivery log in wp-admin with response codes |
/ 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 that cross-contention — your deliveries never lock against WooCommerce's housekeeping. 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 actions and your webhook deliveries never deadlock against each other.