Skip to content

Diagnostic Indexing

Magento indexer stuck at reindex required or processing

The admin grid says Reindex required. The storefront shows a stale price, or a product that should be out of stock is still buyable. You run bin/magento indexer:reindex and nothing happens, or it just says the indexer is already running. Nobody killed it on purpose. A cron run crashed partway through weeks ago, and Magento has believed ever since that the job is still in progress. Here is why that lock never clears itself and a small script that finds it before the changelog backlog makes the eventual catch-up worse.

Python and Node.js REST API plus DB checks Safe by default (report first)
Working in front of many monitors
Photo by Esten Erbol on Unsplash
The short answer

Update on Schedule indexers rely on a cron job that flips a row in indexer_state to working before it processes the *_cl changelog tables, then back to valid when it is done. If that process is killed, by an out of memory kill, a deploy restarting PHP-FPM, or a fatal error, the row is left on working forever, and Magento refuses to run the indexer again. There is no public REST resource for indexer control, so a script cannot fix this over the API alone. It can, however, poll the products endpoint and the changelog backlog to confirm the symptom and flag the exact indexer that is stuck, so a human or a deploy pipeline runs the real reset with database access. Full code, tests, and a dry run guard are below.

The problem in plain words

Most Magento catalogs run their heavier indexers, such as catalog_product_price and catalogsearch_fulltext, in Update on Schedule mode instead of Update on Save. Instead of reindexing the moment a product changes, Magento writes the change into a changelog table, and a cron job called indexer_update_all_views works through that backlog on a timer.

Before it starts, that cron job marks the indexer's row in indexer_state as working, so nothing else tries to run the same indexer at the same time. When it finishes, it flips the row back to valid or invalid. That is a reasonable lock, as long as the process that set it always gets to finish.

It does not always get to finish. A memory limited host runs out of RAM halfway through a large catalog and the PHP process is killed. A deploy restarts PHP-FPM while cron is mid-run. A CLI reindex hits a fatal error, or gets SIGKILL'd by a timeout. In every one of these cases, the code that would flip the row back to valid never executes. The lock is left exactly where it was, permanently claiming the indexer is still running, and every later attempt, whether it is the next cron tick or a manual bin/magento indexer:reindex, either refuses to proceed or silently does nothing. Meanwhile the changelog tables keep collecting rows nobody is reading, so the eventual catch-up run is bigger and more likely to time out again.

Cron starts indexer_state = working Processing changelog catalog_product_price_cl OOM, deploy, or fatal error Process killed row never flips back Stuck on working forever Storefront serves stale price and stock data
The lock is only ever cleared by the same process that set it. If that process dies mid-run, nothing else clears the lock, so the indexer reports working forever while the changelog backlog keeps growing.

Why it happens

This exact failure mode shows up repeatedly in Magento's own issue tracker and community forum: on-schedule indexers reported stuck in working status, and a lock for a running indexer that does not clear even after the process is long gone. See the citations at the end for the specific threads.

The key insight

There is no public /V1/indexer REST resource, because indexer control in Magento is a CLI and database concern, not a Web API concern. So a script cannot safely reset the lock through the REST API alone. What it can do is confirm the symptom from the outside: read indexer_state and mview_state directly, cross check staleness against the storefront-facing /V1/products endpoint, and inspect the *_cl changelog tables for a backlog that is far bigger than normal cron throughput. That is enough to tell a stale-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 indexer process. We add a job that reads the indexer_state and mview_state rows, decides whether a working row is merely slow or actually stuck using age and changelog backlog size, and either reports it as fine, flags it for review, or, only when shell and database access is confirmed available and the process is not still running, resets it and reindexes.

Scheduled job runs on a timer Read indexer_state and mview_state rows Check age and backlog *_cl row counts Stale past threshold? yes no, report ok Flag or reset operator or pipeline acts
The script only flags or resets a working row once it is stale past the threshold. Everything still running normally is left alone.

Build it step by step

1

Get an admin bearer token

The script authenticates like any other Magento REST client. 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 STUCK_THRESHOLD_MINUTES="60"
export CHANGELOG_BACKLOG_MAX="5000"
export DRY_RUN="true"   # start safe, change to false to allow the repair 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 STUCK_THRESHOLD_MINUTES="60"
export CHANGELOG_BACKLOG_MAX="5000"
export DRY_RUN="true"   // start safe, change to false to allow the repair 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.

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 indexer_state rows and the changelog backlog

Indexer control 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 indexer_id, status, and updated_at. The matching *_cl table, for example catalog_product_price_cl, gives a row count that tells you how far behind the indexer has fallen.

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

INDEXER_STATE_SQL = """
SELECT indexer_id, view_id, status, updated_at
FROM indexer_state
"""

CHANGELOG_COUNT_SQL = """
SELECT COUNT(*) FROM {changelog_table}
"""

def fetch_indexer_rows(db):
    return db.query(INDEXER_STATE_SQL)

def fetch_changelog_backlog(db, changelog_table):
    return db.query(CHANGELOG_COUNT_SQL.format(changelog_table=changelog_table))[0][0]
step3.js
// Pseudocode for the DB reads this script depends on.
// Wire these to whatever read-only DB access your deploy provides.

const INDEXER_STATE_SQL = `
  SELECT indexer_id, view_id, status, updated_at
  FROM indexer_state
`;

function fetchIndexerRows(db) {
  return db.query(INDEXER_STATE_SQL);
}

function fetchChangelogBacklog(db, changelogTable) {
  return db.query(`SELECT COUNT(*) AS n FROM ${changelogTable}`).then((r) => r[0].n);
}
4

Decide, with one pure function

Keep the decision in its own function that takes a row, the current time, and the thresholds, and returns an action. A pure function like this is easy to read and easy to test, which we do later. Anything not on working is fine. A working row still inside its expected run time is fine. Past the threshold, a large changelog backlog means the indexer is starved rather than crashed, so that gets its own flag. Otherwise a stale working row is a reset candidate, meaning it looks like a crashed process still holding the lock.

decide.py
def classify_indexer_row(row, now, thresholds, changelog_row_count=None):
    if row["status"] != "working":
        return {"action": "ok", "reason": "not currently working"}

    age_minutes = (now - row["updatedAt"]).total_seconds() / 60

    if age_minutes <= thresholds["stuckWorkingMinutes"]:
        return {"action": "ok", "reason": "still within expected run time"}

    backlog_max = thresholds.get("changelogBacklogMax")
    if changelog_row_count is not None and backlog_max is not None and changelog_row_count > backlog_max:
        return {"action": "flag_backlog", "reason": "changelog backlog exceeds threshold, indexer likely starved"}

    return {"action": "reset_candidate", "reason": "working status stale beyond threshold, indicates crashed process holding lock"}
decide.js
export function classifyIndexerRow(row, now, thresholds, changelogRowCount) {
  if (row.status !== "working") {
    return { action: "ok", reason: "not currently working" };
  }

  const ageMinutes = (now.getTime() - new Date(row.updatedAt).getTime()) / 60000;

  if (ageMinutes <= thresholds.stuckWorkingMinutes) {
    return { action: "ok", reason: "still within expected run time" };
  }

  const backlogMax = thresholds.changelogBacklogMax;
  if (changelogRowCount !== undefined && backlogMax !== undefined && changelogRowCount > backlogMax) {
    return { action: "flag_backlog", reason: "changelog backlog exceeds threshold, indexer likely starved" };
  }

  return { action: "reset_candidate", reason: "working status stale beyond threshold, indicates crashed process holding lock" };
}
5

Cross check against the storefront-facing catalog

A stuck indexer is confirmed when the products the changelog says should have moved still look old on the storefront-facing side. Call GET /rest/V1/products with a searchCriteria filter on updated_at using gteq to pull recently changed SKUs, then compare their price and status fields against what you expect. This does not replace the database read, it corroborates it.

recent_products.py
def recently_updated_products(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[pageSize]": 100,
        "searchCriteria[currentPage]": 1,
    }
    return magento_get("/products", params)["items"]
recent-products.js
async function recentlyUpdatedProducts(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[pageSize]": 100,
    "searchCriteria[currentPage]": 1,
  };
  const data = await magentoGet("/products", params);
  return data.items;
}
6

Report by default, repair only when gated

The default output is a structured alert per stuck indexer: its id, the stale status, minutes since updated_at, and the changelog backlog size, for an operator or a deploy pipeline to act on. The repair path, bin/magento indexer:reset plus clearing mview_state and lock files, is destructive if run against a job that is genuinely still alive, so it only runs when DRY_RUN is false, the caller has confirmed shell and database access, and there is no matching cron_schedule row reporting running within a sane runtime bound.

Run it safe

Always start with DRY_RUN=true. The reset commands are destructive against a truly still-running job, so treat a reset_candidate as a lead to check the process table and cron_schedule before anyone runs bin/magento indexer:reset for real.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, checks the indexer rows against the thresholds, cross checks the catalog 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_indexers.py
"""Flag Magento 2 indexers stuck at Reindex required or Processing, safely.

Update on Schedule indexers hold indexer_state.status = 'working' while a cron
job processes the changelog tables, then flip it back to 'valid' when done. If
that cron process is killed (an OOM, a deploy restarting PHP-FPM, a fatal
error), the row never flips back, and Magento believes the indexer is stuck
running forever. There is no public REST resource for indexer control, so
this reports by default and only gates a real reset behind DRY_RUN=false plus
a confirmed-dead process. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import datetime
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
STUCK_THRESHOLD_MINUTES = float(os.environ.get("STUCK_THRESHOLD_MINUTES", "60"))
CHANGELOG_BACKLOG_MAX = int(os.environ.get("CHANGELOG_BACKLOG_MAX", "5000"))
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_indexer_row(row, now, thresholds, changelog_row_count=None):
    if row["status"] != "working":
        return {"action": "ok", "reason": "not currently working"}

    age_minutes = (now - row["updatedAt"]).total_seconds() / 60

    if age_minutes <= thresholds["stuckWorkingMinutes"]:
        return {"action": "ok", "reason": "still within expected run time"}

    backlog_max = thresholds.get("changelogBacklogMax")
    if changelog_row_count is not None and backlog_max is not None and changelog_row_count > backlog_max:
        return {"action": "flag_backlog", "reason": "changelog backlog exceeds threshold, indexer likely starved"}

    return {"action": "reset_candidate", "reason": "working status stale beyond threshold, indicates crashed process holding lock"}


def recently_updated_products(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[pageSize]": 100,
        "searchCriteria[currentPage]": 1,
    }
    return magento_get("/products", params)["items"]


def fetch_indexer_rows(db):
    """db is a caller-supplied read-only handle to indexer_state (and mview_state).
    Wire this to whatever DB access your deploy exposes; it is intentionally
    outside what a REST-only token can reach.
    """
    return db.query(
        "SELECT indexer_id, view_id, status, updated_at FROM indexer_state"
    )


def fetch_changelog_backlog(db, changelog_table):
    rows = db.query(f"SELECT COUNT(*) AS n FROM {changelog_table}")
    return rows[0]["n"]


def run(db=None, changelog_tables=None):
    changelog_tables = changelog_tables or {}
    now = datetime.datetime.now(datetime.timezone.utc)
    thresholds = {
        "stuckWorkingMinutes": STUCK_THRESHOLD_MINUTES,
        "changelogBacklogMax": CHANGELOG_BACKLOG_MAX,
    }

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

    flagged = 0
    for row in fetch_indexer_rows(db):
        changelog_table = changelog_tables.get(row["indexer_id"])
        backlog = fetch_changelog_backlog(db, changelog_table) if changelog_table else None

        classified_row = {"status": row["status"], "updatedAt": row["updated_at"]}
        result = classify_indexer_row(classified_row, now, thresholds, backlog)

        if result["action"] == "ok":
            continue

        age_minutes = (now - row["updated_at"]).total_seconds() / 60
        log.warning(
            "Indexer %s: %s (stuck %.0f min, backlog=%s). %s",
            row["indexer_id"], result["action"], age_minutes, backlog,
            "would reset" if (result["action"] == "reset_candidate" and not DRY_RUN) else "reporting only",
        )
        flagged += 1

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


if __name__ == "__main__":
    run()
flag-stuck-indexers.js
/**
 * Flag Magento 2 indexers stuck at Reindex required or Processing, safely.
 *
 * Update on Schedule indexers hold indexer_state.status = 'working' while a
 * cron job processes the changelog tables, then flip it back to 'valid' when
 * done. If that cron process is killed (an OOM, a deploy restarting
 * PHP-FPM, a fatal error), the row never flips back, and Magento believes
 * the indexer is stuck running forever. There is no public REST resource
 * for indexer control, so this reports by default and only gates a real
 * reset behind DRY_RUN=false plus a confirmed-dead process. Run on a
 * schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/indexer-stuck-reindex-required/
 */
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 STUCK_THRESHOLD_MINUTES = Number(process.env.STUCK_THRESHOLD_MINUTES || 60);
const CHANGELOG_BACKLOG_MAX = Number(process.env.CHANGELOG_BACKLOG_MAX || 5000);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function classifyIndexerRow(row, now, thresholds, changelogRowCount) {
  if (row.status !== "working") {
    return { action: "ok", reason: "not currently working" };
  }

  const ageMinutes = (now.getTime() - new Date(row.updatedAt).getTime()) / 60000;

  if (ageMinutes <= thresholds.stuckWorkingMinutes) {
    return { action: "ok", reason: "still within expected run time" };
  }

  const backlogMax = thresholds.changelogBacklogMax;
  if (changelogRowCount !== undefined && backlogMax !== undefined && changelogRowCount > backlogMax) {
    return { action: "flag_backlog", reason: "changelog backlog exceeds threshold, indexer likely starved" };
  }

  return { action: "reset_candidate", reason: "working status stale beyond threshold, indicates crashed process holding lock" };
}

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 recentlyUpdatedProducts(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[pageSize]": 100,
    "searchCriteria[currentPage]": 1,
  };
  const data = await magentoGet("/products", params);
  return data.items;
}

async function fetchIndexerRows(db) {
  // db is a caller-supplied read-only handle to indexer_state (and mview_state).
  // Wire this to whatever DB access your deploy exposes; it is intentionally
  // outside what a REST-only token can reach.
  return db.query("SELECT indexer_id, view_id, status, updated_at FROM indexer_state");
}

async function fetchChangelogBacklog(db, changelogTable) {
  const rows = await db.query(`SELECT COUNT(*) AS n FROM ${changelogTable}`);
  return rows[0].n;
}

export async function run(db, changelogTables = {}) {
  const now = new Date();
  const thresholds = {
    stuckWorkingMinutes: STUCK_THRESHOLD_MINUTES,
    changelogBacklogMax: CHANGELOG_BACKLOG_MAX,
  };

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

  let flagged = 0;
  const rows = await fetchIndexerRows(db);
  for (const row of rows) {
    const changelogTable = changelogTables[row.indexer_id];
    const backlog = changelogTable ? await fetchChangelogBacklog(db, changelogTable) : undefined;

    const classifiedRow = { status: row.status, updatedAt: row.updated_at };
    const result = classifyIndexerRow(classifiedRow, now, thresholds, backlog);

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

    const ageMinutes = (now.getTime() - new Date(row.updated_at).getTime()) / 60000;
    console.warn(
      `Indexer ${row.indexer_id}: ${result.action} (stuck ${ageMinutes.toFixed(0)} min, backlog=${backlog}). ${
        result.action === "reset_candidate" && !DRY_RUN ? "would reset" : "reporting only"
      }`
    );
    flagged++;
  }

  console.log(`Done. ${flagged} indexer(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 an indexer gets flagged, reported as a backlog issue, or left alone. Because we kept classify_indexer_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_indexer_classify.py
import datetime
from flag_stuck_indexers import classify_indexer_row

NOW = datetime.datetime(2026, 7, 10, 12, 0, 0, tzinfo=datetime.timezone.utc)
THRESHOLDS = {"stuckWorkingMinutes": 60, "changelogBacklogMax": 5000}


def row(**over):
    base = {"status": "working", "updatedAt": NOW - datetime.timedelta(minutes=90)}
    base.update(over)
    return base


def test_ok_when_not_working():
    r = row(status="valid")
    assert classify_indexer_row(r, NOW, THRESHOLDS)["action"] == "ok"


def test_ok_when_working_within_threshold():
    r = row(updatedAt=NOW - datetime.timedelta(minutes=10))
    assert classify_indexer_row(r, NOW, THRESHOLDS)["action"] == "ok"


def test_reset_candidate_when_stale_and_no_backlog_info():
    assert classify_indexer_row(row(), NOW, THRESHOLDS)["action"] == "reset_candidate"


def test_flag_backlog_when_stale_and_backlog_exceeds_max():
    result = classify_indexer_row(row(), NOW, THRESHOLDS, changelog_row_count=9000)
    assert result["action"] == "flag_backlog"


def test_reset_candidate_when_stale_and_backlog_within_max():
    result = classify_indexer_row(row(), NOW, THRESHOLDS, changelog_row_count=100)
    assert result["action"] == "reset_candidate"


def test_exactly_at_threshold_is_ok():
    r = row(updatedAt=NOW - datetime.timedelta(minutes=60))
    assert classify_indexer_row(r, NOW, THRESHOLDS)["action"] == "ok"
indexer-classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyIndexerRow } from "./flag-stuck-indexers.js";

const NOW = new Date("2026-07-10T12:00:00Z");
const THRESHOLDS = { stuckWorkingMinutes: 60, changelogBacklogMax: 5000 };

const row = (over = {}) => ({
  status: "working",
  updatedAt: new Date(NOW.getTime() - 90 * 60000).toISOString(),
  ...over,
});

test("ok when not working", () => {
  assert.equal(classifyIndexerRow(row({ status: "valid" }), NOW, THRESHOLDS).action, "ok");
});

test("ok when working within threshold", () => {
  const r = row({ updatedAt: new Date(NOW.getTime() - 10 * 60000).toISOString() });
  assert.equal(classifyIndexerRow(r, NOW, THRESHOLDS).action, "ok");
});

test("reset candidate when stale and no backlog info", () => {
  assert.equal(classifyIndexerRow(row(), NOW, THRESHOLDS).action, "reset_candidate");
});

test("flag backlog when stale and backlog exceeds max", () => {
  const result = classifyIndexerRow(row(), NOW, THRESHOLDS, 9000);
  assert.equal(result.action, "flag_backlog");
});

test("reset candidate when stale and backlog within max", () => {
  const result = classifyIndexerRow(row(), NOW, THRESHOLDS, 100);
  assert.equal(result.action, "reset_candidate");
});

test("exactly at threshold is ok", () => {
  const r = row({ updatedAt: new Date(NOW.getTime() - 60 * 60000).toISOString() });
  assert.equal(classifyIndexerRow(r, NOW, THRESHOLDS).action, "ok");
});

Case studies

Out of memory

The catalog that crashed cron every third night

A mid-size catalog on a memory-limited box ran catalog_product_price on schedule. A few nights a week, a large batch of price changes pushed the cron job past its memory limit and the process was killed. Nobody noticed until customers started reporting old prices at checkout, days after the actual price change.

The team added the detection job hourly. It flagged the indexer as a reset_candidate within the first stuck cycle, well before the changelog backlog reached a size that would have made the eventual catch-up run time out too. They then upsized the host and kept the check running as a safety net.

Deploy timing

The search index that always lagged after a release

Every deploy restarted PHP-FPM as part of the rollout. Whenever that restart landed mid-cron, catalogsearch_fulltext was left on working, and search results silently stopped reflecting new products until someone eventually ran a manual reindex.

Adding this check as a post-deploy step caught the stuck state right after each release, flagging it with the exact minutes since updated_at so the on-call engineer had already-verified evidence when they ran bin/magento indexer:reset catalogsearch_fulltext instead of guessing.

What good looks like

After this runs on a schedule, a crashed cron run is caught within one detection cycle instead of surviving silently for weeks. The alert carries the indexer id, how long it has been stuck, and the changelog backlog size, so whoever responds can decide fast whether it is safe to reset. Keep the actual reset gated behind confirmed process state, since that is what keeps the script from fighting a job that is still legitimately running.

FAQ

Why does my Magento indexer stay stuck on Reindex required or Processing?

On Update on Schedule mode, a cron job flips a row in the indexer_state table to working before it processes the changelog tables, then back to valid when it finishes. If that cron process is killed partway through, such as an out of memory error, a deploy restarting PHP-FPM, or a fatal error, the row never flips back. Magento then believes the indexer is still running forever, and every later cron tick or manual reindex sees working and refuses to proceed.

How do I tell if an indexer is truly crashed versus just slow?

Check how long the indexer_state row has been on working. If updated_at is older than roughly two times the indexer's normal run time, and the matching changelog table such as catalog_product_price_cl is growing far past normal cron throughput, that is the signature of a crashed lock rather than a slow but live process.

Can a script fix a stuck indexer automatically over the REST API?

Not safely by itself. There is no public REST resource for indexer control, and the actual repair, bin/magento indexer:reset plus updating mview_state, needs CLI or database access. A script can detect the stuck state through the REST API and changelog counts and report it, but the reset step should be gated on confirming the process is not genuinely still running.

Related field notes

Citations

On the problem:

  1. GitHub Issue: on-schedule indexers stuck in working status. github.com/magento/magento2/issues/36724
  2. GitHub Issue: lock for running indexer does not work. github.com/magento/magento2/issues/12266
  3. Magento Forums: indexer stuck in processing. community.magento.com indexer stuck in processing

On the solution:

  1. Adobe Commerce: manage the indexers, including indexer:reset and indexer:reindex. experienceleague.adobe.com manage indexers
  2. Adobe Commerce: REST API reference. developer.adobe.com/commerce/webapi/rest/reference
  3. Adobe Commerce: Web API tutorials, including searchCriteria. developer.adobe.com/commerce/webapi/rest/tutorials

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce 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 indexer?

If this saved you a stale catalog or a confusing reindex loop, 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