Reconciler Products, Variants and Media
Shopware product export scheduled task silently fails or produces an incomplete feed
A price comparison portal is showing stock that sold out days ago. A storefront feed a marketplace app reads has not changed since last week. Nothing in the admin is red, no error banner, no failed job in sight. The product export cronjob just quietly stopped keeping one or more of your feeds current. Here is why Shopware lets this happen and a small script that finds the stale feeds and repairs the one piece that is safely repairable through the API.
Shopware regenerates product export feeds through a cronjob named product_export.generate_task, handled by ProductExportGenerateTaskHandler. It can go silent in two documented ways: a multi-sales-channel bug where the handler returned early instead of moving on to the next sales channel and export pair once the first one had no matching export (shopware/shopware#1900, fixed via PR #1530), and the underlying scheduled_task row itself getting stuck at queued or skipped instead of resetting to scheduled when a message queue consumer was not running continuously (documented in the 6.4.9.0 and 6.4.19.0 changelogs). Run a small Python or Node.js script that reads the scheduled-task and product-export entities, flags any export whose generatedAt is stale, and safely resets an unstuck scheduled task row when that is the actual cause. Full code, tests, and a dry run guard are below.
The problem in plain words
A product export is a file, usually CSV or XML, that a storefront theme, a price comparison portal, or a marketplace app pulls to know what you sell and for how much. Shopware writes that file on a schedule and stamps product-export.generatedAt every time it succeeds.
The catch is that nothing else watches that timestamp. If the cronjob that regenerates the file quietly stops doing its job for one sales channel, or if the whole cronjob stops firing at all, the file on disk simply stays as it was. There is no red banner in the admin, because as far as Shopware's UI is concerned, nothing crashed. The export just was not regenerated, so the storefront or the portal keeps serving a stale copy of your catalog, prices and all.
Why it happens
The product_export.generate_task cronjob has two failure paths, and they are easy to mix up because both leave the same symptom, a feed that quietly stops updating:
- A multi-sales-channel bug in
ProductExportGenerateTaskHandlerreturned early instead of continuing to the next sales channel and export pair once the first one it looked at had no matching export. On stores with several sales channels, only the first channel kept regenerating, and every export after it silently went stale (shopware/shopware#1900, fixed via PR #1530). - The
scheduled_taskrow backing the cronjob itself can get stuck atqueuedorskippedinstead of being reset toscheduledwhennextExecutionTimehas already passed, typically because the message queue consumer was not running continuously. This is documented in the Shopware changelogs for 6.4.9.0 and 6.4.19.0, and it stops the entire export task from firing, not just one channel. - A partially written export file, where the cronjob starts writing but the process is interrupted, so the feed on disk is neither fully current nor cleanly the previous version.
- An export configured with
generateByCronjobtrue that nobody notices has an unusually long interval, so a genuinely healthy export still looks stale next to a shorter expectation.
Both root causes are quiet on purpose, in the sense that neither one throws a visible error in the admin. The scheduled_task queue entry just sits there, and the export file just does not change. Store owners usually find out from a marketplace complaining about wrong stock, not from Shopware. See the citations at the end for the exact issue, PR, and changelog entries.
These are two different problems wearing the same symptom, so they need two different checks. Reading product-export.generatedAt against its own interval catches the stale feed regardless of cause, including the multi-channel bug where the scheduled_task row can look perfectly healthy while only one channel's export keeps regenerating. Reading scheduled-task status separately catches the case where the whole cronjob stopped firing. There is no safe, version-proof REST call that forces the export file to regenerate in one request, so content regeneration is a flag, not a fix. The one thing safely repairable through the API is an unstuck scheduled_task row, and even then only when it is genuinely stuck, not mid-flight.
The fix, as a flow
We do not try to force-write the export file over the API. We add a job that reads the product_export.generate_task scheduled task and every product-export row generated by cronjob, decides independently whether the feed is stale and whether the task is stuck, and takes the one safe repair action when the task is stuck and overdue. Everything else is reported so a human can run the CLI regeneration or investigate further.
Build it step by step
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.
pip install requests
export SHOPWARE_URL="https://my-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxxxxxxxxxxxxxxxxxxxxxxx"
export SHOPWARE_CLIENT_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export GRACE_MULTIPLIER="1.5"
export DRY_RUN="true" # start safe, change to false to write
// 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 GRACE_MULTIPLIER="1.5"
export DRY_RUN="true" // start safe, change to false to write
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 both searches and the patch.
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"}
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" };
}
Read the cronjob's scheduled_task row
Search scheduled-task for the row named product_export.generate_task. Read back id, status, lastExecutionTime, nextExecutionTime, and runInterval. This tells us whether the cronjob itself is healthy.
def export_task(token):
r = requests.post(
f"{SHOPWARE_URL}/api/search/scheduled-task",
json={
"filter": [{"type": "equals", "field": "name", "value": "product_export.generate_task"}],
"page": 1,
"limit": 1,
"total-count-mode": 1,
},
headers=auth_headers(token),
timeout=30,
)
r.raise_for_status()
rows = r.json()["data"]
return rows[0] if rows else None
async function exportTask(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: "name", value: "product_export.generate_task" }],
page: 1,
limit: 1,
"total-count-mode": 1,
}),
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const body = await res.json();
return body.data[0] || null;
}
Read every product-export row generated by cronjob
Search product-export for rows with generateByCronjob true, with the salesChannel and salesChannelDomain associations so we can identify which channel a stale feed belongs to. Read back id, fileName, interval, generatedAt, and salesChannelId.
def cronjob_exports(token):
r = requests.post(
f"{SHOPWARE_URL}/api/search/product-export",
json={
"filter": [{"type": "equals", "field": "generateByCronjob", "value": True}],
"associations": {"salesChannel": {}, "salesChannelDomain": {}},
"page": 1,
"limit": 100,
"total-count-mode": 1,
},
headers=auth_headers(token),
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
async function cronjobExports(token) {
const res = await fetch(`${SHOPWARE_URL}/api/search/product-export`, {
method: "POST",
headers: { ...authHeaders(token), "Content-Type": "application/json" },
body: JSON.stringify({
filter: [{ type: "equals", field: "generateByCronjob", value: true }],
associations: { salesChannel: {}, salesChannelDomain: {} },
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;
}
Decide, with one pure function
Keep the decision in its own function that takes the task, the export, the current time, and a grace multiplier, and returns which action applies. A pure function like this is easy to read and easy to test, which we do later. It flags a stale feed when generatedAt is null or older than interval * graceMultiplier. It only recommends a reset when the task is queued or skipped and its nextExecutionTime is already in the past. Anything else is left alone.
import datetime
def iso_to_epoch(iso):
return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
STUCK_STATUSES = {"queued", "skipped"}
def decide_export_repair_action(task, export_feed, now, grace_multiplier=1.5):
now_epoch = iso_to_epoch(now)
generated_at = export_feed.get("generatedAt")
is_stale = generated_at is None or (
now_epoch - iso_to_epoch(generated_at) > export_feed["intervalSeconds"] * grace_multiplier
)
is_stuck = (
task is not None
and task.get("status") in STUCK_STATUSES
and iso_to_epoch(task["nextExecutionTime"]) < now_epoch
)
if is_stuck:
return {
"action": "reset_scheduled_task",
"reason": f"scheduled_task is {task['status']} with nextExecutionTime in the past",
}
if is_stale:
return {
"action": "flag_stale_feed",
"reason": "generatedAt is null or older than intervalSeconds * graceMultiplier",
}
return {"action": "none", "reason": "task is healthy and feed is within its interval"}
export function isoToEpoch(iso) {
return Date.parse(iso) / 1000;
}
const STUCK_STATUSES = new Set(["queued", "skipped"]);
export function decideExportRepairAction(task, exportFeed, now, graceMultiplier = 1.5) {
const nowEpoch = isoToEpoch(now);
const generatedAt = exportFeed.generatedAt;
const isStale =
generatedAt === null ||
generatedAt === undefined ||
nowEpoch - isoToEpoch(generatedAt) > exportFeed.intervalSeconds * graceMultiplier;
const isStuck =
task !== null &&
task !== undefined &&
STUCK_STATUSES.has(task.status) &&
isoToEpoch(task.nextExecutionTime) < nowEpoch;
if (isStuck) {
return {
action: "reset_scheduled_task",
reason: `scheduled_task is ${task.status} with nextExecutionTime in the past`,
};
}
if (isStale) {
return {
action: "flag_stale_feed",
reason: "generatedAt is null or older than intervalSeconds * graceMultiplier",
};
}
return { action: "none", reason: "task is healthy and feed is within its interval" };
}
Repair only the safely repairable state
When the decision is reset_scheduled_task, PATCH the scheduled-task row back to status: 'scheduled' with nextExecutionTime set to now. That restores it to a state the next scheduled-task:run sweep will pick up. When the decision is flag_stale_feed, only log it. Never PATCH product-export.generatedAt directly, since that would fake success without regenerating the file.
def reset_scheduled_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()
async function resetScheduledTask(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}`);
}
Wire it together with a dry run guard
The loop reads the task once, then walks every cronjob-generated export and applies the decision function to each. On the first few runs, leave DRY_RUN on so the script only logs what it would do. When you switch it off, it only ever sends the one safe PATCH, and stale feeds without a stuck task are always reported, never silently skipped, since regenerating their content still needs the CLI or an administration retrigger.
Always start with DRY_RUN=true. A reset only clears a stuck scheduled_task row, it does not regenerate any file content. For a stale feed with a healthy task, run bin/console scheduled-task:run-single product_export.generate_task or retrigger the export from the administration, then confirm generatedAt actually advanced.
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 the only write it ever makes is resetting a scheduled_task row that is genuinely stuck and overdue.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Flag stale Shopware product export feeds and repair a stuck cronjob, safely.
The product_export.generate_task cronjob (ProductExportGenerateTaskHandler) can
silently stop regenerating feeds in two ways: a multi-sales-channel bug where the
handler returned early instead of continuing to the next sales channel and export
pair once the first one had no matching export (shopware/shopware#1900, fixed via
PR #1530), and the scheduled_task row itself getting stuck in queued or skipped
status instead of resetting to scheduled when nextExecutionTime has passed and a
message queue consumer was not running continuously (6.4.9.0 and 6.4.19.0
changelogs). This reads the scheduled_task row and every product-export row with
generateByCronjob true, flags any export whose generatedAt is stale, and only
PATCHes the scheduled_task row back to status scheduled when it is genuinely
stuck and overdue. There is no safe REST call that forces the export file content
to regenerate, so a stale feed with a healthy task is only ever flagged for
bin/console scheduled-task:run-single or an administration retrigger. Run on a
schedule. Safe to run again and again.
"""
import os
import logging
import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_product_export")
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")
GRACE_MULTIPLIER = float(os.environ.get("GRACE_MULTIPLIER", "1.5"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
STUCK_STATUSES = {"queued", "skipped"}
def iso_to_epoch(iso):
return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
def decide_export_repair_action(task, export_feed, now, grace_multiplier=1.5):
"""Pure decision function, no I/O.
Computes staleness of export_feed (generatedAt is null, or older than
intervalSeconds * graceMultiplier) and stuckness of task (status in
queued/skipped with nextExecutionTime already in the past), and returns
which corrective action, if any, applies.
"""
now_epoch = iso_to_epoch(now)
generated_at = export_feed.get("generatedAt")
is_stale = generated_at is None or (
now_epoch - iso_to_epoch(generated_at) > export_feed["intervalSeconds"] * grace_multiplier
)
is_stuck = (
task is not None
and task.get("status") in STUCK_STATUSES
and iso_to_epoch(task["nextExecutionTime"]) < now_epoch
)
if is_stuck:
return {
"action": "reset_scheduled_task",
"reason": f"scheduled_task is {task['status']} with nextExecutionTime in the past",
}
if is_stale:
return {
"action": "flag_stale_feed",
"reason": "generatedAt is null or older than intervalSeconds * graceMultiplier",
}
return {"action": "none", "reason": "task is healthy and feed is within its interval"}
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 export_task(token):
r = requests.post(
f"{SHOPWARE_URL}/api/search/scheduled-task",
json={
"filter": [{"type": "equals", "field": "name", "value": "product_export.generate_task"}],
"page": 1,
"limit": 1,
"total-count-mode": 1,
},
headers=auth_headers(token),
timeout=30,
)
r.raise_for_status()
rows = r.json()["data"]
return rows[0] if rows else None
def cronjob_exports(token):
r = requests.post(
f"{SHOPWARE_URL}/api/search/product-export",
json={
"filter": [{"type": "equals", "field": "generateByCronjob", "value": True}],
"associations": {"salesChannel": {}, "salesChannelDomain": {}},
"page": 1,
"limit": 100,
"total-count-mode": 1,
},
headers=auth_headers(token),
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
def reset_scheduled_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 run():
now = datetime.datetime.now(datetime.timezone.utc)
now_iso = now.isoformat().replace("+00:00", "Z")
token = get_token()
task_row = export_task(token)
task_attrs = None
if task_row is not None:
task_attrs = task_row.get("attributes", task_row)
reset_done = False
flagged = 0
for row in cronjob_exports(token):
attrs = row.get("attributes", row)
export_feed = {
"generatedAt": attrs.get("generatedAt"),
"intervalSeconds": attrs.get("interval"),
"generateByCronjob": attrs.get("generateByCronjob", True),
}
decision = decide_export_repair_action(task_attrs, export_feed, now_iso, GRACE_MULTIPLIER)
if decision["action"] == "reset_scheduled_task" and not reset_done:
log.warning(
"scheduled_task product_export.generate_task (%s) is stuck. %s Reason: %s",
task_row["id"], "would reset" if DRY_RUN else "resetting", decision["reason"],
)
if not DRY_RUN:
reset_scheduled_task(token, task_row["id"], now_iso)
reset_done = True
elif decision["action"] == "flag_stale_feed":
log.warning(
"product-export %s (fileName=%s) is stale. Reason: %s. "
"Run bin/console scheduled-task:run-single product_export.generate_task "
"or retrigger from the administration.",
row["id"], attrs.get("fileName"), decision["reason"],
)
flagged += 1
log.info(
"Done. scheduled_task %s. %d stale feed(s) flagged for manual regeneration.",
"reset" if reset_done and not DRY_RUN else ("would reset" if reset_done else "healthy"),
flagged,
)
if __name__ == "__main__":
run()
/**
* Flag stale Shopware product export feeds and repair a stuck cronjob, safely.
*
* The product_export.generate_task cronjob (ProductExportGenerateTaskHandler) can
* silently stop regenerating feeds in two ways: a multi-sales-channel bug where the
* handler returned early instead of continuing to the next sales channel and export
* pair once the first one had no matching export (shopware/shopware#1900, fixed via
* PR #1530), and the scheduled_task row itself getting stuck in queued or skipped
* status instead of resetting to scheduled when nextExecutionTime has passed and a
* message queue consumer was not running continuously (6.4.9.0 and 6.4.19.0
* changelogs). This reads the scheduled_task row and every product-export row with
* generateByCronjob true, flags any export whose generatedAt is stale, and only
* PATCHes the scheduled_task row back to status scheduled when it is genuinely
* stuck and overdue. There is no safe REST call that forces the export file content
* to regenerate, so a stale feed with a healthy task is only ever flagged for
* bin/console scheduled-task:run-single or an administration retrigger. Run on a
* schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/product-export-task-silent-failure/
*/
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 GRACE_MULTIPLIER = Number(process.env.GRACE_MULTIPLIER || 1.5);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const STUCK_STATUSES = new Set(["queued", "skipped"]);
export function isoToEpoch(iso) {
return Date.parse(iso) / 1000;
}
/**
* Pure decision function, no I/O.
*
* Computes staleness of exportFeed (generatedAt is null, or older than
* intervalSeconds * graceMultiplier) and stuckness of task (status in
* queued/skipped with nextExecutionTime already in the past), and returns
* which corrective action, if any, applies.
*/
export function decideExportRepairAction(task, exportFeed, now, graceMultiplier = 1.5) {
const nowEpoch = isoToEpoch(now);
const generatedAt = exportFeed.generatedAt;
const isStale =
generatedAt === null ||
generatedAt === undefined ||
nowEpoch - isoToEpoch(generatedAt) > exportFeed.intervalSeconds * graceMultiplier;
const isStuck =
task !== null &&
task !== undefined &&
STUCK_STATUSES.has(task.status) &&
isoToEpoch(task.nextExecutionTime) < nowEpoch;
if (isStuck) {
return {
action: "reset_scheduled_task",
reason: `scheduled_task is ${task.status} with nextExecutionTime in the past`,
};
}
if (isStale) {
return {
action: "flag_stale_feed",
reason: "generatedAt is null or older than intervalSeconds * graceMultiplier",
};
}
return { action: "none", reason: "task is healthy and feed is within its interval" };
}
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 exportTask(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: "name", value: "product_export.generate_task" }],
page: 1,
limit: 1,
"total-count-mode": 1,
}),
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const body = await res.json();
return body.data[0] || null;
}
async function cronjobExports(token) {
const res = await fetch(`${SHOPWARE_URL}/api/search/product-export`, {
method: "POST",
headers: { ...authHeaders(token), "Content-Type": "application/json" },
body: JSON.stringify({
filter: [{ type: "equals", field: "generateByCronjob", value: true }],
associations: { salesChannel: {}, salesChannelDomain: {} },
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 resetScheduledTask(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}`);
}
export async function run() {
const nowIso = new Date().toISOString();
const token = await getToken();
const taskRow = await exportTask(token);
const taskAttrs = taskRow ? taskRow.attributes || taskRow : null;
let resetDone = false;
let flagged = 0;
for (const row of await cronjobExports(token)) {
const attrs = row.attributes || row;
const exportFeed = {
generatedAt: attrs.generatedAt ?? null,
intervalSeconds: attrs.interval,
generateByCronjob: attrs.generateByCronjob ?? true,
};
const decision = decideExportRepairAction(taskAttrs, exportFeed, nowIso, GRACE_MULTIPLIER);
if (decision.action === "reset_scheduled_task" && !resetDone) {
console.warn(
`scheduled_task product_export.generate_task (${taskRow.id}) is stuck. ${DRY_RUN ? "would reset" : "resetting"}. Reason: ${decision.reason}`
);
if (!DRY_RUN) await resetScheduledTask(token, taskRow.id, nowIso);
resetDone = true;
} else if (decision.action === "flag_stale_feed") {
console.warn(
`product-export ${row.id} (fileName=${attrs.fileName}) is stale. Reason: ${decision.reason}. Run bin/console scheduled-task:run-single product_export.generate_task or retrigger from the administration.`
);
flagged++;
}
}
console.log(
`Done. scheduled_task ${resetDone && !DRY_RUN ? "reset" : resetDone ? "would reset" : "healthy"}. ${flagged} stale feed(s) flagged for manual regeneration.`
);
}
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 the script touches a real scheduled_task row or just reports a stale feed. Because we kept decide_export_repair_action 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.
from check_product_export import decide_export_repair_action
NOW = "2026-07-10T12:00:00Z"
def task(**over):
base = {
"status": "scheduled",
"lastExecutionTime": "2026-07-10T10:00:00Z",
"nextExecutionTime": "2026-07-10T11:00:00Z", # 1 hour ago, past due
"runIntervalSeconds": 3600,
}
base.update(over)
return base
def export_feed(**over):
base = {
"generatedAt": "2026-07-10T11:55:00Z", # 5 minutes ago
"intervalSeconds": 3600,
"generateByCronjob": True,
}
base.update(over)
return base
def test_none_when_task_healthy_and_feed_fresh():
result = decide_export_repair_action(task(status="scheduled"), export_feed(), NOW)
assert result["action"] == "none"
def test_reset_when_task_queued_and_overdue():
result = decide_export_repair_action(task(status="queued"), export_feed(), NOW)
assert result["action"] == "reset_scheduled_task"
def test_reset_when_task_skipped_and_overdue():
result = decide_export_repair_action(task(status="skipped"), export_feed(), NOW)
assert result["action"] == "reset_scheduled_task"
def test_no_reset_when_queued_but_not_yet_overdue():
t = task(status="queued", nextExecutionTime="2026-07-10T12:30:00Z") # future
result = decide_export_repair_action(t, export_feed(), NOW)
assert result["action"] != "reset_scheduled_task"
def test_flag_stale_feed_when_generated_at_null():
result = decide_export_repair_action(task(status="scheduled"), export_feed(generatedAt=None), NOW)
assert result["action"] == "flag_stale_feed"
def test_flag_stale_feed_when_older_than_grace_margin():
# interval 3600s, grace 1.5x, stale past 5400s; this is 6000s old
stale = export_feed(generatedAt="2026-07-10T10:20:00Z")
result = decide_export_repair_action(task(status="scheduled"), stale, NOW)
assert result["action"] == "flag_stale_feed"
def test_not_stale_within_grace_margin():
# 4000s old, under 5400s grace threshold
fresh_enough = export_feed(generatedAt="2026-07-10T10:53:20Z")
result = decide_export_repair_action(task(status="scheduled"), fresh_enough, NOW)
assert result["action"] == "none"
def test_stuck_task_takes_priority_over_stale_feed():
# both conditions true: stuck task wins so the repairable state is surfaced first
result = decide_export_repair_action(task(status="queued"), export_feed(generatedAt=None), NOW)
assert result["action"] == "reset_scheduled_task"
def test_none_when_task_missing_but_feed_fresh():
result = decide_export_repair_action(None, export_feed(), NOW)
assert result["action"] == "none"
def test_custom_grace_multiplier_is_respected():
# 4000s old; with grace 1.0x threshold is 3600s, so this is stale
aged = export_feed(generatedAt="2026-07-10T10:53:20Z")
result = decide_export_repair_action(task(status="scheduled"), aged, NOW, grace_multiplier=1.0)
assert result["action"] == "flag_stale_feed"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideExportRepairAction } from "./check-product-export.js";
const NOW = "2026-07-10T12:00:00Z";
const task = (over = {}) => ({
status: "scheduled",
lastExecutionTime: "2026-07-10T10:00:00Z",
nextExecutionTime: "2026-07-10T11:00:00Z",
runIntervalSeconds: 3600,
...over,
});
const exportFeed = (over = {}) => ({
generatedAt: "2026-07-10T11:55:00Z",
intervalSeconds: 3600,
generateByCronjob: true,
...over,
});
test("none when task healthy and feed fresh", () => {
const result = decideExportRepairAction(task({ status: "scheduled" }), exportFeed(), NOW);
assert.equal(result.action, "none");
});
test("reset when task queued and overdue", () => {
const result = decideExportRepairAction(task({ status: "queued" }), exportFeed(), NOW);
assert.equal(result.action, "reset_scheduled_task");
});
test("reset when task skipped and overdue", () => {
const result = decideExportRepairAction(task({ status: "skipped" }), exportFeed(), NOW);
assert.equal(result.action, "reset_scheduled_task");
});
test("no reset when queued but not yet overdue", () => {
const t = task({ status: "queued", nextExecutionTime: "2026-07-10T12:30:00Z" });
const result = decideExportRepairAction(t, exportFeed(), NOW);
assert.notEqual(result.action, "reset_scheduled_task");
});
test("flag stale feed when generatedAt null", () => {
const result = decideExportRepairAction(task({ status: "scheduled" }), exportFeed({ generatedAt: null }), NOW);
assert.equal(result.action, "flag_stale_feed");
});
test("flag stale feed when older than grace margin", () => {
const stale = exportFeed({ generatedAt: "2026-07-10T10:20:00Z" });
const result = decideExportRepairAction(task({ status: "scheduled" }), stale, NOW);
assert.equal(result.action, "flag_stale_feed");
});
test("not stale within grace margin", () => {
const freshEnough = exportFeed({ generatedAt: "2026-07-10T10:53:20Z" });
const result = decideExportRepairAction(task({ status: "scheduled" }), freshEnough, NOW);
assert.equal(result.action, "none");
});
test("stuck task takes priority over stale feed", () => {
const result = decideExportRepairAction(task({ status: "queued" }), exportFeed({ generatedAt: null }), NOW);
assert.equal(result.action, "reset_scheduled_task");
});
test("none when task missing but feed fresh", () => {
const result = decideExportRepairAction(null, exportFeed(), NOW);
assert.equal(result.action, "none");
});
test("custom grace multiplier is respected", () => {
const aged = exportFeed({ generatedAt: "2026-07-10T10:53:20Z" });
const result = decideExportRepairAction(task({ status: "scheduled" }), aged, NOW, 1.0);
assert.equal(result.action, "flag_stale_feed");
});
Case studies
The second storefront that stopped updating for weeks
A retailer ran two sales channels from one Shopware install, a main storefront and a B2B channel with its own product export feed. The main storefront's export kept regenerating fine every hour. The B2B feed silently stopped the same week someone briefly removed its export configuration to test something, and never got a fresh file again even after it was restored.
Running the check script surfaced it in one pass: the scheduled_task row for product_export.generate_task looked completely healthy, but the B2B channel's product-export row had a generatedAt weeks old. That pointed straight at the per-channel early-return bug rather than a queue problem, and a manual scheduled-task:run-single confirmed the fix once the export was reconfigured correctly.
The comparison portal showing sold-out items as available
A store feeding a price comparison portal noticed complaints about listings for products that had been out of stock for days. The admin looked normal, no failed jobs anywhere. It turned out the host had switched from a persistent messenger:consume worker to only the browser-based admin worker during a maintenance window, and the product_export.generate_task row got stuck at queued when the tab was closed.
The script flagged both problems in the same run: the scheduled task stuck and overdue, and the resulting stale export feed. It reset the task automatically once DRY_RUN was turned off, and the next scheduled sweep regenerated the export within the hour. The team also added a proper messenger:consume systemd unit so it would not happen from that cause again.
After this runs on a schedule, a product export that quietly went stale gets caught within one interval instead of sitting there for weeks, and a genuinely stuck scheduled_task row gets reset automatically so the next sweep can finish the job. A stale feed with a healthy task is always surfaced for a human to run the CLI regeneration, never silently ignored and never faked by patching generatedAt. The storefront, the comparison portal, and your actual stock finally agree again.
FAQ
Why did my Shopware product export feed stop updating?
Two separate things can cause this. The product_export.generate_task cronjob had a bug where it returned early instead of continuing to the next sales channel, so exports for other channels never regenerated. Separately, the scheduled_task row itself can get stuck in queued or skipped status instead of resetting to scheduled, so the whole cronjob stops firing. Either way, the export file on disk stops changing while generatedAt stays frozen in the past.
How do I know if my product export is actually stale?
Read the product-export entity for each sales channel with generateByCronjob true, and compare generatedAt to the configured interval with a grace margin, for example 1.5x. If generatedAt is null or older than interval times 1.5, the feed is stale. Check this independently of the scheduled_task status, since the multi-channel early-return bug can leave one export stale while the scheduled_task row itself looks perfectly healthy.
Can I fix a stale product export with just the Admin API?
Only part of it. There is no reliable REST call across Shopware versions that forces the export file to regenerate in one request, so treat a stale feed as something to flag and repair with bin/console scheduled-task:run-single product_export.generate_task or by retriggering it from the administration. What the API can safely repair is a scheduled_task row stuck in queued or skipped with a past nextExecutionTime, by PATCHing it back to status scheduled. Never patch product-export.generatedAt directly, since that fakes success without regenerating anything.
Related field notes
Citations
On the problem:
- Product export scheduler is not working. Issue #1900 on shopware/shopware. github.com/shopware/shopware/issues/1900
- Shopware changelog: fix scheduled tasks remain in queued status (6.4.19.0). github.com/shopware/shopware changelog 6.4.19.0
- Shopware Community Forum: scheduled task is queued and not scheduled. forum.shopware.com scheduled-task-is-queued-and-not-scheduled
On the solution:
- Shopware Developer Docs: scheduled task infrastructure and hosting guide. developer.shopware.com hosting/infrastructure/scheduled-task
- Shopware Docs: message queue and scheduled tasks, tutorials and FAQ. docs.shopware.com message-queue-and-scheduled-tasks
- 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.
Did this catch your stale feed?
If this saved you a confusing week of a comparison portal showing the wrong stock, 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