Diagnostic Indexing
Catalog price rule cron failure blocks downstream indexers
You added a store view, or a new catalog price rule went live, and now nothing seems to reindex. Prices are wrong, search results feel stale, and it is not just that one store, it is every store on the install. Here is why a single failing catalogrule_apply_all run can hold a lock that stops indexer_reindex_all_invalid and indexer_update_all_views cold, and a small script that finds the SKUs and cron rows that prove it.
The catalogrule_apply_all cron (module-catalog-rule, runs daily by default) recalculates catalog price rule prices and invalidates the catalog_product_price and catalogsearch_fulltext indexers for every store view. When a new store view has an incomplete locale or timezone setup, or a rule's catalogrule_product relationship has not been built yet, the job throws and exits non zero. Magento's scheduler treats that failed run as still holding its lock, so the next indexer_reindex_all_invalid or indexer_update_all_views run cannot acquire it and never executes, leaving rule prices unapplied and every scheduled indexer stale across every store. Run a small Python or Node.js script that computes the expected rule discounted price for representative SKUs, compares it to the live storefront price per store, and separately checks whether the relevant cron_schedule rows are in an error or stuck running state. Full code, tests, and a dry run guard are below.
The problem in plain words
Catalog price rules do not compute their discount at storefront read time. Magento precomputes the ruled price once a day through catalogrule_apply_all, which walks every active rule, every website it targets, and every store view under that website, and writes the resulting price into the index tables that the storefront actually reads. That same cron job also invalidates the dependent indexers, catalog_product_price and catalogsearch_fulltext, so they know they have work to do.
The trouble starts when that daily job cannot finish cleanly for one store view. A brand new store view with an incomplete locale or timezone configuration can make the timezone lookup inside Magento\Framework\Stdlib\DateTime\Timezone throw. A rule whose catalogrule_product relationship table has not been populated yet, which only happens when a rule is saved or applied in the Admin or a product is saved, and never by the daily cron itself, can leave the job trying to call a method on something that is not there, producing errors like "Call to a member function setData() on boolean." Either way the cron process exits non zero mid run.
Why it happens
None of this is a bug in one specific rule. It is a property of how a single shared cron job covers every store view and how Magento's cron scheduler treats a lock. A few concrete ways it shows up on real stores:
- A newly created store view is missing or has an inconsistent locale or timezone configuration, and the timezone lookup in
Magento\Framework\Stdlib\DateTime\Timezonethrows whilecatalogrule_apply_allis iterating that view. - A catalog price rule was just created or edited, but its
catalogrule_productrelationship table has not been rebuilt yet, since that table only populates when a rule is saved or applied in the Admin or a product is saved, not by the daily cron itself, and the job hits a null it did not expect. - Magento's cron scheduler treats a job that exited without recording success as still "running" or in an error state that holds its lock, so
indexer_reindex_all_invalidandindexer_update_all_viewslog "Could not acquire lock for cron job" and simply do not run. - Because the lock is shared across the whole install rather than scoped to one store, the failure on a brand new store view stalls scheduled indexing for every store, including ones that were working fine.
Nothing about this raises a storefront error a shopper would see. Rule prices just quietly stop updating, and catalog search results drift further from the catalog every day the lock stays stuck. See the citations at the end for the exact issue threads and forum reports that describe this behavior.
Clearing a stuck cron lock and forcing catalogrule_apply_all are CLI and database level operations, not something safe to trigger blindly over REST. Resetting locks without understanding why the job failed can mask a real configuration error, and forcing a full reindex during business hours can be expensive. So the honest move is to detect the stuck state precisely, from both the pricing side and the cron side, and report it, leaving the actual fix to an operator with shell access.
The fix, as a flow
We do not call catalogrule_apply_all or touch cron locks directly. We enumerate active catalog price rules, compute the discounted price each one should produce for representative SKUs, and compare that to the live per store price returned by the Products REST endpoint. We separately read cron_schedule for the relevant job codes to see if any are stuck in error or a stale running state. Only when both signals agree, a real price mismatch and a stuck cron row, do we call the situation stuck, and only with an operator's explicit opt in do we perform the one narrow, reversible repair: resetting the specific stuck row to missed.
Build it step by step
Get an admin token
Authenticate against the admin token endpoint with an admin username and password, or use an integration access token if you already have one. Keep the base URL and credentials in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export STORE_CODES="default,eu_de"
export LOCK_TIMEOUT_MINUTES="15"
export DRY_RUN="true" # start safe, change to false only to reset a stuck cron_schedule row
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export STORE_CODES="default,eu_de"
export LOCK_TIMEOUT_MINUTES="15"
export DRY_RUN="true" // start safe, change to false only to reset a stuck cron_schedule row
Enumerate active catalog price rules and target SKUs
There is no public /V1/catalogRule REST endpoint, so this script works from a list of rules you already know about, their rule_id, website_ids, discount type, and amount, alongside representative SKUs each rule targets. Read each SKU's base admin price with GET /rest/V1/products/{sku}.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
def get_token(username, password):
r = requests.post(
f"{MAGENTO_URL}/rest/V1/integration/admin/token",
json={"username": username, "password": password},
timeout=30,
)
r.raise_for_status()
return r.json()
def base_price(token, sku):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/products/{sku}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["price"]
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
async function getToken(username, password) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function basePrice(token, sku) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${encodeURIComponent(sku)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
return body.price;
}
Read the live per store price
For each store view a rule's website targets, fetch the store scoped product with GET /rest/{storeCode}/V1/products/{sku}. That is the price the storefront is actually serving, computed with whatever the price index currently holds, rule applied or not.
def live_price(token, store_code, sku):
r = requests.get(
f"{MAGENTO_URL}/rest/{store_code}/V1/products/{sku}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["price"]
async function livePrice(token, storeCode, sku) {
const res = await fetch(`${MAGENTO_URL}/rest/${storeCode}/V1/products/${encodeURIComponent(sku)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
return body.price;
}
Decide, with one pure function
Keep the decision in its own function that takes only already fetched values: the active rules, the base and live prices per SKU and store, and the cron_schedule rows for the three job codes that matter. It computes the expected discounted price from each applicable rule, flags SKUs where that expected price disagrees with the live price by more than a cent, separately scans the cron rows for an error status or a running status stuck past the lock timeout, and only calls the whole thing stuck when both a price mismatch and a stale cron job are present.
from datetime import datetime
STUCK_JOB_CODES = {"catalogrule_apply_all", "indexer_reindex_all_invalid", "indexer_update_all_views"}
def detect_stuck_catalog_rule_pricing(rules, product_prices, cron_rows, now_iso, lock_timeout_minutes=15):
now = _parse(now_iso)
affected_skus = []
affected_rule_ids = set()
for pp in product_prices:
for rule in rules:
if pp["storeId"] not in _store_ids_for_rule(rule):
continue
if not _rule_active(rule, now_iso):
continue
expected = _expected_price(pp["basePrice"], rule)
if abs(expected - pp["livePrice"]) > 0.01:
affected_skus.append(pp["sku"])
affected_rule_ids.add(rule["ruleId"])
stale_cron_jobs = []
for row in cron_rows:
if row["jobCode"] not in STUCK_JOB_CODES:
continue
if row["status"] == "error":
stale_cron_jobs.append(row["jobCode"])
elif row["status"] == "running":
age_minutes = (now - _parse(row["scheduledAt"])).total_seconds() / 60
if age_minutes > lock_timeout_minutes:
stale_cron_jobs.append(row["jobCode"])
stuck = len(affected_skus) > 0 and len(stale_cron_jobs) > 0
return {
"stuck": stuck,
"affectedSkus": sorted(set(affected_skus)),
"affectedRuleIds": sorted(affected_rule_ids),
"staleCronJobs": sorted(set(stale_cron_jobs)),
}
def _expected_price(base_price, rule):
if rule["simpleAction"] == "by_percent":
return base_price * (1 - rule["discountAmount"] / 100)
return base_price - rule["discountAmount"]
def _rule_active(rule, now_iso):
if rule.get("fromDate") and now_iso < rule["fromDate"]:
return False
if rule.get("toDate") and now_iso > rule["toDate"]:
return False
return True
def _store_ids_for_rule(rule):
# websiteIds stand in for the stores they own for this comparison;
# callers pass storeId values already scoped to a rule's websites.
return set(rule["websiteIds"])
def _parse(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
const STUCK_JOB_CODES = new Set(["catalogrule_apply_all", "indexer_reindex_all_invalid", "indexer_update_all_views"]);
function expectedPrice(basePrice, rule) {
if (rule.simpleAction === "by_percent") return basePrice * (1 - rule.discountAmount / 100);
return basePrice - rule.discountAmount;
}
function ruleActive(rule, nowIso) {
if (rule.fromDate && nowIso < rule.fromDate) return false;
if (rule.toDate && nowIso > rule.toDate) return false;
return true;
}
export function detectStuckCatalogRulePricing(rules, productPrices, cronRows, nowIso, lockTimeoutMinutes = 15) {
const now = new Date(nowIso);
const affectedSkus = new Set();
const affectedRuleIds = new Set();
for (const pp of productPrices) {
for (const rule of rules) {
if (!rule.websiteIds.includes(pp.storeId)) continue;
if (!ruleActive(rule, nowIso)) continue;
const expected = expectedPrice(pp.basePrice, rule);
if (Math.abs(expected - pp.livePrice) > 0.01) {
affectedSkus.add(pp.sku);
affectedRuleIds.add(rule.ruleId);
}
}
}
const staleCronJobs = new Set();
for (const row of cronRows) {
if (!STUCK_JOB_CODES.has(row.jobCode)) continue;
if (row.status === "error") {
staleCronJobs.add(row.jobCode);
} else if (row.status === "running") {
const ageMinutes = (now - new Date(row.scheduledAt)) / 60000;
if (ageMinutes > lockTimeoutMinutes) staleCronJobs.add(row.jobCode);
}
}
const stuck = affectedSkus.size > 0 && staleCronJobs.size > 0;
return {
stuck,
affectedSkus: [...affectedSkus].sort(),
affectedRuleIds: [...affectedRuleIds].sort((a, b) => a - b),
staleCronJobs: [...staleCronJobs].sort(),
};
}
The only narrow, reversible repair
The script never forces catalogrule_apply_all or a full reindex. When DRY_RUN is off and an operator has explicitly confirmed, the one narrow repair is resetting the specific stuck cron_schedule rows back to missed, so the scheduler can re-acquire the lock on its next natural run. This is a database level operation, so it needs a direct connection with write access, not the REST API.
# Narrowly scoped and reversible: only touches the exact stuck job codes,
# only rows still marked running, and only ones past the lock timeout.
# Run manually by an operator with DB access, never from this script itself.
UPDATE cron_schedule
SET status = 'missed'
WHERE job_code IN ('catalogrule_apply_all', 'indexer_reindex_all_invalid', 'indexer_update_all_views')
AND status = 'running'
AND scheduled_at < NOW() - INTERVAL 15 MINUTE;
// Narrowly scoped and reversible: only touches the exact stuck job codes,
// only rows still marked running, and only ones past the lock timeout.
// Run manually by an operator with DB access, never from this script itself.
UPDATE cron_schedule
SET status = 'missed'
WHERE job_code IN ('catalogrule_apply_all', 'indexer_reindex_all_invalid', 'indexer_update_all_views')
AND status = 'running'
AND scheduled_at < NOW() - INTERVAL 15 MINUTE;
Wire it together with a dry run guard
The loop authenticates once, reads base and live prices for your configured SKUs across your configured stores, calls the pure detection function, and reports. Notice the dry run guard. This script only ever reports, by design, since resetting the lock or forcing a reindex is CLI and database territory that belongs to an operator, not to a scheduled job running against production.
This script never calls catalogrule_apply_all, never runs bin/magento indexer:reindex, and never writes to cron_schedule itself. It only reports the affected rule ids, SKUs, price deltas, and the stale cron job codes. Resetting the lock is a manual, narrowly scoped SQL statement an operator runs after reading the report, not an automatic action.
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 never performs the database level lock reset itself, it only tells you the exact rows to look at.
"""Detect a stuck Magento 2 or Adobe Commerce catalogrule_apply_all lock.
catalogrule_apply_all recalculates catalog price rule prices and invalidates
catalog_product_price and catalogsearch_fulltext for every store view. A new
store view with an incomplete locale or timezone setup, or a rule whose
catalogrule_product relationship has not been built yet, can make the job
throw and exit non zero. Magento's scheduler then treats the lock as still
held, so indexer_reindex_all_invalid and indexer_update_all_views cannot
acquire it and stop running for every store. This script compares the
expected rule discounted price to the live storefront price, and separately
checks cron_schedule for error or stale running rows on the relevant job
codes. It never forces catalogrule_apply_all, a reindex, or a cron_schedule
write itself: that is CLI and database operator territory. Safe to run
again and again.
"""
import os
import json
import logging
import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_stuck_catalog_rule")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
ADMIN_USERNAME = os.environ.get("MAGENTO_ADMIN_USERNAME")
ADMIN_PASSWORD = os.environ.get("MAGENTO_ADMIN_PASSWORD")
ADMIN_TOKEN = os.environ.get("MAGENTO_ADMIN_TOKEN")
STORE_CODES = [s.strip() for s in os.environ.get("STORE_CODES", "default").split(",") if s.strip()]
LOCK_TIMEOUT_MINUTES = float(os.environ.get("LOCK_TIMEOUT_MINUTES", "15"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_JSON = os.environ.get("OUTPUT_JSON", "stuck_catalog_rule_report.json")
# Rules and cron rows are supplied by the operator: there is no public
# /V1/catalogRule REST endpoint, and cron_schedule is a database table with
# no REST route. Populate these from your own Admin/DB access, or wire in
# your own fetchers where noted below.
RULES = json.loads(os.environ.get("RULES_JSON", "[]"))
CRON_ROWS = json.loads(os.environ.get("CRON_ROWS_JSON", "[]"))
SKUS = [s.strip() for s in os.environ.get("SKUS", "").split(",") if s.strip()]
STUCK_JOB_CODES = {"catalogrule_apply_all", "indexer_reindex_all_invalid", "indexer_update_all_views"}
def get_token():
if ADMIN_TOKEN:
return ADMIN_TOKEN
r = requests.post(
f"{MAGENTO_URL}/rest/V1/integration/admin/token",
json={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()
def base_price(token, sku):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/products/{sku}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["price"]
def live_price(token, store_code, sku):
r = requests.get(
f"{MAGENTO_URL}/rest/{store_code}/V1/products/{sku}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["price"]
def _expected_price(base, rule):
if rule["simpleAction"] == "by_percent":
return base * (1 - rule["discountAmount"] / 100)
return base - rule["discountAmount"]
def _rule_active(rule, now_iso):
if rule.get("fromDate") and now_iso < rule["fromDate"]:
return False
if rule.get("toDate") and now_iso > rule["toDate"]:
return False
return True
def _parse(value):
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
def detect_stuck_catalog_rule_pricing(rules, product_prices, cron_rows, now_iso, lock_timeout_minutes=LOCK_TIMEOUT_MINUTES):
now = _parse(now_iso)
affected_skus = []
affected_rule_ids = set()
for pp in product_prices:
for rule in rules:
if pp["storeId"] not in set(rule["websiteIds"]):
continue
if not _rule_active(rule, now_iso):
continue
expected = _expected_price(pp["basePrice"], rule)
if abs(expected - pp["livePrice"]) > 0.01:
affected_skus.append(pp["sku"])
affected_rule_ids.add(rule["ruleId"])
stale_cron_jobs = []
for row in cron_rows:
if row["jobCode"] not in STUCK_JOB_CODES:
continue
if row["status"] == "error":
stale_cron_jobs.append(row["jobCode"])
elif row["status"] == "running":
age_minutes = (now - _parse(row["scheduledAt"])).total_seconds() / 60
if age_minutes > lock_timeout_minutes:
stale_cron_jobs.append(row["jobCode"])
stuck = len(affected_skus) > 0 and len(stale_cron_jobs) > 0
return {
"stuck": stuck,
"affectedSkus": sorted(set(affected_skus)),
"affectedRuleIds": sorted(affected_rule_ids),
"staleCronJobs": sorted(set(stale_cron_jobs)),
}
def run():
token = get_token()
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
product_prices = []
for sku in SKUS:
base = base_price(token, sku)
for store_code, store_id in _store_ids(STORE_CODES).items():
try:
live = live_price(token, store_code, sku)
except requests.HTTPError as exc:
log.warning("Could not read live price for %s in %s: %s", sku, store_code, exc)
continue
product_prices.append({"sku": sku, "storeId": store_id, "basePrice": base, "livePrice": live})
result = detect_stuck_catalog_rule_pricing(RULES, product_prices, CRON_ROWS, now_iso)
log.info(
"stuck=%s affectedSkus=%s affectedRuleIds=%s staleCronJobs=%s",
result["stuck"], result["affectedSkus"], result["affectedRuleIds"], result["staleCronJobs"],
)
with open(OUTPUT_JSON, "w") as fh:
json.dump(result, fh, indent=2)
if result["stuck"] and not DRY_RUN:
log.warning(
"DRY_RUN is false, but this script never resets cron_schedule or forces "
"catalogrule_apply_all itself. Review %s and, if confirmed, run the "
"reset SQL manually with DB access.",
OUTPUT_JSON,
)
log.info("Done. Report written to %s.", OUTPUT_JSON)
def _store_ids(store_codes):
# Maps configured store codes to numeric store ids for comparison against
# rule websiteIds. Wire this to your own store code -> store id lookup,
# for example GET /rest/V1/store/storeViews, if the codes are not the ids.
return {code: idx + 1 for idx, code in enumerate(store_codes)}
if __name__ == "__main__":
run()
/**
* Detect a stuck Magento 2 or Adobe Commerce catalogrule_apply_all lock.
*
* catalogrule_apply_all recalculates catalog price rule prices and invalidates
* catalog_product_price and catalogsearch_fulltext for every store view. A new
* store view with an incomplete locale or timezone setup, or a rule whose
* catalogrule_product relationship has not been built yet, can make the job
* throw and exit non zero. Magento's scheduler then treats the lock as still
* held, so indexer_reindex_all_invalid and indexer_update_all_views cannot
* acquire it and stop running for every store. This script compares the
* expected rule discounted price to the live storefront price, and separately
* checks cron_schedule for error or stale running rows on the relevant job
* codes. It never forces catalogrule_apply_all, a reindex, or a cron_schedule
* write itself: that is CLI and database operator territory. Safe to run
* again and again.
*
* Guide: https://www.allanninal.dev/magento/catalog-price-rule-cron-blocks-indexers/
*/
import { pathToFileURL } from "node:url";
import { writeFileSync } from "node:fs";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/$/, "");
const ADMIN_USERNAME = process.env.MAGENTO_ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.MAGENTO_ADMIN_PASSWORD || "change-me";
const ADMIN_TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "";
const STORE_CODES = (process.env.STORE_CODES || "default").split(",").map((s) => s.trim()).filter(Boolean);
const LOCK_TIMEOUT_MINUTES = Number(process.env.LOCK_TIMEOUT_MINUTES || 15);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const OUTPUT_JSON = process.env.OUTPUT_JSON || "stuck_catalog_rule_report.json";
const RULES = JSON.parse(process.env.RULES_JSON || "[]");
const CRON_ROWS = JSON.parse(process.env.CRON_ROWS_JSON || "[]");
const SKUS = (process.env.SKUS || "").split(",").map((s) => s.trim()).filter(Boolean);
const STUCK_JOB_CODES = new Set(["catalogrule_apply_all", "indexer_reindex_all_invalid", "indexer_update_all_views"]);
function expectedPrice(base, rule) {
if (rule.simpleAction === "by_percent") return base * (1 - rule.discountAmount / 100);
return base - rule.discountAmount;
}
function ruleActive(rule, nowIso) {
if (rule.fromDate && nowIso < rule.fromDate) return false;
if (rule.toDate && nowIso > rule.toDate) return false;
return true;
}
export function detectStuckCatalogRulePricing(rules, productPrices, cronRows, nowIso, lockTimeoutMinutes = LOCK_TIMEOUT_MINUTES) {
const now = new Date(nowIso);
const affectedSkus = new Set();
const affectedRuleIds = new Set();
for (const pp of productPrices) {
for (const rule of rules) {
if (!rule.websiteIds.includes(pp.storeId)) continue;
if (!ruleActive(rule, nowIso)) continue;
const expected = expectedPrice(pp.basePrice, rule);
if (Math.abs(expected - pp.livePrice) > 0.01) {
affectedSkus.add(pp.sku);
affectedRuleIds.add(rule.ruleId);
}
}
}
const staleCronJobs = new Set();
for (const row of cronRows) {
if (!STUCK_JOB_CODES.has(row.jobCode)) continue;
if (row.status === "error") {
staleCronJobs.add(row.jobCode);
} else if (row.status === "running") {
const ageMinutes = (now - new Date(row.scheduledAt)) / 60000;
if (ageMinutes > lockTimeoutMinutes) staleCronJobs.add(row.jobCode);
}
}
const stuck = affectedSkus.size > 0 && staleCronJobs.size > 0;
return {
stuck,
affectedSkus: [...affectedSkus].sort(),
affectedRuleIds: [...affectedRuleIds].sort((a, b) => a - b),
staleCronJobs: [...staleCronJobs].sort(),
};
}
async function getToken() {
if (ADMIN_TOKEN) return ADMIN_TOKEN;
const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function basePrice(token, sku) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${encodeURIComponent(sku)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
return body.price;
}
async function livePrice(token, storeCode, sku) {
const res = await fetch(`${MAGENTO_URL}/rest/${storeCode}/V1/products/${encodeURIComponent(sku)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
return body.price;
}
function storeIds(storeCodes) {
// Maps configured store codes to numeric store ids for comparison against
// rule websiteIds. Wire this to your own store code -> store id lookup,
// for example GET /rest/V1/store/storeViews, if the codes are not the ids.
const map = {};
storeCodes.forEach((code, idx) => { map[code] = idx + 1; });
return map;
}
export async function run() {
const token = await getToken();
const nowIso = new Date().toISOString();
const productPrices = [];
const codeToId = storeIds(STORE_CODES);
for (const sku of SKUS) {
const base = await basePrice(token, sku);
for (const [storeCode, storeId] of Object.entries(codeToId)) {
let live;
try {
live = await livePrice(token, storeCode, sku);
} catch (err) {
console.warn(`Could not read live price for ${sku} in ${storeCode}: ${err.message}`);
continue;
}
productPrices.push({ sku, storeId, basePrice: base, livePrice: live });
}
}
const result = detectStuckCatalogRulePricing(RULES, productPrices, CRON_ROWS, nowIso);
console.log(
`stuck=${result.stuck} affectedSkus=${JSON.stringify(result.affectedSkus)} affectedRuleIds=${JSON.stringify(result.affectedRuleIds)} staleCronJobs=${JSON.stringify(result.staleCronJobs)}`,
);
writeFileSync(OUTPUT_JSON, JSON.stringify(result, null, 2));
if (result.stuck && !DRY_RUN) {
console.warn(
`DRY_RUN is false, but this script never resets cron_schedule or forces catalogrule_apply_all itself. Review ${OUTPUT_JSON} and, if confirmed, run the reset SQL manually with DB access.`,
);
}
console.log(`Done. Report written to ${OUTPUT_JSON}.`);
return result;
}
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 whether the situation is actually stuck versus just a rule that has not started yet or a cron job doing fine. Since detect_stuck_catalog_rule_pricing and detectStuckCatalogRulePricing are pure, the tests need no network and no Magento instance. They just feed in plain rule, price, and cron row objects and check the verdict.
from detect_stuck_catalog_rule import detect_stuck_catalog_rule_pricing
NOW = "2026-07-10T12:00:00Z"
def rule(**over):
base = {
"ruleId": 7,
"websiteIds": [1],
"discountAmount": 20,
"simpleAction": "by_percent",
"fromDate": None,
"toDate": None,
}
base.update(over)
return base
def price(**over):
base = {"sku": "SKU-1", "storeId": 1, "basePrice": 100.0, "livePrice": 80.0}
base.update(over)
return base
def cron_row(**over):
base = {"jobCode": "catalogrule_apply_all", "status": "error", "scheduledAt": "2026-07-10T00:00:00Z"}
base.update(over)
return base
def test_stuck_when_mismatch_and_error_cron():
result = detect_stuck_catalog_rule_pricing([rule()], [price(livePrice=100.0)], [cron_row()], NOW)
assert result["stuck"] is True
assert result["affectedSkus"] == ["SKU-1"]
assert result["affectedRuleIds"] == [7]
assert result["staleCronJobs"] == ["catalogrule_apply_all"]
def test_not_stuck_when_price_matches():
result = detect_stuck_catalog_rule_pricing([rule()], [price(livePrice=80.0)], [cron_row()], NOW)
assert result["stuck"] is False
assert result["affectedSkus"] == []
def test_not_stuck_when_cron_is_healthy():
healthy = cron_row(status="success")
result = detect_stuck_catalog_rule_pricing([rule()], [price(livePrice=100.0)], [healthy], NOW)
assert result["stuck"] is False
assert result["staleCronJobs"] == []
def test_running_within_lock_timeout_is_not_stale():
row = cron_row(status="running", scheduledAt="2026-07-10T11:50:00Z")
result = detect_stuck_catalog_rule_pricing([rule()], [price(livePrice=100.0)], [row], NOW, lock_timeout_minutes=15)
assert result["staleCronJobs"] == []
assert result["stuck"] is False
def test_running_past_lock_timeout_is_stale():
row = cron_row(status="running", scheduledAt="2026-07-10T11:30:00Z")
result = detect_stuck_catalog_rule_pricing([rule()], [price(livePrice=100.0)], [row], NOW, lock_timeout_minutes=15)
assert result["staleCronJobs"] == ["catalogrule_apply_all"]
assert result["stuck"] is True
def test_rule_not_yet_active_is_ignored():
future_rule = rule(fromDate="2026-08-01T00:00:00Z")
result = detect_stuck_catalog_rule_pricing([future_rule], [price(livePrice=100.0)], [cron_row()], NOW)
assert result["affectedSkus"] == []
assert result["stuck"] is False
def test_by_fixed_discount_computes_expected_price():
fixed_rule = rule(simpleAction="by_fixed", discountAmount=15)
result = detect_stuck_catalog_rule_pricing([fixed_rule], [price(livePrice=100.0)], [cron_row()], NOW)
assert result["affectedSkus"] == ["SKU-1"]
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectStuckCatalogRulePricing } from "./detect-stuck-catalog-rule.js";
const NOW = "2026-07-10T12:00:00Z";
const rule = (over = {}) => ({
ruleId: 7,
websiteIds: [1],
discountAmount: 20,
simpleAction: "by_percent",
fromDate: null,
toDate: null,
...over,
});
const price = (over = {}) => ({ sku: "SKU-1", storeId: 1, basePrice: 100.0, livePrice: 80.0, ...over });
const cronRow = (over = {}) => ({ jobCode: "catalogrule_apply_all", status: "error", scheduledAt: "2026-07-10T00:00:00Z", ...over });
test("stuck when mismatch and error cron", () => {
const result = detectStuckCatalogRulePricing([rule()], [price({ livePrice: 100.0 })], [cronRow()], NOW);
assert.equal(result.stuck, true);
assert.deepEqual(result.affectedSkus, ["SKU-1"]);
assert.deepEqual(result.affectedRuleIds, [7]);
assert.deepEqual(result.staleCronJobs, ["catalogrule_apply_all"]);
});
test("not stuck when price matches", () => {
const result = detectStuckCatalogRulePricing([rule()], [price({ livePrice: 80.0 })], [cronRow()], NOW);
assert.equal(result.stuck, false);
assert.deepEqual(result.affectedSkus, []);
});
test("not stuck when cron is healthy", () => {
const healthy = cronRow({ status: "success" });
const result = detectStuckCatalogRulePricing([rule()], [price({ livePrice: 100.0 })], [healthy], NOW);
assert.equal(result.stuck, false);
assert.deepEqual(result.staleCronJobs, []);
});
test("running within lock timeout is not stale", () => {
const row = cronRow({ status: "running", scheduledAt: "2026-07-10T11:50:00Z" });
const result = detectStuckCatalogRulePricing([rule()], [price({ livePrice: 100.0 })], [row], NOW, 15);
assert.deepEqual(result.staleCronJobs, []);
assert.equal(result.stuck, false);
});
test("running past lock timeout is stale", () => {
const row = cronRow({ status: "running", scheduledAt: "2026-07-10T11:30:00Z" });
const result = detectStuckCatalogRulePricing([rule()], [price({ livePrice: 100.0 })], [row], NOW, 15);
assert.deepEqual(result.staleCronJobs, ["catalogrule_apply_all"]);
assert.equal(result.stuck, true);
});
test("rule not yet active is ignored", () => {
const futureRule = rule({ fromDate: "2026-08-01T00:00:00Z" });
const result = detectStuckCatalogRulePricing([futureRule], [price({ livePrice: 100.0 })], [cronRow()], NOW);
assert.deepEqual(result.affectedSkus, []);
assert.equal(result.stuck, false);
});
test("by_fixed discount computes expected price", () => {
const fixedRule = rule({ simpleAction: "by_fixed", discountAmount: 15 });
const result = detectStuckCatalogRulePricing([fixedRule], [price({ livePrice: 100.0 })], [cronRow()], NOW);
assert.deepEqual(result.affectedSkus, ["SKU-1"]);
});
Case studies
A regional launch that quietly stalled every reindex
A retailer added a new eu_de store view ahead of a regional launch, but the store view's timezone setting was left at a default that did not match its locale. The next scheduled run of catalogrule_apply_all threw partway through, and by the next morning, sale prices across the entire site, not just the new store, had stopped updating.
Running the script against SKUs on an active site wide clearance rule showed the expected discounted price disagreeing with the live price on every store, and the cron report showed catalogrule_apply_all and indexer_reindex_all_invalid both stuck in a running state well past the lock timeout. The operator fixed the store view's timezone, then manually reset the two stuck rows, and the next scheduled run cleared the backlog.
A flash sale rule saved minutes before cron ran
A merchandiser created a new catalog price rule and saved it just minutes before the daily cron window, without opening it again in the Admin to force catalogrule_product to populate. The daily job hit the incomplete relationship and exited with an unhandled error, leaving its lock looking held for the rest of the day.
The script flagged the flash sale SKUs as mismatched and the same three job codes as stale in cron_schedule. Because both signals agreed, the team knew this was not a wait it out situation, and reset the lock so the rule could actually apply before the sale's start time passed.
After running this on a schedule, a stuck catalog price rule lock stops being an invisible, install wide problem. You get a precise report naming the affected rule ids, the SKUs and stores where price is wrong, and the exact cron_schedule job codes holding the lock, so an operator can fix the root cause, whether that is a store view's locale or an unbuilt rule relationship, and clear only the specific stuck rows rather than guessing at a blind full reindex.
FAQ
Why did all my scheduled indexers stop running after I added a store view?
The daily catalogrule_apply_all cron recalculates rule prices for every store view. A newly created store view with an incomplete locale or timezone setup, or a catalog price rule whose catalogrule_product table has not been built yet, can make that job throw an exception and exit non zero. Magento's scheduler treats the failed run as still holding its lock, so indexer_reindex_all_invalid and indexer_update_all_views cannot acquire it on the next run and stop executing for every store, not only the new one.
Can a script safely fix a stuck catalogrule_apply_all lock through the REST API?
Not directly, and it should not try to. Forcing catalogrule_apply_all, running bin/magento indexer:reindex, and resetting a stuck cron_schedule row are CLI and database level operations with no public REST endpoint. A script can detect the stuck state by comparing expected rule pricing to the live storefront price and by reading cron_schedule for error or stale running rows, then report it. Only with DRY_RUN turned off and an operator's explicit confirmation should it reset the specific stuck cron_schedule rows to missed, and it should never force the price recalculation itself.
How do I tell a stuck catalog price rule apart from a rule that legitimately has not started yet?
Check both signals together. A rule counts as stuck only when the storefront price for a targeted SKU still disagrees with the expected discounted price and the cron_schedule rows for catalogrule_apply_all, indexer_reindex_all_invalid, or indexer_update_all_views show a status of error, or running past the lock timeout. If the rule's from_date has not arrived yet, or the cron rows show a normal success, the price difference is expected and is not a stuck job.
Related field notes
Citations
On the problem:
- catalogrule_apply_all locks indexer_reindex_all_invalid and keeps catalogsearch_fulltext constantly processing, hanging Cron Job. github.com/magento/magento2/issues/29070
- Problem indexing catalog price rules after adding a new storeview. community.magento.com problem indexing catalog price rules after adding a new storeview
- ACSD-51102: Catalog rule applied to large number of products not correctly indexed. experienceleague.adobe.com acsd-51102-catalog-rule-is-not-correctly-indexed
On the solution:
- Manage the indexers, Adobe Commerce Operations. experienceleague.adobe.com commerce-operations manage-indexers
- Configure and run cron jobs, Adobe Commerce Operations. experienceleague.adobe.com commerce-operations configure-cron-jobs
- Cron job is stuck in running status, Adobe Commerce Knowledge Base. experienceleague.adobe.com cron-job-is-stuck-in-running-status
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.
Did this untangle your indexer lock?
If this saved you a confusing afternoon chasing a stale indexer, 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