Skip to content

Repair Cron

cron_schedule fills with duplicate pending jobs

You open the cron_schedule table expecting a handful of pending rows per job and instead find dozens, sometimes hundreds, all pending, all for the same job_code. Real cron work gets delayed or starves entirely behind the pile. Here is why Magento's cron generator keeps stacking duplicates and a small script that finds the surplus and prunes it safely, keeping the one job that still needs to run.

Python and Node.js Direct database access Safe by default (dry run)
A black electronic device
Photo by Elimende Inagella on Unsplash
The short answer

Magento's cron generator, Cron\Model\ObserverManager via ProcessCronQueueObserver::_generate(), builds its list of "already scheduled" timestamps by querying cron_schedule for rows in pending status only, and it runs this check on every cron:run pass, by default every minute. Rows stuck in running status are invisible to that check. If a job's previous run is still executing past its slot, whether it is genuinely long running, stuck, or crashed without ever flipping to success or error, the generator does not see it as scheduled and inserts a fresh pending row for the same job_code, over and over. Run a small Python or Node.js script that queries cron_schedule directly, groups pending rows by job_code, and reports or prunes the surplus while always keeping the single earliest pending row per job so it still fires. Full code, tests, and a dry run guard are below.

The problem in plain words

Every time cron runs, Magento's generator asks one question before it inserts new rows: which job codes already have a pending row scheduled at a given time? If the answer is yes, it skips inserting a duplicate. If the answer is no, it inserts one. That check only looks at status = 'pending'.

A job that is currently executing sits in running status, not pending. So the moment a job's run takes longer than its own schedule slot, or its process crashes and never writes back success or error, the generator stops seeing it as already scheduled. On the very next pass, and every pass after that, it inserts another pending row for the same job_code. Combined with schedule_ahead_for pre-populating several minutes of future rows on every pass, the pending backlog for that one job code can grow far faster than anything is consuming it.

Job run stalls status stays running Generator checks pending rows only running row invisible New pending row inserted every minute Duplicates pile up history_cleanup_every only prunes success, error, and missed rows, never pending ones.
Once a job run gets stuck in running status, the generator keeps treating the job as unscheduled and keeps inserting more pending rows for it.

Why it happens

This is a documented property of how the cron generator decides what to insert, not a one-off glitch. A few concrete ways it shows up on real stores:

None of this throws an error a merchant would notice directly. The symptom is indirect: real cron jobs run late or stop running at all, because the queue is dominated by rows for one stuck job_code. See the citations at the end for the exact threads that describe this behavior.

The key insight

There is no webapi.xml route for cron_schedule in module-cron, unlike products or orders it is a plain internal table, not a REST-modeled entity. So detection and repair both have to go straight at the database. And because schedule_lifetime and history_cleanup_every only prune old success, error, and missed rows, they will never retroactively clean an already bloated pending backlog. The corrective action here has to be a guarded manual prune, not a config change.

The fix, as a flow

We do not touch the cron generator or try to make Magento run a reindex-style command. We query cron_schedule for pending rows, group them by job_code, find the true duplicates that share the same scheduled_at, and separately find job codes whose pending backlog has grown past a sane threshold. A pure function decides exactly which schedule_id values to prune, always keeping the single soonest pending row per job. Only when an operator explicitly opts out of dry run does the script execute the delete.

Read pending rows from cron_schedule Group by job_code and scheduled_at Decide prune set pure function DRY_RUN is false? yes DELETE surplus keeps earliest row no Report only prints schedule_ids
The script only ever deletes the surplus pending rows it has decided are duplicates or excess backlog, and it always leaves the earliest pending row per job_code in place.

Build it step by step

1

Connect to the database, not the REST API

cron_schedule has no webapi.xml route, so there is nothing to authenticate over REST for this task. Keep the database connection details in environment variables, never in the file, and use a read-only account for the detection pass if your setup allows it.

setup (shell)
pip install mysql-connector-python

export MAGENTO_DB_HOST="127.0.0.1"
export MAGENTO_DB_NAME="magento"
export MAGENTO_DB_USER="magento"
export MAGENTO_DB_PASSWORD="change-me"
export MAX_PENDING_PER_JOB="20"
export DRY_RUN="true"   # start safe, change to false to actually delete rows
setup (shell)
npm install mysql2

export MAGENTO_DB_HOST="127.0.0.1"
export MAGENTO_DB_NAME="magento"
export MAGENTO_DB_USER="magento"
export MAGENTO_DB_PASSWORD="change-me"
export MAX_PENDING_PER_JOB="20"
export DRY_RUN="true"   // start safe, change to false to actually delete rows
2

Read the pending rows

Select every column the decision function needs: schedule_id, job_code, status, scheduled_at, created_at. We only pull rows already in pending status, the same status the cron generator itself checks, since those are the only rows we ever consider pruning.

step2.py
import os
import mysql.connector

def get_connection():
    return mysql.connector.connect(
        host=os.environ["MAGENTO_DB_HOST"],
        database=os.environ["MAGENTO_DB_NAME"],
        user=os.environ["MAGENTO_DB_USER"],
        password=os.environ["MAGENTO_DB_PASSWORD"],
    )

def fetch_pending_rows(conn):
    cur = conn.cursor(dictionary=True)
    cur.execute(
        "SELECT schedule_id, job_code, status, scheduled_at, created_at "
        "FROM cron_schedule WHERE status = 'pending'"
    )
    rows = cur.fetchall()
    cur.close()
    return rows
step2.js
import mysql from "mysql2/promise";

async function getConnection() {
  return mysql.createConnection({
    host: process.env.MAGENTO_DB_HOST,
    database: process.env.MAGENTO_DB_NAME,
    user: process.env.MAGENTO_DB_USER,
    password: process.env.MAGENTO_DB_PASSWORD,
  });
}

async function fetchPendingRows(conn) {
  const [rows] = await conn.execute(
    "SELECT schedule_id, job_code, status, scheduled_at, created_at " +
    "FROM cron_schedule WHERE status = 'pending'"
  );
  return rows;
}
3

See the backlog before you decide anything

Two queries tell the whole story. The first finds true duplicates, rows that share both job_code and scheduled_at, which should never legitimately happen. The second finds job codes whose overall pending backlog has grown past a sane threshold, regardless of whether any single timestamp repeats.

step3.sql
-- True duplicates: same job_code and scheduled_at should never repeat
SELECT job_code, scheduled_at, COUNT(*) AS cnt
FROM cron_schedule
WHERE status = 'pending'
GROUP BY job_code, scheduled_at
HAVING cnt > 1
ORDER BY cnt DESC;

-- Overall backlog per job, flag anything past your threshold
SELECT job_code, COUNT(*) AS pending_count,
       MIN(created_at) AS oldest, MAX(created_at) AS newest
FROM cron_schedule
WHERE status = 'pending'
GROUP BY job_code
HAVING pending_count > 20
ORDER BY pending_count DESC;
step3.sql
-- True duplicates: same job_code and scheduled_at should never repeat
SELECT job_code, scheduled_at, COUNT(*) AS cnt
FROM cron_schedule
WHERE status = 'pending'
GROUP BY job_code, scheduled_at
HAVING cnt > 1
ORDER BY cnt DESC;

-- Overall backlog per job, flag anything past your threshold
SELECT job_code, COUNT(*) AS pending_count,
       MIN(created_at) AS oldest, MAX(created_at) AS newest
FROM cron_schedule
WHERE status = 'pending'
GROUP BY job_code
HAVING pending_count > 20
ORDER BY pending_count DESC;
4

Decide, with one pure function

Keep the decision in its own function that takes only the rows already fetched and a threshold, and returns which schedule_id values to prune per job_code. It groups by job_code, then within a job_code groups by identical scheduled_at and keeps only the lowest schedule_id, the earliest created one, per timestamp. On the deduplicated remainder, if the count still exceeds the threshold, it marks the oldest excess rows for pruning by created_at, always keeping at least the single soonest-scheduled pending row per job_code. No database or network calls happen inside it, which is what makes it easy to test.

decide.py
def decide_pending_schedules_to_prune(rows, max_pending_per_job):
    by_job = {}
    for row in rows:
        by_job.setdefault(row["job_code"], []).append(row)

    results = []
    for job_code, job_rows in by_job.items():
        by_time = {}
        for row in job_rows:
            by_time.setdefault(row["scheduled_at"], []).append(row)

        deduped = []
        prune_ids = []
        for scheduled_at, group in by_time.items():
            group_sorted = sorted(group, key=lambda r: r["schedule_id"])
            keeper = group_sorted[0]
            deduped.append(keeper)
            prune_ids.extend(r["schedule_id"] for r in group_sorted[1:])

        deduped.sort(key=lambda r: (r["created_at"], r["schedule_id"]))
        keep_ids = [r["schedule_id"] for r in deduped]

        # Never prune the last remaining pending row for a job_code, even if
        # max_pending_per_job is 0: the job still needs to fire at least once.
        effective_max = max(max_pending_per_job, 1)
        if len(deduped) > effective_max:
            excess = deduped[: len(deduped) - effective_max]
            prune_ids.extend(r["schedule_id"] for r in excess)
            keep_ids = [r["schedule_id"] for r in deduped[len(deduped) - effective_max:]]

        results.append({
            "job_code": job_code,
            "prune_ids": sorted(prune_ids),
            "keep_ids": sorted(keep_ids),
        })
    return results
decide.js
export function decidePendingSchedulesToPrune(rows, maxPendingPerJob) {
  const byJob = new Map();
  for (const row of rows) {
    if (!byJob.has(row.job_code)) byJob.set(row.job_code, []);
    byJob.get(row.job_code).push(row);
  }

  const results = [];
  for (const [jobCode, jobRows] of byJob) {
    const byTime = new Map();
    for (const row of jobRows) {
      if (!byTime.has(row.scheduled_at)) byTime.set(row.scheduled_at, []);
      byTime.get(row.scheduled_at).push(row);
    }

    let deduped = [];
    let pruneIds = [];
    for (const group of byTime.values()) {
      const sorted = [...group].sort((a, b) => a.schedule_id - b.schedule_id);
      deduped.push(sorted[0]);
      pruneIds.push(...sorted.slice(1).map((r) => r.schedule_id));
    }

    deduped.sort((a, b) => a.created_at.localeCompare(b.created_at) || a.schedule_id - b.schedule_id);
    let keepIds = deduped.map((r) => r.schedule_id);

    // Never prune the last remaining pending row for a job_code, even if
    // maxPendingPerJob is 0: the job still needs to fire at least once.
    const effectiveMax = Math.max(maxPendingPerJob, 1);
    if (deduped.length > effectiveMax) {
      const excess = deduped.slice(0, deduped.length - effectiveMax);
      pruneIds.push(...excess.map((r) => r.schedule_id));
      keepIds = deduped.slice(deduped.length - effectiveMax).map((r) => r.schedule_id);
    }

    results.push({
      job_code: jobCode,
      prune_ids: pruneIds.sort((a, b) => a - b),
      keep_ids: keepIds.sort((a, b) => a - b),
    });
  }
  return results;
}
5

Prune the surplus, DRY_RUN guarded

For each flagged job_code, build a single DELETE ... WHERE schedule_id IN (...) statement from the pruned ids. When DRY_RUN is true, the default, the script only logs the ids it would delete. It only executes the delete when an operator explicitly sets DRY_RUN=false.

prune.py
def prune_schedules(conn, schedule_ids):
    if not schedule_ids:
        return 0
    placeholders = ",".join(["%s"] * len(schedule_ids))
    cur = conn.cursor()
    cur.execute(
        f"DELETE FROM cron_schedule WHERE schedule_id IN ({placeholders})",
        schedule_ids,
    )
    conn.commit()
    deleted = cur.rowcount
    cur.close()
    return deleted
prune.js
async function pruneSchedules(conn, scheduleIds) {
  if (scheduleIds.length === 0) return 0;
  const placeholders = scheduleIds.map(() => "?").join(",");
  const [result] = await conn.execute(
    `DELETE FROM cron_schedule WHERE schedule_id IN (${placeholders})`,
    scheduleIds
  );
  return result.affectedRows;
}
6

Wire it together with a dry run guard

The loop connects once, fetches all pending rows, runs the pure decision function per job_code, and reports. Notice the dry run guard. Leave DRY_RUN on so the script only prints the schedule_id values it would delete. When you turn it off, it deletes exactly those ids and nothing else, always leaving the earliest pending row per job_code in place.

Run it safe

This script only ever deletes rows already in pending status that the pure function selected for pruning. It never touches running, success, or error rows, and it always leaves at least the single soonest pending row per job_code so the job still runs. Always start with DRY_RUN=true and read the printed plan before switching it off.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only deletes the exact surplus pending rows the pure function selected.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
prune_cron_schedule.py
"""Find and prune duplicate pending jobs in Magento 2 or Adobe Commerce cron_schedule.

Magento's cron generator, ProcessCronQueueObserver::_generate(), only checks
rows already in pending status when it decides what is already scheduled. It
ignores rows stuck in running status. If a job's previous run is still
executing, whether long running, stuck, or crashed without flipping to
success or error, the generator keeps inserting a fresh pending row for the
same job_code on every cron:run pass. cron_schedule has no webapi.xml route,
so this script queries and prunes the table directly. It only ever deletes
surplus pending rows, always keeping the single earliest pending row per
job_code so the job still fires. Safe to run again and again.
"""
import os
import logging

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

DB_HOST = os.environ.get("MAGENTO_DB_HOST", "127.0.0.1")
DB_NAME = os.environ.get("MAGENTO_DB_NAME", "magento")
DB_USER = os.environ.get("MAGENTO_DB_USER", "magento")
DB_PASSWORD = os.environ.get("MAGENTO_DB_PASSWORD", "")
MAX_PENDING_PER_JOB = int(os.environ.get("MAX_PENDING_PER_JOB", "20"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def decide_pending_schedules_to_prune(rows, max_pending_per_job):
    by_job = {}
    for row in rows:
        by_job.setdefault(row["job_code"], []).append(row)

    results = []
    for job_code, job_rows in by_job.items():
        by_time = {}
        for row in job_rows:
            by_time.setdefault(row["scheduled_at"], []).append(row)

        deduped = []
        prune_ids = []
        for scheduled_at, group in by_time.items():
            group_sorted = sorted(group, key=lambda r: r["schedule_id"])
            keeper = group_sorted[0]
            deduped.append(keeper)
            prune_ids.extend(r["schedule_id"] for r in group_sorted[1:])

        deduped.sort(key=lambda r: (r["created_at"], r["schedule_id"]))
        keep_ids = [r["schedule_id"] for r in deduped]

        # Never prune the last remaining pending row for a job_code, even if
        # max_pending_per_job is 0: the job still needs to fire at least once.
        effective_max = max(max_pending_per_job, 1)
        if len(deduped) > effective_max:
            excess = deduped[: len(deduped) - effective_max]
            prune_ids.extend(r["schedule_id"] for r in excess)
            keep_ids = [r["schedule_id"] for r in deduped[len(deduped) - effective_max:]]

        results.append({
            "job_code": job_code,
            "prune_ids": sorted(prune_ids),
            "keep_ids": sorted(keep_ids),
        })
    return results


def get_connection():
    import mysql.connector
    return mysql.connector.connect(
        host=DB_HOST, database=DB_NAME, user=DB_USER, password=DB_PASSWORD
    )


def fetch_pending_rows(conn):
    cur = conn.cursor(dictionary=True)
    cur.execute(
        "SELECT schedule_id, job_code, status, scheduled_at, created_at "
        "FROM cron_schedule WHERE status = 'pending'"
    )
    rows = cur.fetchall()
    cur.close()
    return rows


def prune_schedules(conn, schedule_ids):
    if not schedule_ids:
        return 0
    placeholders = ",".join(["%s"] * len(schedule_ids))
    cur = conn.cursor()
    cur.execute(
        f"DELETE FROM cron_schedule WHERE schedule_id IN ({placeholders})",
        schedule_ids,
    )
    conn.commit()
    deleted = cur.rowcount
    cur.close()
    return deleted


def run():
    conn = get_connection()
    try:
        rows = fetch_pending_rows(conn)
        plans = decide_pending_schedules_to_prune(rows, MAX_PENDING_PER_JOB)
        total_pruned = 0
        for plan in plans:
            if not plan["prune_ids"]:
                continue
            log.info(
                "job_code=%s would prune %d row(s): %s",
                plan["job_code"], len(plan["prune_ids"]), plan["prune_ids"],
            )
            if not DRY_RUN:
                deleted = prune_schedules(conn, plan["prune_ids"])
                total_pruned += deleted
                log.info("job_code=%s pruned %d row(s)", plan["job_code"], deleted)
            else:
                total_pruned += len(plan["prune_ids"])
        log.info(
            "Done. %d row(s) %s.", total_pruned,
            "would be pruned (dry run)" if DRY_RUN else "pruned",
        )
    finally:
        conn.close()


if __name__ == "__main__":
    run()
prune-cron-schedule.js
/**
 * Find and prune duplicate pending jobs in Magento 2 or Adobe Commerce cron_schedule.
 *
 * Magento's cron generator, ProcessCronQueueObserver::_generate(), only checks
 * rows already in pending status when it decides what is already scheduled. It
 * ignores rows stuck in running status. If a job's previous run is still
 * executing, whether long running, stuck, or crashed without flipping to
 * success or error, the generator keeps inserting a fresh pending row for the
 * same job_code on every cron:run pass. cron_schedule has no webapi.xml route,
 * so this script queries and prunes the table directly. It only ever deletes
 * surplus pending rows, always keeping the single earliest pending row per
 * job_code so the job still fires. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/cron-schedule-duplicate-pending-jobs/
 */
import { pathToFileURL } from "node:url";

const DB_HOST = process.env.MAGENTO_DB_HOST || "127.0.0.1";
const DB_NAME = process.env.MAGENTO_DB_NAME || "magento";
const DB_USER = process.env.MAGENTO_DB_USER || "magento";
const DB_PASSWORD = process.env.MAGENTO_DB_PASSWORD || "";
const MAX_PENDING_PER_JOB = Number(process.env.MAX_PENDING_PER_JOB || 20);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function decidePendingSchedulesToPrune(rows, maxPendingPerJob) {
  const byJob = new Map();
  for (const row of rows) {
    if (!byJob.has(row.job_code)) byJob.set(row.job_code, []);
    byJob.get(row.job_code).push(row);
  }

  const results = [];
  for (const [jobCode, jobRows] of byJob) {
    const byTime = new Map();
    for (const row of jobRows) {
      if (!byTime.has(row.scheduled_at)) byTime.set(row.scheduled_at, []);
      byTime.get(row.scheduled_at).push(row);
    }

    let deduped = [];
    let pruneIds = [];
    for (const group of byTime.values()) {
      const sorted = [...group].sort((a, b) => a.schedule_id - b.schedule_id);
      deduped.push(sorted[0]);
      pruneIds.push(...sorted.slice(1).map((r) => r.schedule_id));
    }

    deduped.sort((a, b) => a.created_at.localeCompare(b.created_at) || a.schedule_id - b.schedule_id);
    let keepIds = deduped.map((r) => r.schedule_id);

    // Never prune the last remaining pending row for a job_code, even if
    // maxPendingPerJob is 0: the job still needs to fire at least once.
    const effectiveMax = Math.max(maxPendingPerJob, 1);
    if (deduped.length > effectiveMax) {
      const excess = deduped.slice(0, deduped.length - effectiveMax);
      pruneIds.push(...excess.map((r) => r.schedule_id));
      keepIds = deduped.slice(deduped.length - effectiveMax).map((r) => r.schedule_id);
    }

    results.push({
      job_code: jobCode,
      prune_ids: pruneIds.sort((a, b) => a - b),
      keep_ids: keepIds.sort((a, b) => a - b),
    });
  }
  return results;
}

async function getConnection() {
  const mysql = await import("mysql2/promise");
  return mysql.default.createConnection({
    host: DB_HOST, database: DB_NAME, user: DB_USER, password: DB_PASSWORD,
  });
}

async function fetchPendingRows(conn) {
  const [rows] = await conn.execute(
    "SELECT schedule_id, job_code, status, scheduled_at, created_at " +
    "FROM cron_schedule WHERE status = 'pending'"
  );
  return rows;
}

async function pruneSchedules(conn, scheduleIds) {
  if (scheduleIds.length === 0) return 0;
  const placeholders = scheduleIds.map(() => "?").join(",");
  const [result] = await conn.execute(
    `DELETE FROM cron_schedule WHERE schedule_id IN (${placeholders})`,
    scheduleIds
  );
  return result.affectedRows;
}

export async function run() {
  const conn = await getConnection();
  try {
    const rows = await fetchPendingRows(conn);
    const plans = decidePendingSchedulesToPrune(rows, MAX_PENDING_PER_JOB);
    let totalPruned = 0;
    for (const plan of plans) {
      if (plan.prune_ids.length === 0) continue;
      console.log(`job_code=${plan.job_code} would prune ${plan.prune_ids.length} row(s): ${plan.prune_ids}`);
      if (!DRY_RUN) {
        const deleted = await pruneSchedules(conn, plan.prune_ids);
        totalPruned += deleted;
        console.log(`job_code=${plan.job_code} pruned ${deleted} row(s)`);
      } else {
        totalPruned += plan.prune_ids.length;
      }
    }
    console.log(`Done. ${totalPruned} row(s) ${DRY_RUN ? "would be pruned (dry run)" : "pruned"}.`);
  } finally {
    await conn.end();
  }
}

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 which rows get deleted out of a live table. Since decide_pending_schedules_to_prune and decidePendingSchedulesToPrune are pure, the tests need no network and no Magento database. They just feed in plain row objects and check which schedule_id values come back marked for pruning.

test_cron_schedule_prune.py
from prune_cron_schedule import decide_pending_schedules_to_prune


def row(schedule_id, job_code, scheduled_at, created_at):
    return {
        "schedule_id": schedule_id,
        "job_code": job_code,
        "status": "pending",
        "scheduled_at": scheduled_at,
        "created_at": created_at,
    }


def test_keeps_earliest_of_true_duplicates():
    rows = [
        row(1, "sales_grid_sync", "2026-07-10 10:00:00", "2026-07-10 09:59:00"),
        row(2, "sales_grid_sync", "2026-07-10 10:00:00", "2026-07-10 09:59:30"),
        row(3, "sales_grid_sync", "2026-07-10 10:00:00", "2026-07-10 10:00:10"),
    ]
    result = decide_pending_schedules_to_prune(rows, max_pending_per_job=20)
    plan = result[0]
    assert plan["job_code"] == "sales_grid_sync"
    assert plan["prune_ids"] == [2, 3]
    assert plan["keep_ids"] == [1]


def test_no_duplicates_no_prune():
    rows = [
        row(1, "indexer_update_all_views", "2026-07-10 10:00:00", "2026-07-10 09:59:00"),
        row(2, "indexer_update_all_views", "2026-07-10 10:01:00", "2026-07-10 10:00:00"),
    ]
    result = decide_pending_schedules_to_prune(rows, max_pending_per_job=20)
    plan = result[0]
    assert plan["prune_ids"] == []
    assert plan["keep_ids"] == [1, 2]


def test_excess_backlog_beyond_threshold_prunes_oldest_first():
    rows = [row(i, "stuck_job", f"2026-07-10 10:{i:02d}:00", f"2026-07-10 09:{i:02d}:00") for i in range(1, 6)]
    result = decide_pending_schedules_to_prune(rows, max_pending_per_job=2)
    plan = result[0]
    assert plan["prune_ids"] == [1, 2, 3]
    assert plan["keep_ids"] == [4, 5]


def test_always_keeps_at_least_one_row_per_job():
    rows = [row(i, "very_stuck_job", f"2026-07-10 10:{i:02d}:00", f"2026-07-10 09:{i:02d}:00") for i in range(1, 4)]
    result = decide_pending_schedules_to_prune(rows, max_pending_per_job=0)
    plan = result[0]
    assert len(plan["keep_ids"]) >= 1


def test_separate_job_codes_do_not_interfere():
    rows = [
        row(1, "job_a", "2026-07-10 10:00:00", "2026-07-10 09:59:00"),
        row(2, "job_a", "2026-07-10 10:00:00", "2026-07-10 09:59:30"),
        row(3, "job_b", "2026-07-10 10:00:00", "2026-07-10 09:59:00"),
    ]
    result = decide_pending_schedules_to_prune(rows, max_pending_per_job=20)
    by_job = {plan["job_code"]: plan for plan in result}
    assert by_job["job_a"]["prune_ids"] == [2]
    assert by_job["job_b"]["prune_ids"] == []
cron-schedule-prune.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decidePendingSchedulesToPrune } from "./prune-cron-schedule.js";

const row = (scheduleId, jobCode, scheduledAt, createdAt) => ({
  schedule_id: scheduleId,
  job_code: jobCode,
  status: "pending",
  scheduled_at: scheduledAt,
  created_at: createdAt,
});

test("keeps earliest of true duplicates", () => {
  const rows = [
    row(1, "sales_grid_sync", "2026-07-10 10:00:00", "2026-07-10 09:59:00"),
    row(2, "sales_grid_sync", "2026-07-10 10:00:00", "2026-07-10 09:59:30"),
    row(3, "sales_grid_sync", "2026-07-10 10:00:00", "2026-07-10 10:00:10"),
  ];
  const [plan] = decidePendingSchedulesToPrune(rows, 20);
  assert.equal(plan.job_code, "sales_grid_sync");
  assert.deepEqual(plan.prune_ids, [2, 3]);
  assert.deepEqual(plan.keep_ids, [1]);
});

test("no duplicates no prune", () => {
  const rows = [
    row(1, "indexer_update_all_views", "2026-07-10 10:00:00", "2026-07-10 09:59:00"),
    row(2, "indexer_update_all_views", "2026-07-10 10:01:00", "2026-07-10 10:00:00"),
  ];
  const [plan] = decidePendingSchedulesToPrune(rows, 20);
  assert.deepEqual(plan.prune_ids, []);
  assert.deepEqual(plan.keep_ids, [1, 2]);
});

test("excess backlog beyond threshold prunes oldest first", () => {
  const rows = [1, 2, 3, 4, 5].map((i) =>
    row(i, "stuck_job", `2026-07-10 10:0${i}:00`, `2026-07-10 09:0${i}:00`)
  );
  const [plan] = decidePendingSchedulesToPrune(rows, 2);
  assert.deepEqual(plan.prune_ids, [1, 2, 3]);
  assert.deepEqual(plan.keep_ids, [4, 5]);
});

test("always keeps at least one row per job", () => {
  const rows = [1, 2, 3].map((i) =>
    row(i, "very_stuck_job", `2026-07-10 10:0${i}:00`, `2026-07-10 09:0${i}:00`)
  );
  const [plan] = decidePendingSchedulesToPrune(rows, 0);
  assert.ok(plan.keep_ids.length >= 1);
});

test("separate job codes do not interfere", () => {
  const rows = [
    row(1, "job_a", "2026-07-10 10:00:00", "2026-07-10 09:59:00"),
    row(2, "job_a", "2026-07-10 10:00:00", "2026-07-10 09:59:30"),
    row(3, "job_b", "2026-07-10 10:00:00", "2026-07-10 09:59:00"),
  ];
  const byJob = Object.fromEntries(
    decidePendingSchedulesToPrune(rows, 20).map((p) => [p.job_code, p])
  );
  assert.deepEqual(byJob.job_a.prune_ids, [2]);
  assert.deepEqual(byJob.job_b.prune_ids, []);
});

Case studies

Crashed deploy

A killed deploy left one job stuck in running forever

A mid-size catalog store restarted its application servers during a deploy while a custom export job was mid-run. The row for that job never flipped to success or error, it just sat in running status. From that moment, every cron pass inserted a fresh pending row for the same job_code, and within a day the table held over 400 pending rows for that one job alone.

Running the script in dry run showed the exact backlog, all created after the deploy, all for the same job_code, with the one legitimate pending row highlighted as the row it would keep. The operator pruned the surplus, restarted the stuck process by hand, and the job_code went back to a handful of pending rows on the next pass.

Long-running job

An indexer-adjacent job that outran its own schedule

A store with a large catalog ran a custom cron job that synced product data to a search provider. On busy days the sync took longer than its own schedule_generate_every window, so the generator never saw it as pending and kept queuing more copies of the same job. Unrelated cron groups started running late because the queue was dominated by one job_code.

The detection query flagged the job_code immediately, with a pending count far beyond what its configured frequency should ever produce. The team lowered the job's actual frequency, added a lock inside the job itself, and used the script's dry run to clear the existing backlog once so unrelated jobs could catch up.

What good looks like

After running this, cron_schedule stops silently filling up behind a single stuck or slow job. You get a precise, per job_code list of what is a true duplicate versus a legitimate backlog, a dry run you can review before anything is deleted, and a guarantee that the one pending row a job actually needs is never touched. The underlying stuck process or overrun job still deserves a look, but the queue itself stops choking on it.

FAQ

Why does cron_schedule keep filling up with duplicate pending rows for the same job?

Magento's cron generator, ProcessCronQueueObserver::_generate(), only looks at rows already in pending status when it decides what is already scheduled. It ignores rows stuck in running status. If a job's previous run is still executing, whether because it is genuinely long running, stuck, or crashed without flipping to success or error, the generator does not see it as scheduled and inserts a fresh pending row for the same job_code on every cron:run pass, which by default happens every minute.

Can I clean up cron_schedule through the Magento REST API?

No. Magento's module-cron does not ship a webapi.xml route for cron_schedule, so it is not reachable over REST like products or orders are. Detection and repair both have to query and modify the table directly. schedule_lifetime and history_cleanup_every only prune old success, error, and missed rows, they do not touch an already bloated pending backlog.

Is it safe to delete rows from cron_schedule?

Yes, when you delete only surplus pending rows and always keep the single earliest scheduled pending row per job_code untouched, so the job still fires once. Never delete running, success, or error rows as part of this cleanup, and run in dry run first so you can see exactly which schedule_id values would be removed before anything is deleted.

Related field notes

Citations

On the problem:

  1. Many duplicate pending cron jobs in cron_schedule table (Magento Community Forum). community.magento.com many-duplicate-pending-cron-jobs
  2. Cron may double schedule jobs in edge case, magento/magento2 issue 10279. github.com/magento/magento2/issues/10279
  3. cron_schedule forever increasing in size, lots of pending jobs never cleared, magento/magento2 issue 11002. github.com/magento/magento2/issues/11002

On the solution:

  1. Configure and run cron jobs, Adobe Commerce Operations. experienceleague.adobe.com configure-cron-jobs
  2. Custom cron job and cron group reference, Adobe Commerce Operations. experienceleague.adobe.com custom-cron-reference
  3. Cron (scheduled tasks), Adobe Commerce Admin. experienceleague.adobe.com commerce-admin cron

Stuck on a tricky one?

If you have a problem in Magento indexing, cron, MSI stock, or order grid 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 clear your cron backlog?

If this saved you a confusing morning of late cron jobs, 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 Magento field notes