Reconciler Events, workflows, and jobs
Workflow left half done
A workflow reserves stock, then a later step throws while charging a payment or booking a fulfillment. You expect Medusa to undo the reservation and leave things clean. Sometimes it does. Sometimes it does not, because the step that reserved the stock was never given a way to undo itself, or the process that was running the workflow died before it got the chance. Here is why a Medusa v2 workflow can be left half done and a small script that finds the leftover reservations and tells you, safely, what to do about them.
Medusa v2 workflows are sagas. Each step's rollback logic is opt-in, defined by passing a compensation function into createStep, so any step that skips one leaves its side effect in place if a later step throws. createRemoteLinkStep is a real example: it shipped without compensation and left module link rows behind after a rollback (GitHub #9844). Separately, if the process crashes mid transaction, or the in-memory Workflow Engine is used instead of @medusajs/workflow-engine-redis in production, the saga never reaches the compensating phase at all, so a step that already committed, like a reservation made by reserveInventoryStep before a later payment step fails, stays committed while the workflow_execution row sits stuck at a state like invoking, never done or reverted. Run a script that lists reservations, resolves each one's parent order, and reports the ones that are clearly orphaned, deleting only the unambiguous case where the order is gone or canceled. Full code, tests, and a dry run guard are below.
The problem in plain words
A Medusa workflow is a chain of steps, and it is built like a database transaction that spans multiple systems: a saga. When every step succeeds, everything commits together and the workflow finishes. When a step throws partway through, Medusa is supposed to walk backward through the steps that already ran and undo each one, in order, so the world ends up as if the workflow never started.
The catch is that undoing a step is not automatic. Each step's author has to explicitly write a compensation function and hand it to createStep. If they do not, that step has no rollback at all, so its side effect just stays in place forever when a later step fails. createRemoteLinkStep, the step Medusa uses internally to write module link rows such as a promotion linked to a campaign, shipped without one, and GitHub issue #9844 documents exactly that: a rollback that leaves link rows behind. On top of that, compensation only ever runs if the workflow engine gets the chance to run it. If the Node process crashes, restarts, or the in-memory Workflow Engine is used in production instead of @medusajs/workflow-engine-redis, the saga's state is lost mid flight, and the steps that already committed, like a reservation created by reserveInventoryStep, are never rolled back at all.
Why it happens
Medusa's workflow engine gives every step the chance to opt in to a rollback, but it never forces one. A few common ways stores end up with half-run workflows:
- A step is written without a compensation function, so it has nothing to undo.
createRemoteLinkStepis a documented case of this, and it left module link rows in place after a rollback, per GitHub issue #9844. - A long-running workflow does not save its state correctly to the
workflow_executiontable, so the row is missing or wrong when something later needs to check on it, matching the pattern in GitHub issue #9077. - The process running the workflow crashes or restarts mid transaction. If the in-memory Workflow Engine module is used instead of
@medusajs/workflow-engine-redisin production, the saga's progress lives only in that process's memory and is gone the moment it dies, so compensation never gets invoked for the steps that already committed. - Fulfillment does not always delete the inventory reservation it should, leaving reservations stuck after an order-level operation like cancellation, as reported in GitHub issue #11266.
The net effect is the same in every case. A step's side effect, most often a reservation, is left committed in the database while the workflow that created it never reached a clean end. See the citations at the end for the exact issues and docs.
A half-run workflow can leave two different kinds of mess behind, and only one of them is safe to touch automatically. A reservation whose parent order is missing entirely, or whose order was canceled, is unambiguous: nothing legitimate needs that stock anymore, so it can be deleted. A stuck workflow_execution row that is still mid flight, or an orphaned module link row from a step like createRemoteLinkStep, is not unambiguous. Deleting or reversing those without knowing exactly what a human intended risks corrupting a live process, so the right move there is to report the transaction id, workflow id, and step name and let a person decide.
The fix, as a flow
We do not touch live checkouts or in-flight workflows. The job lists reservations, resolves each one's parent order, classifies it with a pure decision function, and only deletes the unambiguous orphans. Everything else, including any reservation tied to a still-active order, is left alone and reported.
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. Keep the backend URL and credentials in environment variables, never in the file.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export STALE_MINUTES="10"
export DRY_RUN="true" # start safe, change to false to write
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 STALE_MINUTES="10"
export DRY_RUN="true" // start safe, change to false to write
List reservations that came from a line item
Ask for every reservation with the fields the decision needs: line_item_id, created_at, and the order it points at. A reservation with no line_item_id is a manual one someone created by hand, not something a workflow left behind, so it is never in scope here.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def list_reservations(token):
reservations = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/reservations", {
"fields": "id,line_item_id,inventory_item_id,location_id,quantity,created_at,*line_item.order_id",
"limit": limit,
"offset": offset,
})
reservations.extend(data["reservations"])
offset += limit
if offset >= data["count"]:
return reservations
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });
async function listReservations() {
const reservations = [];
let offset = 0;
const limit = 200;
while (true) {
const { reservations: page, count } = await sdk.admin.reservation.list({
fields: "id,line_item_id,inventory_item_id,location_id,quantity,created_at,*line_item.order_id",
limit,
offset,
});
reservations.push(...page);
offset += limit;
if (offset >= count) return reservations;
}
}
Look up each reservation's parent order
For a reservation that came from a line item, fetch the order it belongs to and read only id and status. A 404 here means the order no longer resolves at all. Catch that case explicitly and pass along null instead of letting the request blow up the whole run.
def get_order_or_none(token, order_id):
"""Returns {"id": str, "status": str} or None if the order no longer resolves."""
if not order_id:
return None
r = requests.get(
f"{BACKEND_URL}/admin/orders/{order_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,status"},
timeout=30,
)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()["order"]
async function getOrderOrNull(orderId) {
if (!orderId) return null;
try {
const { order } = await sdk.admin.order.retrieve(orderId, { fields: "id,status" });
return order;
} catch (err) {
if (err?.response?.status === 404 || err?.status === 404) return null;
throw err;
}
}
Decide, with one pure function
Keep the classification in its own function that takes a reservation, the order it resolved to (or null), the current time, and the staleness window, and returns a plain answer. It never touches the network, so it is trivial to unit test with fixture data. Only two outcomes are ever eligible for an automatic delete: orphaned_no_order and orphaned_canceled_order. Everything else is either healthy or just flagged for a human to look at.
def classify_reservation(reservation, order, now_iso, stale_minutes=10):
"""Pure decision function. No I/O.
reservation: {"id": str, "line_item_id": str | None, "created_at": str (ISO)}
order: {"id": str, "status": str} | None
now_iso: str (ISO timestamp)
stale_minutes: int
Returns "orphaned_no_order" | "orphaned_canceled_order" |
"stale_pending_review" | "healthy".
"""
if reservation.get("line_item_id") is None:
return "healthy"
if order is None:
return "orphaned_no_order"
if order.get("status") == "canceled":
return "orphaned_canceled_order"
age_ms = _parse_iso_ms(now_iso) - _parse_iso_ms(reservation["created_at"])
if age_ms > stale_minutes * 60000 and order.get("status") not in ("pending", "completed"):
return "stale_pending_review"
return "healthy"
def _parse_iso_ms(iso):
from datetime import datetime
return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, line_item_id: string | null, created_at: string }} reservation
* @param {{ id: string, status: string } | null} order
* @param {string} nowIso
* @param {number} staleMinutes
* @returns {"orphaned_no_order" | "orphaned_canceled_order" | "stale_pending_review" | "healthy"}
*/
export function classifyReservation(reservation, order, nowIso, staleMinutes = 10) {
if (reservation.line_item_id == null) return "healthy";
if (order == null) return "orphaned_no_order";
if (order.status === "canceled") return "orphaned_canceled_order";
const ageMs = Date.parse(nowIso) - Date.parse(reservation.created_at);
if (ageMs > staleMinutes * 60000 && !["pending", "completed"].includes(order.status)) {
return "stale_pending_review";
}
return "healthy";
}
Delete only the unambiguous orphans, report the rest
When a reservation is classified as orphaned_no_order or orphaned_canceled_order, log its id and its (missing or canceled) order, then call DELETE /admin/reservations/{id}. When it comes back stale_pending_review, only report it, since that status means the order is still active in some other sense and a human needs to look before anything is touched.
DELETABLE = {"orphaned_no_order", "orphaned_canceled_order"}
def delete_reservation(token, reservation_id):
r = requests.delete(
f"{BACKEND_URL}/admin/reservations/{reservation_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
const DELETABLE = new Set(["orphaned_no_order", "orphaned_canceled_order"]);
async function deleteReservation(reservationId) {
return sdk.admin.reservation.delete(reservationId);
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the reservation id and classification it would act on. Read the output, agree with it, then switch it off to let it write. Run it on a schedule, for example every fifteen minutes, so a half-run workflow never sits unnoticed for long.
Always start with DRY_RUN=true. This script deletes reservations one at a time, logging the transaction_id context and the stale reservation id before each delete, and it only ever deletes the two unambiguous classifications. A stuck workflow_execution row still mid flight, or an orphaned module link row from a step like createRemoteLinkStep, is reported only, never written to, because acting on those without a human's judgment risks corrupting a live process.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever deletes a reservation whose parent order is gone or canceled.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Reconcile Medusa reservations left behind by a workflow that never finished.
Medusa v2 workflows are sagas: each step's rollback is opt-in through a compensation
function passed to createStep, so a step without one, such as createRemoteLinkStep
(GitHub #9844), leaves its side effect in place if a later step throws. Separately, a
crashed process or the in-memory Workflow Engine used in production means the saga
never reaches the compensating phase at all, so an already-committed reservation from
reserveInventoryStep stays committed while workflow_execution is stuck in a
non-terminal state (GitHub #9077, #12913, #11266).
This lists reservations, resolves each line_item_id's parent order, classifies each
reservation with a pure function, and deletes only the unambiguous orphan cases:
an order that no longer resolves (404) or an order whose status is canceled.
Everything else is reported only. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_half_run_workflows")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
STALE_MINUTES = float(os.environ.get("STALE_MINUTES", "10"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
DELETABLE = {"orphaned_no_order", "orphaned_canceled_order"}
def get_admin_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 admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def admin_delete(token, path):
r = requests.delete(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def _parse_iso_ms(iso):
return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000
def classify_reservation(reservation, order, now_iso, stale_minutes=10):
"""Pure decision function. No I/O.
reservation: {"id": str, "line_item_id": str | None, "created_at": str (ISO)}
order: {"id": str, "status": str} | None
now_iso: str (ISO timestamp)
stale_minutes: int
Returns "orphaned_no_order" | "orphaned_canceled_order" |
"stale_pending_review" | "healthy".
"""
if reservation.get("line_item_id") is None:
return "healthy"
if order is None:
return "orphaned_no_order"
if order.get("status") == "canceled":
return "orphaned_canceled_order"
age_ms = _parse_iso_ms(now_iso) - _parse_iso_ms(reservation["created_at"])
if age_ms > stale_minutes * 60000 and order.get("status") not in ("pending", "completed"):
return "stale_pending_review"
return "healthy"
def list_reservations(token):
reservations = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/reservations", {
"fields": "id,line_item_id,inventory_item_id,location_id,quantity,created_at,*line_item.order_id",
"limit": limit,
"offset": offset,
})
reservations.extend(data["reservations"])
offset += limit
if offset >= data["count"]:
return reservations
def get_order_or_none(token, order_id):
if not order_id:
return None
r = requests.get(
f"{BACKEND_URL}/admin/orders/{order_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,status"},
timeout=30,
)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()["order"]
def run():
token = get_admin_token()
reservations = list_reservations(token)
now_iso = datetime.now(timezone.utc).isoformat()
deleted = 0
reported = 0
for reservation in reservations:
line_item = reservation.get("line_item") or {}
order_id = line_item.get("order_id")
order = get_order_or_none(token, order_id) if reservation.get("line_item_id") else None
outcome = classify_reservation(reservation, order, now_iso, STALE_MINUTES)
if outcome == "healthy":
continue
if outcome in DELETABLE:
log.warning(
"Reservation %s classified as %s. order_id=%s. %s",
reservation["id"], outcome, order_id, "Would delete" if DRY_RUN else "Deleting",
)
if not DRY_RUN:
admin_delete(token, f"/admin/reservations/{reservation['id']}")
deleted += 1
else:
log.info(
"Reservation %s reported as %s. order_id=%s status=%s (needs human review, not touched).",
reservation["id"], outcome, order_id, (order or {}).get("status"),
)
reported += 1
log.info(
"Done. %d reservation(s) %s, %d reservation(s) reported for review.",
deleted, "to delete" if DRY_RUN else "deleted", reported,
)
if __name__ == "__main__":
run()
/**
* Reconcile Medusa reservations left behind by a workflow that never finished.
*
* Medusa v2 workflows are sagas: each step's rollback is opt-in through a compensation
* function passed to createStep, so a step without one, such as createRemoteLinkStep
* (GitHub #9844), leaves its side effect in place if a later step throws. Separately, a
* crashed process or the in-memory Workflow Engine used in production means the saga
* never reaches the compensating phase at all, so an already-committed reservation from
* reserveInventoryStep stays committed while workflow_execution is stuck in a
* non-terminal state (GitHub #9077, #12913, #11266).
*
* This lists reservations, resolves each line_item_id's parent order, classifies each
* reservation with a pure function, and deletes only the unambiguous orphan cases:
* an order that no longer resolves (404) or an order whose status is canceled.
* Everything else is reported only. Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/workflow-left-half-done/
*/
import { pathToFileURL } from "node:url";
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const STALE_MINUTES = Number(process.env.STALE_MINUTES || 10);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const DELETABLE = new Set(["orphaned_no_order", "orphaned_canceled_order"]);
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, line_item_id: string | null, created_at: string }} reservation
* @param {{ id: string, status: string } | null} order
* @param {string} nowIso
* @param {number} staleMinutes
* @returns {"orphaned_no_order" | "orphaned_canceled_order" | "stale_pending_review" | "healthy"}
*/
export function classifyReservation(reservation, order, nowIso, staleMinutes = 10) {
if (reservation.line_item_id == null) return "healthy";
if (order == null) return "orphaned_no_order";
if (order.status === "canceled") return "orphaned_canceled_order";
const ageMs = Date.parse(nowIso) - Date.parse(reservation.created_at);
if (ageMs > staleMinutes * 60000 && !["pending", "completed"].includes(order.status)) {
return "stale_pending_review";
}
return "healthy";
}
async function getAdminToken() {
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(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function adminGet(token, path, params = {}) {
const url = new URL(`${BACKEND_URL}${path}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
return res.json();
}
async function adminDelete(token, path) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status} on DELETE ${path}`);
return res.json();
}
async function listReservations(token) {
const reservations = [];
let offset = 0;
const limit = 200;
while (true) {
const data = await adminGet(token, "/admin/reservations", {
fields: "id,line_item_id,inventory_item_id,location_id,quantity,created_at,*line_item.order_id",
limit,
offset,
});
reservations.push(...data.reservations);
offset += limit;
if (offset >= data.count) return reservations;
}
}
async function getOrderOrNull(token, orderId) {
if (!orderId) return null;
const res = await fetch(new URL(`${BACKEND_URL}/admin/orders/${orderId}?fields=id,status`), {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Medusa ${res.status} on GET /admin/orders/${orderId}`);
const body = await res.json();
return body.order;
}
export async function run() {
const token = await getAdminToken();
const reservations = await listReservations(token);
const nowIso = new Date().toISOString();
let deleted = 0;
let reported = 0;
for (const reservation of reservations) {
const orderId = reservation.line_item?.order_id;
const order = reservation.line_item_id ? await getOrderOrNull(token, orderId) : null;
const outcome = classifyReservation(reservation, order, nowIso, STALE_MINUTES);
if (outcome === "healthy") continue;
if (DELETABLE.has(outcome)) {
console.warn(
`Reservation ${reservation.id} classified as ${outcome}. order_id=${orderId}. ${DRY_RUN ? "Would delete" : "Deleting"}`
);
if (!DRY_RUN) await adminDelete(token, `/admin/reservations/${reservation.id}`);
deleted++;
} else {
console.log(
`Reservation ${reservation.id} reported as ${outcome}. order_id=${orderId} status=${order?.status} (needs human review, not touched).`
);
reported++;
}
}
console.log(`Done. ${deleted} reservation(s) ${DRY_RUN ? "to delete" : "deleted"}, ${reported} reservation(s) reported for review.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
classify_reservation is the part most worth testing, because it decides which reservations are safe to delete versus merely reported. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain data structures and a fixed clock, and checks the answer.
from reconcile_half_run_workflows import classify_reservation
NOW = "2026-07-10T00:20:00+00:00"
def reservation(**over):
base = {"id": "res_1", "line_item_id": "item_1", "created_at": "2026-07-10T00:00:00+00:00"}
base.update(over)
return base
def test_healthy_when_no_line_item_id():
r = reservation(line_item_id=None)
assert classify_reservation(r, None, NOW, 10) == "healthy"
def test_orphaned_no_order_when_order_missing():
r = reservation()
assert classify_reservation(r, None, NOW, 10) == "orphaned_no_order"
def test_orphaned_canceled_order_when_order_canceled():
order = {"id": "order_1", "status": "canceled"}
r = reservation()
assert classify_reservation(r, order, NOW, 10) == "orphaned_canceled_order"
def test_healthy_when_order_pending_even_if_old():
order = {"id": "order_1", "status": "pending"}
r = reservation()
assert classify_reservation(r, order, NOW, 10) == "healthy"
def test_stale_pending_review_when_old_and_order_in_limbo():
order = {"id": "order_1", "status": "requires_action"}
r = reservation()
assert classify_reservation(r, order, NOW, 10) == "stale_pending_review"
def test_healthy_when_young_even_if_order_in_limbo():
order = {"id": "order_1", "status": "requires_action"}
r = reservation(created_at="2026-07-10T00:15:00+00:00")
assert classify_reservation(r, order, NOW, 10) == "healthy"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyReservation } from "./reconcile-half-run-workflows.js";
const NOW = "2026-07-10T00:20:00Z";
const reservation = (over = {}) => ({
id: "res_1",
line_item_id: "item_1",
created_at: "2026-07-10T00:00:00Z",
...over,
});
test("healthy when no line_item_id", () => {
const r = reservation({ line_item_id: null });
assert.equal(classifyReservation(r, null, NOW, 10), "healthy");
});
test("orphaned_no_order when order missing", () => {
const r = reservation();
assert.equal(classifyReservation(r, null, NOW, 10), "orphaned_no_order");
});
test("orphaned_canceled_order when order canceled", () => {
const order = { id: "order_1", status: "canceled" };
const r = reservation();
assert.equal(classifyReservation(r, order, NOW, 10), "orphaned_canceled_order");
});
test("healthy when order pending even if old", () => {
const order = { id: "order_1", status: "pending" };
const r = reservation();
assert.equal(classifyReservation(r, order, NOW, 10), "healthy");
});
test("stale_pending_review when old and order in limbo", () => {
const order = { id: "order_1", status: "requires_action" };
const r = reservation();
assert.equal(classifyReservation(r, order, NOW, 10), "stale_pending_review");
});
test("healthy when young even if order in limbo", () => {
const order = { id: "order_1", status: "requires_action" };
const r = reservation({ created_at: "2026-07-10T00:15:00Z" });
assert.equal(classifyReservation(r, order, NOW, 10), "healthy");
});
Case studies
A promotion link row that outlived its cart
A team built a custom workflow around createRemoteLinkStep to link a promotion to a campaign as part of a larger checkout flow. A later step in that same workflow failed on a validation error, and the rollback ran, but the link row stayed in place because that step had never been given a compensation function, matching the exact gap reported in GitHub issue #9844.
The reservations created earlier in the same workflow were resolved against orders and found to belong to an order that no longer existed. The script reported the workflow's transaction id alongside the deleted reservations, and the team used that id to track down and clean up the link row by hand, since a link row was outside what the script was built to touch.
A deploy that landed mid-checkout
A store redeployed its Medusa backend during a busy period, and the deploy happened to land while a handful of checkouts were mid workflow. Those processes died before their steps could compensate, and because the store was running the in-memory Workflow Engine rather than @medusajs/workflow-engine-redis, the saga state for those transactions was gone the moment the old process exited.
Running the script in dry run surfaced the reservations from those interrupted checkouts. Most resolved to orders that were still pending and were correctly left alone as healthy. A few resolved to orders that had since been canceled by the customer trying again, and those were the unambiguous case the script deleted, freeing the stock immediately.
After this runs on a schedule, a workflow that stops halfway never gets to quietly hold stock forever. The unambiguous orphans, reservations with no order or a canceled one, are cleared automatically and logged one delete at a time. Anything still mid flight, or any orphaned link row from a step without compensation, is reported with enough detail, the transaction id, workflow id, and step name, for a human to safely decide what to do. Nothing gets touched on a guess.
FAQ
Why does a Medusa workflow sometimes leave data behind after it fails?
Medusa v2 workflows are sagas, and each step's rollback is opt-in through a compensation function passed to createStep. If a step has no compensation function, such as createRemoteLinkStep, its side effect stays in place when a later step throws. Separately, if the process crashes or restarts mid-workflow, or the in-memory Workflow Engine is used in production instead of the Redis one, the saga never reaches the compensating phase at all, so already-committed steps like an inventory reservation stay committed while the workflow_execution row is stuck in a non-terminal state.
How do I find workflows that never finished in Medusa?
List reservations with GET /admin/reservations and, for each one that has a line_item_id, look up its parent order with GET /admin/orders/{order_id}. A reservation whose order returns 404 or whose order.status is canceled is an orphan left by a half run workflow. You can also list workflow_execution rows through the Workflow Engine module and filter for a state that is not done or reverted and an updated_at older than a threshold, which points at the exact transaction and step that got stuck.
Is it safe to automatically delete leftover reservations from a failed workflow?
Only for the unambiguous case: a reservation whose parent order no longer resolves at all, or whose order status is canceled. Deleting a reservation tied to a live, in-progress order risks double selling stock or corrupting that order, so anything still mid-flight, or any orphaned module link row, should only be reported with its transaction_id, workflow_id, and step name for a human to review.
Related field notes
Citations
On the problem:
- createRemoteLinkStep does not perform any compensation. Medusa GitHub Issue #9844. github.com/medusajs/medusa/issues/9844
- Long-running workflows don't save correctly in the workflow_execution table. Medusa GitHub Issue #9077. github.com/medusajs/medusa/issues/9077
- Order fulfillment is not deleting inventory item reservations sometimes. Medusa GitHub Issue #11266. github.com/medusajs/medusa/issues/11266
On the solution:
- Medusa Documentation: Compensation Function. docs.medusajs.com/learn/fundamentals/workflows/compensation-function
- Medusa Documentation: Store Workflow Executions. docs.medusajs.com/learn/fundamentals/workflows/store-executions
- Medusa Documentation: How to Use the Workflow Engine Module. docs.medusajs.com/resources/infrastructure-modules/workflow-engine/how-to-use
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 workflow?
If this saved you from chasing a phantom reservation or a support ticket about stock that would not add up, 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