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_batchesabove 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.
/ 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.
- 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 LOCKEDand 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 reworkedrelease_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. - 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 --forceignores it, and claim releases and WooCommerce's own writes to the table can still collide. - 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 // 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.
| Concern | Hand-tuned Action Scheduler | Webhook Actions |
|---|---|---|
| Concurrency | Overlapping runners claim the same rows, triggering 1213 deadlocks | Single persistent delivery queue with a serialized claim |
| On failure | Deadlocked claim rolls back; the runner throws and the rows stay pending for the next run | Failed HTTP deliveries retried 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 |
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.