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.

Python and Node.js Medusa Admin API Reconcile against domain data, not a job log
The short answer

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.

src/jobs/my-job.ts handler + cron schedule deployed to production Instance boots WORKER_MODE=server only job never registered No cron tick no error, no log Expected effect: never
The job file is correct and deployed. The instance just never registered it, because it never ran in worker or shared mode, and nothing anywhere records that as a failure.

Why it happens

Every one of these is a real gap in the default setup, not one single bug:

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.

The key insight

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.

List domain records price lists, campaigns Compute expected run cron schedule + last run Past grace window? interval × 1.5 buffer Run missed? yes no, leave alone Flag, then guarded re-run + mark synced
The script flags first. Repair only re-invokes the specific workflow once per record, guarded by a dry run flag and a marker so the same gap is never reprocessed.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
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
2

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.

step2.py
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"]
step2.js
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;
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

decide.py
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
decide.js
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);
}
5

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.

apply.py
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,
    )
apply.js
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]);
}
6

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.

Run it safe

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_missed_runs.py
"""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-missed-runs.js
/**
 * 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.

test_scheduled_missed_runs.py
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
missed-runs.test.js
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

Split deployment

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.

Crash during a tick window

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.

What good looks like

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:

  1. medusajs/medusa GitHub issue #8422: Scheduled Jobs fail when @medusajs/workflow-engine-redis is used. github.com/medusajs/medusa/issues/8422
  2. medusajs/medusa GitHub issue #3320: Cron jobs randomly registered and never gets executed. github.com/medusajs/medusa/issues/3320
  3. 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:

  1. Medusa Documentation: Scheduled Job Not Running on Schedule. docs.medusajs.com/resources/troubleshooting/scheduled-job-not-running
  2. Medusa Documentation: Worker Mode of Medusa Instance. docs.medusajs.com/learn/production/worker-mode
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes