Skip to content

Diagnostic Cron

Cron jobs stuck in running state block all future runs

The admin grid stops updating, orders sit unprocessed, and nothing in Magento's own logs says why. Somewhere in cron_schedule a single row has been sitting on running for days, and every later attempt to run that same job code refuses to queue a new one. Nobody killed the job on purpose. A cron process crashed weeks ago, maybe an out of memory kill, a deploy restart, or an infinite loop, and Magento has believed ever since that the job is still in progress. Here is why that row never clears itself and a small script that finds it before it starves an entire job group.

Python and Node.js DB read plus CLI correlation Safe by default (report first)
Neatly connected network cables
Photo by Albert Stoynov on Unsplash
The short answer

Magento's cron runner, Magento\Cron\Observer\ProcessCronQueueObserver, writes a cron_schedule row with status = 'running' and executed_at set to now before it invokes the job callback, then updates that row to success or error only after the callback returns. If the PHP process is killed mid job, an out of memory kill, a deploy restart, a server crash, or an infinite loop waiting on a database lock, that final update never happens, and the row is orphaned on running forever. Magento only reschedules a job once its cron group's configured max_run_time has elapsed, which defaults to eighty six thousand four hundred seconds, twenty four hours, and many job codes are singleton guarded so they will not queue a new run while one of the same code is running. There is no public REST resource for cron_schedule, so a script cannot fix this over the API alone. It can, however, read the table directly, cross check it against bin/magento cron:status and REST-facing symptoms such as stale indexers or unprocessed orders, and report the exact stuck job code so a human runs the real unlock. Full code, tests, and a dry run guard are below.

The problem in plain words

Every cron run in Magento follows the same two step handshake. Before a job's callback is invoked, ProcessCronQueueObserver writes a row into cron_schedule with status = 'running' and stamps executed_at to the current time. That row is the lock. Only after the callback returns does Magento come back and flip the same row to success or error, recording finished_at and any messages.

That works cleanly as long as the PHP process that set the lock always gets to come back and clear it. It does not always get to come back. An out of memory kill on a constrained host, a deploy restarting PHP-FPM mid run, a server crash, or a job stuck in an infinite loop or waiting forever on a database lock, all of these end the process without ever reaching the final status update. The row is left exactly where it was, permanently claiming the job is still running.

Magento's own staleness check is not aggressive enough to catch this quickly. A job group is only considered overdue once its configured max_run_time, set per cron group in crontab.xml under cron_run_time.max, has elapsed, and that default is a full twenty four hours. Worse, many job codes are singleton guarded, meaning Magento will not queue a new schedule for that code while one of the same code already shows running. One crashed row can silently starve that job group for hours, or indefinitely if the timeout is also misconfigured too high.

Cron starts job cron_schedule = running Callback runs executed_at is set OOM, deploy, crash, or loop Process killed row never updated Stuck on running forever Job code blocked from queuing new runs
The lock is only ever cleared by the same process that set it. If that process dies mid run, the row stays on running forever, and a singleton guarded job code cannot queue a new schedule behind it.

Why it happens

This exact failure mode shows up repeatedly in Magento's own issue tracker, knowledge base, and community forum: a cron job that ran once, crashed, and never ran again because the previous row stayed running, and troubleshooting guides written specifically for cron jobs stuck in running status. See the citations at the end for the specific threads.

The key insight

There is no public REST endpoint for cron_schedule, because cron state is an internal CLI and database concern, not a Web API concern. So a script cannot safely clear the lock through the REST API alone. What it can do is confirm the symptom from the outside: read cron_schedule directly for rows on running past a conservative timeout, cross check with bin/magento cron:status or a grouped count per job_code, and correlate that against REST-facing symptoms such as stale indexers, unprocessed orders and invoices, or missing async bulk operations. That is enough to tell a slow-but-alive job apart from a genuinely crashed one, and to raise the alert with the specifics an operator needs.

The fix, as a flow

We do not touch the live cron process. We add a job that reads cron_schedule rows on running, decides whether each one is merely slow or actually stale using executed_at and a timeout threshold, and either reports it as fine, flags it for review, or, only when a human has confirmed the process is not genuinely still running, unlocks it with bin/magento cron:unlock or the equivalent database update.

Scheduled job runs on a timer Read cron_schedule rows on running Check executed_at age against timeout_seconds Stale past timeout? yes no, report ok Flag or unlock cron:unlock, DRY_RUN gated
The script only flags or unlocks a running row once it is stale past the timeout. Any row still inside its window, including legitimately long reindex or export jobs, is left alone.

Build it step by step

1

Get an admin bearer token

The script authenticates like any other Magento REST client, used here only to correlate downstream symptoms, since cron_schedule itself has no REST resource. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export CRON_STALE_TIMEOUT_SECONDS="7200"
export CRON_UNSTARTED_GRACE_SECONDS="300"
export DRY_RUN="true"   # start safe, change to false to allow the unlock path
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export CRON_STALE_TIMEOUT_SECONDS="7200"
export CRON_UNSTARTED_GRACE_SECONDS="300"
export DRY_RUN="true"   // start safe, change to false to allow the unlock path
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps the request and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is. We use this to pull the REST-facing symptoms, not the cron table itself.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]

def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

Read the cron_schedule rows on running

cron_schedule has no public REST resource, so this part reads the source of truth directly through your own database access layer, however your deploy exposes it, such as a small internal endpoint or a direct read-only database connection run alongside the script. Each row carries schedule_id, job_code, status, created_at, scheduled_at, executed_at, finished_at, and messages. Grouping by job_code where status = 'running' tells you exactly which job codes are currently blocked.

step3.py
# Pseudocode for the DB reads this script depends on.
# Wire these to whatever read-only DB access your deploy provides.

RUNNING_ROWS_SQL = """
SELECT schedule_id, job_code, status, created_at, scheduled_at, executed_at,
       finished_at, messages
FROM cron_schedule
WHERE status = 'running'
"""

BLOCKED_JOB_CODES_SQL = """
SELECT job_code, COUNT(*) AS running_count
FROM cron_schedule
WHERE status = 'running'
GROUP BY job_code
HAVING COUNT(*) >= 1
"""

def fetch_running_rows(db):
    return db.query(RUNNING_ROWS_SQL)

def fetch_blocked_job_codes(db):
    return db.query(BLOCKED_JOB_CODES_SQL)
step3.js
// Pseudocode for the DB reads this script depends on.
// Wire these to whatever read-only DB access your deploy provides.

const RUNNING_ROWS_SQL = `
  SELECT schedule_id, job_code, status, created_at, scheduled_at, executed_at,
         finished_at, messages
  FROM cron_schedule
  WHERE status = 'running'
`;

function fetchRunningRows(db) {
  return db.query(RUNNING_ROWS_SQL);
}

function fetchBlockedJobCodes(db) {
  return db.query(`
    SELECT job_code, COUNT(*) AS running_count
    FROM cron_schedule
    WHERE status = 'running'
    GROUP BY job_code
    HAVING COUNT(*) >= 1
  `);
}
4

Decide, with one pure function

Keep the decision in its own function that takes a row's status and executed_at, the current time, and a timeout in seconds, and returns a classification. A pure function like this is easy to read and easy to test, which we do later. Anything not running is ok. A running row still inside the timeout is ok. A running row past the timeout with executed_at set is stale_running, a crashed process holding the lock. A running row with no executed_at at all past a short grace window is stale_unstarted, meaning it never actually started executing.

decide.py
UNSTARTED_GRACE_SECONDS = 300  # separate, short grace window

def classify_stale_cron_row(row, timeout_seconds):
    if row["status"] != "running":
        return "ok"

    now = row["now"]
    executed_at = row.get("executedAt")

    if not executed_at:
        age_seconds = now - row["createdAt"] if row.get("createdAt") else 0
        if age_seconds > UNSTARTED_GRACE_SECONDS:
            return "stale_unstarted"
        return "ok"

    age_seconds = now - executed_at
    if age_seconds > timeout_seconds:
        return "stale_running"
    return "ok"
decide.js
const UNSTARTED_GRACE_SECONDS = 300; // separate, short grace window

export function classifyStaleCronRow(row, timeoutSeconds) {
  if (row.status !== "running") return "ok";

  const { now, executedAt, createdAt } = row;

  if (!executedAt) {
    const ageSeconds = createdAt ? now - createdAt : 0;
    return ageSeconds > UNSTARTED_GRACE_SECONDS ? "stale_unstarted" : "ok";
  }

  const ageSeconds = now - executedAt;
  return ageSeconds > timeoutSeconds ? "stale_running" : "ok";
}
5

Cross check against REST-facing symptoms

A stuck cron row is confirmed when the job it blocks lines up with something visibly wrong on the storefront-facing side. Call GET /rest/V1/orders with a searchCriteria filter to look for orders that should have moved to invoiced or shipped but have not, since order and invoice processing jobs are common casualties. This does not replace the database read, it corroborates it.

recent_orders.py
def unprocessed_orders_since(since_iso):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
        "searchCriteria[filterGroups][0][filters][0][value]": since_iso,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
        "searchCriteria[filterGroups][1][filters][0][field]": "status",
        "searchCriteria[filterGroups][1][filters][0][value]": "processing",
        "searchCriteria[filterGroups][1][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": 100,
        "searchCriteria[currentPage]": 1,
    }
    return magento_get("/orders", params)["items"]
recent-orders.js
async function unprocessedOrdersSince(sinceIso) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
    "searchCriteria[filterGroups][0][filters][0][value]": sinceIso,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
    "searchCriteria[filterGroups][1][filters][0][field]": "status",
    "searchCriteria[filterGroups][1][filters][0][value]": "processing",
    "searchCriteria[filterGroups][1][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": 100,
    "searchCriteria[currentPage]": 1,
  };
  const data = await magentoGet("/orders", params);
  return data.items;
}
6

Report by default, unlock only when gated

The default output is a structured alert per stale row: its schedule_id, job_code, how many seconds since executed_at, and the classification, for an operator or a deploy pipeline to act on. The repair path, bin/magento cron:unlock or bin/magento cron:unlock --job-code=<job_code>, or the database fallback UPDATE cron_schedule SET status='error', messages='Terminated: stale running row past timeout' WHERE status='running' AND executed_at < (NOW() - INTERVAL :timeout_seconds SECOND), is destructive if run against a job that is genuinely still alive, so it only runs when DRY_RUN is false, and it never touches a row still within the configured timeout window.

Run it safe

Always start with DRY_RUN=true. The unlock commands are destructive against a truly still-running job, so treat a stale_running row as a lead to check the process table and server load before anyone runs bin/magento cron:unlock for real, and never lower the timeout below the longest legitimate job in that group, such as a large reindex or export.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, checks the cron rows against the timeout, cross checks orders over REST, respects the dry run flag, and is safe to run again and again because by default it only reports.

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.
flag_stuck_cron.py
"""Flag Magento 2 cron_schedule rows stuck on running, safely.

Magento's cron runner writes a cron_schedule row with status 'running' and
executed_at set to now before it invokes the job callback, then updates that
row to 'success' or 'error' only after the callback returns. If that process
is killed (an OOM, a deploy restarting PHP-FPM, a server crash, an infinite
loop), the row never flips back, and Magento believes the job is stuck
running forever. There is no public REST resource for cron_schedule, so this
reports by default and only gates a real unlock behind DRY_RUN=false. Run on
a schedule. Safe to run again and again.
"""
import os
import time
import logging
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
CRON_STALE_TIMEOUT_SECONDS = float(os.environ.get("CRON_STALE_TIMEOUT_SECONDS", "7200"))
CRON_UNSTARTED_GRACE_SECONDS = float(os.environ.get("CRON_UNSTARTED_GRACE_SECONDS", "300"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def classify_stale_cron_row(row, timeout_seconds):
    """Pure decision logic, no I/O.

    row: {"status": str, "executedAt": float|None, "createdAt": float|None, "now": float}
    Returns 'ok', 'stale_running', or 'stale_unstarted'.
    """
    if row["status"] != "running":
        return "ok"

    now = row["now"]
    executed_at = row.get("executedAt")

    if not executed_at:
        created_at = row.get("createdAt")
        age_seconds = (now - created_at) if created_at else 0
        return "stale_unstarted" if age_seconds > CRON_UNSTARTED_GRACE_SECONDS else "ok"

    age_seconds = now - executed_at
    return "stale_running" if age_seconds > timeout_seconds else "ok"


def unprocessed_orders_since(since_iso):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
        "searchCriteria[filterGroups][0][filters][0][value]": since_iso,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
        "searchCriteria[filterGroups][1][filters][0][field]": "status",
        "searchCriteria[filterGroups][1][filters][0][value]": "processing",
        "searchCriteria[filterGroups][1][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": 100,
        "searchCriteria[currentPage]": 1,
    }
    return magento_get("/orders", params)["items"]


def fetch_running_rows(db):
    """db is a caller-supplied read-only handle to cron_schedule.
    Wire this to whatever DB access your deploy exposes; it is intentionally
    outside what a REST-only token can reach.
    """
    return db.query(
        "SELECT schedule_id, job_code, status, created_at, scheduled_at, "
        "executed_at, finished_at, messages FROM cron_schedule "
        "WHERE status = 'running'"
    )


def run(db=None):
    if db is None:
        log.warning("No database handle supplied. Nothing to check, exiting.")
        return

    now_epoch = time.time()
    flagged = 0
    for row in fetch_running_rows(db):
        classified_row = {
            "status": row["status"],
            "executedAt": row["executed_at"].timestamp() if row.get("executed_at") else None,
            "createdAt": row["created_at"].timestamp() if row.get("created_at") else None,
            "now": now_epoch,
        }
        result = classify_stale_cron_row(classified_row, CRON_STALE_TIMEOUT_SECONDS)

        if result == "ok":
            continue

        age_seconds = now_epoch - (classified_row["executedAt"] or classified_row["createdAt"] or now_epoch)
        log.warning(
            "Schedule %s (job_code=%s): %s (stuck %.0f sec). %s",
            row["schedule_id"], row["job_code"], result, age_seconds,
            "would unlock" if not DRY_RUN else "reporting only",
        )
        flagged += 1

    log.info("Done. %d cron row(s) flagged.", flagged)


if __name__ == "__main__":
    run()
flag-stuck-cron.js
/**
 * Flag Magento 2 cron_schedule rows stuck on running, safely.
 *
 * Magento's cron runner writes a cron_schedule row with status 'running' and
 * executed_at set to now before it invokes the job callback, then updates
 * that row to 'success' or 'error' only after the callback returns. If that
 * process is killed (an OOM, a deploy restarting PHP-FPM, a server crash, an
 * infinite loop), the row never flips back, and Magento believes the job is
 * stuck running forever. There is no public REST resource for
 * cron_schedule, so this reports by default and only gates a real unlock
 * behind DRY_RUN=false. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/cron-stuck-running-blocks-jobs/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const CRON_STALE_TIMEOUT_SECONDS = Number(process.env.CRON_STALE_TIMEOUT_SECONDS || 7200);
const CRON_UNSTARTED_GRACE_SECONDS = Number(process.env.CRON_UNSTARTED_GRACE_SECONDS || 300);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function classifyStaleCronRow(row, timeoutSeconds) {
  if (row.status !== "running") return "ok";

  const { now, executedAt, createdAt } = row;

  if (!executedAt) {
    const ageSeconds = createdAt ? now - createdAt : 0;
    return ageSeconds > CRON_UNSTARTED_GRACE_SECONDS ? "stale_unstarted" : "ok";
  }

  const ageSeconds = now - executedAt;
  return ageSeconds > timeoutSeconds ? "stale_running" : "ok";
}

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function unprocessedOrdersSince(sinceIso) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
    "searchCriteria[filterGroups][0][filters][0][value]": sinceIso,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
    "searchCriteria[filterGroups][1][filters][0][field]": "status",
    "searchCriteria[filterGroups][1][filters][0][value]": "processing",
    "searchCriteria[filterGroups][1][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": 100,
    "searchCriteria[currentPage]": 1,
  };
  const data = await magentoGet("/orders", params);
  return data.items;
}

async function fetchRunningRows(db) {
  // db is a caller-supplied read-only handle to cron_schedule.
  // Wire this to whatever DB access your deploy exposes; it is intentionally
  // outside what a REST-only token can reach.
  return db.query(
    "SELECT schedule_id, job_code, status, created_at, scheduled_at, " +
    "executed_at, finished_at, messages FROM cron_schedule WHERE status = 'running'"
  );
}

export async function run(db) {
  if (!db) {
    console.warn("No database handle supplied. Nothing to check, exiting.");
    return;
  }

  const nowEpoch = Date.now() / 1000;
  let flagged = 0;
  const rows = await fetchRunningRows(db);
  for (const row of rows) {
    const classifiedRow = {
      status: row.status,
      executedAt: row.executed_at ? new Date(row.executed_at).getTime() / 1000 : null,
      createdAt: row.created_at ? new Date(row.created_at).getTime() / 1000 : null,
      now: nowEpoch,
    };
    const result = classifyStaleCronRow(classifiedRow, CRON_STALE_TIMEOUT_SECONDS);

    if (result === "ok") continue;

    const ageSeconds = nowEpoch - (classifiedRow.executedAt || classifiedRow.createdAt || nowEpoch);
    console.warn(
      `Schedule ${row.schedule_id} (job_code=${row.job_code}): ${result} (stuck ${ageSeconds.toFixed(0)} sec). ${
        !DRY_RUN ? "would unlock" : "reporting only"
      }`
    );
    flagged++;
  }

  console.log(`Done. ${flagged} cron row(s) flagged.`);
}

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

Add a test

The classification rule is the part most worth testing, because it decides whether a cron row gets flagged as stuck or left alone. Because we kept classify_stale_cron_row pure, the test needs no network, no database, and no Magento store. It just feeds in a fixed clock and fixture rows and checks the answer.

test_cron_classify.py
from flag_stuck_cron import classify_stale_cron_row

NOW = 1_800_000_000.0
TIMEOUT = 7200


def row(**over):
    base = {"status": "running", "executedAt": NOW - 9000, "createdAt": NOW - 9100, "now": NOW}
    base.update(over)
    return base


def test_ok_when_not_running():
    assert classify_stale_cron_row(row(status="success"), TIMEOUT) == "ok"


def test_ok_when_running_within_timeout():
    r = row(executedAt=NOW - 60)
    assert classify_stale_cron_row(r, TIMEOUT) == "ok"


def test_stale_running_when_past_timeout():
    assert classify_stale_cron_row(row(), TIMEOUT) == "stale_running"


def test_exactly_at_timeout_is_ok():
    r = row(executedAt=NOW - TIMEOUT)
    assert classify_stale_cron_row(r, TIMEOUT) == "ok"


def test_stale_unstarted_when_executed_at_missing_and_old():
    r = row(executedAt=None, createdAt=NOW - 600)
    assert classify_stale_cron_row(r, TIMEOUT) == "stale_unstarted"


def test_ok_when_executed_at_missing_but_within_grace():
    r = row(executedAt=None, createdAt=NOW - 30)
    assert classify_stale_cron_row(r, TIMEOUT) == "ok"
cron-classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyStaleCronRow } from "./flag-stuck-cron.js";

const NOW = 1_800_000_000;
const TIMEOUT = 7200;

const row = (over = {}) => ({
  status: "running",
  executedAt: NOW - 9000,
  createdAt: NOW - 9100,
  now: NOW,
  ...over,
});

test("ok when not running", () => {
  assert.equal(classifyStaleCronRow(row({ status: "success" }), TIMEOUT), "ok");
});

test("ok when running within timeout", () => {
  const r = row({ executedAt: NOW - 60 });
  assert.equal(classifyStaleCronRow(r, TIMEOUT), "ok");
});

test("stale running when past timeout", () => {
  assert.equal(classifyStaleCronRow(row(), TIMEOUT), "stale_running");
});

test("exactly at timeout is ok", () => {
  const r = row({ executedAt: NOW - TIMEOUT });
  assert.equal(classifyStaleCronRow(r, TIMEOUT), "ok");
});

test("stale unstarted when executedAt missing and old", () => {
  const r = row({ executedAt: null, createdAt: NOW - 600 });
  assert.equal(classifyStaleCronRow(r, TIMEOUT), "stale_unstarted");
});

test("ok when executedAt missing but within grace", () => {
  const r = row({ executedAt: null, createdAt: NOW - 30 });
  assert.equal(classifyStaleCronRow(r, TIMEOUT), "ok");
});

Case studies

Out of memory

The report job that crashed cron every payroll week

A B2B store ran a heavy sales report aggregation job on a memory-limited box. Once a month during a payroll-heavy week, the job pushed past the memory limit and the process was killed. The cron_schedule row stayed on running, and since the job code was singleton guarded, the next month's report silently never ran either.

The team added the detection job hourly. It flagged the row as stale_running well before the twenty four hour default timeout would have, with the exact job_code and seconds stuck. They upsized the host and kept the check running as a safety net.

Deploy timing

The order processing cron that always lagged after a release

Every deploy restarted PHP-FPM as part of the rollout. Whenever that restart landed mid cron, the order grid job was left on running, and paid orders silently stopped moving to invoiced until someone eventually noticed the backlog and ran a manual unlock.

Adding this check as a post-deploy step caught the stuck row right after each release, flagging it with the exact seconds since executed_at so the on-call engineer had already-verified evidence when they ran bin/magento cron:unlock instead of guessing.

What good looks like

After this runs on a schedule, a crashed cron process is caught within one detection cycle instead of surviving silently for a day or more. The alert carries the schedule_id, the job_code, and how long it has been stuck, so whoever responds can decide fast whether it is safe to unlock. Keep the actual unlock gated behind a human check of the process table, since that is what keeps the script from fighting a job that is still legitimately running.

FAQ

Why does a Magento cron job get stuck on running forever?

Magento's cron runner writes a cron_schedule row with status running and executed_at set to now before it calls the job, then updates that row to success or error only after the job returns. If the PHP process is killed mid job, by an out of memory error, a deploy restarting PHP-FPM, a server crash, or an infinite loop, that final update never happens. The row is left on running forever, and Magento only reschedules it once the cron group's configured max_run_time has passed, which defaults to twenty four hours.

How do I tell if a cron job is truly stuck versus just running long?

Check how long the cron_schedule row has been on running by comparing executed_at against now. A conservative operational threshold such as two hours is usually far shorter than Magento's own twenty four hour max_run_time default, so a row past that threshold while its job code is also blocking new schedules is the signature of a crashed process rather than a slow but live one.

Can a script fix a stuck cron job automatically over the REST API?

Not safely by itself. There is no public REST resource for cron_schedule, and the real repair, bin/magento cron:unlock or an UPDATE on the stale row, needs CLI or database access. A script can detect the stuck row through a database read and correlate it against REST-facing symptoms such as stale indexers or unprocessed orders, but the unlock step should stay gated behind a dry run and a confirmed timeout.

Related field notes

Citations

On the problem:

  1. GitHub Issue: cron job not running after crashed once. github.com/magento/magento2/issues/23054
  2. Magento knowledge base: cron jobs stuck in running status. github.com/magento/knowledge-base cron job stuck in running status
  3. Magento Community: cron job won't run, status is missed, one is stuck in running. community.magento.com cron job won't run

On the solution:

  1. Adobe Commerce: configure and run cron jobs. experienceleague.adobe.com configure and run cron jobs
  2. Adobe Commerce Cloud: the crons property. experienceleague.adobe.com crons property
  3. Magento source: ProcessCronQueueObserver. github.com/magento/magento2 ProcessCronQueueObserver.php

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce cron, indexing, catalog data, orders, or inventory 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 stuck cron job?

If this saved you a starved job group or a confusing silent backlog, 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