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.
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.
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:
- A webhook that is supposed to call
setStepSuccessis misconfigured, points at the wrong URL, or the provider retries it in a way Medusa never receives. - A worker process crashes or is redeployed mid step, so the in-process promise that was going to resolve the step is gone and nothing ever tells the workflow to continue.
- A subscriber that was meant to fire on an event never registers, or throws before it reaches the call that would advance the step.
- The row is rewritten, delete then reinsert, on every step transition in some versions rather than updated in place, so on rapid polling it can appear to disappear and reappear between reads, the flapping symptom from GitHub issue #9077.
- No explicit
retentionTimewas set on the workflow, so Medusa has no built in TTL sweep to expire or reap a dangling row, and these rows accumulate indefinitely, as reported in GitHub issue #11175.
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.
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.
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. 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.
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
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
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.
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()
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;
}
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.
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
/**
* 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;
}
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.
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
export function detectFlapping(previousIds, currentIds, everSeenIds) {
const missingLastPoll = [...everSeenIds].filter((id) => !previousIds.has(id));
return new Set(missingLastPoll.filter((id) => currentIds.has(id)));
}
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.
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.
"""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 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.
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
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
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.
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.
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:
- Long-running workflows don't save correctly in workflow_execution table. Medusa GitHub Issue #9077. github.com/medusajs/medusa/issues/9077
- [Bug]: workflow execution table size grows too large (workflow_execution). Medusa GitHub Issue #11175. github.com/medusajs/medusa/issues/11175
- Medusa Documentation: Store Workflow Executions. docs.medusajs.com/learn/fundamentals/workflows/store-executions
On the solution:
- Medusa Documentation: Retry Failed Steps. docs.medusajs.com/learn/fundamentals/workflows/retry-failed-steps
- Medusa Documentation: Workflow Engine Module. docs.medusajs.com/resources/infrastructure-modules/workflow-engine
- 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.
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