Reconciler Order State Machine

Bulk order status editor leaves orders mid transition

You select forty orders in the Shopware 6 admin, pick a status from the bulk editor, and move on. A little while later someone notices one of those orders never actually moved. Its siblings from the same batch went from in progress to completed just fine, but this one is still stuck in the middle, and nothing retried it. Here is why the bulk editor drops orders like this and a small script that finds the stuck ones and finishes the job.

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

Shopware's bulk order editor loops over your selected orders and fires a state transition for each one in quick succession, for example POST /api/_action/order/{id}/state/complete. Every transition runs the order.state.changed event chain, which includes the StockUpdater subscriber issuing its own UPDATE product SET stock = stock + :diff and UPDATE product SET available_stock = ... statements per line item. When two or more orders in the same batch share a product, those statements race for the same product row lock, and InnoDB's deadlock detector kills one transaction with SQLSTATE[40001]: Deadlock found when trying to get lock; try restarting transaction, confirmed in shopware/shopware#3524. The killed transaction rolls back, so that order's state never completes even though sibling orders already moved on, and there is no automatic retry. Run a small Python or Node.js script that searches for orders sitting in an unexpected in-between state, flags the ones whose transactions or deliveries have clearly moved past what the order-level state shows, and re-issues the transition for each one individually, never in a batch. Full code, tests, and sources are below.

The problem in plain words

The bulk order status editor in the Shopware 6 admin is meant to save you clicking through orders one by one. Select a batch, pick a target status, and Shopware transitions every order in the selection to that status.

The trouble is that each of those transitions is not a simple flag flip. Moving an order to completed fires the full order.state.changed event chain, and one of the listeners on that chain is the StockUpdater subscriber, which writes the product's stock and available stock back to the database for every line item on the order. When the batch contains several orders that happen to sell the same product, which is completely ordinary for a popular item, their stock update statements land on the same product row at close to the same moment. InnoDB detects the lock contention as a deadlock, picks one transaction to kill, and that order's transition rolls back mid flight. The rest of the batch finishes normally. The killed order is left stuck between states, and because the bulk editor does not retry a failed transition, nobody notices until a customer or a report catches the mismatch.

Order A, complete shares product X Order B, complete shares product X StockUpdater writes stock, available_stock same product row row lock contention InnoDB deadlock kills one transaction SQLSTATE[40001] Order B rolls back stuck Order A completes normally. No automatic retry runs for Order B. It stays mid transition until someone notices.
Sibling orders in the same batch finish transitioning. The one whose transaction lost the deadlock race is left stuck in between, with no automatic retry.

Why it happens

This is a concurrency defect in how the bulk editor drives the state machine, not a corrupted order. A few things make it easy to hit and easy to miss:

The key insight

The order that got stuck is not corrupted, it just lost a race. Its transaction rolled back cleanly, which means the fix is simply to run that same transition again, this time without the contention that caused it to fail. The one rule that matters is never re-run it inside another bulk batch, and never in parallel with anything touching the same product, because that recreates the exact same lock race. Retry it alone, confirm the new state, and move on.

The fix, as a flow

We search for orders sitting in in_progress, the working set the bulk job targeted, along with their transactions, deliveries, and line items. A pure function compares each order's state against its batch siblings and their downstream states to decide whether it looks stuck mid transition. Anything flagged gets its transition re-issued alone, one order at a time with a short delay between calls, and we re-fetch the order afterward to confirm it actually reached the target state before calling it resolved.

Search order transactions, deliveries classifyOrderTransition compare to batch siblings stuck_needs_retry? vs ok or in_progress yes no, leave as is Retry transition alone, serially, DRY_RUN Confirm state GET order, check technicalName matches
Only orders the pure function classifies as stuck get retried, one at a time, and each retry is confirmed with a fresh read before it is marked resolved.

Build it step by step

1

Get Admin API credentials

Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export TARGET_ORDER_STATE="completed"
export TARGET_TRANSITION="complete"
export STUCK_THRESHOLD_SECONDS="5"
export DRY_RUN="true"   # start safe, change to false to retry for real
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export TARGET_ORDER_STATE="completed"
export TARGET_TRANSITION="complete"
export STUCK_THRESHOLD_SECONDS="5"
export DRY_RUN="true"   // start safe, change to false to retry for real
2

Authenticate and talk to the Admin API

A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper for the search, the state transition, and the confirmation read.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Pull the working set the bulk job targeted

Search order filtered to stateMachineState.technicalName = "in_progress", with the transactions, deliveries, stateMachineState, and lineItems associations, sorted by updatedAt ascending. This is the same batch the bulk editor touched, still holding whichever orders never moved past in_progress.

step3.py
def search_in_progress_orders(token, page=1, limit=500):
    body = {
        "filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "in_progress"}],
        "associations": {"transactions": {}, "deliveries": {}, "stateMachineState": {}, "lineItems": {}},
        "sort": [{"field": "updatedAt", "order": "ASC"}],
        "page": page,
        "limit": limit,
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/order", token, body)
step3.js
async function searchInProgressOrders(token, page = 1, limit = 500) {
  const body = {
    filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "in_progress" }],
    associations: { transactions: {}, deliveries: {}, stateMachineState: {}, lineItems: {} },
    sort: [{ field: "updatedAt", order: "ASC" }],
    page,
    limit,
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/order", token, body);
}
4

Classify each order, with one pure function

Keep the decision out of the network code entirely. Given an order's technical name, its transaction and delivery state names, its updatedAt, and the batch's expected target state and median updatedAt, this function returns ok, stuck_needs_retry, or in_progress. An order counts as stuck when its transactions or deliveries already show a state that logically follows the target order state, such as paid or shipped, while the order itself has not moved, or when it lags the batch's median update time by more than a threshold while siblings sharing the same products already transitioned.

decide.py
FOLLOW_ON_STATES = {"paid", "shipped"}

def classify_order_transition(order, expected):
    technical_name = order["stateMachineTechnicalName"]
    if technical_name == expected["targetOrderState"]:
        return "ok"

    followed_on = any(s in FOLLOW_ON_STATES for s in order.get("transactionStates", [])) or \
        any(s in FOLLOW_ON_STATES for s in order.get("deliveryStates", []))

    lag_seconds = expected["batchMedianUpdatedAt"] - order["updatedAt"]
    lagging = lag_seconds > expected["thresholdSeconds"]

    if followed_on or lagging:
        return "stuck_needs_retry"

    return "in_progress"
decide.js
const FOLLOW_ON_STATES = new Set(["paid", "shipped"]);

export function classifyOrderTransition(order, expected) {
  const technicalName = order.stateMachineTechnicalName;
  if (technicalName === expected.targetOrderState) return "ok";

  const followedOn =
    (order.transactionStates || []).some((s) => FOLLOW_ON_STATES.has(s)) ||
    (order.deliveryStates || []).some((s) => FOLLOW_ON_STATES.has(s));

  const lagSeconds = expected.batchMedianUpdatedAt - order.updatedAt;
  const lagging = lagSeconds > expected.thresholdSeconds;

  if (followedOn || lagging) return "stuck_needs_retry";

  return "in_progress";
}
5

Retry the stuck orders, one at a time

For every order classified stuck_needs_retry, re-issue the transition alone through POST /api/_action/order/{orderId}/state/{transitionName}, never inside another bulk batch and never in parallel with the others. Guard the call with DRY_RUN, and always run the retries serially with a short delay, since parallel retries recreate the exact same product row contention that caused the problem.

retry.py
def retry_transition(token, order_id, transition_name):
    if DRY_RUN:
        log.warning("DRY RUN: would POST /api/_action/order/%s/state/%s", order_id, transition_name)
        return
    api("POST", f"/api/_action/order/{order_id}/state/{transition_name}", token, {})


def confirm_target_state(token, order_id, target_state):
    data = api("GET", f"/api/order/{order_id}?associations[stateMachineState]={{}}", token)
    technical_name = (((data.get("data") or {}).get("stateMachineState")) or {}).get("technicalName")
    return technical_name == target_state
retry.js
async function retryTransition(token, orderId, transitionName) {
  if (DRY_RUN) {
    console.warn(`DRY RUN: would POST /api/_action/order/${orderId}/state/${transitionName}`);
    return;
  }
  await api("POST", `/api/_action/order/${orderId}/state/${transitionName}`, token, {});
}

async function confirmTargetState(token, orderId, targetState) {
  const data = await api("GET", `/api/order/${orderId}?associations[stateMachineState]={}`, token);
  const technicalName = data?.data?.stateMachineState?.technicalName;
  return technicalName === targetState;
}
6

Wire it together with a dry run guard and a delay

The loop ties every piece together: search the batch, compute the median updatedAt, classify each order, retry the stuck ones serially with a short pause between calls, and confirm each one actually reached the target state before counting it resolved. If a retry itself throws a deadlock, back off and requeue it for the next run rather than forcing a raw stateId write, which bypasses the state machine's own validity checks.

Run it safe

Always start with DRY_RUN=true, retry orders one at a time with a short delay between calls, and confirm the new state with a fresh read before you consider an order fixed. Never PATCH stateId directly and never retry inside another bulk batch, since that recreates the same product row race that caused the problem in the first place.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs every order it flags and retries, respects the dry run flag, and confirms each retry against a fresh read before it is counted as resolved.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

retry_stuck_bulk_transitions.py
"""Find Shopware 6 orders stuck mid transition after a bulk order status edit, and retry them, safely.

The bulk order status editor fires a state transition for every selected order in quick
succession, for example POST /api/_action/order/{id}/state/complete. Each transition runs
the order.state.changed event chain, which includes the StockUpdater subscriber issuing its
own stock update statements per line item product. When two or more orders in the same batch
share a product, those statements race for the same product row lock, and InnoDB's deadlock
detector kills one transaction with SQLSTATE[40001] (confirmed in shopware/shopware#3524).
That transaction rolls back, so the order never reaches the target state, with no automatic
retry. This script finds orders still sitting in_progress whose siblings already moved on,
and retries each one alone, one at a time, confirming the result before marking it resolved.
It never writes stateId directly and never retries inside another bulk batch.
Run on a schedule. Safe to run again and again.
"""
import os
import time
import logging
import statistics
import requests

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

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
TARGET_ORDER_STATE = os.environ.get("TARGET_ORDER_STATE", "completed")
TARGET_TRANSITION = os.environ.get("TARGET_TRANSITION", "complete")
STUCK_THRESHOLD_SECONDS = float(os.environ.get("STUCK_THRESHOLD_SECONDS", "5"))
RETRY_DELAY_SECONDS = float(os.environ.get("RETRY_DELAY_SECONDS", "1.5"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

FOLLOW_ON_STATES = {"paid", "shipped"}


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def search_in_progress_orders(token, page=1, limit=500):
    body = {
        "filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "in_progress"}],
        "associations": {"transactions": {}, "deliveries": {}, "stateMachineState": {}, "lineItems": {}},
        "sort": [{"field": "updatedAt", "order": "ASC"}],
        "page": page,
        "limit": limit,
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/order", token, body)


def classify_order_transition(order, expected):
    technical_name = order["stateMachineTechnicalName"]
    if technical_name == expected["targetOrderState"]:
        return "ok"

    followed_on = any(s in FOLLOW_ON_STATES for s in order.get("transactionStates", [])) or \
        any(s in FOLLOW_ON_STATES for s in order.get("deliveryStates", []))

    lag_seconds = expected["batchMedianUpdatedAt"] - order["updatedAt"]
    lagging = lag_seconds > expected["thresholdSeconds"]

    if followed_on or lagging:
        return "stuck_needs_retry"

    return "in_progress"


def retry_transition(token, order_id, transition_name):
    if DRY_RUN:
        log.warning("DRY RUN: would POST /api/_action/order/%s/state/%s", order_id, transition_name)
        return
    api("POST", f"/api/_action/order/{order_id}/state/{transition_name}", token, {})


def confirm_target_state(token, order_id, target_state):
    data = api("GET", f"/api/order/{order_id}?associations[stateMachineState]={{}}", token)
    technical_name = (((data.get("data") or {}).get("stateMachineState")) or {}).get("technicalName")
    return technical_name == target_state


def to_plain_order(row):
    transactions = row.get("transactions") or []
    deliveries = row.get("deliveries") or []
    return {
        "id": row["id"],
        "orderNumber": row.get("orderNumber", "?"),
        "stateMachineTechnicalName": (row.get("stateMachineState") or {}).get("technicalName"),
        "updatedAt": row.get("updatedAt"),
        "transactionStates": [
            (t.get("stateMachineState") or {}).get("technicalName") for t in transactions
        ],
        "deliveryStates": [
            (d.get("stateMachineState") or {}).get("technicalName") for d in deliveries
        ],
    }


def run():
    token = get_token()
    checked = 0
    retried = 0
    resolved = 0

    data = search_in_progress_orders(token)
    rows = data.get("data", [])
    checked = len(rows)
    if not rows:
        log.info("Done. 0 order(s) checked, nothing in_progress.")
        return

    plain_orders = [to_plain_order(row) for row in rows]
    updated_epochs = [
        o["updatedAt"] for o in plain_orders if isinstance(o["updatedAt"], (int, float))
    ]
    batch_median = statistics.median(updated_epochs) if updated_epochs else 0

    expected = {
        "targetOrderState": TARGET_ORDER_STATE,
        "batchMedianUpdatedAt": batch_median,
        "thresholdSeconds": STUCK_THRESHOLD_SECONDS,
    }

    for order in plain_orders:
        classification = classify_order_transition(order, expected)
        if classification != "stuck_needs_retry":
            continue

        retried += 1
        log.warning(
            "STUCK order=%s id=%s state=%s. %s",
            order["orderNumber"], order["id"], order["stateMachineTechnicalName"],
            "would retry" if DRY_RUN else "retrying",
        )
        retry_transition(token, order["id"], TARGET_TRANSITION)

        if not DRY_RUN:
            if confirm_target_state(token, order["id"], TARGET_ORDER_STATE):
                resolved += 1
                log.info("Order %s confirmed at target state %s.", order["orderNumber"], TARGET_ORDER_STATE)
            else:
                log.error("Order %s still not at target state after retry. Needs another pass.", order["orderNumber"])
            time.sleep(RETRY_DELAY_SECONDS)

    log.info(
        "Done. %d order(s) checked, %d flagged stuck, %d confirmed resolved.",
        checked, retried, resolved,
    )


if __name__ == "__main__":
    run()
retry-stuck-bulk-transitions.js
/**
 * Find Shopware 6 orders stuck mid transition after a bulk order status edit, and retry them, safely.
 *
 * The bulk order status editor fires a state transition for every selected order in quick
 * succession, for example POST /api/_action/order/{id}/state/complete. Each transition runs
 * the order.state.changed event chain, which includes the StockUpdater subscriber issuing its
 * own stock update statements per line item product. When two or more orders in the same batch
 * share a product, those statements race for the same product row lock, and InnoDB's deadlock
 * detector kills one transaction with SQLSTATE[40001] (confirmed in shopware/shopware#3524).
 * That transaction rolls back, so the order never reaches the target state, with no automatic
 * retry. This script finds orders still sitting in_progress whose siblings already moved on,
 * and retries each one alone, one at a time, confirming the result before marking it resolved.
 * It never writes stateId directly and never retries inside another bulk batch.
 * Run on a schedule. Safe to run again and again.
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const TARGET_ORDER_STATE = process.env.TARGET_ORDER_STATE || "completed";
const TARGET_TRANSITION = process.env.TARGET_TRANSITION || "complete";
const STUCK_THRESHOLD_SECONDS = Number(process.env.STUCK_THRESHOLD_SECONDS || 5);
const RETRY_DELAY_SECONDS = Number(process.env.RETRY_DELAY_SECONDS || 1.5);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const FOLLOW_ON_STATES = new Set(["paid", "shipped"]);

export function classifyOrderTransition(order, expected) {
  const technicalName = order.stateMachineTechnicalName;
  if (technicalName === expected.targetOrderState) return "ok";

  const followedOn =
    (order.transactionStates || []).some((s) => FOLLOW_ON_STATES.has(s)) ||
    (order.deliveryStates || []).some((s) => FOLLOW_ON_STATES.has(s));

  const lagSeconds = expected.batchMedianUpdatedAt - order.updatedAt;
  const lagging = lagSeconds > expected.thresholdSeconds;

  if (followedOn || lagging) return "stuck_needs_retry";

  return "in_progress";
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function searchInProgressOrders(token, page = 1, limit = 500) {
  const body = {
    filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "in_progress" }],
    associations: { transactions: {}, deliveries: {}, stateMachineState: {}, lineItems: {} },
    sort: [{ field: "updatedAt", order: "ASC" }],
    page,
    limit,
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/order", token, body);
}

async function retryTransition(token, orderId, transitionName) {
  if (DRY_RUN) {
    console.warn(`DRY RUN: would POST /api/_action/order/${orderId}/state/${transitionName}`);
    return;
  }
  await api("POST", `/api/_action/order/${orderId}/state/${transitionName}`, token, {});
}

async function confirmTargetState(token, orderId, targetState) {
  const data = await api("GET", `/api/order/${orderId}?associations[stateMachineState]={}`, token);
  const technicalName = data?.data?.stateMachineState?.technicalName;
  return technicalName === targetState;
}

function toPlainOrder(row) {
  const transactions = row.transactions || [];
  const deliveries = row.deliveries || [];
  return {
    id: row.id,
    orderNumber: row.orderNumber || "?",
    stateMachineTechnicalName: row.stateMachineState?.technicalName,
    updatedAt: row.updatedAt,
    transactionStates: transactions.map((t) => t.stateMachineState?.technicalName),
    deliveryStates: deliveries.map((d) => d.stateMachineState?.technicalName),
  };
}

function median(values) {
  if (values.length === 0) return 0;
  const sorted = [...values].sort((a, b) => a - b);
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}

function sleep(seconds) {
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}

export async function run() {
  const token = await getToken();
  let retried = 0;
  let resolved = 0;

  const data = await searchInProgressOrders(token);
  const rows = data.data || [];
  const checked = rows.length;
  if (checked === 0) {
    console.log("Done. 0 order(s) checked, nothing in_progress.");
    return;
  }

  const plainOrders = rows.map(toPlainOrder);
  const updatedEpochs = plainOrders
    .map((o) => o.updatedAt)
    .filter((v) => typeof v === "number");
  const batchMedian = median(updatedEpochs);

  const expected = {
    targetOrderState: TARGET_ORDER_STATE,
    batchMedianUpdatedAt: batchMedian,
    thresholdSeconds: STUCK_THRESHOLD_SECONDS,
  };

  for (const order of plainOrders) {
    const classification = classifyOrderTransition(order, expected);
    if (classification !== "stuck_needs_retry") continue;

    retried++;
    console.warn(
      `STUCK order=${order.orderNumber} id=${order.id} state=${order.stateMachineTechnicalName}. ${DRY_RUN ? "would retry" : "retrying"}`
    );
    await retryTransition(token, order.id, TARGET_TRANSITION);

    if (!DRY_RUN) {
      const ok = await confirmTargetState(token, order.id, TARGET_ORDER_STATE);
      if (ok) {
        resolved++;
        console.log(`Order ${order.orderNumber} confirmed at target state ${TARGET_ORDER_STATE}.`);
      } else {
        console.error(`Order ${order.orderNumber} still not at target state after retry. Needs another pass.`);
      }
      await sleep(RETRY_DELAY_SECONDS);
    }
  }

  console.log(`Done. ${checked} order(s) checked, ${retried} flagged stuck, ${resolved} confirmed resolved.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides which orders get retried. Because classify_order_transition is pure, no network and no Shopware instance is needed. It just takes plain values in and checks the answer.

test_bulk_transition.py
from retry_stuck_bulk_transitions import classify_order_transition


def order(**over):
    base = {
        "stateMachineTechnicalName": "in_progress",
        "updatedAt": 1000,
        "transactionStates": ["open"],
        "deliveryStates": ["shipped"],
    }
    base.update(over)
    return base


def expected(**over):
    base = {"targetOrderState": "completed", "batchMedianUpdatedAt": 1000, "thresholdSeconds": 5}
    base.update(over)
    return base


def test_ok_when_already_at_target_state():
    assert classify_order_transition(order(stateMachineTechnicalName="completed"), expected()) == "ok"


def test_stuck_when_delivery_already_shipped_but_order_lags():
    o = order(deliveryStates=["shipped"])
    assert classify_order_transition(o, expected()) == "stuck_needs_retry"


def test_stuck_when_transaction_already_paid_but_order_lags():
    o = order(transactionStates=["paid"], deliveryStates=["open"])
    assert classify_order_transition(o, expected()) == "stuck_needs_retry"


def test_stuck_when_lag_exceeds_threshold():
    o = order(transactionStates=["open"], deliveryStates=["open"], updatedAt=900)
    assert classify_order_transition(o, expected(batchMedianUpdatedAt=1000, thresholdSeconds=5)) == "stuck_needs_retry"


def test_in_progress_when_within_threshold_and_no_follow_on_state():
    o = order(transactionStates=["open"], deliveryStates=["open"], updatedAt=998)
    assert classify_order_transition(o, expected(batchMedianUpdatedAt=1000, thresholdSeconds=5)) == "in_progress"


def test_exactly_at_threshold_is_not_lagging():
    o = order(transactionStates=["open"], deliveryStates=["open"], updatedAt=995)
    assert classify_order_transition(o, expected(batchMedianUpdatedAt=1000, thresholdSeconds=5)) == "in_progress"
bulk-transition.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyOrderTransition } from "./retry-stuck-bulk-transitions.js";

const order = (over = {}) => ({
  stateMachineTechnicalName: "in_progress",
  updatedAt: 1000,
  transactionStates: ["open"],
  deliveryStates: ["shipped"],
  ...over,
});

const expected = (over = {}) => ({
  targetOrderState: "completed",
  batchMedianUpdatedAt: 1000,
  thresholdSeconds: 5,
  ...over,
});

test("ok when already at target state", () => {
  assert.equal(classifyOrderTransition(order({ stateMachineTechnicalName: "completed" }), expected()), "ok");
});

test("stuck when delivery already shipped but order lags", () => {
  const o = order({ deliveryStates: ["shipped"] });
  assert.equal(classifyOrderTransition(o, expected()), "stuck_needs_retry");
});

test("stuck when transaction already paid but order lags", () => {
  const o = order({ transactionStates: ["paid"], deliveryStates: ["open"] });
  assert.equal(classifyOrderTransition(o, expected()), "stuck_needs_retry");
});

test("stuck when lag exceeds threshold", () => {
  const o = order({ transactionStates: ["open"], deliveryStates: ["open"], updatedAt: 900 });
  assert.equal(classifyOrderTransition(o, expected({ batchMedianUpdatedAt: 1000, thresholdSeconds: 5 })), "stuck_needs_retry");
});

test("in_progress when within threshold and no follow-on state", () => {
  const o = order({ transactionStates: ["open"], deliveryStates: ["open"], updatedAt: 998 });
  assert.equal(classifyOrderTransition(o, expected({ batchMedianUpdatedAt: 1000, thresholdSeconds: 5 })), "in_progress");
});

test("exactly at threshold is not lagging", () => {
  const o = order({ transactionStates: ["open"], deliveryStates: ["open"], updatedAt: 995 });
  assert.equal(classifyOrderTransition(o, expected({ batchMedianUpdatedAt: 1000, thresholdSeconds: 5 })), "in_progress");
});

Case studies

Popular bundle

Forty orders, one bundle, one straggler

A subscription box shop ran a bulk complete on forty orders at the end of the month, most of them containing the same seasonal bundle item. Thirty nine moved to completed within seconds. One sat on in progress, and its own delivery had already flipped to shipped, which made no sense for an order that was supposedly still in progress.

Running the reconciler script found it immediately: the order's transaction and delivery had already advanced past what the order-level state showed, a clean case of a killed transaction mid transition. A single retry, confirmed with a fresh read, closed the gap without touching anything else in the batch.

Warehouse close-out

A end-of-day batch left two orders behind

A warehouse ops team closed out the day's fulfilled orders with the bulk editor, selecting well over a hundred orders that shared a handful of high-volume products across dozens of shipments. Two orders quietly stayed on in progress overnight, and nobody had a reason to look until the next morning's report showed them still open.

The scheduled reconciler caught both the next time it ran, retried them one at a time with a short delay between calls, confirmed each one reached completed, and logged the resolution. The team added the job to their nightly schedule so a repeat of the same race never sits unnoticed again.

What good looks like

After this runs on a schedule, a bulk order status edit that loses a race to a shared-product deadlock no longer leaves that order stranded. The reconciler finds it by comparing it against its batch siblings, retries the transition alone with no new contention, and confirms the result before calling it done, so nobody has to notice the mismatch by accident.

FAQ

Why does the Shopware 6 bulk order editor leave some orders stuck between states?

The bulk editor fires a state transition for every selected order in quick succession. Each transition triggers the StockUpdater subscriber, which issues its own stock update statements per product. When two or more orders in the same batch share a product, their transactions can race for the same product row lock, and InnoDB's deadlock detector kills one of them with a deadlock error. That order's transaction rolls back mid transition, so it never reaches the target state even though its siblings do.

Is it safe to retry the stuck orders automatically?

Yes, as long as each retry is issued one order at a time through the normal state transition endpoint, never in the same bulk batch and never in parallel. Retrying serially avoids re-triggering the same product row contention, and confirming the new state after each call means the script never marks an order resolved that did not actually move.

Why not just fix the state directly on the stuck order?

Shopware's state machine validates every move, so a raw write to stateId bypasses those checks and can leave the order in a state its transactions and deliveries never agree with. Re-issuing the transition through the state machine's own endpoint is the only way to move the order forward without creating a second, different kind of inconsistency.

Related field notes

Citations

On the problem:

  1. GitHub Issue: Bulk editor for orders fails to move order to "done". github.com/shopware/shopware/issues/3524
  2. GitHub Issue: Change order state workflow doesn't make sense. github.com/shopware/shopware/issues/2539
  3. Shopware changelog: Order bulk edit processing, release 6.4.5.0. github.com/shopware/shopware changelog order-bulk-edit-processing

On the solution:

  1. Shopware Developer Documentation: Using the State Machine. developer.shopware.com using-the-state-machine
  2. Shopware Admin API Reference: Order Management. shopware.stoplight.io admin-api order-management
  3. Shopware Developer Documentation: Admin API concepts. developer.shopware.com concepts admin-api

Stuck on a tricky one?

If you have a problem in Shopware order states, payments, stock, or the message queue 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 clear up a stuck batch?

If this saved you a confusing morning chasing one order in a batch of forty, 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 Shopware field notes