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.

Python and Node.js Medusa Admin API Safe by default (dry run)
The short answer

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.

Step 1 commits reserveInventoryStep Step 2 throws payment or fulfillment fails or the process crashes no compensation, or none ran Reservation stays stock still held workflow_execution stuck state: "invoking" never "done" or "reverted"
A step commits its side effect, a later step throws, and either that step had no compensation function or the process died before compensation ran. Either way, the side effect is stuck and the workflow_execution row never reaches a terminal state.

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:

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.

The key insight

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.

List reservations id, line_item_id Look up parent order id, status, or 404 Pure decision fn classifyReservation Order missing or canceled? yes, dry run off no, report only Stale or healthy logged for a human DELETE reservation only the unambiguous case
Every reservation is classified first. Only an orphan with no order, or one tied to a canceled order, is ever deleted, and only with DRY_RUN off. Anything mid flight is reported, not touched.

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. Keep the backend URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export STALE_MINUTES="10"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install @medusajs/js-sdk

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export STALE_MINUTES="10"
export DRY_RUN="true"   // start safe, change to false to write
2

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.

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

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.

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

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.

decide.py
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
decide.js
/**
 * 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";
}
5

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.

step5.py
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()
step5.js
const DELETABLE = new Set(["orphaned_no_order", "orphaned_canceled_order"]);

async function deleteReservation(reservationId) {
  return sdk.admin.reservation.delete(reservationId);
}
6

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.

Run it safe

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_half_run_workflows.py
"""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-half-run-workflows.js
/**
 * 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.

test_workflow_reservations.py
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"
reservations.test.js
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

Missing compensation

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.

Crashed process

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.

What good looks like

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:

  1. createRemoteLinkStep does not perform any compensation. Medusa GitHub Issue #9844. github.com/medusajs/medusa/issues/9844
  2. Long-running workflows don't save correctly in the workflow_execution table. Medusa GitHub Issue #9077. github.com/medusajs/medusa/issues/9077
  3. Order fulfillment is not deleting inventory item reservations sometimes. Medusa GitHub Issue #11266. github.com/medusajs/medusa/issues/11266

On the solution:

  1. Medusa Documentation: Compensation Function. docs.medusajs.com/learn/fundamentals/workflows/compensation-function
  2. Medusa Documentation: Store Workflow Executions. docs.medusajs.com/learn/fundamentals/workflows/store-executions
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes