Reconciler Message Queue and Scheduled Tasks
Shopware 6 scheduled task run interval resets after deploy
Someone tuned a scheduled task to run less often, maybe once a day instead of every five minutes, and it worked fine for weeks. Then a deploy went out and the task is back to running every five minutes, with nothing in the changelog explaining why. Here is why scheduled-task:register keeps doing this and a small script that finds the drifted tasks and restores your tuning without ever fighting the vendor defaults.
Shopware requires bin/console scheduled-task:register to run on every deploy, since that command is how newly-shipped scheduled tasks from plugins and core get added to the scheduled_task table. Its registry, TaskRegistry::registerTasks(), upserts every known task by its unique name and writes defaultRunInterval from the task handler's hardcoded interval each time. If you tuned a task's runInterval by hand and something re-derives it from defaultRunInterval, the next deploy silently snaps it back. Keep a baseline of your intentional intervals per task name, diff it against the live rows with the search API, and PATCH only the ones that drifted, dry run first.
The problem in plain words
Every scheduled task in Shopware has two interval-shaped fields: defaultRunInterval, which is baked into the task handler's code as getDefaultInterval(), and runInterval, which is the number that actually controls how often the task fires. They start out equal. The moment someone opens Settings, Scheduled Tasks in the Admin, or sends a PATCH to the Admin API, and changes how often a task runs, only runInterval moves. defaultRunInterval never changes, because it is not stored configuration, it is a reflection of what the code says the default should be.
That is fine until the next deploy. Deploys are supposed to run bin/console scheduled-task:register so that any scheduled tasks a new plugin or core update ships get added to the table. That command's registry upserts every known task by name and, depending on version and on any reset tooling running alongside it, can rewrite runInterval back to the same value as defaultRunInterval. Your tuning lived only in one column, with no history and no changelog entry, so it disappears without a trace the moment something touches it.
Why it happens
bin/console scheduled-task:registermust run on every deploy, since it is the only mechanism that adds newly-shipped scheduled tasks from plugins or core into thescheduled_tasktable.TaskRegistry::registerTasks()upserts every known task by its uniquename, and it writesdefaultRunIntervalfrom the task handler's hardcodedgetDefaultInterval()value every single time it runs, not just the first time.- A merchant or agency tunes a task's live
runIntervalthrough the Admin UI at Settings, Scheduled Tasks, or through a direct API PATCH, so it runs less or more often than the shipped default. - That customization exists only in the
runIntervalcolumn. Depending on the version, some admin tooling and "reset tasks" runbooks re-deriverunIntervalfromdefaultRunIntervalas part of cleanup, so the very next deploy, or a naive fix script run by someone trying to help, snaps the interval straight back to the vendor default. - There is no changelog entry and no diff shown anywhere. The task just quietly starts firing on the old cadence again.
defaultRunInterval is vendor-owned code metadata. It exists to tell you what the task handler ships with, and scheduled-task:register is supposed to keep re-asserting it every deploy, that part is correct behavior, not a bug. The bug is anything that also copies that value into the live runInterval column. The fix is never to touch defaultRunInterval. It is to keep your own baseline of intended runInterval values per task name, and restore only the rows that drifted from that baseline after each deploy.
The fix, as a flow
We authenticate with the Admin API, search the scheduled-task entity for every row, and compare each one's live runInterval against your own baseline map keyed by task name (never against defaultRunInterval, since that field is expected to keep tracking the vendor default). Anything that matches the baseline is left alone. Anything that drifted gets a single PATCH to restore just the runInterval field, gated behind a dry run flag.
Build it step by step
Get Admin API credentials
Create an Integration under Settings, System, Integrations in the Shopware Admin, and copy its access key id and secret access key. Keep the shop URL and both credentials in environment variables, and start with DRY_RUN on so the first runs only report what they would change.
pip install requests
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxxxxxxxxxxxxxxxxxxxxxxx"
export SHOPWARE_CLIENT_SECRET="your-secret-access-key"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxxxxxxxxxxxxxxxxxxxxxxx"
export SHOPWARE_CLIENT_SECRET="your-secret-access-key"
export DRY_RUN="true" // start safe, change to false to write
Authenticate and build a small API helper
Exchange the client id and secret for a bearer token at POST /api/oauth/token, then send that token as Authorization: Bearer <token> with Accept: application/json on every following call. A tiny helper wraps the token fetch and a generic authorized request.
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_access_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 api(method, path, token, json_body=None):
r = requests.request(
method, f"{SHOPWARE_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
json=json_body, timeout=30,
)
r.raise_for_status()
return r.json() if r.content else None
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 getAccessToken() {
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;
}
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 ${method} ${path} ${res.status}`);
return res.status === 204 ? null : res.json();
}
Fetch every scheduled task
Search the scheduled-task entity with a high limit and read back the fields the decision needs: id, name, runInterval, defaultRunInterval, and status. Since ids are per-install UUIDs, the baseline map has to be keyed by name, not by id.
def fetch_scheduled_tasks(token):
body = {
"page": 1,
"limit": 500,
"filter": [],
"sort": [{"field": "name", "order": "ASC"}],
"total-count-mode": 1,
}
data = api("POST", "/api/search/scheduled-task", token, body)
return [
{
"id": row["id"],
"name": row["name"],
"runInterval": row["runInterval"],
"defaultRunInterval": row["defaultRunInterval"],
"status": row.get("status"),
}
for row in data["data"]
]
async function fetchScheduledTasks(token) {
const body = {
page: 1,
limit: 500,
filter: [],
sort: [{ field: "name", order: "ASC" }],
"total-count-mode": 1,
};
const data = await api("POST", "/api/search/scheduled-task", token, body);
return data.data.map((row) => ({
id: row.id,
name: row.name,
runInterval: row.runInterval,
defaultRunInterval: row.defaultRunInterval,
status: row.status,
}));
}
Decide, with one pure function
Keep the decision in its own function that takes the current tasks and your baseline map and returns exactly the repairs needed. It never compares against defaultRunInterval, only against your own recorded expectation for that task name, and it skips tasks the baseline has no opinion about. That makes it trivial to unit test with plain fixture data.
def decide_interval_repairs(current_tasks, expected_intervals):
repairs = []
for task in current_tasks:
expected = expected_intervals.get(task["name"])
if expected is None:
continue
if expected == task["runInterval"]:
continue
repairs.append({
"id": task["id"],
"name": task["name"],
"from": task["runInterval"],
"to": expected,
})
return repairs
export function decideIntervalRepairs(currentTasks, expectedIntervals) {
const repairs = [];
for (const task of currentTasks) {
const expected = expectedIntervals[task.name];
if (expected === undefined) continue;
if (expected === task.runInterval) continue;
repairs.push({ id: task.id, name: task.name, from: task.runInterval, to: expected });
}
return repairs;
}
Restore only the drifted rows
For each repair, PATCH /api/scheduled-task/{id} with just {"runInterval": <expected>}. Never write defaultRunInterval, that field is vendor-owned and scheduled-task:register is supposed to keep re-asserting it. A single scalar field write is all this needs.
def apply_repair(token, repair):
api("PATCH", f"/api/scheduled-task/{repair['id']}", token, {"runInterval": repair["to"]})
async function applyRepair(token, repair) {
await api("PATCH", `/api/scheduled-task/${repair.id}`, token, { runInterval: repair.to });
}
Wire it together with a dry run guard
Load your baseline of intended intervals, keyed by task name, fetch the live tasks, run them through decide_interval_repairs, and log every repair found. Only call the PATCH loop when DRY_RUN=false is explicitly set. Run this once right after every deploy, since that is the moment drift can happen.
Always start with DRY_RUN=true and read the diff before writing anything. This is safe to auto-fix compared to order or payment state, since runInterval is a plain scalar with no state machine transition and no monetary or inventory side effect, but an over-eager interval, such as setting a cleanup task back to run every few seconds, can still add real load, so dry run first every time.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, keeps a baseline map of expected intervals, logs what it finds, respects the dry run flag, and is safe to run again and again because it only ever writes runInterval on rows that drifted from your baseline.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Restore a Shopware 6 scheduled task runInterval after a deploy resets it.
bin/console scheduled-task:register runs on every deploy and rewrites
defaultRunInterval from each task handler's hardcoded default every time.
If a runInterval was tuned by hand, that customization only lives in the
runInterval column, and some reset tooling re-derives it from
defaultRunInterval, silently undoing the tuning. This script keeps a
baseline of intended intervals per task name, diffs it against the live
scheduled_task table, and restores only the rows that drifted.
Run right after every deploy. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("scheduled_task_interval_reset")
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Baseline of intentionally-customized intervals, in seconds, keyed by task name.
# Keep this checked into your deploy repo and update it whenever you retune a task.
EXPECTED_INTERVALS = {
"product_export.generate": 3600,
"log_entry.cleanup": 86400,
}
def get_access_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 api(method, path, token, json_body=None):
r = requests.request(
method, f"{SHOPWARE_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
json=json_body, timeout=30,
)
r.raise_for_status()
return r.json() if r.content else None
def fetch_scheduled_tasks(token):
body = {
"page": 1,
"limit": 500,
"filter": [],
"sort": [{"field": "name", "order": "ASC"}],
"total-count-mode": 1,
}
data = api("POST", "/api/search/scheduled-task", token, body)
return [
{
"id": row["id"],
"name": row["name"],
"runInterval": row["runInterval"],
"defaultRunInterval": row["defaultRunInterval"],
"status": row.get("status"),
}
for row in data["data"]
]
def decide_interval_repairs(current_tasks, expected_intervals):
repairs = []
for task in current_tasks:
expected = expected_intervals.get(task["name"])
if expected is None:
continue
if expected == task["runInterval"]:
continue
repairs.append({
"id": task["id"],
"name": task["name"],
"from": task["runInterval"],
"to": expected,
})
return repairs
def apply_repair(token, repair):
api("PATCH", f"/api/scheduled-task/{repair['id']}", token, {"runInterval": repair["to"]})
def run():
token = get_access_token()
current_tasks = fetch_scheduled_tasks(token)
repairs = decide_interval_repairs(current_tasks, EXPECTED_INTERVALS)
for repair in repairs:
log.info(
"Task %s drifted: runInterval %s -> %s. %s",
repair["name"], repair["from"], repair["to"],
"would restore" if DRY_RUN else "restoring",
)
if not DRY_RUN:
apply_repair(token, repair)
log.info("Done. %d task(s) %s.", len(repairs), "to restore" if DRY_RUN else "restored")
if __name__ == "__main__":
run()
/**
* Restore a Shopware 6 scheduled task runInterval after a deploy resets it.
*
* bin/console scheduled-task:register runs on every deploy and rewrites
* defaultRunInterval from each task handler's hardcoded default every time.
* If a runInterval was tuned by hand, that customization only lives in the
* runInterval column, and some reset tooling re-derives it from
* defaultRunInterval, silently undoing the tuning. This script keeps a
* baseline of intended intervals per task name, diffs it against the live
* scheduled_task table, and restores only the rows that drifted.
* Run right after every deploy. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/scheduled-task-interval-reset/
*/
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 || "SWIAdummy";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// Baseline of intentionally-customized intervals, in seconds, keyed by task name.
// Keep this checked into your deploy repo and update it whenever you retune a task.
const EXPECTED_INTERVALS = {
"product_export.generate": 3600,
"log_entry.cleanup": 86400,
};
export function decideIntervalRepairs(currentTasks, expectedIntervals) {
const repairs = [];
for (const task of currentTasks) {
const expected = expectedIntervals[task.name];
if (expected === undefined) continue;
if (expected === task.runInterval) continue;
repairs.push({ id: task.id, name: task.name, from: task.runInterval, to: expected });
}
return repairs;
}
async function getAccessToken() {
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;
}
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 ${method} ${path} ${res.status}`);
return res.status === 204 ? null : res.json();
}
async function fetchScheduledTasks(token) {
const body = {
page: 1,
limit: 500,
filter: [],
sort: [{ field: "name", order: "ASC" }],
"total-count-mode": 1,
};
const data = await api("POST", "/api/search/scheduled-task", token, body);
return data.data.map((row) => ({
id: row.id,
name: row.name,
runInterval: row.runInterval,
defaultRunInterval: row.defaultRunInterval,
status: row.status,
}));
}
async function applyRepair(token, repair) {
await api("PATCH", `/api/scheduled-task/${repair.id}`, token, { runInterval: repair.to });
}
export async function run() {
const token = await getAccessToken();
const currentTasks = await fetchScheduledTasks(token);
const repairs = decideIntervalRepairs(currentTasks, EXPECTED_INTERVALS);
for (const repair of repairs) {
console.log(
`Task ${repair.name} drifted: runInterval ${repair.from} -> ${repair.to}. ${DRY_RUN ? "would restore" : "restoring"}`
);
if (!DRY_RUN) await applyRepair(token, repair);
}
console.log(`Done. ${repairs.length} task(s) ${DRY_RUN ? "to restore" : "restored"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
decideIntervalRepairs is the part most worth testing, because it decides which live scheduled tasks get overwritten. It takes plain arrays and objects and returns plain objects, with no network call inside it, so the test needs no Shopware instance at all.
from scheduled_task_interval_reset import decide_interval_repairs
def task(**over):
base = {"id": "a1b2c3", "name": "product_export.generate", "runInterval": 86400,
"defaultRunInterval": 86400, "status": "scheduled"}
base.update(over)
return base
def test_flags_drifted_task_back_to_baseline():
tasks = [task(runInterval=86400)]
baseline = {"product_export.generate": 3600}
repairs = decide_interval_repairs(tasks, baseline)
assert repairs == [{"id": "a1b2c3", "name": "product_export.generate", "from": 86400, "to": 3600}]
def test_skips_task_already_matching_baseline():
tasks = [task(runInterval=3600)]
baseline = {"product_export.generate": 3600}
assert decide_interval_repairs(tasks, baseline) == []
def test_skips_task_with_no_baseline_opinion():
tasks = [task(name="mail_queue.send", runInterval=60)]
baseline = {"product_export.generate": 3600}
assert decide_interval_repairs(tasks, baseline) == []
def test_never_compares_against_default_run_interval():
# defaultRunInterval equals runInterval here (deploy just reset it), but the
# baseline still expects something different, so it must still be flagged.
tasks = [task(runInterval=86400, defaultRunInterval=86400)]
baseline = {"product_export.generate": 3600}
repairs = decide_interval_repairs(tasks, baseline)
assert repairs[0]["to"] == 3600
def test_multiple_tasks_mixed_results():
tasks = [
task(id="t1", name="product_export.generate", runInterval=86400),
task(id="t2", name="log_entry.cleanup", runInterval=86400),
task(id="t3", name="mail_queue.send", runInterval=60),
]
baseline = {"product_export.generate": 3600, "log_entry.cleanup": 86400}
repairs = decide_interval_repairs(tasks, baseline)
assert repairs == [{"id": "t1", "name": "product_export.generate", "from": 86400, "to": 3600}]
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideIntervalRepairs } from "./scheduled-task-interval-reset.js";
const task = (over = {}) => ({
id: "a1b2c3",
name: "product_export.generate",
runInterval: 86400,
defaultRunInterval: 86400,
status: "scheduled",
...over,
});
test("flags drifted task back to baseline", () => {
const repairs = decideIntervalRepairs([task({ runInterval: 86400 })], { "product_export.generate": 3600 });
assert.deepEqual(repairs, [{ id: "a1b2c3", name: "product_export.generate", from: 86400, to: 3600 }]);
});
test("skips task already matching baseline", () => {
const repairs = decideIntervalRepairs([task({ runInterval: 3600 })], { "product_export.generate": 3600 });
assert.deepEqual(repairs, []);
});
test("skips task with no baseline opinion", () => {
const repairs = decideIntervalRepairs(
[task({ name: "mail_queue.send", runInterval: 60 })],
{ "product_export.generate": 3600 }
);
assert.deepEqual(repairs, []);
});
test("never compares against defaultRunInterval", () => {
const repairs = decideIntervalRepairs(
[task({ runInterval: 86400, defaultRunInterval: 86400 })],
{ "product_export.generate": 3600 }
);
assert.equal(repairs[0].to, 3600);
});
test("multiple tasks mixed results", () => {
const tasks = [
task({ id: "t1", name: "product_export.generate", runInterval: 86400 }),
task({ id: "t2", name: "log_entry.cleanup", runInterval: 86400 }),
task({ id: "t3", name: "mail_queue.send", runInterval: 60 }),
];
const baseline = { "product_export.generate": 3600, "log_entry.cleanup": 86400 };
const repairs = decideIntervalRepairs(tasks, baseline);
assert.deepEqual(repairs, [{ id: "t1", name: "product_export.generate", from: 86400, to: 3600 }]);
});
Case studies
The marketplace feed that started spamming again
An agency tuned product_export.generate down from every fifteen minutes to once an hour, since the marketplace only polled the feed hourly anyway and the frequent regeneration was loading the server for nothing. It stayed at an hour for two months, until a routine plugin update deploy ran scheduled-task:register and the interval was back to fifteen minutes the next morning.
They added the baseline script to the deploy pipeline, right after the register command. Now every deploy ends with a dry run diff in the log, and if anything drifted, a second run with DRY_RUN=false restores it before anyone notices the extra load.
Support kept asking why the cleanup task felt aggressive
A merchant had stretched log_entry.cleanup out to once a day so logs stuck around long enough to debug an issue from the day before. After an unrelated core update, support started getting complaints that recent logs were vanishing within the hour, exactly the shipped default.
Running the script in dry run immediately showed the one drifted row, comparing the live runInterval against the merchant's own baseline rather than against defaultRunInterval. A single PATCH fixed it, and the baseline file now lives in their ops repo so the next person does not have to rediscover the cause.
After this runs right after every deploy, tuned scheduled tasks stay tuned. defaultRunInterval keeps tracking whatever the code ships with, exactly as designed, and your own baseline map is the single source of truth for what should actually be running. Nothing gets patched unless it drifted, so the script has nothing to do on a normal deploy and something small and visible to do on the ones that reset a task by accident.
FAQ
Why does my Shopware scheduled task interval keep resetting after every deploy?
Every deploy is expected to run bin/console scheduled-task:register, which upserts every known scheduled task by name and writes defaultRunInterval from the task handler's hardcoded getDefaultInterval value. If a runInterval was tuned by hand in the Admin or by a direct API call, that tuning only lives in the runInterval column, and a naive reset script or runbook can re-derive runInterval from defaultRunInterval, silently undoing the customization.
Is it safe to auto-fix a scheduled task runInterval with a script?
Yes, more so than order or payment fixes, because runInterval is a plain scalar scheduling setting with no state machine transition and no monetary or inventory side effect. It is still worth running dry run first, because setting a cleanup task to run every few seconds by mistake can add real load to the queue.
Should I ever write to defaultRunInterval to stop the reset?
No. defaultRunInterval is vendor-owned code metadata that scheduled-task:register is meant to keep re-asserting from the task handler's getDefaultInterval. The customization belongs only in runInterval, and the fix is to restore runInterval from your own baseline after each deploy, never to fight defaultRunInterval.
Related field notes
Citations
On the problem:
- Shopware Developer Documentation: Scheduled Task hosting and infrastructure guide, including scheduled-task:register on deploy. developer.shopware.com/docs/guides/hosting/infrastructure/scheduled-task.html
- Shopware 6 Tutorials and FAQ: Message Queue and Scheduled Tasks. docs.shopware.com/en/shopware-6-en/tutorials-and-faq/message-queue-and-scheduled-tasks
- Shopware Community Forum: Scheduled Task is queued and not scheduled. forum.shopware.com/t/scheduled-task-is-queued-and-not-scheduled/93886
On the solution:
- Shopware Developer Documentation: Add Scheduled Task, plugin fundamentals. developer.shopware.com/docs/guides/plugins/plugins/plugin-fundamentals/add-scheduled-task.html
- Shopware Admin API Reference: the ScheduledTask entity. shopware.stoplight.io/docs/admin-api/64a7af99179ca-scheduled-task
- Shopware Developer Documentation: Admin API general usage, authentication and search. developer.shopware.com/docs/guides/integrations-api/admin-api/
Stuck on a tricky one?
If you have a problem in Shopware order states, stock, the message queue, or data integrity 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 save your scheduled tasks?
If this saved you a confusing hour tracing a task back to a deploy, 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