wp-action-scheduler
Design and review Action Scheduler jobs in WordPress plugins
Install
npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/plugin-scaffold/wp-action-scheduler
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
git clone https://github.com/Lonsdale201/wp-agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
WordPress plugin: Action Scheduler
Action Scheduler is a WordPress-native job queue used by WooCommerce and many
high-volume plugins. It is not just a nicer wp_schedule_event() wrapper: it
stores actions in queue tables, tracks status, claims batches, logs attempts,
supports groups, and can run through WP-Cron, async loopback requests, admin
tools, and WP-CLI.
Use wp-plugin-cron first when the main question is "native WP-Cron or Action
Scheduler?". Use this skill once the answer is Action Scheduler or the plugin
already depends on it.
When to load references
- Need copy-ready scheduling/callback patterns for async, single, recurring, cron-expression, unique, chunked, and activation/deactivation flows: read references/api-patterns.md.
- Debugging stuck queues, failed actions, duplicate jobs, table/schema problems, or WP-CLI/admin operations: read references/operational-debugging.md.
Misconception this skill corrects
"Action Scheduler means the callback will run once, soon, and in order."
Wrong mental model. Action Scheduler is an at-least-once background queue. Jobs can run late, fail, be retried manually, be re-created by recurring schedules, or be triggered by WP-CLI/admin tools. Callbacks must be idempotent and should advance durable state, not rely on "this hook only fires once".
When to use this skill
Trigger when ANY of the following is true:
- Scheduling with
as_enqueue_async_action(),as_schedule_single_action(),as_schedule_recurring_action(), oras_schedule_cron_action(). - Replacing many WP-Cron single events with a real queue.
- Building WooCommerce order/customer/subscription/membership background work.
- Reviewing duplicate actions, stuck
pendingactions,failedactions, or oversized queue tables. - Adding WP-CLI or admin debugging instructions for queued jobs.
- Deciding whether to use
$unique,as_has_scheduled_action(), oras_next_scheduled_action()guards.
Dependency and load timing
Action Scheduler can be present as:
- WooCommerce bundled package.
- Standalone plugin.
- Composer package bundled by another plugin.
Do not assume your plugin owns the loaded version. Action Scheduler registers
available versions and initializes the latest registered version. In local
4.0.0, registration happens on plugins_loaded priority 0, initialization
loads the procedural API, and action_scheduler_init fires when the store,
logger, runner, admin view, and recurring scheduler are ready.
Rules:
- Register your job callbacks on every request, early enough for runners:
add_action( 'myplugin/process_order', ... )should not be hidden behind an admin-only screen load. - Schedule actions on or after
action_scheduler_initwhen scheduling at runtime. - In activation hooks, guard with
function_exists( 'as_schedule_single_action' )before calling the API. If Action Scheduler is an optional dependency, fall back to WP-Cron or show an admin notice. - Never schedule at plugin file top-level before WordPress and dependencies load.
add_action( 'action_scheduler_init', static function (): void {
if ( ! as_has_scheduled_action( 'myplugin/hourly_sync', array(), 'myplugin' ) ) {
as_schedule_recurring_action(
time() + HOUR_IN_SECONDS,
HOUR_IN_SECONDS,
'myplugin/hourly_sync',
array(),
'myplugin',
true
);
}
} );
Public API in Action Scheduler 4.0.0
Scheduling functions return an action ID as int; 0 means scheduling failed.
| Function | Use |
|---|---|
as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) |
Run once as soon as possible. |
as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) |
Run once at/after a Unix timestamp. |
as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) |
Fixed interval recurrence in seconds. |
as_schedule_cron_action( $timestamp, $schedule, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) |
Cron-expression recurrence. |
as_unschedule_action( $hook, $args = array(), $group = '' ) |
Cancel the next pending matching action. |
as_unschedule_all_actions( $hook, $args = array(), $group = '' ) |
Cancel all pending matching actions. |
as_next_scheduled_action( $hook, $args = null, $group = '' ) |
Return next timestamp, true for async/running, or false. |
as_has_scheduled_action( $hook, $args = null, $group = '' ) |
Efficient boolean check for pending/running actions. |
as_get_scheduled_actions( $args = array(), $return_format = OBJECT ) |
Query actions by hook, group, status, date, etc. |
as_get_datetime_object( $date_string = null, $timezone = 'UTC' ) |
Build AS DateTime object for queries. |
as_supports( $feature ) |
Feature detection. In 4.0.0 it supports ensure_recurring_actions_hook. |
The $priority parameter is queue priority, not callback priority. Lower
numbers run first; 4.0.0 expects 0-255 and defaults to 10.
Hook, args, and group rules
- Use namespaced hook names:
myplugin/process_order, notprocess_order. - Use one stable group per plugin or feature:
myplugin,myplugin-import,myplugin-webhooks. - Keep action args small and JSON-serializable. The 4.0.0 DB store can keep
larger encoded args in
extended_args, but validates against an 8000-character encoded limit and still hashes/indexes args for lookup. - Pass identifiers, not large DTOs,
WC_Orderobjects, full API payloads, or secrets. Reload current state inside the callback. - Callback args are passed positionally with
do_action_ref_array( $hook, array_values( $args ) ). Associative keys are for storage/query readability, not named parameter delivery. - Unscheduling matches the hook/args/group combination you pass. For per-entity jobs, pass the exact args. For deactivation, prefer canceling a plugin-owned group with an empty hook if the group is exclusive to your plugin.
as_enqueue_async_action(
'myplugin/process_order',
array( 'order_id' => 123 ),
'myplugin'
);
add_action(
'myplugin/process_order',
static function ( int $order_id ): void {
myplugin_process_order( $order_id );
},
10,
1
);
Unique actions
In Action Scheduler 4.0's DBStore, $unique = true suppresses insertion when a
pending or running action has the same hook + group + encoded args. The JSON
representation is part of the identity: key insertion order and scalar types
matter. Large args use the stored hash in the indexed args column.
This changed from Action Scheduler 3.x, whose DBStore checked hook and group but
not args. Do not claim one meaning across unknown active versions or alternative
stores. Inspect the runtime version/source and use exact
as_has_scheduled_action( $hook, $args, $group ) checks as a compatibility
guard when supporting mixed 3.x/4.x environments.
On 4.0, $unique = true is useful for both global empty-arg coordinators and
per-entity queue rows with canonical args. It is still not a durable business
lock or exactly-once guarantee: callbacks remain idempotent and remote side
effects need provider idempotency or an owned atomic state transition.
if ( function_exists( 'as_enqueue_async_action' ) ) {
$ref = new ReflectionFunction( 'as_enqueue_async_action' );
if ( $ref->getNumberOfParameters() >= 4 ) {
as_enqueue_async_action( 'myplugin/reindex', array(), 'myplugin', true );
} elseif ( ! as_has_scheduled_action( 'myplugin/reindex', array(), 'myplugin' ) ) {
as_enqueue_async_action( 'myplugin/reindex', array(), 'myplugin' );
}
}
Do not use uniqueness as the only safety mechanism for payment capture, inventory mutation, email send, or external API side effects. The callback must still check durable state.
$args = array( 'order_id' => $order_id );
if ( ! as_has_scheduled_action( 'myplugin/process_order', $args, 'myplugin' ) ) {
as_enqueue_async_action( 'myplugin/process_order', $args, 'myplugin' );
}
Idempotent callback pattern
add_action(
'myplugin/capture_payment',
static function ( int $order_id ): void {
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
if ( 'yes' === $order->get_meta( '_myplugin_payment_captured', true ) ) {
return;
}
myplugin_capture_payment_for_order( $order );
$order->update_meta_data( '_myplugin_payment_captured', 'yes' );
$order->save();
},
10,
1
);
For failures that should be retried or surfaced, throw an exception. For permanent no-op cases, return cleanly. Swallowing all exceptions makes failures look complete and hides broken jobs from the admin UI and logs.
Recurring actions
For plugin-owned recurring actions:
- Register the callback on every request.
- Schedule idempotently on activation and/or
action_scheduler_init. - Clear on deactivation with exact hook, args, and group.
- Use
action_scheduler_ensure_recurring_actionsin AS 3.9.3+ when you need a repair hook for recurring actions that may have been deleted manually.
add_action( 'action_scheduler_ensure_recurring_actions', static function (): void {
if ( ! function_exists( 'as_supports' ) || ! as_supports( 'ensure_recurring_actions_hook' ) ) {
return;
}
if ( ! as_has_scheduled_action( 'myplugin/hourly_sync', array(), 'myplugin' ) ) {
as_schedule_recurring_action(
time() + HOUR_IN_SECONDS,
HOUR_IN_SECONDS,
'myplugin/hourly_sync',
array(),
'myplugin',
true
);
}
} );
Statuses, tables, and runner model
Core statuses in 4.0.0:
pendingin-progresscompletefailedcanceled
The DB store uses prefixed tables based on these base names:
actionscheduler_actionsactionscheduler_claimsactionscheduler_groupsactionscheduler_logs
Do not write direct SQL for normal plugin behavior. Use the public API, admin UI, or WP-CLI. Direct SQL is acceptable only for emergency diagnostics with a backup and site-specific approval.
The tables are shared infrastructure, not automatically owned by whichever plugin loaded Action Scheduler first. On deactivation or uninstall, cancel only actions in your plugin's exclusive group (or exact hook/args sets). Never drop the shared tables from a distributed plugin uninstaller: another active plugin may depend on the same queue.
The default queue runner schedules WP-Cron hook action_scheduler_run_queue
every minute and can dispatch async admin-context requests on shutdown. Default
web runner batch size is filterable through
action_scheduler_queue_runner_batch_size and defaults to 25.
WP-CLI and admin debugging
Admin UI: Tools -> Scheduled Actions.
Common WP-CLI commands in 4.0.0:
wp action-scheduler action list --group=myplugin --status=pending
wp action-scheduler action next myplugin/process_order --group=myplugin
wp action-scheduler action get 123 --format=json
wp action-scheduler action logs 123
wp action-scheduler action run 123
wp action-scheduler run --group=myplugin --batch-size=25 --batches=1
wp action-scheduler clean --status=complete,canceled --before='31 days ago'
wp action-scheduler fix-schema
Use WP-CLI for deterministic local/dev runs and for production debugging when the web runner is too slow or loopback requests are blocked.
Action and log retention in 4.0
Action Scheduler 4.0 deletes old rows through a dedicated daily scheduled task, normally around 03:00 site-local time, with bounded batches and continuation actions. The cleaner implementation and its action hooks are internal; do not call them from extension code.
- Completed/canceled actions default to 31-day retention through
action_scheduler_retention_period. - Failed actions default to three 31-day months through
action_scheduler_retention_period_for_failed. action_scheduler_enable_failed_action_cleanupcan disable failed cleanup.
Queue rows/logs are operational evidence, not permanent audit storage. Copy any required accounting or external-delivery record into plugin-owned durable state.
Critical rules
- Use
action_scheduler_initas the safe runtime scheduling point. - Register callbacks on every request, not only inside admin pages or AJAX handlers.
- Keep args small, scalar/array, JSON-serializable, and non-sensitive.
- Treat args as positional callback params; array keys are not named params.
- Prefer plugin-prefixed hook names and stable group names.
- On AS 4.0 DBStore, understand
$uniqueas exact hook+group+encoded-args queue suppression; version-detect before relying on that identity across AS 3.x. - Never treat
$uniqueas exactly-once execution; still make callbacks idempotent. - Use
as_has_scheduled_action()for a boolean guard; useas_next_scheduled_action()only when you need the timestamp. - Throw for real job failures; return for permanent no-op cases.
- Do not mutate AS tables directly in normal plugin code.
- Do not delete shared Action Scheduler tables during plugin uninstall.
- For large workloads, enqueue chunks/cursors instead of one massive action.
Common mistakes
// WRONG - callback hidden in an admin screen, runner cannot find it from WP-Cron.
if ( is_admin() && isset( $_GET['page'] ) && 'myplugin' === $_GET['page'] ) {
add_action( 'myplugin/process_order', 'myplugin_process_order' );
}
// WRONG - passes a large payload and secrets through queued args.
as_enqueue_async_action( 'myplugin/send_payload', $full_api_payload, 'myplugin' );
// WRONG - associative args treated as named callback parameters.
add_action( 'myplugin/process_order', static function ( array $args ): void {
myplugin_process_order( $args['order_id'] );
}, 10, 1 );
// RIGHT - pass ID, reload state, receive positional callback arg.
as_enqueue_async_action(
'myplugin/process_order',
array( 'order_id' => $order_id ),
'myplugin'
);
add_action( 'myplugin/process_order', 'myplugin_process_order', 10, 1 );
// WRONG - this only matches empty-arg actions for this hook+group. It will not
// clear per-order jobs scheduled with array( 'order_id' => $order_id ).
as_unschedule_all_actions( 'myplugin/process_order', array(), 'myplugin' );
// RIGHT - exact hook + args + group for one per-entity job.
as_unschedule_all_actions(
'myplugin/process_order',
array( 'order_id' => $order_id ),
'myplugin'
);
// RIGHT - on deactivation, clear all pending actions in an exclusive
// plugin-owned group.
as_unschedule_all_actions( '', array(), 'myplugin' );
Cross-references
- Run
wp-plugin-cronbefore this when choosing between WP-Cron and Action Scheduler. - Run
wp-plugin-lifecyclefor activation/deactivation structure and multisite activation behavior. - Run
wp-plugin-presenterwhen an action produces admin/REST/email output. - Run
wp-security-auditfor callbacks processing persisted IDs, external API payloads, or user-supplied data.
What this skill does NOT cover
- Building an external queue on Redis, SQS, RabbitMQ, or Beanstalkd.
- Forking or replacing Action Scheduler internals.
- Native WP-Cron basics; use
wp-plugin-cron. - WooCommerce-specific business rules for orders/subscriptions/memberships; combine this with the relevant WooCommerce skill.
Source notes
Validated against Action Scheduler 4.0.0 bundled with local WooCommerce 11.0.0 on 2026-08-05:
functions.phppublic API signatures.ActionScheduler::init()andaction_scheduler_inittiming.ActionScheduler_Action::execute()positional arg delivery.ActionScheduler_Storestatuses and DB store args length behavior.ActionScheduler_QueueRunnerrunner hook and batch size.ActionScheduler_DBStoreargs-aware unique insert.ActionScheduler_QueueCleanerdedicated cleanup and failed retention.- WP-CLI command classes under
classes/WP_CLI.
References
- Official documentation: https://actionscheduler.org
- Official documentation: https://actionscheduler.org/api/
- Official documentation: https://github.com/woocommerce/action-scheduler
Files (wp-agent-skills)
-
references
-
api-patterns.md 6.8 KB
# Action Scheduler API patterns These examples target Action Scheduler 4.0.0 and use the public procedural API. Keep hook names and groups plugin-prefixed. ## Async one-shot Use for "do this soon, off the current request". ```php final class MyPlugin_Order_Jobs { public const GROUP = 'myplugin'; public const HOOK_PROCESS_ORDER = 'myplugin/process_order'; public static function register(): void { add_action( self::HOOK_PROCESS_ORDER, array( self::class, 'process_order' ), 10, 1 ); } public static function enqueue_process_order( int $order_id ): int { if ( ! function_exists( 'as_enqueue_async_action' ) ) { return 0; } $args = array( 'order_id' => $order_id ); return as_enqueue_async_action( self::HOOK_PROCESS_ORDER, $args, self::GROUP, true ); } public static function process_order( int $order_id ): void { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } if ( 'yes' === $order->get_meta( '_myplugin_processed', true ) ) { return; } myplugin_process_order_now( $order ); $order->update_meta_data( '_myplugin_processed', 'yes' ); $order->save(); } } ``` Call `MyPlugin_Order_Jobs::register()` on every request, e.g. during plugin bootstrap. ## Single scheduled action Use for "run once at/after this timestamp". ```php $action_id = as_schedule_single_action( strtotime( '+15 minutes' ), 'myplugin/send_followup_email', array( 'user_id' => $user_id ), 'myplugin' ); if ( 0 === $action_id ) { error_log( 'MyPlugin failed to schedule followup email.' ); } ``` ## Recurring action with activation/deactivation ```php final class MyPlugin_Sync_Schedule { private const GROUP = 'myplugin'; private const HOOK = 'myplugin/hourly_sync'; public static function register(): void { add_action( self::HOOK, array( self::class, 'run' ) ); add_action( 'action_scheduler_init', array( self::class, 'ensure_scheduled' ) ); add_action( 'action_scheduler_ensure_recurring_actions', array( self::class, 'ensure_scheduled' ) ); } public static function activate(): void { if ( function_exists( 'as_schedule_recurring_action' ) ) { self::ensure_scheduled(); } } public static function deactivate(): void { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::HOOK, array(), self::GROUP ); } } public static function ensure_scheduled(): void { if ( ! function_exists( 'as_has_scheduled_action' ) ) { return; } if ( as_has_scheduled_action( self::HOOK, array(), self::GROUP ) ) { return; } as_schedule_recurring_action( time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, self::HOOK, array(), self::GROUP, true ); } public static function run(): void { myplugin_run_hourly_sync(); } } ``` If using `register_activation_hook()`, call `MyPlugin_Sync_Schedule::activate`. If using `register_deactivation_hook()`, call `MyPlugin_Sync_Schedule::deactivate`. ## Cron-expression action Use for calendar-like recurrence that fixed intervals cannot express. ```php as_schedule_cron_action( time(), '5 4 * * *', 'myplugin/daily_report', array(), 'myplugin', true ); ``` The first `$timestamp` delays the first eligible cron-expression match. The cron expression itself decides later recurrences. ## Chunked workload with cursor Use when there may be thousands of records. Do not enqueue one gigantic action. ```php add_action( 'myplugin/import_batch', 'myplugin_import_batch', 10, 2 ); function myplugin_start_import( string $source_id ): int { return as_enqueue_async_action( 'myplugin/import_batch', array( 'source_id' => $source_id, 'cursor' => 0, ), 'myplugin-import', true ); } function myplugin_import_batch( string $source_id, int $cursor ): void { $result = myplugin_import_next_rows( $source_id, $cursor, 100 ); if ( $result->has_more() ) { as_enqueue_async_action( 'myplugin/import_batch', array( 'source_id' => $source_id, 'cursor' => $result->next_cursor(), ), 'myplugin-import' ); } } ``` ## Unscheduling Cancel one exact pending action: ```php as_unschedule_action( 'myplugin/process_order', array( 'order_id' => $order_id ), 'myplugin' ); ``` Cancel all pending empty-arg actions under one hook and group, such as a plugin-owned recurring sync: ```php as_unschedule_all_actions( 'myplugin/hourly_sync', array(), 'myplugin' ); ``` Cancel all pending plugin actions in a group: ```php as_unschedule_all_actions( '', array(), 'myplugin' ); ``` Use broad group cancellation only on deactivation or destructive admin actions, and only if the group is uniquely owned by the plugin. There is no procedural helper for "cancel all actions under this hook and group regardless of args". For per-entity actions, pass exact args. For deactivation, use an exclusive group and cancel by group. ## Querying actions ```php $pending_ids = as_get_scheduled_actions( array( 'hook' => 'myplugin/process_order', 'group' => 'myplugin', 'status' => ActionScheduler_Store::STATUS_PENDING, 'per_page' => 50, 'orderby' => 'date', 'order' => 'ASC', ), 'ids' ); ``` Prefer `as_has_scheduled_action()` for existence checks. Use `as_get_scheduled_actions()` when building debug/admin views or migration tools. ## Unique action compatibility On Action Scheduler 4.0 DBStore, this suppresses the exact pending/running `hook + group + encoded args` identity, so canonical per-entity args work. On Action Scheduler 3.x DBStore, the same flag suppresses by hook+group and can incorrectly block another entity. Use the exact-args pre-check when supporting both generations, but keep the callback idempotent because this is not an exactly-once guarantee. ```php function myplugin_enqueue_exact_compat( string $hook, array $args, string $group ): int { if ( ! function_exists( 'as_enqueue_async_action' ) ) { return 0; } if ( function_exists( 'as_has_scheduled_action' ) && as_has_scheduled_action( $hook, $args, $group ) ) { return 0; } return as_enqueue_async_action( $hook, $args, $group ); } ``` The mixed-version guard can race because check and insert are separate. For plugins that require Action Scheduler 4.0+, call the modern signature directly with `$unique = true` for atomic DBStore suppression of the exact identity. -
operational-debugging.md 6.3 KB
# Action Scheduler operational debugging Use this reference when jobs are late, duplicated, failed, stuck, or invisible. Examples target Action Scheduler 4.0.0. ## First checks 1. Confirm the callback is registered on every request. 2. Confirm the scheduled action hook, args, and group match the callback and queries. 3. Check the admin UI: Tools -> Scheduled Actions. 4. Use WP-CLI to list the queue. ```bash wp action-scheduler action list --group=myplugin --format=table wp action-scheduler action list --group=myplugin --status=failed wp action-scheduler action list --hook=myplugin/process_order --status=pending ``` If actions are pending but not running, inspect runner conditions. Action Scheduler normally runs via `action_scheduler_run_queue` every minute through WP-Cron and also dispatches async admin-context loopback requests. ## Run a controlled batch ```bash wp action-scheduler run --group=myplugin --batch-size=25 --batches=1 ``` Use hook filtering when only one job type should run: ```bash wp action-scheduler run --hooks=myplugin/process_order --batch-size=10 --batches=1 ``` Use `--force` only when you understand why concurrent batch limits were hit: ```bash wp action-scheduler run --group=myplugin --batch-size=25 --batches=1 --force ``` ## Inspect one action ```bash wp action-scheduler action get 123 --format=json wp action-scheduler action logs 123 wp action-scheduler action run 123 ``` If the log says no callbacks are registered, the plugin scheduled an action but does not register `add_action( $hook, ... )` in the runner context. ## Common statuses - `pending`: waiting to run or due but not claimed yet. - `in-progress`: claimed/running. - `complete`: callback finished without throwing. - `failed`: callback threw or validation/fatal monitoring marked failure. - `canceled`: canceled before execution. ## Duplicate jobs Symptoms: - Multiple pending rows with same hook/group. - Same external side effect happens more than once. Likely causes: - No `$unique = true` on AS 4.0 for an exact hook+group+args queue identity. - Guard used wrong args or wrong group. - Activation scheduled repeatedly without a guard. - Callback re-enqueues itself without a cursor/state gate. - Manual rerun from admin or WP-CLI. Debug: ```bash wp action-scheduler action list --hook=myplugin/process_order --group=myplugin --status=pending ``` Fix: - On AS 4.0 DBStore, add `$unique = true` when the exact hook+group+encoded-args identity must have at most one pending/running row. - On mixed AS 3.x/4.x support, use `as_has_scheduled_action( $hook, $args, $group )` as an exact-args compatibility guard and verify which copy/store is active. - Add durable callback idempotency (`_processed` meta, custom table unique key, status transition check, or external idempotency key). ## Failed jobs Failed actions are useful; do not hide them by catching every exception and returning success. Use this callback shape: ```php try { myplugin_do_remote_work( $entity_id ); } catch ( MyPlugin_Permanent_Skip $e ) { return; } catch ( Throwable $e ) { throw $e; } ``` Return only when the action is genuinely complete or permanently unnecessary. Throw when the operator should see and investigate the failure. ## Args are too long The 4.0.0 DB store can store larger encoded args in `extended_args`, but it still validates encoded args against an 8000-character limit. Older stores can be stricter. If you see an error about `ActionScheduler_Action::$args too long`, stop passing payloads. Store payloads in a custom table, option, transient, or external API, then pass only an ID/cursor. Bad: ```php as_enqueue_async_action( 'myplugin/import', $full_payload, 'myplugin' ); ``` Good: ```php $batch_id = myplugin_store_import_payload( $payload ); as_enqueue_async_action( 'myplugin/import', array( 'batch_id' => $batch_id ), 'myplugin' ); ``` ## Schema/table problems Action Scheduler 4.0.0 DB store table base names: - `actionscheduler_actions` - `actionscheduler_claims` - `actionscheduler_groups` - `actionscheduler_logs` They are prefixed by `$wpdb->prefix`, e.g. `wp_actionscheduler_actions`. If tables are missing or mismatched: ```bash wp action-scheduler fix-schema wp action-scheduler data-store ``` Do not ship plugin code that writes these tables directly. Use direct SQL only for emergency diagnostics or one-off repair scripts with a backup. ## Cleanup Action Scheduler 4.0 schedules a dedicated cleanup action daily, normally near 03:00 in the site's local timezone. It processes bounded batches and schedules continuations while a backlog remains. Completed/canceled actions default to 31-day retention; failed actions are now also purged, after three 31-day months by default. Failed rows/logs are therefore not durable audit storage. ```bash wp action-scheduler clean --status=complete,canceled --before='31 days ago' wp action-scheduler clean --status=failed --before='90 days ago' --batch-size=100 ``` The documented controls are `action_scheduler_retention_period`, `action_scheduler_retention_period_for_failed`, and `action_scheduler_enable_failed_action_cleanup`. Do not call the internal cleaner hooks or automatically delete failed actions from distributed plugin runtime code. ## Runner tuning Avoid tuning globals as a first response. Fix callback duration, chunking, and idempotency first. Relevant runner filters in 4.0.0: - `action_scheduler_queue_runner_batch_size` defaults web runner batches to 25. - `action_scheduler_queue_runner_concurrent_batches` defaults concurrent batches to 1. - `action_scheduler_queue_runner_time_limit` controls queue runner time limit. - `action_scheduler_run_schedule` controls the WP-Cron schedule for `action_scheduler_run_queue`. Only add these filters in site-specific operational plugins or documented enterprise deployments. A distributed plugin should not globally raise queue throughput without knowing the host capacity. ## Production incident checklist - List failed actions by group/hook. - Inspect one failed action and logs. - Confirm callbacks are registered in WP-CLI context. - Run a tiny batch with `--batch-size=1 --batches=1`. - Check whether failures are transient or permanent. - Fix the callback, then rerun selected failed actions manually if appropriate. - Add idempotency before rerunning side-effect jobs. - Clean old complete/canceled rows only after the queue is healthy.
-
-
SKILL.md 17.1 KB
--- name: wp-action-scheduler description: Design and review Action Scheduler jobs in WordPress plugins using Action Scheduler 4.0 public APIs - async, single, recurring, and cron-expression actions; action_scheduler_init load timing; hook/args/group naming; args-aware unique identity and priority; idempotent callbacks; chunked workloads; activation/deactivation cleanup; WooCommerce-bundled or standalone dependency usage; admin and WP-CLI debugging; queue runner limits; failed-action retention; and safe operational troubleshooting. Use when a plugin schedules background jobs with as_enqueue_async_action, as_schedule_single_action, as_schedule_recurring_action, as_schedule_cron_action, as_get_scheduled_actions, or integrates with WooCommerce background queues. metadata: wp-skills-author: "Soczo Kristof" wp-skills-contact: "mailto:lonsdale201@hotmail.com" wp-skills-plugin: "action-scheduler" wp-skills-plugin-version-tested: "4.0.0" wp-skills-wp-version-tested: "7.1" wp-skills-php-min: "7.2" wp-skills-last-updated: "2026-08-05" --- # WordPress plugin: Action Scheduler Action Scheduler is a WordPress-native job queue used by WooCommerce and many high-volume plugins. It is not just a nicer `wp_schedule_event()` wrapper: it stores actions in queue tables, tracks status, claims batches, logs attempts, supports groups, and can run through WP-Cron, async loopback requests, admin tools, and WP-CLI. Use `wp-plugin-cron` first when the main question is "native WP-Cron or Action Scheduler?". Use this skill once the answer is Action Scheduler or the plugin already depends on it. ## When to load references - Need copy-ready scheduling/callback patterns for async, single, recurring, cron-expression, unique, chunked, and activation/deactivation flows: read [references/api-patterns.md](references/api-patterns.md). - Debugging stuck queues, failed actions, duplicate jobs, table/schema problems, or WP-CLI/admin operations: read [references/operational-debugging.md](references/operational-debugging.md). ## Misconception this skill corrects > "Action Scheduler means the callback will run once, soon, and in order." Wrong mental model. Action Scheduler is an at-least-once background queue. Jobs can run late, fail, be retried manually, be re-created by recurring schedules, or be triggered by WP-CLI/admin tools. Callbacks must be idempotent and should advance durable state, not rely on "this hook only fires once". ## When to use this skill Trigger when ANY of the following is true: - Scheduling with `as_enqueue_async_action()`, `as_schedule_single_action()`, `as_schedule_recurring_action()`, or `as_schedule_cron_action()`. - Replacing many WP-Cron single events with a real queue. - Building WooCommerce order/customer/subscription/membership background work. - Reviewing duplicate actions, stuck `pending` actions, `failed` actions, or oversized queue tables. - Adding WP-CLI or admin debugging instructions for queued jobs. - Deciding whether to use `$unique`, `as_has_scheduled_action()`, or `as_next_scheduled_action()` guards. ## Dependency and load timing Action Scheduler can be present as: - WooCommerce bundled package. - Standalone plugin. - Composer package bundled by another plugin. Do not assume your plugin owns the loaded version. Action Scheduler registers available versions and initializes the latest registered version. In local 4.0.0, registration happens on `plugins_loaded` priority `0`, initialization loads the procedural API, and `action_scheduler_init` fires when the store, logger, runner, admin view, and recurring scheduler are ready. Rules: - Register your job callbacks on every request, early enough for runners: `add_action( 'myplugin/process_order', ... )` should not be hidden behind an admin-only screen load. - Schedule actions on or after `action_scheduler_init` when scheduling at runtime. - In activation hooks, guard with `function_exists( 'as_schedule_single_action' )` before calling the API. If Action Scheduler is an optional dependency, fall back to WP-Cron or show an admin notice. - Never schedule at plugin file top-level before WordPress and dependencies load. ```php add_action( 'action_scheduler_init', static function (): void { if ( ! as_has_scheduled_action( 'myplugin/hourly_sync', array(), 'myplugin' ) ) { as_schedule_recurring_action( time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, 'myplugin/hourly_sync', array(), 'myplugin', true ); } } ); ``` ## Public API in Action Scheduler 4.0.0 Scheduling functions return an action ID as `int`; `0` means scheduling failed. | Function | Use | |---|---| | `as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false, $priority = 10 )` | Run once as soon as possible. | | `as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false, $priority = 10 )` | Run once at/after a Unix timestamp. | | `as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '', $unique = false, $priority = 10 )` | Fixed interval recurrence in seconds. | | `as_schedule_cron_action( $timestamp, $schedule, $hook, $args = array(), $group = '', $unique = false, $priority = 10 )` | Cron-expression recurrence. | | `as_unschedule_action( $hook, $args = array(), $group = '' )` | Cancel the next pending matching action. | | `as_unschedule_all_actions( $hook, $args = array(), $group = '' )` | Cancel all pending matching actions. | | `as_next_scheduled_action( $hook, $args = null, $group = '' )` | Return next timestamp, `true` for async/running, or `false`. | | `as_has_scheduled_action( $hook, $args = null, $group = '' )` | Efficient boolean check for pending/running actions. | | `as_get_scheduled_actions( $args = array(), $return_format = OBJECT )` | Query actions by hook, group, status, date, etc. | | `as_get_datetime_object( $date_string = null, $timezone = 'UTC' )` | Build AS DateTime object for queries. | | `as_supports( $feature )` | Feature detection. In 4.0.0 it supports `ensure_recurring_actions_hook`. | The `$priority` parameter is queue priority, not callback priority. Lower numbers run first; 4.0.0 expects `0-255` and defaults to `10`. ## Hook, args, and group rules - Use namespaced hook names: `myplugin/process_order`, not `process_order`. - Use one stable group per plugin or feature: `myplugin`, `myplugin-import`, `myplugin-webhooks`. - Keep action args small and JSON-serializable. The 4.0.0 DB store can keep larger encoded args in `extended_args`, but validates against an 8000-character encoded limit and still hashes/indexes args for lookup. - Pass identifiers, not large DTOs, `WC_Order` objects, full API payloads, or secrets. Reload current state inside the callback. - Callback args are passed positionally with `do_action_ref_array( $hook, array_values( $args ) )`. Associative keys are for storage/query readability, not named parameter delivery. - Unscheduling matches the hook/args/group combination you pass. For per-entity jobs, pass the exact args. For deactivation, prefer canceling a plugin-owned group with an empty hook if the group is exclusive to your plugin. ```php as_enqueue_async_action( 'myplugin/process_order', array( 'order_id' => 123 ), 'myplugin' ); add_action( 'myplugin/process_order', static function ( int $order_id ): void { myplugin_process_order( $order_id ); }, 10, 1 ); ``` ## Unique actions In Action Scheduler 4.0's DBStore, `$unique = true` suppresses insertion when a pending or running action has the same `hook + group + encoded args`. The JSON representation is part of the identity: key insertion order and scalar types matter. Large args use the stored hash in the indexed `args` column. This changed from Action Scheduler 3.x, whose DBStore checked hook and group but not args. Do not claim one meaning across unknown active versions or alternative stores. Inspect the runtime version/source and use exact `as_has_scheduled_action( $hook, $args, $group )` checks as a compatibility guard when supporting mixed 3.x/4.x environments. On 4.0, `$unique = true` is useful for both global empty-arg coordinators and per-entity queue rows with canonical args. It is still not a durable business lock or exactly-once guarantee: callbacks remain idempotent and remote side effects need provider idempotency or an owned atomic state transition. ```php if ( function_exists( 'as_enqueue_async_action' ) ) { $ref = new ReflectionFunction( 'as_enqueue_async_action' ); if ( $ref->getNumberOfParameters() >= 4 ) { as_enqueue_async_action( 'myplugin/reindex', array(), 'myplugin', true ); } elseif ( ! as_has_scheduled_action( 'myplugin/reindex', array(), 'myplugin' ) ) { as_enqueue_async_action( 'myplugin/reindex', array(), 'myplugin' ); } } ``` Do not use uniqueness as the only safety mechanism for payment capture, inventory mutation, email send, or external API side effects. The callback must still check durable state. ```php $args = array( 'order_id' => $order_id ); if ( ! as_has_scheduled_action( 'myplugin/process_order', $args, 'myplugin' ) ) { as_enqueue_async_action( 'myplugin/process_order', $args, 'myplugin' ); } ``` ## Idempotent callback pattern ```php add_action( 'myplugin/capture_payment', static function ( int $order_id ): void { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } if ( 'yes' === $order->get_meta( '_myplugin_payment_captured', true ) ) { return; } myplugin_capture_payment_for_order( $order ); $order->update_meta_data( '_myplugin_payment_captured', 'yes' ); $order->save(); }, 10, 1 ); ``` For failures that should be retried or surfaced, throw an exception. For permanent no-op cases, return cleanly. Swallowing all exceptions makes failures look complete and hides broken jobs from the admin UI and logs. ## Recurring actions For plugin-owned recurring actions: - Register the callback on every request. - Schedule idempotently on activation and/or `action_scheduler_init`. - Clear on deactivation with exact hook, args, and group. - Use `action_scheduler_ensure_recurring_actions` in AS 3.9.3+ when you need a repair hook for recurring actions that may have been deleted manually. ```php add_action( 'action_scheduler_ensure_recurring_actions', static function (): void { if ( ! function_exists( 'as_supports' ) || ! as_supports( 'ensure_recurring_actions_hook' ) ) { return; } if ( ! as_has_scheduled_action( 'myplugin/hourly_sync', array(), 'myplugin' ) ) { as_schedule_recurring_action( time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, 'myplugin/hourly_sync', array(), 'myplugin', true ); } } ); ``` ## Statuses, tables, and runner model Core statuses in 4.0.0: - `pending` - `in-progress` - `complete` - `failed` - `canceled` The DB store uses prefixed tables based on these base names: - `actionscheduler_actions` - `actionscheduler_claims` - `actionscheduler_groups` - `actionscheduler_logs` Do not write direct SQL for normal plugin behavior. Use the public API, admin UI, or WP-CLI. Direct SQL is acceptable only for emergency diagnostics with a backup and site-specific approval. The tables are shared infrastructure, not automatically owned by whichever plugin loaded Action Scheduler first. On deactivation or uninstall, cancel only actions in your plugin's exclusive group (or exact hook/args sets). Never drop the shared tables from a distributed plugin uninstaller: another active plugin may depend on the same queue. The default queue runner schedules WP-Cron hook `action_scheduler_run_queue` every minute and can dispatch async admin-context requests on shutdown. Default web runner batch size is filterable through `action_scheduler_queue_runner_batch_size` and defaults to `25`. ## WP-CLI and admin debugging Admin UI: Tools -> Scheduled Actions. Common WP-CLI commands in 4.0.0: ```bash wp action-scheduler action list --group=myplugin --status=pending wp action-scheduler action next myplugin/process_order --group=myplugin wp action-scheduler action get 123 --format=json wp action-scheduler action logs 123 wp action-scheduler action run 123 wp action-scheduler run --group=myplugin --batch-size=25 --batches=1 wp action-scheduler clean --status=complete,canceled --before='31 days ago' wp action-scheduler fix-schema ``` Use WP-CLI for deterministic local/dev runs and for production debugging when the web runner is too slow or loopback requests are blocked. ## Action and log retention in 4.0 Action Scheduler 4.0 deletes old rows through a dedicated daily scheduled task, normally around 03:00 site-local time, with bounded batches and continuation actions. The cleaner implementation and its action hooks are internal; do not call them from extension code. - Completed/canceled actions default to 31-day retention through `action_scheduler_retention_period`. - Failed actions default to three 31-day months through `action_scheduler_retention_period_for_failed`. - `action_scheduler_enable_failed_action_cleanup` can disable failed cleanup. Queue rows/logs are operational evidence, not permanent audit storage. Copy any required accounting or external-delivery record into plugin-owned durable state. ## Critical rules - Use `action_scheduler_init` as the safe runtime scheduling point. - Register callbacks on every request, not only inside admin pages or AJAX handlers. - Keep args small, scalar/array, JSON-serializable, and non-sensitive. - Treat args as positional callback params; array keys are not named params. - Prefer plugin-prefixed hook names and stable group names. - On AS 4.0 DBStore, understand `$unique` as exact hook+group+encoded-args queue suppression; version-detect before relying on that identity across AS 3.x. - Never treat `$unique` as exactly-once execution; still make callbacks idempotent. - Use `as_has_scheduled_action()` for a boolean guard; use `as_next_scheduled_action()` only when you need the timestamp. - Throw for real job failures; return for permanent no-op cases. - Do not mutate AS tables directly in normal plugin code. - Do not delete shared Action Scheduler tables during plugin uninstall. - For large workloads, enqueue chunks/cursors instead of one massive action. ## Common mistakes ```php // WRONG - callback hidden in an admin screen, runner cannot find it from WP-Cron. if ( is_admin() && isset( $_GET['page'] ) && 'myplugin' === $_GET['page'] ) { add_action( 'myplugin/process_order', 'myplugin_process_order' ); } // WRONG - passes a large payload and secrets through queued args. as_enqueue_async_action( 'myplugin/send_payload', $full_api_payload, 'myplugin' ); // WRONG - associative args treated as named callback parameters. add_action( 'myplugin/process_order', static function ( array $args ): void { myplugin_process_order( $args['order_id'] ); }, 10, 1 ); // RIGHT - pass ID, reload state, receive positional callback arg. as_enqueue_async_action( 'myplugin/process_order', array( 'order_id' => $order_id ), 'myplugin' ); add_action( 'myplugin/process_order', 'myplugin_process_order', 10, 1 ); // WRONG - this only matches empty-arg actions for this hook+group. It will not // clear per-order jobs scheduled with array( 'order_id' => $order_id ). as_unschedule_all_actions( 'myplugin/process_order', array(), 'myplugin' ); // RIGHT - exact hook + args + group for one per-entity job. as_unschedule_all_actions( 'myplugin/process_order', array( 'order_id' => $order_id ), 'myplugin' ); // RIGHT - on deactivation, clear all pending actions in an exclusive // plugin-owned group. as_unschedule_all_actions( '', array(), 'myplugin' ); ``` ## Cross-references - Run `wp-plugin-cron` before this when choosing between WP-Cron and Action Scheduler. - Run `wp-plugin-lifecycle` for activation/deactivation structure and multisite activation behavior. - Run `wp-plugin-presenter` when an action produces admin/REST/email output. - Run `wp-security-audit` for callbacks processing persisted IDs, external API payloads, or user-supplied data. ## What this skill does NOT cover - Building an external queue on Redis, SQS, RabbitMQ, or Beanstalkd. - Forking or replacing Action Scheduler internals. - Native WP-Cron basics; use `wp-plugin-cron`. - WooCommerce-specific business rules for orders/subscriptions/memberships; combine this with the relevant WooCommerce skill. ## Source notes Validated against Action Scheduler 4.0.0 bundled with local WooCommerce 11.0.0 on 2026-08-05: - `functions.php` public API signatures. - `ActionScheduler::init()` and `action_scheduler_init` timing. - `ActionScheduler_Action::execute()` positional arg delivery. - `ActionScheduler_Store` statuses and DB store args length behavior. - `ActionScheduler_QueueRunner` runner hook and batch size. - `ActionScheduler_DBStore` args-aware unique insert. - `ActionScheduler_QueueCleaner` dedicated cleanup and failed retention. - WP-CLI command classes under `classes/WP_CLI`. ## References - Official documentation: <https://actionscheduler.org> - Official documentation: <https://actionscheduler.org/api/> - Official documentation: <https://github.com/woocommerce/action-scheduler>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.