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.
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.
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:
- The admin dashboard tab is closed, the network drops, or the browser navigates away right as the summary is about to render, so the operator never sees the review prompt and never clicks confirm.
- A UI bug on the import screen fails to display the "review summary" step at all, so there is nothing to click even though the transaction is sitting there waiting.
- The confirm request is sent but the workflow engine's Redis-backed event bus or subscriber is down or misconfigured, so the
setStepSuccesssignal never reaches the paused transaction even though the client thinks it fired. - The operator simply forgets, since the import screen gives no ongoing reminder that a transaction has been sitting in preprocessing for hours.
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.
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.
Build it step by step
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.
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
// 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
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.
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)
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));
}
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.
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()
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();
}
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.
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}
/**
* 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 };
}
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.
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.
"""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 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.
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"
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
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.
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.
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:
- Product import hangs, sometimes already at preprocessing, no event emitted. Medusa GitHub Issue #5716. github.com/medusajs/medusa/issues/5716
- Import products job is stuck at pre-processing. Medusa GitHub Issue #4057. github.com/medusajs/medusa/issues/4057
- Import Products Stuck in Processing. Medusa GitHub Issue #7082. github.com/medusajs/medusa/issues/7082
On the solution:
- Medusa Documentation: importProductsWorkflow Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/importProductsWorkflow
- Medusa Documentation: Workflow Engine Module. docs.medusajs.com/resources/infrastructure-modules/workflow-engine
- 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.
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