Reconciler Events, workflows, and jobs
Scheduled job did not run
You wrote a file in src/jobs, exported a function and a schedule, deployed it, and waited. The price list never expired on time. The campaign never got swept. The nightly sync never happened. Nothing crashed, nothing logged an error, and there is no dashboard anywhere that says the job was even supposed to run. Here is why Medusa v2 lets a scheduled job vanish without a trace, and a script that finds the gap in your own data and repairs it safely.
Medusa v2 only registers and ticks the workflow behind a scheduled job on instances running in worker or shared MEDUSA_WORKER_MODE. A server-mode-only instance never registers it, so the job silently never fires, no error anywhere. Separately, the default in-memory Workflow Engine and Event Bus modules do not persist scheduling or execution state across restarts or multiple instances, so a tick that was due right at deploy or crash time is simply dropped. There is no job-run ledger and no catch-up logic built in, so you cannot query "did this run" directly. Instead, pull the domain data the job maintains, such as updated_at or ends_at on price lists or campaigns, compute the expected next run from the job's own cron schedule, and flag any record whose gap exceeds one interval plus a grace buffer. Full code, tests, and a dry run guard are below.
The problem in plain words
A scheduled job in Medusa v2 is just a file in src/jobs that exports a handler function and a config object with a name and a schedule. Medusa reads that file at startup, wraps the handler in a workflow, and registers a cron tick for it. That is the whole contract, and it works fine as long as the instance that boots is the one meant to tick jobs.
The trouble is that not every Medusa instance is meant to. In production you usually split the deployment: some instances answer HTTP requests, some run background work, and MEDUSA_WORKER_MODE decides which is which. A scheduled job is only registered and ticked on an instance running in worker or shared mode. An instance stuck in server mode boots clean, serves every admin and store route correctly, and simply never registers the job at all. Nothing warns you. The route list looks normal. The only symptom is that the thing the job was supposed to do never happens.
Why it happens
Every one of these is a real gap in the default setup, not one single bug:
- Scheduled jobs are only registered and ticked on instances running in
workerorsharedMEDUSA_WORKER_MODE. Aserver-mode-only instance boots fine and answers every request correctly, but it never registers the workflow behind the job, so the schedule never exists to tick in the first place. - The default Workflow Engine and Event Bus modules,
@medusajs/workflow-engine-inmemoryand@medusajs/event-bus-local, keep scheduling and execution state only in the memory of the process that is running. Neither survives a restart, and neither is shared across multiple instances. - A tick that was due right at the moment of a deploy or a crash is simply dropped, since nothing durable recorded that it was about to fire. This mirrors GitHub issue #3320, where cron jobs are randomly registered and some never execute at all.
- Switching to
@medusajs/workflow-engine-redisfixes durability for the engine, but has its own reported gap: GitHub issue #8422 documents scheduled jobs failing specifically once the Redis workflow engine is in use, so moving to Redis is necessary but not automatically sufficient on its own. - Timezone handling has its own history of surprises. GitHub issue #13393 reports background jobs that sometimes do not run, or run at the wrong local time, which can look identical to a job that never fires at all if you are only watching the clock in one timezone.
This is a common source of confusion because Medusa gives you no /admin/scheduled-jobs history endpoint and no run ledger of any kind. The job file looks right, the deploy succeeded, and the only sign anything is wrong is that the data the job was supposed to touch quietly stops moving. See the citations at the end for the exact issues and docs.
You cannot ask Medusa whether a scheduled job ran, because it keeps no execution history. So detection has to be indirect: pick the timestamp field the job itself maintains, such as a price list's ends_at or a custom last_synced_at, compute the expected next run from the job's own cron schedule, and compare that against the current time with a grace buffer. A gap bigger than one full interval plus the buffer is strong evidence the tick was skipped, not merely late. Repair the same way: never guess-replay the cron, only re-invoke the specific workflow once, under a dry run guard, and write your own marker so the same gap is never reprocessed.
The fix, as a flow
We do not touch the scheduler and we do not try to reconstruct a job history that does not exist. We list the records the job maintains, compute the expected run time from its cron schedule against each record's own timestamp, and flag anything whose gap has passed one interval plus a grace multiplier. Behind DRY_RUN, repair re-invokes the workflow once per flagged record and writes a marker so the gap cannot be reprocessed.
Build it step by step
Get an admin session and the base URL
Point the script at your Medusa backend and an admin user with rights to read and update the resource your job maintains. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export JOB_CRON="0 * * * *" # the schedule from your src/jobs config, e.g. hourly
export DRY_RUN="true" # start safe, only logs the flagged record ids
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export JOB_CRON="0 * * * *" // the schedule from your src/jobs config, e.g. hourly
export DRY_RUN="true" // start safe, only logs the flagged record ids
Authenticate against the Admin API
Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.
import os, requests
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
import Medusa from "@medusajs/js-sdk";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
async function login() {
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
List the records the job is supposed to maintain
Pick the resource that proves the job ran. A price list expiry sweep touches ends_at and updated_at on price lists. A campaign sweep touches campaigns the same way. Ask for the fields the decision needs and page through with limit and offset, since a real store can have more records than one page holds.
PRICE_LIST_FIELDS = "id,title,ends_at,updated_at"
def list_price_lists(token, limit=100):
headers = {"Authorization": f"Bearer {token}"}
out, offset = [], 0
while True:
r = requests.get(
f"{BASE_URL}/admin/price-lists",
params={"fields": PRICE_LIST_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["price_lists"])
offset += limit
if offset >= body["count"]:
return out
const PRICE_LIST_FIELDS = "id,title,ends_at,updated_at";
async function listPriceLists(sdk, limit = 100) {
const out = [];
let offset = 0;
while (true) {
const body = await sdk.admin.priceList.list({
fields: PRICE_LIST_FIELDS, limit, offset,
});
out.push(...body.price_lists);
offset += limit;
if (offset >= body.count) return out;
}
}
Decide, with one pure function
Keep the decision in a function with no network calls. A tiny cron helper, minute-aligned, supporting *, exact values, comma lists, and */N steps, which covers the schedules Medusa jobs actually use, works out the average interval and the most recent expected run on or before now. Flag a record when the gap exceeds the interval times a grace multiplier, for example one and a half. A record with no anchor at all is treated as always missed. This is the part that gets tested in isolation.
from datetime import datetime, timedelta
def _parse_field(field, lo, hi):
if field == "*":
return set(range(lo, hi + 1))
values = set()
for part in field.split(","):
if part.startswith("*/"):
values.update(range(lo, hi + 1, int(part[2:])))
elif "-" in part:
a, b = part.split("-")
values.update(range(int(a), int(b) + 1))
else:
values.add(int(part))
return values
def _parse_cron(cron_expression):
minute, hour, dom, month, dow = cron_expression.strip().split()
return {
"minute": _parse_field(minute, 0, 59),
"hour": _parse_field(hour, 0, 23),
"dom": _parse_field(dom, 1, 31),
"month": _parse_field(month, 1, 12),
"dow": _parse_field(dow, 0, 6),
}
def _matches(dt, spec):
if dt.minute not in spec["minute"] or dt.hour not in spec["hour"] or dt.month not in spec["month"]:
return False
dom_ok = dt.day in spec["dom"]
dow_ok = (dt.weekday() + 1) % 7 in spec["dow"]
if spec["dom"] != set(range(1, 32)) and spec["dow"] != set(range(0, 7)):
return dom_ok or dow_ok
return dom_ok and dow_ok
def next_run_after(cron_expression, after, limit_minutes=527040):
"""Smallest minute-aligned datetime strictly after `after` matching the cron spec."""
spec = _parse_cron(cron_expression)
cursor = after.replace(second=0, microsecond=0) + timedelta(minutes=1)
for _ in range(limit_minutes):
if _matches(cursor, spec):
return cursor
cursor += timedelta(minutes=1)
raise RuntimeError("No matching run found within search window")
def find_missed_runs(records, cron_expression, now, grace_multiplier=1.5):
"""Pure: no I/O. records is a list of {"id", "lastRunAt"} dicts already fetched."""
anchor = next_run_after(cron_expression, now - timedelta(days=1))
interval_ms = (next_run_after(cron_expression, anchor) - anchor).total_seconds() * 1000
missed = []
for record in records:
last_run_at = record.get("lastRunAt")
if last_run_at is None:
missed.append({"id": record["id"], "expectedRunAt": now, "missedByMs": interval_ms * grace_multiplier + 1})
continue
# The tick that should have fired right after the record's own last run.
expected_run_at = next_run_after(cron_expression, last_run_at)
gap_ms = (now - expected_run_at).total_seconds() * 1000
if gap_ms > interval_ms * grace_multiplier and last_run_at < expected_run_at:
missed.append({"id": record["id"], "expectedRunAt": expected_run_at, "missedByMs": gap_ms})
missed.sort(key=lambda m: m["missedByMs"], reverse=True)
return missed
function parseField(field, lo, hi) {
if (field === "*") return new Set(Array.from({ length: hi - lo + 1 }, (_, i) => lo + i));
const values = new Set();
for (const part of field.split(",")) {
if (part.startsWith("*/")) {
const step = Number(part.slice(2));
for (let v = lo; v <= hi; v += step) values.add(v);
} else if (part.includes("-")) {
const [a, b] = part.split("-").map(Number);
for (let v = a; v <= b; v++) values.add(v);
} else {
values.add(Number(part));
}
}
return values;
}
function parseCron(cronExpression) {
const [minute, hour, dom, month, dow] = cronExpression.trim().split(/\s+/);
return {
minute: parseField(minute, 0, 59),
hour: parseField(hour, 0, 23),
dom: parseField(dom, 1, 31),
month: parseField(month, 1, 12),
dow: parseField(dow, 0, 6),
};
}
function fullRange(lo, hi) {
return new Set(Array.from({ length: hi - lo + 1 }, (_, i) => lo + i));
}
function matches(date, spec) {
if (!spec.minute.has(date.getUTCMinutes())) return false;
if (!spec.hour.has(date.getUTCHours())) return false;
if (!spec.month.has(date.getUTCMonth() + 1)) return false;
const domOk = spec.dom.has(date.getUTCDate());
const dowOk = spec.dow.has(date.getUTCDay());
const domIsFull = spec.dom.size === fullRange(1, 31).size;
const dowIsFull = spec.dow.size === fullRange(0, 6).size;
if (!domIsFull && !dowIsFull) return domOk || dowOk;
return domOk && dowOk;
}
export function nextRunAfter(cronExpression, after, limitMinutes = 527040) {
// Smallest minute-aligned date strictly after `after` matching the cron spec.
const spec = parseCron(cronExpression);
const cursor = new Date(after.getTime());
cursor.setUTCSeconds(0, 0);
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
for (let i = 0; i < limitMinutes; i++) {
if (matches(cursor, spec)) return new Date(cursor.getTime());
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
}
throw new Error("No matching run found within search window");
}
export function findMissedRuns(records, cronExpression, now, graceMultiplier = 1.5) {
// Pure: no I/O. records is a plain array of { id, lastRunAt } already fetched.
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const anchor = nextRunAfter(cronExpression, dayAgo);
const intervalMs = nextRunAfter(cronExpression, anchor).getTime() - anchor.getTime();
const missed = [];
for (const record of records) {
if (record.lastRunAt === null || record.lastRunAt === undefined) {
missed.push({ id: record.id, expectedRunAt: now, missedByMs: intervalMs * graceMultiplier + 1 });
continue;
}
// The tick that should have fired right after the record's own last run.
const expectedRunAt = nextRunAfter(cronExpression, record.lastRunAt);
const gapMs = now.getTime() - expectedRunAt.getTime();
if (gapMs > intervalMs * graceMultiplier && new Date(record.lastRunAt).getTime() < expectedRunAt.getTime()) {
missed.push({ id: record.id, expectedRunAt, missedByMs: gapMs });
}
}
return missed.sort((a, b) => b.missedByMs - a.missedByMs);
}
Repair by re-invoking the workflow once, then mark it synced
Never try to replay a cron tick generically, there is no execution history to make that idempotent. For each flagged record, re-run the exact workflow the job would have run, using the record's id, then write your own last_synced_at marker on that record's metadata. The marker is what stops the same gap from being reprocessed the next time the script runs.
def mark_synced(token, price_list_id, synced_at_iso):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/price-lists/{price_list_id}",
json={"metadata": {"last_synced_at": synced_at_iso}},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()
def rerun_expiry_sweep_for_one(price_list_id):
"""Invoke the same workflow src/jobs/expire-price-lists.ts would run,
scoped to exactly one record so a missed tick affects nothing else."""
import subprocess
subprocess.run(
["npx", "medusa", "exec", "./src/scripts/expire-one-price-list.ts", price_list_id],
check=True,
)
async function markSynced(sdk, priceListId, syncedAtIso) {
return sdk.client.fetch(`/admin/price-lists/${priceListId}`, {
method: "POST",
body: { metadata: { last_synced_at: syncedAtIso } },
});
}
async function rerunExpirySweepForOne(priceListId) {
// Invoke the same workflow src/jobs/expire-price-lists.ts would run,
// scoped to exactly one record so a missed tick affects nothing else.
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
await promisify(execFile)("npx", ["medusa", "exec", "./src/scripts/expire-one-price-list.ts", priceListId]);
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports the flagged records, sorted by how badly they missed their run. Read the output, confirm no worker or shared-mode instance was actually alive during that window, then switch DRY_RUN off to let it re-run the workflow once per record and write the sync marker.
Never invoke a generic cron replay across every job you have. Always target the one workflow the specific job would have run, always write the last_synced_at marker immediately after a successful re-run so the gap cannot be reprocessed, and always start with DRY_RUN=true so a human reviews the flagged list before anything is re-invoked.
The full code
Here is the complete script in one file for each language. It authenticates, lists the price lists the job maintains, flags every record whose expected run has passed the grace window with a pure function, and either reports the batch or re-invokes the expiry workflow once per flagged record and writes a sync marker, depending on DRY_RUN.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa v2 records whose scheduled job missed a run, because the
instance never registered the job in server-only WORKER_MODE, or the
default in-memory workflow engine dropped the tick across a restart.
Never replays a cron tick generically. DRY_RUN=true only reports the
flagged records. Safe to run again and again, because repair writes a
last_synced_at marker that stops the same gap from being reprocessed.
"""
import os
import logging
from datetime import datetime, timedelta, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_missed_runs")
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
JOB_CRON = os.environ.get("JOB_CRON", "0 * * * *")
GRACE_MULTIPLIER = float(os.environ.get("GRACE_MULTIPLIER", "1.5"))
PRICE_LIST_FIELDS = "id,title,ends_at,updated_at"
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_price_lists(token, limit=100):
headers = {"Authorization": f"Bearer {token}"}
out, offset = [], 0
while True:
r = requests.get(
f"{BASE_URL}/admin/price-lists",
params={"fields": PRICE_LIST_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["price_lists"])
offset += limit
if offset >= body["count"]:
return out
def _parse_field(field, lo, hi):
"""Parse one cron field (*, N, N-M, N,M, */S) into a set of allowed ints."""
if field == "*":
return set(range(lo, hi + 1))
values = set()
for part in field.split(","):
if part.startswith("*/"):
values.update(range(lo, hi + 1, int(part[2:])))
elif "-" in part:
a, b = part.split("-")
values.update(range(int(a), int(b) + 1))
else:
values.add(int(part))
return values
def _parse_cron(cron_expression):
minute, hour, dom, month, dow = cron_expression.strip().split()
return {
"minute": _parse_field(minute, 0, 59),
"hour": _parse_field(hour, 0, 23),
"dom": _parse_field(dom, 1, 31),
"month": _parse_field(month, 1, 12),
"dow": _parse_field(dow, 0, 6),
}
def _matches(dt, spec):
if dt.minute not in spec["minute"] or dt.hour not in spec["hour"] or dt.month not in spec["month"]:
return False
dom_ok = dt.day in spec["dom"]
dow_ok = (dt.weekday() + 1) % 7 in spec["dow"]
if spec["dom"] != set(range(1, 32)) and spec["dow"] != set(range(0, 7)):
return dom_ok or dow_ok
return dom_ok and dow_ok
def next_run_after(cron_expression, after, limit_minutes=527040):
"""Smallest minute-aligned datetime strictly after `after` matching the cron spec."""
spec = _parse_cron(cron_expression)
cursor = after.replace(second=0, microsecond=0) + timedelta(minutes=1)
for _ in range(limit_minutes):
if _matches(cursor, spec):
return cursor
cursor += timedelta(minutes=1)
raise RuntimeError("No matching run found within search window")
def find_missed_runs(records, cron_expression, now, grace_multiplier=1.5):
"""Pure: no I/O. records is a list of {"id", "lastRunAt"} dicts already fetched."""
anchor = next_run_after(cron_expression, now - timedelta(days=1))
interval_ms = (next_run_after(cron_expression, anchor) - anchor).total_seconds() * 1000
missed = []
for record in records:
last_run_at = record.get("lastRunAt")
if last_run_at is None:
missed.append({
"id": record["id"],
"expectedRunAt": now,
"missedByMs": interval_ms * grace_multiplier + 1,
})
continue
# The tick that should have fired right after the record's own last run.
expected_run_at = next_run_after(cron_expression, last_run_at)
gap_ms = (now - expected_run_at).total_seconds() * 1000
if gap_ms > interval_ms * grace_multiplier and last_run_at < expected_run_at:
missed.append({
"id": record["id"],
"expectedRunAt": expected_run_at,
"missedByMs": gap_ms,
})
missed.sort(key=lambda m: m["missedByMs"], reverse=True)
return missed
def to_record(price_list):
"""Adapt a Medusa price list into the {id, lastRunAt} shape the pure function expects."""
last_run_iso = price_list.get("updated_at") or price_list.get("ends_at")
last_run_at = None
if last_run_iso:
last_run_at = datetime.fromisoformat(last_run_iso.replace("Z", "+00:00"))
return {"id": price_list["id"], "lastRunAt": last_run_at}
def mark_synced(token, price_list_id, synced_at_iso):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/price-lists/{price_list_id}",
json={"metadata": {"last_synced_at": synced_at_iso}},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()
def rerun_expiry_sweep_for_one(price_list_id):
"""Invoke the same workflow src/jobs/expire-price-lists.ts would run,
scoped to exactly one record so a missed tick affects nothing else."""
import subprocess
subprocess.run(
["npx", "medusa", "exec", "./src/scripts/expire-one-price-list.ts", price_list_id],
check=True,
)
def run():
token = get_token()
price_lists = list_price_lists(token)
by_id = {p["id"]: p for p in price_lists}
records = [to_record(p) for p in price_lists]
now = datetime.now(timezone.utc)
missed = find_missed_runs(records, JOB_CRON, now, GRACE_MULTIPLIER)
if not missed:
log.info("No missed runs across %d record(s).", len(price_lists))
return
for item in missed:
title = by_id[item["id"]].get("title")
log.warning(
"Record %s (%s) missed run expected at %s, missed by %.0f ms.",
item["id"], title, item["expectedRunAt"].isoformat(), item["missedByMs"],
)
if not DRY_RUN:
synced_at_iso = now.isoformat()
for item in missed:
log.info("Record %s: re-running expiry sweep.", item["id"])
rerun_expiry_sweep_for_one(item["id"])
mark_synced(token, item["id"], synced_at_iso)
log.info("Done. %d record(s) %s.", len(missed), "to review" if DRY_RUN else "repaired")
if __name__ == "__main__":
run()
/**
* Find Medusa v2 records whose scheduled job missed a run, because the
* instance never registered the job in server-only WORKER_MODE, or the
* default in-memory workflow engine dropped the tick across a restart.
* Never replays a cron tick generically. DRY_RUN=true only reports the
* flagged records. Safe to run again and again, because repair writes a
* last_synced_at marker that stops the same gap from being reprocessed.
*/
import { pathToFileURL } from "node:url";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const JOB_CRON = process.env.JOB_CRON || "0 * * * *";
const GRACE_MULTIPLIER = Number(process.env.GRACE_MULTIPLIER || 1.5);
const PRICE_LIST_FIELDS = "id,title,ends_at,updated_at";
function parseField(field, lo, hi) {
if (field === "*") return new Set(Array.from({ length: hi - lo + 1 }, (_, i) => lo + i));
const values = new Set();
for (const part of field.split(",")) {
if (part.startsWith("*/")) {
const step = Number(part.slice(2));
for (let v = lo; v <= hi; v += step) values.add(v);
} else if (part.includes("-")) {
const [a, b] = part.split("-").map(Number);
for (let v = a; v <= b; v++) values.add(v);
} else {
values.add(Number(part));
}
}
return values;
}
function parseCron(cronExpression) {
const [minute, hour, dom, month, dow] = cronExpression.trim().split(/\s+/);
return {
minute: parseField(minute, 0, 59),
hour: parseField(hour, 0, 23),
dom: parseField(dom, 1, 31),
month: parseField(month, 1, 12),
dow: parseField(dow, 0, 6),
};
}
function fullRange(lo, hi) {
return new Set(Array.from({ length: hi - lo + 1 }, (_, i) => lo + i));
}
function matches(date, spec) {
if (!spec.minute.has(date.getUTCMinutes())) return false;
if (!spec.hour.has(date.getUTCHours())) return false;
if (!spec.month.has(date.getUTCMonth() + 1)) return false;
const domOk = spec.dom.has(date.getUTCDate());
const dowOk = spec.dow.has(date.getUTCDay());
const domIsFull = spec.dom.size === fullRange(1, 31).size;
const dowIsFull = spec.dow.size === fullRange(0, 6).size;
if (!domIsFull && !dowIsFull) return domOk || dowOk;
return domOk && dowOk;
}
export function nextRunAfter(cronExpression, after, limitMinutes = 527040) {
// Smallest minute-aligned date strictly after `after` matching the cron spec.
const spec = parseCron(cronExpression);
const cursor = new Date(after.getTime());
cursor.setUTCSeconds(0, 0);
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
for (let i = 0; i < limitMinutes; i++) {
if (matches(cursor, spec)) return new Date(cursor.getTime());
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
}
throw new Error("No matching run found within search window");
}
export function findMissedRuns(records, cronExpression, now, graceMultiplier = 1.5) {
// Pure: no I/O. records is a plain array of { id, lastRunAt } already fetched.
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const anchor = nextRunAfter(cronExpression, dayAgo);
const intervalMs = nextRunAfter(cronExpression, anchor).getTime() - anchor.getTime();
const missed = [];
for (const record of records) {
if (record.lastRunAt === null || record.lastRunAt === undefined) {
missed.push({ id: record.id, expectedRunAt: now, missedByMs: intervalMs * graceMultiplier + 1 });
continue;
}
// The tick that should have fired right after the record's own last run.
const expectedRunAt = nextRunAfter(cronExpression, record.lastRunAt);
const gapMs = now.getTime() - expectedRunAt.getTime();
if (gapMs > intervalMs * graceMultiplier && new Date(record.lastRunAt).getTime() < expectedRunAt.getTime()) {
missed.push({ id: record.id, expectedRunAt, missedByMs: gapMs });
}
}
return missed.sort((a, b) => b.missedByMs - a.missedByMs);
}
function toRecord(priceList) {
// Adapt a Medusa price list into the { id, lastRunAt } shape the pure function expects.
const lastRunIso = priceList.updated_at || priceList.ends_at;
return { id: priceList.id, lastRunAt: lastRunIso ? new Date(lastRunIso) : null };
}
async function login() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function listPriceLists(sdk, limit = 100) {
const out = [];
let offset = 0;
while (true) {
const body = await sdk.admin.priceList.list({
fields: PRICE_LIST_FIELDS, limit, offset,
});
out.push(...body.price_lists);
offset += limit;
if (offset >= body.count) return out;
}
}
async function markSynced(sdk, priceListId, syncedAtIso) {
return sdk.client.fetch(`/admin/price-lists/${priceListId}`, {
method: "POST",
body: { metadata: { last_synced_at: syncedAtIso } },
});
}
async function rerunExpirySweepForOne(priceListId) {
// Invoke the same workflow src/jobs/expire-price-lists.ts would run,
// scoped to exactly one record so a missed tick affects nothing else.
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
await promisify(execFile)("npx", ["medusa", "exec", "./src/scripts/expire-one-price-list.ts", priceListId]);
}
export async function run() {
const sdk = await login();
const priceLists = await listPriceLists(sdk);
const byId = new Map(priceLists.map((p) => [p.id, p]));
const records = priceLists.map(toRecord);
const now = new Date();
const missed = findMissedRuns(records, JOB_CRON, now, GRACE_MULTIPLIER);
if (missed.length === 0) {
console.log(`No missed runs across ${priceLists.length} record(s).`);
return;
}
for (const item of missed) {
const title = byId.get(item.id)?.title;
console.warn(
`Record ${item.id} (${title}) missed run expected at ${item.expectedRunAt.toISOString()}, missed by ${Math.round(item.missedByMs)} ms.`
);
}
if (!DRY_RUN) {
const syncedAtIso = now.toISOString();
for (const item of missed) {
console.log(`Record ${item.id}: re-running expiry sweep.`);
await rerunExpirySweepForOne(item.id);
await markSynced(sdk, item.id, syncedAtIso);
}
}
console.log(`Done. ${missed.length} record(s) ${DRY_RUN ? "to review" : "repaired"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The function worth testing is the one that decides the outcome, find_missed_runs. It is pure, no network and no external cron library, just date math against a tiny cron parser, so the tests feed in plain records plus a fixed clock and check the answer.
from datetime import datetime, timezone
from find_missed_runs import find_missed_runs
NOW = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
HOURLY = "0 * * * *"
def record(**over):
base = {"id": "plist_1", "lastRunAt": datetime(2026, 7, 9, 23, 0, 0, tzinfo=timezone.utc)}
base.update(over)
return base
def test_not_missed_when_last_run_is_recent():
result = find_missed_runs([record()], HOURLY, NOW)
assert result == []
def test_missed_when_last_run_is_far_in_the_past():
old = record(lastRunAt=datetime(2026, 7, 9, 20, 0, 0, tzinfo=timezone.utc))
result = find_missed_runs([old], HOURLY, NOW)
assert len(result) == 1
assert result[0]["id"] == "plist_1"
def test_missing_last_run_is_always_missed():
never_run = record(lastRunAt=None)
result = find_missed_runs([never_run], HOURLY, NOW)
assert len(result) == 1
assert result[0]["id"] == "plist_1"
def test_handles_multiple_records_independently_and_sorts_by_missed_by_desc():
recent = record(id="plist_recent", lastRunAt=datetime(2026, 7, 9, 23, 0, 0, tzinfo=timezone.utc))
worst = record(id="plist_worst", lastRunAt=datetime(2026, 7, 8, 0, 0, 0, tzinfo=timezone.utc))
mid = record(id="plist_mid", lastRunAt=datetime(2026, 7, 9, 18, 0, 0, tzinfo=timezone.utc))
result = find_missed_runs([recent, worst, mid], HOURLY, NOW)
ids = [r["id"] for r in result]
assert ids == ["plist_worst", "plist_mid"]
def test_grace_multiplier_widens_the_allowed_gap():
borderline = record(lastRunAt=datetime(2026, 7, 9, 21, 0, 0, tzinfo=timezone.utc))
strict = find_missed_runs([borderline], HOURLY, NOW, grace_multiplier=1.0)
loose = find_missed_runs([borderline], HOURLY, NOW, grace_multiplier=5.0)
assert len(strict) == 1
assert len(loose) == 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { findMissedRuns } from "./find-missed-runs.js";
const NOW = new Date("2026-07-10T00:00:00Z");
const HOURLY = "0 * * * *";
const record = (over = {}) => ({
id: "plist_1",
lastRunAt: new Date("2026-07-09T23:00:00Z"),
...over,
});
test("not missed when last run is recent", () => {
const result = findMissedRuns([record()], HOURLY, NOW);
assert.deepEqual(result, []);
});
test("missed when last run is far in the past", () => {
const old = record({ lastRunAt: new Date("2026-07-09T20:00:00Z") });
const result = findMissedRuns([old], HOURLY, NOW);
assert.equal(result.length, 1);
assert.equal(result[0].id, "plist_1");
});
test("missing last run is always missed", () => {
const neverRun = record({ lastRunAt: null });
const result = findMissedRuns([neverRun], HOURLY, NOW);
assert.equal(result.length, 1);
assert.equal(result[0].id, "plist_1");
});
test("handles multiple records independently and sorts by missedByMs desc", () => {
const recent = record({ id: "plist_recent", lastRunAt: new Date("2026-07-09T23:00:00Z") });
const worst = record({ id: "plist_worst", lastRunAt: new Date("2026-07-08T00:00:00Z") });
const mid = record({ id: "plist_mid", lastRunAt: new Date("2026-07-09T18:00:00Z") });
const result = findMissedRuns([recent, worst, mid], HOURLY, NOW);
assert.deepEqual(result.map((r) => r.id), ["plist_worst", "plist_mid"]);
});
test("grace multiplier widens the allowed gap", () => {
const borderline = record({ lastRunAt: new Date("2026-07-09T21:00:00Z") });
const strict = findMissedRuns([borderline], HOURLY, NOW, 1.0);
const loose = findMissedRuns([borderline], HOURLY, NOW, 5.0);
assert.equal(strict.length, 1);
assert.equal(loose.length, 0);
});
Case studies
The instance that never knew it was supposed to sweep price lists
A team split their Medusa deployment into an API tier and a worker tier during a scaling project, and set MEDUSA_WORKER_MODE=server on the API tier and worker on the other, exactly as the docs describe. A deploy script had a typo that shipped the price list expiry job's environment file to the server tier instead of the worker tier.
Price lists that should have expired kept applying discounted prices for weeks. Nothing crashed, the storefront looked fine, and the only sign was a finance report showing margin lower than expected. Running the reconciler against /admin/price-lists found forty three price lists whose ends_at had passed the grace window with no matching activity, all traced back to the same misconfigured tier.
The campaign sweep dropped by a mid-tick restart
A store ran a nightly campaign sweep on the default in-memory workflow engine, before moving to Redis. An out-of-memory crash restarted the single worker instance at 2:03am, four minutes after the sweep's scheduled 2:00am tick. The tick had been registered in the crashed process's memory and nothing else recorded that it was ever due.
One promotional campaign that should have closed stayed active for an extra day, applying a discount past its intended end date. The reconciler flagged it the next morning by comparing the campaign's ends_at against the sweep's cron schedule, the team confirmed no worker instance was alive during the gap by checking the deploy logs, and a guarded re-run closed the campaign and wrote the sync marker so the same night could never be reprocessed twice.
Run this reconciler on a schedule that matches how often the underlying job is supposed to tick. It never replays a cron trigger generically and never fabricates a run record, so it can never double-apply a workflow or mask a real gap. It only tells you which records the job's own data proves were skipped, computed from the job's real cron schedule, and it only writes when DRY_RUN is off and the marker confirms nothing has already caught up underneath it. The durable fix stays infrastructure-level: confirm every instance meant to run scheduled jobs has MEDUSA_WORKER_MODE set to worker or shared, and configure @medusajs/workflow-engine-redis and @medusajs/event-bus-redis so a restart never silently drops a tick again.
FAQ
Why did my Medusa scheduled job never run at all?
Medusa v2 only registers and ticks scheduled jobs on instances running in worker or shared WORKER_MODE. A server-mode-only instance never registers the underlying workflow for that job, so it silently never fires, with no error anywhere. Separately, the default in-memory workflow engine and event bus do not persist scheduling state across restarts or multiple instances, so a tick that was due at deploy or crash time is simply dropped.
How do I detect a missed scheduled job run without a job history API?
Medusa has no admin endpoint that records scheduled job executions, so detection has to be indirect. Pull the domain data the job maintains, such as a price list or campaign's updated_at or ends_at, compute the expected next run from the job's cron schedule, and flag any record where the gap between now and the expected run exceeds one full interval plus a grace buffer.
Can I safely replay a missed Medusa scheduled job run?
There is no built-in replay, since Medusa keeps no execution history to mark a run as caught up idempotently. The safe approach is to flag the gap, and only under an explicit DRY_RUN=false guard, manually invoke the same workflow the job would have run exactly once, then write your own last_synced_at marker on the record so the same gap is never reprocessed.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #8422: Scheduled Jobs fail when @medusajs/workflow-engine-redis is used. github.com/medusajs/medusa/issues/8422
- medusajs/medusa GitHub issue #3320: Cron jobs randomly registered and never gets executed. github.com/medusajs/medusa/issues/3320
- medusajs/medusa GitHub issue #13393: background jobs sometimes don't run or run in wrong timezone. github.com/medusajs/medusa/issues/13393
On the solution:
- Medusa Documentation: Scheduled Job Not Running on Schedule. docs.medusajs.com/resources/troubleshooting/scheduled-job-not-running
- Medusa Documentation: Worker Mode of Medusa Instance. docs.medusajs.com/learn/production/worker-mode
- Medusa Documentation: Redis Workflow Engine Module. docs.medusajs.com/resources/infrastructure-modules/workflow-engine/redis
Stuck on a tricky one?
If you have a problem in Medusa pricing, inventory, orders, promotions, or workflows 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 catch a silently skipped tick?
If this saved you from a stale price list or a discount that would not expire, 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