Claude Skill

wp-action-scheduler

Design and review Action Scheduler jobs in WordPress plugins

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-plugin-scaffold_wp-action-scheduler-52f6020.zip · 11 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/plugin-scaffold/wp-action-scheduler
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git 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(), 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.
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.
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_actions in 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:

  • 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:

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

// 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

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.

No comments yet.

Reviews (0)

No reviews yet.

Related