Skip to content

Diagnostic Catalog Import

Product import batch job hangs at preprocessing with no event

You upload a CSV of products, the admin says it is preprocessing, and then nothing. No completion event, no error, no product.created fires, and the summary you were supposed to review never shows up or nobody notices it. Hours later the import is still preprocessing. Here is why Medusa leaves it there on purpose and a small script that finds the transactions stuck this way and reports them for a human to resume or discard safely.

Python and Node.js Medusa Admin API Safe by default (dry run)
A stack of papers
Photo by Kelly Sikkema on Unsplash
The short answer

Medusa v2's importProductsWorkflow, which powers POST /admin/products/import, deliberately pauses at waitConfirmationProductImportStep once normalizeCsvStep finishes. That pause is the preprocessing state you see, and the transaction sits idle in the workflow engine's data store until something calls POST /admin/products/import/:transaction_id/confirm, which runs setStepSuccess on it. If that confirm call is dropped, the operator never notices the review prompt, or the Redis-backed event bus carrying the signal is down, the transaction never resumes and no product.created or product.updated event ever fires, because v2 only emits those after the workflow fully succeeds. Run a script that tracks each import's transaction_id yourself, polls the workflow engine's state, and flags any transaction still invoking or waiting past a timeout with no completion event. Full code, tests, and a dry run guard are below.

The problem in plain words

Importing products is not a quick write. Medusa reads your CSV, normalizes every row, and then, before it touches the database, it stops and waits for a human to look at what it is about to do. That stop is not a bug, it is waitConfirmationProductImportStep, a real step in importProductsWorkflow whose entire job is to hold the workflow open until someone confirms the summary of how many products will be created and updated.

The trouble is that the stop has no timer. Medusa is happy to wait forever. Normally the admin UI shows the summary, an operator clicks confirm, and the workflow finishes in a second. But if that confirm request never lands, because the browser tab was closed before the summary rendered, the UI has a bug that swallows the prompt, or the workflow engine's event subscriber that would carry the signal back to the paused transaction is misconfigured or the Redis connection behind it is down, the transaction just sits exactly where it was left. Preprocessing forever, with nothing to say it failed, because nothing has failed. It is paused, correctly, waiting for input that is never coming.

CSV normalized normalizeCsvStep done Pauses, waits for confirm waitConfirmationProductImportStep this is the preprocessing state confirm call never arrives Dropped, UI bug, or event bus misconfigured Stuck at preprocessing no product.created event no failure, just silence
The pause at waitConfirmationProductImportStep is intentional. Without a confirm call reaching the paused transaction, it never resumes and never emits a completion event either way.

Why it happens

The whole design leans on a human closing the loop, and there is no built in timeout to close it for them. A few common ways an import ends up stuck on preprocessing:

In every case the transaction is not broken, it is precisely where the workflow put it. That is also why nothing alerts you. A stalled workflow that is still waiting has not failed, so it never triggers a failure branch, and v2 only fires product.created or product.updated events once the workflow fully succeeds, so a paused transaction produces neither an error nor a success event. See the citations at the end for the exact issue threads and docs.

The key insight

Confirming an import is a claim that the summary you are looking at is still the one you want applied. That is exactly why this is not something to automate. Silently confirming a transaction that has been sitting for hours risks importing a CSV the operator has long since moved on from, maybe superseded by a newer file, maybe abandoned on purpose. So the safe move is never to call confirm programmatically. It is to detect the stall from the outside, using elapsed time and workflow state, and hand a human the exact transaction id and summary they need to decide.

The fix, as a flow

We never touch the paused transaction. The script keeps its own record of every import it starts, polls the workflow engine's state for each one, and flags any transaction still invoking or waiting past a timeout with no completion event seen. Everything else is left alone.

Track import job transaction_id, summary Poll workflow state invoking, waiting, done... Pure decision fn classifyImportJob Past timeout, no event? yes, flag it no, still normal Report to operator confirm or re-submit
Every tracked import transaction is measured against a timeout. Only stuck ones are reported, with their summary and elapsed minutes, so an operator can decide whether to confirm or start over.

Build it step by step

1

Authenticate against the Admin API

Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a Bearer token on every /admin/* call. Since v2 has no route to list every pending import, this script keeps its own record of each transaction it started, in a local JSON side-table keyed by transaction_id. Keep every credential in environment variables, never in the file.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="https://your-medusa-backend.com"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export IMPORT_TIMEOUT_MINUTES="15"
export DRY_RUN="true"   # start safe, this script only reports either way
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MEDUSA_BACKEND_URL="https://your-medusa-backend.com"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export IMPORT_TIMEOUT_MINUTES="15"
export DRY_RUN="true"   // start safe, this script only reports either way
2

Track every import you start

When POST /admin/products/import returns, capture transaction_id and the summary of rows to create and update, and persist that alongside the time you started tracking it. This is the only record Medusa gives you up front, since there is no queryable status field for a pending import transaction through a simple GET.

step2.py
import os
import json
import requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
TRACKING_FILE = os.environ.get("IMPORT_TRACKING_FILE", "import_jobs.json")

def load_tracked_jobs():
    if not os.path.exists(TRACKING_FILE):
        return {}
    with open(TRACKING_FILE) as f:
        return json.load(f)

def save_tracked_jobs(jobs):
    with open(TRACKING_FILE, "w") as f:
        json.dump(jobs, f, indent=2, default=str)
step2.js
import fs from "node:fs";

const TRACKING_FILE = process.env.IMPORT_TRACKING_FILE || "import_jobs.json";

function loadTrackedJobs() {
  if (!fs.existsSync(TRACKING_FILE)) return {};
  return JSON.parse(fs.readFileSync(TRACKING_FILE, "utf8"));
}

function saveTrackedJobs(jobs) {
  fs.writeFileSync(TRACKING_FILE, JSON.stringify(jobs, null, 2));
}
3

Read each transaction's workflow state

Self-hosted Medusa persists long running workflow transactions in the workflow_execution table, accessible through a custom admin route or a direct read only query, filtering on workflow_id = 'import-products' and the transaction_id you tracked. Confirming a transaction as a probe is never safe, since confirming triggers real writes, so this is the only detection signal to use.

step3.py
def auth_headers(token):
    return {"Authorization": f"Bearer {token}"}

def get_token(email, password):
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": email, "password": password},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]

def fetch_workflow_state(token, transaction_id):
    """Reads the workflow_execution row for this transaction via a custom
    read only admin route that queries workflow_id = 'import-products'."""
    r = requests.get(
        f"{BACKEND_URL}/admin/workflow-executions/import-products/{transaction_id}",
        headers=auth_headers(token),
        timeout=30,
    )
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()
step3.js
function authHeaders(token) {
  return { Authorization: `Bearer ${token}` };
}

async function getToken(backendUrl, email, password) {
  const res = await fetch(`${backendUrl}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  });
  if (!res.ok) throw new Error(`Auth failed: ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function fetchWorkflowState(backendUrl, token, transactionId) {
  // Reads the workflow_execution row for this transaction via a custom
  // read only admin route that queries workflow_id = 'import-products'.
  const res = await fetch(
    `${backendUrl}/admin/workflow-executions/import-products/${transactionId}`,
    { headers: authHeaders(token) }
  );
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}
4

Decide, with one pure function

Keep the decision in its own function that takes a job's tracked state, the current time, and a timeout, then returns a status and how many minutes it has been stuck. It never touches the network, so it is trivial to unit test with fixed clock values. A job only counts as stuck when its workflow state is still invoking or waiting, it is past the timeout, and no completion event has been observed for it.

decide.py
def classify_import_job(job, now, timeout_ms):
    """Pure decision function. No I/O.

    job: {"transactionId": str, "createdAt": datetime, "workflowState": str,
          "lastEventAt": datetime | None}
    now: datetime
    timeout_ms: int (milliseconds)

    Returns {"status": "ok"|"completed"|"failed"|"stuck", "minutesStuck": float}
    """
    state = job["workflowState"]
    if state == "done":
        return {"status": "completed", "minutesStuck": 0.0}
    if state in ("failed", "reverted"):
        return {"status": "failed", "minutesStuck": 0.0}

    elapsed_ms = (now - job["createdAt"]).total_seconds() * 1000
    minutes_stuck = elapsed_ms / 60000

    if elapsed_ms > timeout_ms and job.get("lastEventAt") is None:
        return {"status": "stuck", "minutesStuck": minutes_stuck}
    return {"status": "ok", "minutesStuck": minutes_stuck}
decide.js
/**
 * Pure decision function. No I/O.
 *
 * @param {{ transactionId: string, createdAt: Date, workflowState: "invoking"|"waiting"|"done"|"failed"|"reverted", lastEventAt: Date|null }} job
 * @param {Date} now
 * @param {number} timeoutMs
 * @returns {{ status: "ok"|"completed"|"failed"|"stuck", minutesStuck: number }}
 */
export function classifyImportJob(job, now, timeoutMs) {
  if (job.workflowState === "done") {
    return { status: "completed", minutesStuck: 0 };
  }
  if (job.workflowState === "failed" || job.workflowState === "reverted") {
    return { status: "failed", minutesStuck: 0 };
  }

  const elapsedMs = now.getTime() - job.createdAt.getTime();
  const minutesStuck = elapsedMs / 60000;

  if (elapsedMs > timeoutMs && job.lastEventAt === null) {
    return { status: "stuck", minutesStuck };
  }
  return { status: "ok", minutesStuck };
}
5

Report, never auto-confirm

For every job the pure function calls stuck, emit a structured alert with the transaction_id, the original summary, minutes_stuck, and the workflow_state. This script never calls the confirm route programmatically. When DRY_RUN is false, the only action taken is marking the transaction flagged_stale in your own tracking file, a read only bookkeeping note, never a call to Medusa.

Run it safe

This script is flag and report only. It never calls POST /admin/products/import/:transaction_id/confirm, and it never discards a transaction on your behalf. DRY_RUN=false only changes whether it marks a stuck transaction flagged_stale in your own side-table, never anything inside Medusa. The actual repair, resuming the import after checking the summary still looks right, or discarding it and re-submitting a fresh CSV, is a decision for a human who can see what that transaction was about to write.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, tracks jobs in a local JSON file, and is safe to run again and again on a schedule because it only ever reads workflow state and writes to its own tracking file, never to Medusa's import transactions themselves.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
flag_stuck_import.py
"""Flag Medusa product import transactions stuck at preprocessing.

importProductsWorkflow, which powers POST /admin/products/import, deliberately
pauses at waitConfirmationProductImportStep after normalizeCsvStep finishes. That
pause is the preprocessing state, and the transaction sits idle in the workflow
engine's data store until something calls
POST /admin/products/import/:transaction_id/confirm, which runs setStepSuccess on
it. If that confirm call is dropped, the operator never notices the review prompt,
or the workflow engine's event bus is misconfigured, the transaction never resumes
and no product.created or product.updated event ever fires, since v2 only emits
those after the workflow fully succeeds.

Medusa v2 has no route that lists every pending import, so this script keeps its
own tracking file of transaction_id, summary, and start time, and polls the
workflow engine's state for each tracked transaction. Anything still invoking or
waiting past IMPORT_TIMEOUT_MINUTES with no completion event observed is reported
as stuck. It never calls confirm on your behalf. Run on a schedule.
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("flag_stuck_import")

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL")
ADMIN_PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD")
IMPORT_TIMEOUT_MINUTES = float(os.environ.get("IMPORT_TIMEOUT_MINUTES", "15"))
TRACKING_FILE = os.environ.get("IMPORT_TRACKING_FILE", "import_jobs.json")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def classify_import_job(job, now, timeout_ms):
    """Pure decision function. No I/O.

    job: {"transactionId": str, "createdAt": datetime, "workflowState": str,
          "lastEventAt": datetime | None}
    now: datetime
    timeout_ms: int (milliseconds)

    Returns {"status": "ok"|"completed"|"failed"|"stuck", "minutesStuck": float}
    """
    state = job["workflowState"]
    if state == "done":
        return {"status": "completed", "minutesStuck": 0.0}
    if state in ("failed", "reverted"):
        return {"status": "failed", "minutesStuck": 0.0}

    elapsed_ms = (now - job["createdAt"]).total_seconds() * 1000
    minutes_stuck = elapsed_ms / 60000

    if elapsed_ms > timeout_ms and job.get("lastEventAt") is None:
        return {"status": "stuck", "minutesStuck": minutes_stuck}
    return {"status": "ok", "minutesStuck": minutes_stuck}


def load_tracked_jobs():
    if not os.path.exists(TRACKING_FILE):
        return {}
    with open(TRACKING_FILE) as f:
        return json.load(f)


def save_tracked_jobs(jobs):
    with open(TRACKING_FILE, "w") as f:
        json.dump(jobs, f, indent=2, default=str)


def get_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def fetch_workflow_state(token, transaction_id):
    """Reads the workflow_execution row for this transaction via a custom
    read only admin route that queries workflow_id = 'import-products'.
    Returns a dict like {"state": "invoking", "lastEventAt": None} or None
    if the transaction can no longer be found."""
    r = requests.get(
        f"{BACKEND_URL}/admin/workflow-executions/import-products/{transaction_id}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


def run():
    jobs = load_tracked_jobs()
    if not jobs:
        log.info("No tracked import transactions found in %s.", TRACKING_FILE)
        return

    token = get_token()
    timeout_ms = IMPORT_TIMEOUT_MINUTES * 60000
    now = datetime.datetime.now(datetime.timezone.utc)
    stuck_count = 0

    for transaction_id, job in jobs.items():
        state = fetch_workflow_state(token, transaction_id)
        if state is None:
            continue

        classified_job = {
            "transactionId": transaction_id,
            "createdAt": datetime.datetime.fromisoformat(job["createdAt"]),
            "workflowState": state["state"],
            "lastEventAt": (
                datetime.datetime.fromisoformat(state["lastEventAt"])
                if state.get("lastEventAt") else None
            ),
        }
        result = classify_import_job(classified_job, now, timeout_ms)

        if result["status"] == "stuck":
            stuck_count += 1
            log.warning(
                "STUCK import: transaction_id=%s summary=%s minutes_stuck=%.1f workflow_state=%s. "
                "Operator action: inspect the summary, then either confirm to resume it or "
                "discard it and re-submit a fresh import.",
                transaction_id, job.get("summary"), result["minutesStuck"], classified_job["workflowState"],
            )
            if not DRY_RUN:
                job["flagged_stale"] = True
        elif result["status"] in ("completed", "failed"):
            jobs.pop(transaction_id, None)

    save_tracked_jobs(jobs)
    log.info("Done. %d import transaction(s) flagged stuck out of %d tracked.", stuck_count, len(jobs))


if __name__ == "__main__":
    run()
flag-stuck-import.js
/**
 * Flag Medusa product import transactions stuck at preprocessing.
 *
 * importProductsWorkflow, which powers POST /admin/products/import, deliberately
 * pauses at waitConfirmationProductImportStep after normalizeCsvStep finishes. That
 * pause is the preprocessing state, and the transaction sits idle in the workflow
 * engine's data store until something calls
 * POST /admin/products/import/:transaction_id/confirm, which runs setStepSuccess on
 * it. If that confirm call is dropped, the operator never notices the review prompt,
 * or the workflow engine's event bus is misconfigured, the transaction never resumes
 * and no product.created or product.updated event ever fires, since v2 only emits
 * those after the workflow fully succeeds.
 *
 * Medusa v2 has no route that lists every pending import, so this script keeps its
 * own tracking file of transaction_id, summary, and start time, and polls the
 * workflow engine's state for each tracked transaction. Anything still invoking or
 * waiting past IMPORT_TIMEOUT_MINUTES with no completion event observed is reported
 * as stuck. It never calls confirm on your behalf. Run on a schedule.
 * Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/csv-import-stuck-preprocessing/
 */
import { pathToFileURL } from "node:url";
import fs from "node:fs";

const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "https://your-medusa-backend.com";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const IMPORT_TIMEOUT_MINUTES = Number(process.env.IMPORT_TIMEOUT_MINUTES || 15);
const TRACKING_FILE = process.env.IMPORT_TRACKING_FILE || "import_jobs.json";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

/**
 * Pure decision function. No I/O.
 *
 * @param {{ transactionId: string, createdAt: Date, workflowState: "invoking"|"waiting"|"done"|"failed"|"reverted", lastEventAt: Date|null }} job
 * @param {Date} now
 * @param {number} timeoutMs
 * @returns {{ status: "ok"|"completed"|"failed"|"stuck", minutesStuck: number }}
 */
export function classifyImportJob(job, now, timeoutMs) {
  if (job.workflowState === "done") {
    return { status: "completed", minutesStuck: 0 };
  }
  if (job.workflowState === "failed" || job.workflowState === "reverted") {
    return { status: "failed", minutesStuck: 0 };
  }

  const elapsedMs = now.getTime() - job.createdAt.getTime();
  const minutesStuck = elapsedMs / 60000;

  if (elapsedMs > timeoutMs && job.lastEventAt === null) {
    return { status: "stuck", minutesStuck };
  }
  return { status: "ok", minutesStuck };
}

function loadTrackedJobs() {
  if (!fs.existsSync(TRACKING_FILE)) return {};
  return JSON.parse(fs.readFileSync(TRACKING_FILE, "utf8"));
}

function saveTrackedJobs(jobs) {
  fs.writeFileSync(TRACKING_FILE, JSON.stringify(jobs, null, 2));
}

async function getToken() {
  const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Auth failed: ${res.status}`);
  const body = await res.json();
  return body.token;
}

/**
 * Reads the workflow_execution row for this transaction via a custom read only
 * admin route that queries workflow_id = 'import-products'. Returns an object
 * like { state: "invoking", lastEventAt: null } or null if not found.
 */
async function fetchWorkflowState(token, transactionId) {
  const res = await fetch(
    `${BACKEND_URL}/admin/workflow-executions/import-products/${transactionId}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}

export async function run() {
  const jobs = loadTrackedJobs();
  if (Object.keys(jobs).length === 0) {
    console.log(`No tracked import transactions found in ${TRACKING_FILE}.`);
    return;
  }

  const token = await getToken();
  const timeoutMs = IMPORT_TIMEOUT_MINUTES * 60000;
  const now = new Date();
  let stuckCount = 0;

  for (const [transactionId, job] of Object.entries(jobs)) {
    const state = await fetchWorkflowState(token, transactionId);
    if (state === null) continue;

    const classifiedJob = {
      transactionId,
      createdAt: new Date(job.createdAt),
      workflowState: state.state,
      lastEventAt: state.lastEventAt ? new Date(state.lastEventAt) : null,
    };
    const result = classifyImportJob(classifiedJob, now, timeoutMs);

    if (result.status === "stuck") {
      stuckCount++;
      console.warn(
        `STUCK import: transaction_id=${transactionId} summary=${JSON.stringify(job.summary)} ` +
        `minutes_stuck=${result.minutesStuck.toFixed(1)} workflow_state=${classifiedJob.workflowState}. ` +
        `Operator action: inspect the summary, then either confirm to resume it or discard it and re-submit a fresh import.`
      );
      if (!DRY_RUN) job.flagged_stale = true;
    } else if (result.status === "completed" || result.status === "failed") {
      delete jobs[transactionId];
    }
  }

  saveTrackedJobs(jobs);
  console.log(`Done. ${stuckCount} import transaction(s) flagged stuck out of ${Object.keys(jobs).length} tracked.`);
}

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

Add a test

classifyImportJob is the part most worth testing, because it decides which import transactions ever get surfaced to an operator. It is pure, so the test needs no Medusa backend and no network. It just feeds in plain data and a fixed clock, and checks the answer.

test_csv_import_classify.py
from datetime import datetime, timezone

from flag_stuck_import import classify_import_job

NOW = datetime(2026, 7, 10, 0, 20, 0, tzinfo=timezone.utc)


def job(**over):
    base = {
        "transactionId": "tx_01",
        "createdAt": datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc),
        "workflowState": "waiting",
        "lastEventAt": None,
    }
    base.update(over)
    return base


def test_stuck_when_waiting_past_timeout_with_no_event():
    result = classify_import_job(job(), NOW, 10 * 60000)
    assert result["status"] == "stuck"
    assert result["minutesStuck"] == 20.0


def test_ok_when_within_timeout():
    result = classify_import_job(job(), NOW, 30 * 60000)
    assert result["status"] == "ok"


def test_completed_when_state_is_done():
    result = classify_import_job(job(workflowState="done"), NOW, 1)
    assert result["status"] == "completed"


def test_failed_when_state_is_failed():
    result = classify_import_job(job(workflowState="failed"), NOW, 1)
    assert result["status"] == "failed"


def test_failed_when_state_is_reverted():
    result = classify_import_job(job(workflowState="reverted"), NOW, 1)
    assert result["status"] == "failed"


def test_ok_when_past_timeout_but_event_seen():
    seen = datetime(2026, 7, 10, 0, 5, 0, tzinfo=timezone.utc)
    result = classify_import_job(job(lastEventAt=seen), NOW, 10 * 60000)
    assert result["status"] == "ok"


def test_invoking_state_also_evaluated_for_stuck():
    result = classify_import_job(job(workflowState="invoking"), NOW, 10 * 60000)
    assert result["status"] == "stuck"
csv-import-classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyImportJob } from "./flag-stuck-import.js";

const NOW = new Date("2026-07-10T00:20:00Z");

const job = (over = {}) => ({
  transactionId: "tx_01",
  createdAt: new Date("2026-07-10T00:00:00Z"),
  workflowState: "waiting",
  lastEventAt: null,
  ...over,
});

test("stuck when waiting past timeout with no event", () => {
  const result = classifyImportJob(job(), NOW, 10 * 60000);
  assert.equal(result.status, "stuck");
  assert.equal(result.minutesStuck, 20);
});

test("ok when within timeout", () => {
  const result = classifyImportJob(job(), NOW, 30 * 60000);
  assert.equal(result.status, "ok");
});

test("completed when state is done", () => {
  const result = classifyImportJob(job({ workflowState: "done" }), NOW, 1);
  assert.equal(result.status, "completed");
});

test("failed when state is failed", () => {
  const result = classifyImportJob(job({ workflowState: "failed" }), NOW, 1);
  assert.equal(result.status, "failed");
});

test("failed when state is reverted", () => {
  const result = classifyImportJob(job({ workflowState: "reverted" }), NOW, 1);
  assert.equal(result.status, "failed");
});

test("ok when past timeout but event seen", () => {
  const seen = new Date("2026-07-10T00:05:00Z");
  const result = classifyImportJob(job({ lastEventAt: seen }), NOW, 10 * 60000);
  assert.equal(result.status, "ok");
});

test("invoking state also evaluated for stuck", () => {
  const result = classifyImportJob(job({ workflowState: "invoking" }), NOW, 10 * 60000);
  assert.equal(result.status, "stuck");
});

Case studies

Dropped request

The merchandiser who closed the tab too soon

A catalog manager uploaded a two thousand row CSV to refresh seasonal pricing, watched the preprocessing spinner, and closed the browser tab to answer a call before the summary screen appeared. The transaction sat waiting for a confirm that was never coming, and nobody noticed for two days because nothing looked broken, there was simply no error to see.

Running the script hourly caught it well inside the first cycle. The alert included the exact summary of rows to create and update, which the manager checked against the CSV she still had open, confirmed it matched, and resumed the import through the confirm route herself.

Broken event bus

The staging environment with Redis pointed at the wrong host

A team stood up a staging Medusa instance and pointed the workflow engine's Redis connection string at the wrong host during a config copy. Every import confirm click in the admin UI appeared to work, but the signal never reached the paused transactions, so every single import from that day stayed on preprocessing.

The script flagged nine stuck transactions on the same afternoon, all past the timeout with identical symptoms, which was the tell that pointed the team at the Redis misconfiguration rather than nine unrelated user mistakes. Once Redis was corrected, the team discarded the stale transactions and re-submitted fresh imports rather than trusting confirm calls that predated the fix.

What good looks like

After this runs on a schedule, a dropped confirm click or a broken event bus gets caught within one polling cycle instead of being discovered by accident days later. The report gives an operator exactly the transaction id, the original summary, and how long it has been stuck, enough to decide with confidence whether to resume the import or start fresh. Nothing about a pending import is ever confirmed by a guess.

FAQ

Why does my Medusa product import stay on preprocessing forever?

Medusa v2's importProductsWorkflow deliberately pauses after normalizeCsvStep at waitConfirmationProductImportStep so an operator can review the summary before anything is written. The workflow only resumes when something calls POST /admin/products/import/:transaction_id/confirm, which triggers setStepSuccess on that paused transaction. If the confirm request is dropped, the admin UI never shows the prompt, or the event bus that would carry the signal is misconfigured, the transaction just sits there with nothing to wake it up.

How do I find a Medusa product import that is stuck?

Medusa v2 has no admin route that lists every import transaction, so you need to track each one yourself. Record the transaction_id and summary returned when POST /admin/products/import is called, then treat any transaction whose workflow engine state is still invoking or waiting well past a timeout, such as fifteen minutes, with no product.created or product.updated event observed, as stuck. Self-hosted Medusa keeps this state in the workflow_execution table for the import-products workflow.

Is it safe to auto-confirm a stuck Medusa product import?

No. Confirming a stuck import triggers real writes from whatever CSV was uploaded, and that file may be stale or already superseded by the time anyone notices the transaction is stuck. The safe pattern is to flag the transaction with its summary and elapsed time and let a human decide, either resuming it with the confirm route after checking the summary still looks right, or discarding it and starting a fresh import instead.

Related field notes

Citations

On the problem:

  1. Product import hangs, sometimes already at preprocessing, no event emitted. Medusa GitHub Issue #5716. github.com/medusajs/medusa/issues/5716
  2. Import products job is stuck at pre-processing. Medusa GitHub Issue #4057. github.com/medusajs/medusa/issues/4057
  3. Import Products Stuck in Processing. Medusa GitHub Issue #7082. github.com/medusajs/medusa/issues/7082

On the solution:

  1. Medusa Documentation: importProductsWorkflow Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/importProductsWorkflow
  2. Medusa Documentation: Workflow Engine Module. docs.medusajs.com/resources/infrastructure-modules/workflow-engine
  3. Medusa Documentation: product (JS SDK Admin Reference, includes import and confirmImport). docs.medusajs.com/resources/references/js-sdk/admin/product

Stuck on a tricky one?

If you have a problem in Medusa storefront access, 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 untangle a stuck import?

If this saved you from staring at a preprocessing spinner that would not budge, 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