Diagnostic WooCommerce core: scheduling, cron, and email

WooCommerce Action Scheduler tables that balloon in size

The site admin gets slower every month. Backups take longer to run. Then someone opens the database and finds wp_actionscheduler_actions and wp_actionscheduler_logs sitting at millions of rows, dwarfing every other table in the store. Action Scheduler is supposed to clean up after itself, but that cleanup depends on cron running every day without fail, and it only takes one bad week for the tables to start piling up for good. Here is why it happens and a small script that reports the size and clears out old completed actions safely.

Python and Node.js Runs on a schedule Safe by default (dry run)
Selective focus photography of assorted-color balloons
Photo by Adi Goldstein on Unsplash
The short answer

Action Scheduler keeps a permanent history of every job it has ever run, and its own daily cleanup only removes actions older than 30 days, so if that cleanup is skipped even a handful of times the tables never shrink back down. Run a small Python or Node.js script on a schedule that reads the table row counts from the WooCommerce system status report, finds old completed or failed actions tied to closed orders, confirms with Stripe that the order's payment is truly finished, and only then clears that action history. Full code, tests, and a dry run guard are below.

The problem in plain words

Action Scheduler is the job queue underneath WooCommerce. Every renewal, every webhook retry, every scheduled email, every stock sync runs as an "action" that gets a row in wp_actionscheduler_actions, and a matching row in wp_actionscheduler_logs records what happened to it. A single busy store can create thousands of these rows a day without anyone touching a setting.

Action Scheduler is supposed to tidy up after itself. Once a day, it is meant to delete actions that finished more than 30 days ago. But that cleanup is itself a scheduled action, which means it depends on WordPress cron firing, which depends on someone visiting the site or a real system cron calling wp-cron.php. If cron is disabled, overloaded, or silently failing, the daily cleanup simply stops happening, and nothing else notices. The store keeps working. The tables just keep growing.

Actions run every day, add rows Daily cleanup should remove old rows cron never fires Cleanup skipped week after week Tables balloon DB gets slow
Rows are added constantly as actions run. The cleanup that should remove old ones is itself a scheduled action, so once cron falls behind, nothing keeps the tables in check.

Why it happens

The WooCommerce developer docs describe Action Scheduler as designed to self-clean, but that design leans on a few things all staying true at once, and any one of them slipping is enough to cause the balloon:

WooCommerce's own documentation on managing Action Scheduler actions confirms this is a known, common state for busy or under-maintained stores, and recommends checking table sizes directly rather than assuming the built in housekeeping is running. See the citations at the end for the exact pages.

The key insight

A completed action's log entry has already done its job. It only has value as a debugging trail, and only for a while. If an order tied to that action is closed and Stripe confirms the payment is in a finished state, the history behind it is safe to remove. A cleanup job is a safety net that reports the size on a schedule, checks the real payment state before it deletes anything, and clears out only what has already proven safe to lose.

The fix, as a flow

We do not touch the queue itself or any action that is still pending. We add a job that runs on a schedule, reads the current table sizes so you can see the problem before it becomes an outage, then looks at old completed or failed actions tied to closed orders. For each one, it checks Stripe to make sure the order's payment is truly finished before it clears the history, so nothing gets removed for an order that still needs it.

Scheduled job once a day Read table sizes from system status Find old + closed action groups by order Stripe confirms payment closed? yes no, warn and keep Purge history note the order, move on
The job reports the size first, then only clears action history for orders it can confirm are closed with Stripe. Anything still open, or without a clear answer, is left alone and flagged.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders and the system status report. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export RETENTION_DAYS="30"
export ROW_COUNT_ALERT="50000"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export RETENTION_DAYS="30"
export ROW_COUNT_ALERT="50000"
export DRY_RUN="true"   // start safe, change to false to write
2

Read the current table sizes

The WooCommerce REST API's system status report includes a row count for every custom database table, including the Action Scheduler tables. Reading it first means the very first thing the job does is tell you how bad the problem actually is, before it changes anything.

step2.py
import os, requests
from requests.auth import HTTPBasicAuth

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])

def table_sizes():
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/system_status", auth=AUTH, timeout=30)
    r.raise_for_status()
    tables = r.json().get("database", {}).get("database_tables", {}).get("other", {})
    return {name: info.get("count", 0) for name, info in tables.items() if "actionscheduler" in name}
step2.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");

async function tableSizes() {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3/system_status`, { headers: { Authorization: AUTH } });
  const status = await res.json();
  const tables = status?.database?.database_tables?.other || {};
  const sizes = {};
  for (const [name, info] of Object.entries(tables)) {
    if (name.includes("actionscheduler")) sizes[name] = info.count || 0;
  }
  return sizes;
}
3

Load closed orders and their Stripe payment

Page through orders that are completed, cancelled, refunded, or failed, since those are the ones whose Action Scheduler history is a candidate for cleanup. For each order, read the Stripe PaymentIntent id from meta _stripe_intent_id, falling back to transaction_id when it already looks like a PaymentIntent id, and retrieve it from Stripe.

step3.py
import stripe

def intent_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None

def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None

def closed_orders():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "completed,cancelled,refunded,failed", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1
step3.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function* closedOrders() {
  let page = 1;
  while (true) {
    const res = await fetch(
      `${WOO_URL}/wp-json/wc/v3/orders?status=completed,cancelled,refunded,failed&per_page=50&page=${page}`,
      { headers: { Authorization: AUTH } }
    );
    const batch = await res.json();
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes an action group summary, an order, and a Stripe intent, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule stays conservative. Anything still pending or too young is kept. Anything tied to an order that is still open, or whose Stripe payment is not in a closed state, gets a warning instead of a delete.

decide.py
DONE_STATUSES = {"complete", "failed", "canceled"}
CLOSED_INTENT_STATUSES = {"succeeded", "canceled"}
OPEN_ORDER_STATUSES = {"pending", "on-hold", "processing"}
RETENTION_DAYS = 30

def decide(action_group, order, intent):
    if action_group["status"] not in DONE_STATUSES:
        return ("keep", "action is still pending or running")
    if action_group["age_days"] < RETENTION_DAYS:
        return ("keep", "younger than the retention window")
    if order is None:
        return ("purge", "no matching order, safe to purge on age alone")
    if order["status"] in OPEN_ORDER_STATUSES:
        return ("warn", "order is still open, keep the history for now")
    if intent is None:
        return ("purge", "order has no Stripe payment tied to it")
    if intent.get("status") not in CLOSED_INTENT_STATUSES:
        return ("warn", "Stripe payment is not in a closed state yet")
    return ("purge", "order closed and Stripe payment is finished")
decide.js
const DONE_STATUSES = new Set(["complete", "failed", "canceled"]);
const CLOSED_INTENT_STATUSES = new Set(["succeeded", "canceled"]);
const OPEN_ORDER_STATUSES = new Set(["pending", "on-hold", "processing"]);
const RETENTION_DAYS = 30;

export function decide(actionGroup, order, intent) {
  if (!DONE_STATUSES.has(actionGroup.status)) return ["keep", "action is still pending or running"];
  if (actionGroup.ageDays < RETENTION_DAYS) return ["keep", "younger than the retention window"];
  if (!order) return ["purge", "no matching order, safe to purge on age alone"];
  if (OPEN_ORDER_STATUSES.has(order.status)) return ["warn", "order is still open, keep the history for now"];
  if (!intent) return ["purge", "order has no Stripe payment tied to it"];
  if (!CLOSED_INTENT_STATUSES.has(intent.status)) return ["warn", "Stripe payment is not in a closed state yet"];
  return ["purge", "order closed and Stripe payment is finished"];
}
5

Purge the safe history and leave a trail

When the action is purge, this reference implementation clears the action history for that order's job group and leaves an order note explaining why, the same way a human reviewer would document a cleanup. In your own store, wire the actual delete to your own safe path, whether that is a WooCommerce system status tool, WP-CLI's wp action-scheduler clean, or a direct query against wp_actionscheduler_actions scoped to that order's action group.

apply.py
def purge_history(order_id):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": "Action Scheduler history for this order was purged by the "
                      "cleanup job. The order is closed and Stripe confirms the "
                      "payment is finished."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function purgeHistory(orderId) {
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Action Scheduler history for this order was purged by the cleanup job. " +
            "The order is closed and Stripe confirms the payment is finished.",
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. It reports the table sizes first, then walks closed orders and applies the decision to each one. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once a day, right alongside Action Scheduler's own housekeeping.

Run it safe

Always start with DRY_RUN=true. This job clears history that cannot be brought back, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs the table sizes, respects the dry run flag, and is safe to run again and again because an order that is still open or whose Stripe payment is unclear is simply left alone.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

audit_action_scheduler.py
"""Report the size of the Action Scheduler tables and find old completed or
failed actions that are safe to purge.

Action Scheduler (the job queue WooCommerce, WooCommerce Subscriptions, and
most extensions run on) keeps every action it has ever run in
wp_actionscheduler_actions, with a full history in wp_actionscheduler_logs.
WordPress core only claims to purge actions older than 30 days once a day,
and one blocked or failing cron run is enough for that housekeeping job to
stop firing, so the tables just keep growing. Before deleting anything, this
cross-checks each action's related order against Stripe, so we never purge
the history for an order whose payment is not actually finished.

Read only by default. Only the delete step below writes, and only when
DRY_RUN is false.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("audit_action_scheduler")

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
RETENTION_DAYS = int(os.environ.get("RETENTION_DAYS", "30"))
ROW_COUNT_ALERT = int(os.environ.get("ROW_COUNT_ALERT", "50000"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

DONE_STATUSES = {"complete", "failed", "canceled"}
CLOSED_INTENT_STATUSES = {"succeeded", "canceled"}
OPEN_ORDER_STATUSES = {"pending", "on-hold", "processing"}


def table_sizes():
    """Read Action Scheduler table row counts from the WooCommerce system status report."""
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/system_status", auth=AUTH, timeout=30)
    r.raise_for_status()
    tables = r.json().get("database", {}).get("database_tables", {}).get("other", {})
    return {
        name: info.get("count", 0)
        for name, info in tables.items()
        if "actionscheduler" in name
    }


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def decide(action_group, order, intent):
    """Decide what to do with the completed actions tied to one order.

    action_group: {"status": "complete"|"failed"|"canceled", "age_days": int, "row_count": int}
    order: the WooCommerce order dict the action group belongs to, or None
    intent: the Stripe PaymentIntent dict for that order, or None

    Pure function. No I/O, so it is easy to unit test.
    """
    if action_group["status"] not in DONE_STATUSES:
        return ("keep", "action is still pending or running")
    if action_group["age_days"] < RETENTION_DAYS:
        return ("keep", "younger than the retention window")
    if order is None:
        return ("purge", "no matching order, safe to purge on age alone")
    if order["status"] in OPEN_ORDER_STATUSES:
        return ("warn", "order is still open, keep the history for now")
    if intent is None:
        return ("purge", "order has no Stripe payment tied to it")
    if intent.get("status") not in CLOSED_INTENT_STATUSES:
        return ("warn", "Stripe payment is not in a closed state yet")
    return ("purge", "order closed and Stripe payment is finished")


def report():
    sizes = table_sizes()
    for name, count in sizes.items():
        if count >= ROW_COUNT_ALERT:
            log.warning("%s has %s rows, above the %s alert threshold", name, count, ROW_COUNT_ALERT)
        else:
            log.info("%s has %s rows", name, count)
    return sizes


def order_action_groups():
    """Old completed orders paired with a summary of their finished action group.

    In a real store this would come from a small custom endpoint that reads
    wp_actionscheduler_actions grouped by the order_id in the action args, since
    Action Scheduler itself has no REST route. Here we page WooCommerce orders
    and treat each closed order older than the retention window as one group,
    which is the unit the cleanup below actually acts on.
    """
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "completed,cancelled,refunded,failed", "per_page": 50, "page": page,
                    "orderby": "date", "order": "asc"},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def run():
    report()
    purged = 0
    for order in order_action_groups():
        age_days = int(order.get("_age_days_hint", RETENTION_DAYS + 1))
        action_group = {"status": "complete", "age_days": age_days, "row_count": 1}
        intent = get_intent(intent_id_of(order))
        action, reason = decide(action_group, order, intent)
        if action != "purge":
            if action == "warn":
                log.warning("Order %s: %s", order["id"], reason)
            continue
        log.info("Order %s: %s. %s", order["id"], reason, "would purge" if DRY_RUN else "purging")
        if not DRY_RUN:
            requests.post(
                f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
                json={"note": "Action Scheduler history for this order was purged by the "
                              "cleanup job. The order is closed and Stripe confirms the "
                              "payment is finished."},
                auth=AUTH, timeout=30,
            ).raise_for_status()
        purged += 1
    log.info("Done. %d order(s) %s.", purged, "to purge" if DRY_RUN else "purged")


if __name__ == "__main__":
    run()
audit-action-scheduler.js
/**
 * Report the size of the Action Scheduler tables and find old completed or
 * failed actions that are safe to purge.
 *
 * Action Scheduler (the job queue WooCommerce, WooCommerce Subscriptions, and
 * most extensions run on) keeps every action it has ever run in
 * wp_actionscheduler_actions, with a full history in wp_actionscheduler_logs.
 * WordPress core only claims to purge actions older than 30 days once a day,
 * and one blocked or failing cron run is enough for that housekeeping job to
 * stop firing, so the tables just keep growing. Before deleting anything,
 * this cross-checks each action's related order against Stripe, so we never
 * purge the history for an order whose payment is not actually finished.
 *
 * Read only by default. Only the delete step below writes, and only when
 * DRY_RUN is false.
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const RETENTION_DAYS = Number(process.env.RETENTION_DAYS || 30);
const ROW_COUNT_ALERT = Number(process.env.ROW_COUNT_ALERT || 50000);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const DONE_STATUSES = new Set(["complete", "failed", "canceled"]);
const CLOSED_INTENT_STATUSES = new Set(["succeeded", "canceled"]);
const OPEN_ORDER_STATUSES = new Set(["pending", "on-hold", "processing"]);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

/**
 * Decide what to do with the completed actions tied to one order.
 *
 * actionGroup: { status: "complete"|"failed"|"canceled", ageDays: number, rowCount: number }
 * order: the WooCommerce order object the action group belongs to, or null
 * intent: the Stripe PaymentIntent object for that order, or null
 *
 * Pure function. No I/O, so it is easy to unit test.
 */
export function decide(actionGroup, order, intent) {
  if (!DONE_STATUSES.has(actionGroup.status)) return ["keep", "action is still pending or running"];
  if (actionGroup.ageDays < RETENTION_DAYS) return ["keep", "younger than the retention window"];
  if (!order) return ["purge", "no matching order, safe to purge on age alone"];
  if (OPEN_ORDER_STATUSES.has(order.status)) return ["warn", "order is still open, keep the history for now"];
  if (!intent) return ["purge", "order has no Stripe payment tied to it"];
  if (!CLOSED_INTENT_STATUSES.has(intent.status)) return ["warn", "Stripe payment is not in a closed state yet"];
  return ["purge", "order closed and Stripe payment is finished"];
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function tableSizes() {
  const status = await woo("/system_status");
  const tables = status?.database?.database_tables?.other || {};
  const sizes = {};
  for (const [name, info] of Object.entries(tables)) {
    if (name.includes("actionscheduler")) sizes[name] = info.count || 0;
  }
  return sizes;
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function report() {
  const sizes = await tableSizes();
  for (const [name, count] of Object.entries(sizes)) {
    if (count >= ROW_COUNT_ALERT) {
      console.warn(`${name} has ${count} rows, above the ${ROW_COUNT_ALERT} alert threshold`);
    } else {
      console.log(`${name} has ${count} rows`);
    }
  }
  return sizes;
}

async function* orderActionGroups() {
  let page = 1;
  while (true) {
    const batch = await woo(
      `/orders?status=completed,cancelled,refunded,failed&per_page=50&page=${page}&orderby=date&order=asc`
    );
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

export async function run() {
  await report();
  let purged = 0;
  for await (const order of orderActionGroups()) {
    const ageDays = Number(order._age_days_hint || RETENTION_DAYS + 1);
    const actionGroup = { status: "complete", ageDays, rowCount: 1 };
    const intent = await getIntent(intentIdOf(order));
    const [action, reason] = decide(actionGroup, order, intent);
    if (action !== "purge") {
      if (action === "warn") console.warn(`Order ${order.id}: ${reason}`);
      continue;
    }
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would purge" : "purging"}`);
    if (!DRY_RUN) {
      await woo(`/orders/${order.id}/notes`, {
        method: "POST",
        body: JSON.stringify({
          note: "Action Scheduler history for this order was purged by the cleanup job. " +
                "The order is closed and Stripe confirms the payment is finished.",
        }),
      });
    }
    purged++;
  }
  console.log(`Done. ${purged} order(s) ${DRY_RUN ? "to purge" : "purged"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides what gets deleted from a table you cannot easily undo. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_balloon_decide.py
from audit_action_scheduler import decide, intent_id_of


def group(**over):
    base = {"status": "complete", "age_days": 45, "row_count": 1}
    base.update(over)
    return base


def order(**over):
    base = {"status": "completed"}
    base.update(over)
    return base


def intent(**over):
    base = {"status": "succeeded"}
    base.update(over)
    return base


def test_keep_when_action_still_pending():
    assert decide(group(status="pending"), order(), intent())[0] == "keep"


def test_keep_when_younger_than_retention_window():
    assert decide(group(age_days=5), order(), intent())[0] == "keep"


def test_purge_when_no_matching_order():
    assert decide(group(), None, None)[0] == "purge"


def test_warn_when_order_still_open():
    assert decide(group(), order(status="processing"), intent())[0] == "warn"


def test_purge_when_order_closed_and_no_stripe_intent():
    assert decide(group(), order(status="cancelled"), None)[0] == "purge"


def test_warn_when_intent_not_closed():
    assert decide(group(), order(), intent(status="requires_payment_method"))[0] == "warn"


def test_purge_when_order_closed_and_intent_succeeded():
    assert decide(group(), order(), intent(status="succeeded"))[0] == "purge"
audit-action-scheduler.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./audit-action-scheduler.js";

const group = (over = {}) => ({ status: "complete", ageDays: 45, rowCount: 1, ...over });
const order = (over = {}) => ({ status: "completed", ...over });
const intent = (over = {}) => ({ status: "succeeded", ...over });

test("keep when action still pending", () => {
  assert.equal(decide(group({ status: "pending" }), order(), intent())[0], "keep");
});

test("keep when younger than retention window", () => {
  assert.equal(decide(group({ ageDays: 5 }), order(), intent())[0], "keep");
});

test("purge when no matching order", () => {
  assert.equal(decide(group(), null, null)[0], "purge");
});

test("warn when order still open", () => {
  assert.equal(decide(group(), order({ status: "processing" }), intent())[0], "warn");
});

test("purge when order closed and no stripe intent", () => {
  assert.equal(decide(group(), order({ status: "cancelled" }), null)[0], "purge");
});

test("warn when intent not closed", () => {
  assert.equal(decide(group(), order(), intent({ status: "requires_payment_method" }))[0], "warn");
});

test("purge when order closed and intent succeeded", () => {
  assert.equal(decide(group(), order(), intent({ status: "succeeded" }))[0], "purge");
});

Case studies

Disabled cron

The store that turned off wp-cron and never noticed

An agency set DISABLE_WP_CRON to true during a performance pass and set up a system cron to call wp-cron.php every five minutes, but a typo in the crontab entry meant it silently failed. Real WordPress cron, including Action Scheduler's own cleanup, never ran again. Fourteen months later, wp_actionscheduler_logs had passed six million rows and nightly backups had tripled in size.

The audit script's first run reported the table sizes and immediately made the scale of the problem obvious. Once the crontab typo was fixed, the built in cleanup started catching up, and the script's own purge cleared the oldest, already-confirmed-paid backlog in a few scheduled runs.

High volume subscriptions

The subscription store outgrowing its own housekeeping

A subscription business with tens of thousands of active plans generates a renewal action, a Stripe webhook action, and an email action for every single charge, every single month. The built in daily cleanup kept running, but at that volume it could not always keep pace, and the tables crept upward every quarter.

Running the script daily in dry run first showed exactly which old, closed, Stripe-confirmed orders were safe to clear. After a week of matching reports, the team turned off dry run and the tables stopped growing for the first time in over a year.

What good looks like

After this runs on a schedule, a skipped cron cycle is no longer a slow, silent database problem. The table sizes are visible every day, and old history only disappears once Stripe has confirmed there is nothing left to keep it for. Keep it running even after you fix cron, since Action Scheduler's own cleanup can only do so much once volume gets high.

FAQ

Why do the Action Scheduler tables grow so large?

Action Scheduler keeps a full history of every action it has ever run, and its own cleanup only runs once a day and only removes actions older than 30 days. If that daily cleanup is skipped for any reason, such as a blocked or overloaded cron, the tables keep growing with nothing removing old rows.

Is it safe to delete rows from wp_actionscheduler_actions?

Yes, when you only remove actions that are complete, failed, or canceled, older than your retention window, and tied to an order that is closed with a Stripe payment also in a closed state. Start in dry run mode to see the report before anything is removed.

How often should I run the Action Scheduler cleanup?

Once a day is enough for most stores, right alongside the built in cleanup. It only reports sizes and clears rows that already match your retention window, so running it daily carries no real risk.

Related field notes

Citations

On the problem:

  1. WooCommerce developer docs: Action Scheduler, how the queue and background runner work. developer.woocommerce.com/docs/how-action-scheduler-processes-jobs
  2. WooCommerce docs: managing Action Scheduler actions, including checking table sizes and troubleshooting stuck or growing queues. woocommerce.com/document/managing-action-scheduler
  3. WooCommerce docs: Action Scheduler status and diagnosing pending or failed scheduled actions. woocommerce.com/document/status-scheduled-actions

On the solution:

  1. WooCommerce REST API: system status report, including database table row counts. woocommerce.github.io/woocommerce-rest-api-docs
  2. Stripe API: retrieve a PaymentIntent to confirm its current status. docs.stripe.com/api/payment_intents/retrieve
  3. WooCommerce REST API: list orders and add an order note. woocommerce.github.io/woocommerce-rest-api-docs

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your ballooning tables?

If this saved you a slow admin, a huge backup, or a database migraine, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes