Skip to content

Diagnostic Workflows & Background Jobs

Long running workflow executions get stuck in invoking state

You go looking at a long running workflow days after it started and its workflow_execution row still says invoking. It never moved to done, never moved to failed, it is just sitting there. The step waiting on an external signal, a webhook, a worker, a subscriber, never got its answer, and Medusa has no built in clock to give up on it. Here is why that row gets stuck and a small script that finds the stuck ones and reports them for an operator to retry safely.

Python and Node.js Medusa Admin API Safe by default (dry run)
Wires in a server rack
Photo by imgix on Unsplash
The short answer

Medusa only writes a workflow_execution row when a workflow is marked store: true, which long running workflows get automatically, and that row's state is meant to move through invoking to done, failed, or compensating as async steps finish. A step marked async: true, or one with a retryInterval, only advances that row when it gets its external completion signal, a webhook calling setStepSuccess, a worker checking in, a subscriber firing. If that signal never comes, the row is stuck mid invoke, and because there is no built in TTL sweep without an explicit retentionTime, it can sit there indefinitely (GitHub #9077, #11175). Run a script that lists rows still in invoking, compares their age against an expected TTL, and reports the stuck transaction ids for a human to retry with retryStep. Full code, tests, and a dry run guard are below.

The problem in plain words

Most workflow steps run and finish in the same tick. Medusa calls the step, the step does its work, and the workflow moves on. A long running workflow is different on purpose. It marks itself store: true so Medusa writes a row to the workflow_execution table, and it can contain a step that does not finish right away, an async step waiting on something outside the process.

That something outside the process is exactly the part Medusa cannot control. A payment provider is supposed to call a webhook once the charge clears. A background worker is supposed to check back in once a long job finishes. A subscriber is supposed to fire once an event lands. When any of those never happens, because the webhook was misconfigured, the worker crashed halfway through, or the subscriber silently failed to register, the step never calls setStepSuccess, and the workflow has no way to know it should move on. The workflow_execution row just sits there with state: "invoking", and in some versions the row is deleted and reinserted on every step transition rather than updated in place, so on a fast poll you can even watch it flicker out of existence and back, which is the exact flapping behavior reported in GitHub issue #9077.

Workflow invokes async step, store: true Waits on external signal webhook, worker, or subscriber to call setStepSuccess signal never arrives Step never completes no setStepSuccess call workflow_execution stuck state: "invoking" forever no TTL sweep without retentionTime
An async step waits on a signal from outside the process. When that signal never comes, the step never calls setStepSuccess, and the workflow_execution row is left on invoking with nothing to expire it.

Why it happens

Medusa's workflow engine gives async steps the ability to pause and wait, but it puts the responsibility for resuming them entirely outside the workflow itself. A few common ways rows end up stuck on invoking:

The net effect is the same in every case. Nothing inside Medusa is watching the clock on that row, so a signal that never arrives leaves it stuck for as long as the table exists. See the citations at the end for the exact issues and docs.

The key insight

A stuck row on invoking is a symptom, not the disease. The disease is whatever kept the async step from ever hearing back, a dead webhook, a crashed worker, a subscriber that never fired. So the safe move is never to force that row into a different state or delete it directly, since the transaction id it holds may still be tied to a real in-flight compensation, or to a side effect like a payment capture that has not actually resolved yet. The safe move is to flag it, with its age and its workflow id, so an operator can look at the specific stalled step and retry it through Medusa's own retryStep path.

The fix, as a flow

We do not touch the workflow_execution table directly. The job lists rows still in invoking, compares each row's age against an expected TTL with a pure function, and reports every row that has been stuck too long. Nothing is retried or deleted automatically. That is left to an operator using Medusa's own retry path.

List invoking rows state: "invoking" Compute elapsed time now minus updated_at Pure decision fn isStuckInvoking Past workflow's TTL? yes, flag it no, still normal Report to operator retryStep, never a raw delete
Every invoking row is measured against its expected TTL. Only rows stuck past that threshold are reported, with the exact transaction id and workflow id an operator needs to retry the stalled step safely.

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. There is no public /admin/workflow-executions REST route in v2, so this script talks to Postgres directly with a read only query, the same table Medusa's own Settings, Workflows view reads server side. Keep every credential in environment variables, never in the file.

setup (shell)
pip install psycopg2-binary

export MEDUSA_DATABASE_URL="postgres://user:pass@localhost:5432/medusa"
export DEFAULT_TTL_MINUTES="20"
export DRY_RUN="true"   # start safe, this script only reports either way
setup (shell)
npm install pg

export MEDUSA_DATABASE_URL="postgres://user:pass@localhost:5432/medusa"
export DEFAULT_TTL_MINUTES="20"
export DRY_RUN="true"   // start safe, this script only reports either way
2

List rows still in the invoking state

Query workflow_execution directly, since there is no public REST route for it. Read back the fields the decision needs: the workflow id, the transaction id, the state, and both timestamps. Inside Medusa's own code this same data comes back from listWorkflowExecutions({ state: "invoking" }) on the Workflow Engine Module, so either path reads the same underlying rows.

step2.py
import os
import psycopg2
import psycopg2.extras

DATABASE_URL = os.environ["MEDUSA_DATABASE_URL"]

INVOKING_QUERY = """
    SELECT id, workflow_id, transaction_id, state, retention_time, created_at, updated_at
    FROM workflow_execution
    WHERE state = 'invoking'
"""

def list_invoking_rows():
    conn = psycopg2.connect(DATABASE_URL)
    try:
        with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
            cur.execute(INVOKING_QUERY)
            return [dict(row) for row in cur.fetchall()]
    finally:
        conn.close()
step2.js
import pg from "pg";

const { Pool } = pg;
const pool = new Pool({ connectionString: process.env.MEDUSA_DATABASE_URL });

const INVOKING_QUERY = `
  SELECT id, workflow_id, transaction_id, state, retention_time, created_at, updated_at
  FROM workflow_execution
  WHERE state = 'invoking'
`;

async function listInvokingRows() {
  const { rows } = await pool.query(INVOKING_QUERY);
  return rows;
}
3

Decide, with one pure function

Keep the decision in its own function that takes a plain row, the current time, a map of TTL overrides per workflow id, and a default TTL, then returns true or false. It never touches the network or the database, so it is trivial to unit test with fixed clock values. A row only counts as stuck when its state is invoking and the time since its last update (falling back to created_at) is past the TTL for that workflow.

decide.py
def is_stuck_invoking(row, now_ms, ttl_ms_by_workflow, default_ttl_ms):
    """Pure decision function. No I/O.

    row: {"state": str, "created_at": datetime, "updated_at": datetime | None, "workflow_id": str}
    now_ms: int (epoch milliseconds)
    ttl_ms_by_workflow: dict[str, int]
    default_ttl_ms: int

    Returns True only when row["state"] == "invoking" and the row has been
    sitting past its TTL since it was last updated (or created, if never updated).
    """
    if row.get("state") != "invoking":
        return False

    reference = row.get("updated_at") or row.get("created_at")
    if reference is None:
        return False

    ttl_ms = ttl_ms_by_workflow.get(row.get("workflow_id"), default_ttl_ms)
    elapsed_ms = now_ms - reference.timestamp() * 1000
    return elapsed_ms > ttl_ms
decide.js
/**
 * Pure decision function. No I/O.
 *
 * @param {{ state: string, createdAt: Date, updatedAt: Date | null, workflowId: string }} row
 * @param {number} nowMs
 * @param {Record<string, number>} ttlMsByWorkflow
 * @param {number} defaultTtlMs
 * @returns {boolean}
 */
export function isStuckInvoking(row, nowMs, ttlMsByWorkflow, defaultTtlMs) {
  if (row.state !== "invoking") return false;

  const reference = row.updatedAt ?? row.createdAt;
  if (!reference) return false;

  const ttlMs = ttlMsByWorkflow[row.workflowId] ?? defaultTtlMs;
  const elapsedMs = nowMs - reference.getTime();
  return elapsedMs > ttlMs;
}
4

Also watch for the flapping symptom

Some versions rewrite the row, delete then reinsert, on every step transition instead of updating it in place, so a transaction id can be present on one poll, absent on the next, then present again, exactly the behavior reported in GitHub issue #9077. Keep the last two polls' transaction id sets in memory and flag any id that disappears and later reappears, separately from the TTL check.

flapping.py
def detect_flapping(previous_ids, current_ids, ever_seen_ids):
    """Pure. Returns the set of transaction_ids seen before, missing last poll,
    and present again now (present -> absent -> present)."""
    reappeared = (ever_seen_ids - previous_ids) & current_ids
    return reappeared
flapping.js
export function detectFlapping(previousIds, currentIds, everSeenIds) {
  const missingLastPoll = [...everSeenIds].filter((id) => !previousIds.has(id));
  return new Set(missingLastPoll.filter((id) => currentIds.has(id)));
}
5

Report, never repair automatically

Log every row flagged as stuck, with its transaction_id, workflow_id, and elapsed time. This script never deletes or updates workflow_execution directly, since doing so risks orphaning an in-flight compensation or double-triggering a side effect. When DRY_RUN is false, the only action taken is printing the confirmed list clearly enough for an operator to act on with Medusa's own retryStep path.

Run it safe

This script is flag and report only. It never calls retryStep, never deletes a row, and never updates workflow_execution directly. DRY_RUN=false only changes whether it prints a confirmation banner before the list, since there is nothing destructive to gate. The actual repair, retrying the specific stalled step or cancelling the transaction through the workflow's own compensation logic, is a decision for a human who can see what that transaction was doing.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, connects read only to Postgres, and is safe to run again and again on a schedule because it never writes to workflow_execution at all.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
flag_stuck_invoking.py
"""Flag Medusa workflow_execution rows stuck in the invoking state.

Medusa only persists a workflow_execution row when a workflow is marked store: true,
which long running workflows get automatically, and the row's state is meant to move
through invoking to done, failed, or compensating as async steps complete. An async
step (async: true, or one with a retryInterval) only advances the row when it gets its
external completion signal: a webhook calling setStepSuccess, a worker checking back in,
a subscriber firing. If that signal never arrives, the row is stuck mid invoke, and
without an explicit retentionTime there is no built in TTL sweep to expire it, so it can
sit there indefinitely (GitHub #9077, #11175).

This connects read only to Postgres, lists rows still in the invoking state, flags the
ones stuck past an expected TTL with a pure function, and reports the transaction ids
for an operator to retry with the Workflow Engine Module's retryStep. It never deletes
or updates workflow_execution directly. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import psycopg2
import psycopg2.extras
from datetime import datetime, timezone

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_stuck_invoking")

DATABASE_URL = os.environ["MEDUSA_DATABASE_URL"]
DEFAULT_TTL_MINUTES = float(os.environ.get("DEFAULT_TTL_MINUTES", "20"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Per workflow_id TTL overrides, in minutes. Extend this if a workflow legitimately
# needs longer than DEFAULT_TTL_MINUTES to receive its async completion signal.
TTL_MINUTES_BY_WORKFLOW = {}

INVOKING_QUERY = """
    SELECT id, workflow_id, transaction_id, state, retention_time, created_at, updated_at
    FROM workflow_execution
    WHERE state = 'invoking'
"""


def is_stuck_invoking(row, now_ms, ttl_ms_by_workflow, default_ttl_ms):
    """Pure decision function. No I/O.

    row: {"state": str, "created_at": datetime, "updated_at": datetime | None, "workflow_id": str}
    now_ms: int (epoch milliseconds)
    ttl_ms_by_workflow: dict[str, int]
    default_ttl_ms: int

    Returns True only when row["state"] == "invoking" and the row has been
    sitting past its TTL since it was last updated (or created, if never updated).
    """
    if row.get("state") != "invoking":
        return False

    reference = row.get("updated_at") or row.get("created_at")
    if reference is None:
        return False

    ttl_ms = ttl_ms_by_workflow.get(row.get("workflow_id"), default_ttl_ms)
    elapsed_ms = now_ms - reference.timestamp() * 1000
    return elapsed_ms > ttl_ms


def detect_flapping(previous_ids, current_ids, ever_seen_ids):
    """Pure. Returns the set of transaction_ids seen before, missing last poll,
    and present again now (present -> absent -> present)."""
    reappeared = (ever_seen_ids - previous_ids) & current_ids
    return reappeared


def list_invoking_rows():
    conn = psycopg2.connect(DATABASE_URL)
    try:
        with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
            cur.execute(INVOKING_QUERY)
            return [dict(row) for row in cur.fetchall()]
    finally:
        conn.close()


def run():
    now_ms = datetime.now(timezone.utc).timestamp() * 1000
    ttl_ms_by_workflow = {k: v * 60000 for k, v in TTL_MINUTES_BY_WORKFLOW.items()}
    default_ttl_ms = DEFAULT_TTL_MINUTES * 60000

    rows = list_invoking_rows()
    stuck = [row for row in rows if is_stuck_invoking(row, now_ms, ttl_ms_by_workflow, default_ttl_ms)]

    if DRY_RUN:
        log.info("Dry run. Reporting only, no state is ever changed by this script.")

    for row in stuck:
        reference = row.get("updated_at") or row.get("created_at")
        elapsed_minutes = (now_ms - reference.timestamp() * 1000) / 60000
        log.warning(
            "Stuck invoking: transaction_id=%s workflow_id=%s elapsed=%.1fmin. "
            "Operator action: retryStep, or cancel via the workflow's own compensation.",
            row["transaction_id"], row["workflow_id"], elapsed_minutes,
        )

    log.info("Done. %d workflow_execution row(s) stuck on invoking out of %d total in that state.",
              len(stuck), len(rows))


if __name__ == "__main__":
    run()
flag-stuck-invoking.js
/**
 * Flag Medusa workflow_execution rows stuck in the invoking state.
 *
 * Medusa only persists a workflow_execution row when a workflow is marked store: true,
 * which long running workflows get automatically, and the row's state is meant to move
 * through invoking to done, failed, or compensating as async steps complete. An async
 * step (async: true, or one with a retryInterval) only advances the row when it gets its
 * external completion signal: a webhook calling setStepSuccess, a worker checking back in,
 * a subscriber firing. If that signal never arrives, the row is stuck mid invoke, and
 * without an explicit retentionTime there is no built in TTL sweep to expire it, so it can
 * sit there indefinitely (GitHub #9077, #11175).
 *
 * This connects read only to Postgres, lists rows still in the invoking state, flags the
 * ones stuck past an expected TTL with a pure function, and reports the transaction ids
 * for an operator to retry with the Workflow Engine Module's retryStep. It never deletes
 * or updates workflow_execution directly. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/workflow-execution-stuck-invoking/
 */
import { pathToFileURL } from "node:url";
import pg from "pg";

const DATABASE_URL = process.env.MEDUSA_DATABASE_URL || "postgres://user:pass@localhost:5432/medusa";
const DEFAULT_TTL_MINUTES = Number(process.env.DEFAULT_TTL_MINUTES || 20);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Per workflow_id TTL overrides, in minutes. Extend this if a workflow legitimately
// needs longer than DEFAULT_TTL_MINUTES to receive its async completion signal.
const TTL_MINUTES_BY_WORKFLOW = {};

const INVOKING_QUERY = `
  SELECT id, workflow_id, transaction_id, state, retention_time, created_at, updated_at
  FROM workflow_execution
  WHERE state = 'invoking'
`;

/**
 * Pure decision function. No I/O.
 *
 * @param {{ state: string, createdAt: Date, updatedAt: Date | null, workflowId: string }} row
 * @param {number} nowMs
 * @param {Record<string, number>} ttlMsByWorkflow
 * @param {number} defaultTtlMs
 * @returns {boolean}
 */
export function isStuckInvoking(row, nowMs, ttlMsByWorkflow, defaultTtlMs) {
  if (row.state !== "invoking") return false;

  const reference = row.updatedAt ?? row.createdAt;
  if (!reference) return false;

  const ttlMs = ttlMsByWorkflow[row.workflowId] ?? defaultTtlMs;
  const elapsedMs = nowMs - reference.getTime();
  return elapsedMs > ttlMs;
}

export function detectFlapping(previousIds, currentIds, everSeenIds) {
  const missingLastPoll = [...everSeenIds].filter((id) => !previousIds.has(id));
  return new Set(missingLastPoll.filter((id) => currentIds.has(id)));
}

async function listInvokingRows() {
  const pool = new pg.Pool({ connectionString: DATABASE_URL });
  try {
    const { rows } = await pool.query(INVOKING_QUERY);
    return rows;
  } finally {
    await pool.end();
  }
}

export async function run() {
  const nowMs = Date.now();
  const ttlMsByWorkflow = Object.fromEntries(
    Object.entries(TTL_MINUTES_BY_WORKFLOW).map(([k, v]) => [k, v * 60000])
  );
  const defaultTtlMs = DEFAULT_TTL_MINUTES * 60000;

  const rows = await listInvokingRows();
  const stuck = rows.filter((row) =>
    isStuckInvoking(
      {
        state: row.state,
        createdAt: row.created_at,
        updatedAt: row.updated_at,
        workflowId: row.workflow_id,
      },
      nowMs,
      ttlMsByWorkflow,
      defaultTtlMs
    )
  );

  if (DRY_RUN) {
    console.log("Dry run. Reporting only, no state is ever changed by this script.");
  }

  for (const row of stuck) {
    const reference = row.updated_at || row.created_at;
    const elapsedMinutes = (nowMs - new Date(reference).getTime()) / 60000;
    console.warn(
      `Stuck invoking: transaction_id=${row.transaction_id} workflow_id=${row.workflow_id} elapsed=${elapsedMinutes.toFixed(1)}min. ` +
      `Operator action: retryStep, or cancel via the workflow's own compensation.`
    );
  }

  console.log(`Done. ${stuck.length} workflow_execution row(s) stuck on invoking out of ${rows.length} total in that state.`);
}

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

Add a test

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

test_workflow_stuck.py
from datetime import datetime, timezone

from flag_stuck_invoking import is_stuck_invoking

NOW = datetime(2026, 7, 10, 0, 20, 0, tzinfo=timezone.utc)
NOW_MS = NOW.timestamp() * 1000


def row(**over):
    base = {
        "state": "invoking",
        "workflow_id": "create-order",
        "created_at": datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc),
        "updated_at": datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc),
    }
    base.update(over)
    return base


def test_stuck_when_invoking_past_default_ttl():
    assert is_stuck_invoking(row(), NOW_MS, {}, 10 * 60000) is True


def test_not_stuck_when_within_ttl():
    assert is_stuck_invoking(row(), NOW_MS, {}, 30 * 60000) is False


def test_not_stuck_when_state_is_done():
    assert is_stuck_invoking(row(state="done"), NOW_MS, {}, 5 * 60000) is False


def test_not_stuck_when_state_is_failed():
    assert is_stuck_invoking(row(state="failed"), NOW_MS, {}, 5 * 60000) is False


def test_uses_per_workflow_ttl_override():
    ttl_overrides = {"create-order": 30 * 60000}
    assert is_stuck_invoking(row(), NOW_MS, ttl_overrides, 10 * 60000) is False


def test_falls_back_to_created_at_when_updated_at_missing():
    r = row(updated_at=None, created_at=datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc))
    assert is_stuck_invoking(r, NOW_MS, {}, 10 * 60000) is True


def test_not_stuck_when_no_timestamps_at_all():
    r = row(updated_at=None, created_at=None)
    assert is_stuck_invoking(r, NOW_MS, {}, 1) is False
stuck-invoking.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isStuckInvoking } from "./flag-stuck-invoking.js";

const NOW_MS = Date.parse("2026-07-10T00:20:00Z");

const row = (over = {}) => ({
  state: "invoking",
  workflowId: "create-order",
  createdAt: new Date("2026-07-10T00:00:00Z"),
  updatedAt: new Date("2026-07-10T00:00:00Z"),
  ...over,
});

test("stuck when invoking past default TTL", () => {
  assert.equal(isStuckInvoking(row(), NOW_MS, {}, 10 * 60000), true);
});

test("not stuck when within TTL", () => {
  assert.equal(isStuckInvoking(row(), NOW_MS, {}, 30 * 60000), false);
});

test("not stuck when state is done", () => {
  assert.equal(isStuckInvoking(row({ state: "done" }), NOW_MS, {}, 5 * 60000), false);
});

test("not stuck when state is failed", () => {
  assert.equal(isStuckInvoking(row({ state: "failed" }), NOW_MS, {}, 5 * 60000), false);
});

test("uses per workflow TTL override", () => {
  const ttlOverrides = { "create-order": 30 * 60000 };
  assert.equal(isStuckInvoking(row(), NOW_MS, ttlOverrides, 10 * 60000), false);
});

test("falls back to createdAt when updatedAt missing", () => {
  const r = row({ updatedAt: null, createdAt: new Date("2026-07-10T00:00:00Z") });
  assert.equal(isStuckInvoking(r, NOW_MS, {}, 10 * 60000), true);
});

test("not stuck when no timestamps at all", () => {
  const r = row({ updatedAt: null, createdAt: null });
  assert.equal(isStuckInvoking(r, NOW_MS, {}, 1), false);
});

Case studies

Dead webhook

A payment provider that changed its callback URL

A store's custom payment integration used an async step to wait for a provider's webhook before marking a workflow's capture step complete. The provider rotated its webhook signing setup during a routine update, and the callback silently stopped reaching the store's endpoint. Nobody noticed right away, because the checkout itself had already returned success to the customer.

The script's dry run surfaced a growing list of transaction ids all pointing at the same workflow id, all elapsed well past the TTL. That pattern, many stuck rows on one workflow, was the tell. The team fixed the webhook URL, then had an operator retry each stalled step individually with retryStep rather than touching the table.

Crashed worker

A background export job that never checked back in

A long running workflow kicked off a background job to generate a large export, with a step marked async: true that expected the job to call setStepSuccess when it finished. A worker redeploy killed the in-progress job mid run, and because the job never resumed, that one step, and the workflow behind it, never got a completion signal.

Running the script hourly caught it inside of two cycles instead of weeks later. It reported a single stuck transaction, comfortably past its TTL, with nothing else affected. An operator queued a fresh export job and let the original transaction get cancelled through the workflow's own compensation logic.

What good looks like

After this runs on a schedule, a dead webhook or a crashed worker gets caught within one polling cycle instead of being discovered by accident weeks later. The report gives an operator exactly the transaction id, workflow id, and elapsed time they need to retry the one stalled step, or to cancel it cleanly through the workflow's own compensation. Nothing in workflow_execution is ever touched by a guess.

FAQ

Why does a Medusa workflow_execution row stay on invoking forever?

Medusa only persists a workflow_execution row when the workflow is marked store: true, which long running workflows get automatically, and that row is meant to move through invoking to done, failed, or compensating as its async steps complete. An async step, one marked async: true or one with a retryInterval, only advances the row when it receives its external completion signal, such as a webhook calling setStepSuccess. If that webhook never arrives, the worker crashes mid step, or a subscriber never fires, the row is left mid invoke with no built in TTL to expire it, so it can sit there indefinitely.

How do I find stuck workflow executions in Medusa?

Resolve the Workflow Engine Module in a server side script or job with req.scope.resolve(Modules.WORKFLOW_ENGINE) and call listWorkflowExecutions({ state: "invoking" }, { select: ["id","workflow_id","transaction_id","state","execution","retention_time","created_at","updated_at"] }). There is no public /admin/workflow-executions REST route in v2, so this same table is what the Admin UI's Settings, Workflows view reads server side. For each row, compare now minus updated_at against the workflow's retentionTime or a conservative default, and flag anything still invoking past that threshold.

Is it safe to delete a stuck workflow_execution row directly?

No. Forcibly deleting or updating that row risks orphaning an in-flight compensation or double-triggering a side effect tied to that transaction, such as a payment capture or a fulfillment. The safe path is the Workflow Engine Module's documented retryStep call for the specific stalled step, and if the underlying async action can truly never complete, an operator should fail or cancel the transaction through the workflow's own compensation logic, never a raw DELETE or UPDATE on workflow_execution.

Related field notes

Citations

On the problem:

  1. Long-running workflows don't save correctly in workflow_execution table. Medusa GitHub Issue #9077. github.com/medusajs/medusa/issues/9077
  2. [Bug]: workflow execution table size grows too large (workflow_execution). Medusa GitHub Issue #11175. github.com/medusajs/medusa/issues/11175
  3. Medusa Documentation: Store Workflow Executions. docs.medusajs.com/learn/fundamentals/workflows/store-executions

On the solution:

  1. Medusa Documentation: Retry Failed Steps. docs.medusajs.com/learn/fundamentals/workflows/retry-failed-steps
  2. Medusa Documentation: Workflow Engine Module. docs.medusajs.com/resources/infrastructure-modules/workflow-engine
  3. Medusa Documentation: Debug Workflows. docs.medusajs.com/learn/debugging-and-testing/debug-workflows

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 staring at a workflow_execution row that would not budge, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Medusa field notes