Reconciler Message Queue and Scheduled Tasks

Shopware scheduled task stuck in queued status forever

A cache warmer, a cleanup job, or a mail queue task sat down at status queued and never moved again. The store keeps running, but that one job silently stopped, and nothing in the admin tells you why. Here is why Shopware leaves a task behind like this and a small script that finds the stuck row and resets it so it can run again.

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

Shopware writes a scheduled task's row to queued right before it dispatches the message that will actually run it. If the worker dies, is redeployed, or times out between that write and a successful dispatch, or if messenger:consume just is not running to pick the message up, nothing reverts the row, so it sits at queued forever. Run a small Python or Node.js script against the Admin API that lists rows with status=='queued', flags the ones overdue past their runInterval by a safe margin, and PATCHes them back to status:'scheduled' with a fresh nextExecutionTime. Full code, tests, and a dry run guard are below.

The problem in plain words

Shopware runs scheduled tasks through scheduled-task:run, backed by TaskScheduler and TaskRegistry. On each pass it looks for tasks whose nextExecutionTime has arrived, flips their row to queued, and then dispatches a message onto the message queue that a consumer will later pick up and actually execute.

Those are two separate steps. The status write happens first, the dispatch and the consumption happen after. If the worker process gets killed, crashes, times out, or is redeployed in between, or if the message does get dispatched but nothing is running messenger:consume to consume it, the row is left holding queued with no one ever finishing the job. Shopware added partial self-healing in 6.4.9.0 and 6.4.19.0, but those only cover failed-to-dispatch tasks and stale rows caught on the next registration pass. A queued row whose message was silently dropped, with nextExecutionTime never advancing, can still sit there indefinitely, especially on hosts running only the admin worker instead of a persistent messenger:consume or cron loop.

TaskScheduler writes status queued Dispatch message onto the queue worker dies or no consumer Row stays queued never reverted Task never runs again
The status write and the actual dispatch and consumption are separate steps. Anything that breaks the chain between them leaves the row stuck at queued.

Why it happens

The scheduled_task table only has one status column, and nothing watches it from the outside. A few common ways stores end up with a task stuck at queued:

Shopware shipped fixes for parts of this. The 6.4.9.0 changelog covers tasks stuck in queued despite never being dispatched, and 6.4.19.0 adds a fix so TaskRegistry resets stale scheduled and queued rows on its next registration pass. Both narrow the window, neither guarantees a row can never be abandoned, which is also the complaint in the Shopware community thread linked below. See the citations at the end for the exact sources.

The key insight

A queued row is not a state machine transition, it is a plain enum column. There is no /_action/state endpoint for scheduled_task, because it does not need one. So the safe pattern is a plain field PATCH that resets status back to scheduled and nudges nextExecutionTime to now, but only for rows that have actually sat overdue past their own runInterval by a safe margin. Anything younger than that could just be mid-flight, so we leave it alone.

The fix, as a flow

We do not touch a live dispatch in progress. We add a job that lists every scheduled_task row with status=='queued', checks how overdue each one is against its own runInterval, and resets only the ones that are stuck well past that. Everything still within its normal window is left alone for the worker to finish.

Scheduled job runs on a timer List queued tasks status equals queued Read timing fields nextExecutionTime, runInterval Overdue past threshold? yes no, skip PATCH scheduled row can re-dispatch
The script only resets rows that are queued and stuck well past their own runInterval. Everything else is left for the worker to finish on its own.

Build it step by step

1

Get an Admin API access token

Create an integration in your Shopware admin under Settings, System, Integrations. Give it access and grab the access key id and secret key. Keep the URL and both keys in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://my-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxxxxxxxxxxxxxxxxxxxxxxx"
export SHOPWARE_CLIENT_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export STALE_THRESHOLD_SECONDS="300"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://my-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxxxxxxxxxxxxxxxxxxxxxxx"
export SHOPWARE_CLIENT_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export STALE_THRESHOLD_SECONDS="300"
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and talk to the Admin API

Trade the client id and secret for a bearer token with POST /api/oauth/token, then send Authorization: Bearer <token> and Accept: application/json on every call after. A small helper gets the token once and reuses it for the search and the patch.

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,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def auth_headers(token):
    return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
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" },
    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;
}

function authHeaders(token) {
  return { Authorization: `Bearer ${token}`, Accept: "application/json" };
}
3

List the scheduled_task rows still at queued

Search for rows whose status is exactly queued, sorted by nextExecutionTime so the most overdue ones come first. Read back id, name, status, runInterval, nextExecutionTime, and lastExecutionTime for the decision.

step3.py
def queued_tasks(token):
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/scheduled-task",
        json={
            "filter": [{"type": "equals", "field": "status", "value": "queued"}],
            "sort": [{"field": "nextExecutionTime", "order": "ASC"}],
            "page": 1,
            "limit": 100,
            "total-count-mode": 1,
        },
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]
step3.js
async function queuedTasks(token) {
  const res = await fetch(`${SHOPWARE_URL}/api/search/scheduled-task`, {
    method: "POST",
    headers: { ...authHeaders(token), "Content-Type": "application/json" },
    body: JSON.stringify({
      filter: [{ type: "equals", field: "status", value: "queued" }],
      sort: [{ field: "nextExecutionTime", order: "ASC" }],
      page: 1,
      limit: 100,
      "total-count-mode": 1,
    }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const body = await res.json();
  return body.data;
}
4

Decide, with one pure function

Keep the decision in its own function that takes a task, the current time, and a stale threshold, and returns true or false. A pure function like this is easy to read and easy to test, which we do later. A row only counts as stuck when it is still queued and it is overdue past the larger of its own runInterval or the stale threshold. Anything else, including any other status, is left alone.

decide.py
import datetime

def iso_to_epoch(iso):
    return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()

def is_task_stuck_queued(task, now_iso, stale_threshold_seconds=300):
    if task.get("status") != "queued":
        return False
    now_epoch = iso_to_epoch(now_iso)
    next_epoch = iso_to_epoch(task["nextExecutionTime"])
    overdue_seconds = now_epoch - next_epoch
    return overdue_seconds > max(task["runInterval"], stale_threshold_seconds)
decide.js
export function isoToEpoch(iso) {
  return Date.parse(iso) / 1000;
}

export function isTaskStuckQueued(task, nowIso, staleThresholdSeconds = 300) {
  if (task.status !== "queued") return false;
  const nowEpoch = isoToEpoch(nowIso);
  const nextEpoch = isoToEpoch(task.nextExecutionTime);
  const overdueSeconds = nowEpoch - nextEpoch;
  return overdueSeconds > Math.max(task.runInterval, staleThresholdSeconds);
}
5

Reset the stuck row with a plain PATCH

scheduled_task.status is a simple enum column, not a state machine field, so there is no /_action/state transition here. A plain PATCH to /api/scheduled-task/{id} setting status back to scheduled and nextExecutionTime to now is enough for the next scheduled-task:run or TaskRegistry pass to pick it back up.

apply.py
def reset_task(token, task_id, now_iso):
    r = requests.patch(
        f"{SHOPWARE_URL}/api/scheduled-task/{task_id}",
        json={"status": "scheduled", "nextExecutionTime": now_iso},
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()

def confirm_reset(token, task_id):
    r = requests.get(
        f"{SHOPWARE_URL}/api/scheduled-task/{task_id}",
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]["attributes"]["status"] == "scheduled"
apply.js
async function resetTask(token, taskId, nowIso) {
  const res = await fetch(`${SHOPWARE_URL}/api/scheduled-task/${taskId}`, {
    method: "PATCH",
    headers: { ...authHeaders(token), "Content-Type": "application/json" },
    body: JSON.stringify({ status: "scheduled", nextExecutionTime: nowIso }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
}

async function confirmReset(token, taskId) {
  const res = await fetch(`${SHOPWARE_URL}/api/scheduled-task/${taskId}`, {
    headers: authHeaders(token),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const body = await res.json();
  return body.data.attributes.status === "scheduled";
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs which rows it would reset, with the task name and its old nextExecutionTime. When you switch it off, it sends the PATCH and re-reads the row to confirm the status actually flipped to scheduled. Run this alongside a check that messenger:consume is really running, since resetting a row with no live consumer just recreates the same stuck state next interval.

Run it safe

Always start with DRY_RUN=true. Resetting the status is only half the fix. Pair it with an alert or a health check confirming messenger:consume, or your configured transport worker, is actually consuming messages, otherwise the next queued write stalls the same way.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only resets rows that are still queued and stuck well past their own interval.

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

reset_stuck_task.py
"""Reset Shopware scheduled_task rows stuck in status queued, safely.
Only resets rows that are still queued and are overdue past their own
runInterval (or a stale threshold, whichever is larger). Run on a schedule.
Safe to run again and again. Pair this with a check that messenger:consume
is actually running, or the reset row will just stall again next interval.
"""
import os
import logging
import datetime
import requests

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

SHOPWARE_URL = os.environ.get("SHOPWARE_URL", "https://example.test").rstrip("/")
CLIENT_ID = os.environ.get("SHOPWARE_CLIENT_ID", "SWIAdummy")
CLIENT_SECRET = os.environ.get("SHOPWARE_CLIENT_SECRET", "dummy-secret")
STALE_THRESHOLD_SECONDS = int(os.environ.get("STALE_THRESHOLD_SECONDS", "300"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def iso_to_epoch(iso):
    return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()


def is_task_stuck_queued(task, now_iso, stale_threshold_seconds=300):
    if task.get("status") != "queued":
        return False
    now_epoch = iso_to_epoch(now_iso)
    next_epoch = iso_to_epoch(task["nextExecutionTime"])
    overdue_seconds = now_epoch - next_epoch
    return overdue_seconds > max(task["runInterval"], stale_threshold_seconds)


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,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def auth_headers(token):
    return {"Authorization": f"Bearer {token}", "Accept": "application/json"}


def queued_tasks(token):
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/scheduled-task",
        json={
            "filter": [{"type": "equals", "field": "status", "value": "queued"}],
            "sort": [{"field": "nextExecutionTime", "order": "ASC"}],
            "page": 1,
            "limit": 100,
            "total-count-mode": 1,
        },
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def reset_task(token, task_id, now_iso):
    r = requests.patch(
        f"{SHOPWARE_URL}/api/scheduled-task/{task_id}",
        json={"status": "scheduled", "nextExecutionTime": now_iso},
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()


def confirm_reset(token, task_id):
    r = requests.get(
        f"{SHOPWARE_URL}/api/scheduled-task/{task_id}",
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]["attributes"]["status"] == "scheduled"


def run():
    now = datetime.datetime.now(datetime.timezone.utc)
    now_iso = now.isoformat().replace("+00:00", "Z")
    token = get_token()
    reset = 0
    for task in queued_tasks(token):
        attrs = task.get("attributes", task)
        if not is_task_stuck_queued(attrs, now_iso, STALE_THRESHOLD_SECONDS):
            continue
        log.warning(
            "Task %s (%s) stuck queued since %s. %s",
            attrs.get("name"), task.get("id"), attrs.get("nextExecutionTime"),
            "would reset" if DRY_RUN else "resetting",
        )
        if not DRY_RUN:
            reset_task(token, task["id"], now_iso)
            ok = confirm_reset(token, task["id"])
            if not ok:
                raise RuntimeError(f"Reset did not stick for task {task['id']}")
        reset += 1
    log.info("Done. %d task(s) %s.", reset, "to reset" if DRY_RUN else "reset")


if __name__ == "__main__":
    run()
reset-stuck-task.js
/**
 * Reset Shopware scheduled_task rows stuck in status queued, safely.
 * Only resets rows that are still queued and are overdue past their own
 * runInterval (or a stale threshold, whichever is larger). Run on a schedule.
 * Safe to run again and again. Pair this with a check that messenger:consume
 * is actually running, or the reset row will just stall again next interval.
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAdummy";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const STALE_THRESHOLD_SECONDS = Number(process.env.STALE_THRESHOLD_SECONDS || 300);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function isoToEpoch(iso) {
  return Date.parse(iso) / 1000;
}

export function isTaskStuckQueued(task, nowIso, staleThresholdSeconds = 300) {
  if (task.status !== "queued") return false;
  const nowEpoch = isoToEpoch(nowIso);
  const nextEpoch = isoToEpoch(task.nextExecutionTime);
  const overdueSeconds = nowEpoch - nextEpoch;
  return overdueSeconds > Math.max(task.runInterval, staleThresholdSeconds);
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "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;
}

function authHeaders(token) {
  return { Authorization: `Bearer ${token}`, Accept: "application/json" };
}

async function queuedTasks(token) {
  const res = await fetch(`${SHOPWARE_URL}/api/search/scheduled-task`, {
    method: "POST",
    headers: { ...authHeaders(token), "Content-Type": "application/json" },
    body: JSON.stringify({
      filter: [{ type: "equals", field: "status", value: "queued" }],
      sort: [{ field: "nextExecutionTime", order: "ASC" }],
      page: 1,
      limit: 100,
      "total-count-mode": 1,
    }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const body = await res.json();
  return body.data;
}

async function resetTask(token, taskId, nowIso) {
  const res = await fetch(`${SHOPWARE_URL}/api/scheduled-task/${taskId}`, {
    method: "PATCH",
    headers: { ...authHeaders(token), "Content-Type": "application/json" },
    body: JSON.stringify({ status: "scheduled", nextExecutionTime: nowIso }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
}

async function confirmReset(token, taskId) {
  const res = await fetch(`${SHOPWARE_URL}/api/scheduled-task/${taskId}`, {
    headers: authHeaders(token),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const body = await res.json();
  return body.data.attributes.status === "scheduled";
}

export async function run() {
  const nowIso = new Date().toISOString();
  const token = await getToken();
  let reset = 0;
  for (const task of await queuedTasks(token)) {
    const attrs = task.attributes || task;
    if (!isTaskStuckQueued(attrs, nowIso, STALE_THRESHOLD_SECONDS)) continue;
    console.warn(
      `Task ${attrs.name} (${task.id}) stuck queued since ${attrs.nextExecutionTime}. ${DRY_RUN ? "would reset" : "resetting"}`
    );
    if (!DRY_RUN) {
      await resetTask(token, task.id, nowIso);
      const ok = await confirmReset(token, task.id);
      if (!ok) throw new Error(`Reset did not stick for task ${task.id}`);
    }
    reset++;
  }
  console.log(`Done. ${reset} task(s) ${DRY_RUN ? "to reset" : "reset"}.`);
}

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 whether a real row gets reset. Because we kept is_task_stuck_queued pure and pass in the current time, the test needs no network and no Shopware store. It just feeds in plain objects with a fixed clock and checks the answer.

test_scheduled_stuck.py
from reset_stuck_task import is_task_stuck_queued

NOW = "2026-07-10T00:10:00Z"


def task(**over):
    base = {
        "status": "queued",
        "nextExecutionTime": "2026-07-10T00:00:00Z",  # 10 minutes ago
        "runInterval": 60,  # 1 minute interval
    }
    base.update(over)
    return base


def test_stuck_when_queued_and_overdue_past_threshold():
    assert is_task_stuck_queued(task(), NOW, 300) is True


def test_not_stuck_when_freshly_queued():
    t = task(nextExecutionTime="2026-07-10T00:09:50Z")
    assert is_task_stuck_queued(t, NOW, 300) is False


def test_not_applicable_when_status_scheduled():
    assert is_task_stuck_queued(task(status="scheduled"), NOW, 300) is False


def test_not_applicable_when_status_skipped():
    assert is_task_stuck_queued(task(status="skipped"), NOW, 300) is False


def test_not_applicable_when_status_failed():
    assert is_task_stuck_queued(task(status="failed"), NOW, 300) is False


def test_uses_run_interval_when_larger_than_threshold():
    # 10 minutes overdue, runInterval is 20 minutes, threshold is 5 minutes
    t = task(runInterval=1200)
    assert is_task_stuck_queued(t, NOW, 300) is False


def test_exactly_at_threshold_boundary_is_not_stuck():
    # overdue exactly equals max(runInterval, threshold): boundary is not "over"
    t = task(nextExecutionTime="2026-07-10T00:05:00Z", runInterval=60)
    assert is_task_stuck_queued(t, NOW, 300) is False


def test_just_past_threshold_boundary_is_stuck():
    t = task(nextExecutionTime="2026-07-10T00:04:59Z", runInterval=60)
    assert is_task_stuck_queued(t, NOW, 300) is True
scheduled-stuck.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isTaskStuckQueued } from "./reset-stuck-task.js";

const NOW = "2026-07-10T00:10:00Z";

const task = (over = {}) => ({
  status: "queued",
  nextExecutionTime: "2026-07-10T00:00:00Z",
  runInterval: 60,
  ...over,
});

test("stuck when queued and overdue past threshold", () => {
  assert.equal(isTaskStuckQueued(task(), NOW, 300), true);
});

test("not stuck when freshly queued", () => {
  const t = task({ nextExecutionTime: "2026-07-10T00:09:50Z" });
  assert.equal(isTaskStuckQueued(t, NOW, 300), false);
});

test("not applicable when status scheduled", () => {
  assert.equal(isTaskStuckQueued(task({ status: "scheduled" }), NOW, 300), false);
});

test("not applicable when status skipped", () => {
  assert.equal(isTaskStuckQueued(task({ status: "skipped" }), NOW, 300), false);
});

test("not applicable when status failed", () => {
  assert.equal(isTaskStuckQueued(task({ status: "failed" }), NOW, 300), false);
});

test("uses runInterval when larger than threshold", () => {
  const t = task({ runInterval: 1200 });
  assert.equal(isTaskStuckQueued(t, NOW, 300), false);
});

test("exactly at threshold boundary is not stuck", () => {
  const t = task({ nextExecutionTime: "2026-07-10T00:05:00Z", runInterval: 60 });
  assert.equal(isTaskStuckQueued(t, NOW, 300), false);
});

test("just past threshold boundary is stuck", () => {
  const t = task({ nextExecutionTime: "2026-07-10T00:04:59Z", runInterval: 60 });
  assert.equal(isTaskStuckQueued(t, NOW, 300), true);
});

Case studies

Deploy killed the worker

The cache invalidation task that stopped for a week

A mid-size store rolled out a new container image right as shopware.invalidate_cache flipped to queued. The old container was torn down before the message was dispatched, and the new one never touched that row. Cache invalidation quietly stopped, and stale product data lingered for a week before anyone noticed the pattern in stale prices.

The team added the reset script on an hourly cron. It found the row sitting at queued far past its runInterval, reset it to scheduled, and the very next scheduled-task:run pass picked it back up. They now run the check after every deploy, not just on a timer.

Admin worker only

The host that never ran messenger:consume

A smaller shop relied only on the built-in admin worker in the browser tab, with no messenger:consume process and no cron entry at all. Every scheduled task looked fine until the admin tab was closed overnight, at which point log_entry.cleanup queued up and simply waited.

Resetting the row bought them nothing on its own, since there was still no consumer running. Once they added a proper messenger:consume systemd unit alongside the reset script, both problems went away together: the stuck row got cleared, and new dispatches actually got consumed going forward.

What good looks like

After this runs on a schedule, a scheduled task that got orphaned by a bad deploy or a dead worker is back to scheduled within the hour, not stuck for days. Pair it with a real health check on messenger:consume, since the reset only clears the symptom. The two together mean tasks recover on their own and nobody has to notice a stale cache or a missed cleanup job before it gets fixed.

FAQ

Why is my Shopware scheduled task stuck at queued?

Shopware's scheduled-task worker writes the scheduled_task row to status queued right before it dispatches the task's message onto the message queue. If the worker is killed, crashes, or times out between that write and a successful dispatch, or if nothing is running messenger:consume to pick the message up, no code path reverts the row back to scheduled, so it sits at queued indefinitely.

Is it safe to reset a stuck scheduled_task row with a script?

Yes, when the script only resets rows that are still status queued and are overdue past their runInterval by a safe margin, and it runs in dry run first. Resetting the status field to scheduled and nudging nextExecutionTime is exactly what Shopware's own TaskRegistry does on a healthy pass, so it does not skip work or double run anything.

Do I still need messenger:consume running after I reset the task?

Yes. Resetting the row only clears the stuck status so the next scheduled-task:run pass can dispatch it again. If nothing is consuming the message queue, the exact same row will queue up and stall again at the next interval, so pair the reset with a check that messenger:consume or your configured transport worker is actually running.

Related field notes

Citations

On the problem:

  1. Shopware changelog: scheduled tasks stuck in queued despite never dispatched (6.4.9.0). github.com/shopware/shopware changelog 6.4.9.0
  2. Shopware changelog: fix scheduled tasks remain in queued status (6.4.19.0). github.com/shopware/shopware changelog 6.4.19.0
  3. Shopware Community Forum: scheduled task is queued and not scheduled. forum.shopware.com scheduled-task-is-queued-and-not-scheduled

On the solution:

  1. Shopware Developer Docs: scheduled task infrastructure and hosting guide. developer.shopware.com hosting/infrastructure/scheduled-task
  2. Shopware Docs: message queue and scheduled tasks, tutorials and FAQ. docs.shopware.com message-queue-and-scheduled-tasks
  3. Shopware Admin API Reference: the ScheduledTask entity. shopware.stoplight.io admin-api scheduled-task

Stuck on a tricky one?

If you have a problem in Shopware orders, the message queue, scheduled tasks, or storefront behavior 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 your stuck task?

If this saved you a confusing week of a silently stopped job, 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