Diagnostic Storefront Visibility

Shopware Elasticsearch alias not refreshed after a full reindex

You ran a full reindex, waited for it to finish, and the console gave no error. But the storefront still shows an old price, a product that should be gone is still searchable, and a new one nobody can find. Nothing crashed. The read alias just never moved. Here is why Shopware can finish building a new Elasticsearch index and still leave the storefront pointed at the old one, and a small script that tells you when that has happened.

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

Shopware indexes to Elasticsearch by building a brand new, timestamped index such as sw_product_20260710113224 alongside the live one, then flips the read alias product.elasticsearch.index to the new index only after the finish step runs through the message queue, normally driven by the shopware.elasticsearch.create.alias scheduled task. If bin/console es:index ran while only the admin worker was active, or the process was killed mid run, those messages stay queued and the alias switch never fires. The storefront keeps serving the old index with no error. Run a small Python or Node.js script against the Admin API that compares the database count of active, visible products against the Elasticsearch alias count and checks the message queue backlog, then flags the alias as stale when the counts have drifted apart with nothing left in flight. Full code, tests, and a dry run guard are below.

The problem in plain words

Shopware never indexes in place. Every full reindex writes into a fresh, timestamped Elasticsearch index that sits next to the one the storefront is currently reading from. Only once every product has been written to that new index does Shopware flip the alias, product.elasticsearch.index, so the storefront starts reading the new data instead.

That alias flip is not instant and it is not part of the indexing command itself. It runs as a message queue step, normally finished off by the shopware.elasticsearch.create.alias scheduled task. If you run bin/console es:index or dal:refresh:index and the messenger workers are not actually consuming, because only the admin worker in a browser tab is running, or because the process that was consuming got killed or timed out partway through, the messages that finish indexing and trigger the alias switch simply stay queued forever. Shopware gives you no error for this. The database has already moved on, but the storefront keeps quietly serving the old, now stale index until someone notices or the queue happens to drain.

es:index runs builds new timestamped index Finish message queued should trigger alias switch no worker consuming it Alias never flips still points at old index Storefront shows stale data
The new index gets built, but the alias switch is a separate message queue step. Without a consumer running, the switch never happens and the storefront keeps reading the old index.

Why it happens

Shopware's Elasticsearch indexing was built around a blue green pattern on purpose, so the storefront never has to read a half built index. A few common ways stores end up with the alias stuck on the old side anyway:

The dangerous part is the silence. Shopware does not throw an error, does not invalidate a cache, and does not warn in the admin. Prices update in the database, closed out products get marked inactive, new products get created, and the storefront search and listings just keep answering from whatever the alias resolved to the last time it actually moved. See the citations at the end for the exact docs and reported threads.

The key insight

An alias divergence only means something when indexing has actually finished. While a reindex is still running, the database count and the Elasticsearch alias count are expected to disagree, that is normal and temporary. The signal we actually care about is the database count and the Elasticsearch alias count staying apart while the message queue backlog for indexing is empty. Empty queue plus a real gap means the switch step never ran. A queue that is still draining just means be patient.

The fix, as a flow

We do not touch the alias directly and we do not delete anything from a read only check. We add a job that reads the database's own count of active, sales channel visible products through the Admin API, reads the Elasticsearch alias count, and checks the indexing backlog. Only when the backlog is empty and the counts disagree beyond a small tolerance do we flag the alias as stale, and only then does a human or a guarded, dry run aware script step in to run es:create:alias.

Scheduled job runs on a timer Read DB and ES counts product total, alias count Read queue backlog indexing messages pending Backlog empty and counts diverge? yes no, skip Flag alias stale report, or es:create:alias
The script only flags the alias stale when the indexing queue is empty and the database and Elasticsearch counts still disagree beyond tolerance. A queue still draining is left alone.

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. Detection reads through the Admin API, the direct Elasticsearch check is an ops level step you run alongside it.

setup (shell)
pip install requests

export SHOPWARE_URL="https://my-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxxxxxxxxxxxxxxxxxxxxxxx"
export SHOPWARE_CLIENT_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export TOLERANCE_PCT="1.0"
export DRY_RUN="true"   # start safe, change to false to run es:create:alias
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 TOLERANCE_PCT="1.0"
export DRY_RUN="true"   // start safe, change to false to run es:create:alias
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 calls.

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

Read the database's own truth count

Search products with a filter for active equal to true and read the total back with total-count-mode set to 1. This is the count the storefront should agree with once the alias is current.

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

Read the indexing backlog and the alias switch task

Check the shopware.elasticsearch.create.alias scheduled task and the message queue stats. A backlog of pending ElasticsearchIndexingMessage work means indexing is still running, so any count gap right now is expected and not a real problem yet.

step4.py
def alias_task_status(token):
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/scheduled-task",
        json={
            "filter": [{"type": "equals", "field": "name",
                        "value": "shopware.elasticsearch.create.alias"}],
            "page": 1,
            "limit": 1,
        },
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()
    data = r.json()["data"]
    return data[0] if data else None

def message_queue_backlog(token):
    r = requests.get(
        f"{SHOPWARE_URL}/api/_action/message-queue/stats",
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()
    stats = r.json()
    return sum(row.get("size", 0) for row in stats if "ElasticsearchIndexingMessage" in row.get("name", ""))
step4.js
async function aliasTaskStatus(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: "shopware.elasticsearch.create.alias" }],
      page: 1,
      limit: 1,
    }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const body = await res.json();
  return body.data[0] || null;
}

async function messageQueueBacklog(token) {
  const res = await fetch(`${SHOPWARE_URL}/api/_action/message-queue/stats`, {
    headers: authHeaders(token),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const stats = await res.json();
  return stats
    .filter((row) => (row.name || "").includes("ElasticsearchIndexingMessage"))
    .reduce((sum, row) => sum + (row.size || 0), 0);
}
5

Decide, with one pure function

Keep the decision in its own function that takes the database count, the Elasticsearch alias count, the queue backlog, and a tolerance percent, and returns true or false. A pure function like this is easy to read and easy to test, which we do later. It only flags the alias stale when the backlog is empty, meaning nothing is still indexing, and the two counts disagree by more than the tolerance.

decide.py
def is_alias_stale(db_count, es_alias_count, queue_backlog, tolerance_pct=1.0):
    if queue_backlog > 0:
        return False
    divergence_pct = abs(db_count - es_alias_count) / max(db_count, 1) * 100
    return divergence_pct > tolerance_pct
decide.js
export function isAliasStale(dbCount, esAliasCount, queueBacklog, tolerancePct = 1.0) {
  if (queueBacklog > 0) return false;
  const divergencePct = (Math.abs(dbCount - esAliasCount) / Math.max(dbCount, 1)) * 100;
  return divergencePct > tolerancePct;
}
6

Wire it together with a dry run guard

The loop ties every piece together. The Elasticsearch side count, GET {ELASTICSEARCH_URL}/_alias/*product* then GET {ELASTICSEARCH_URL}/{that_index}/_count, is an ops level check against Elasticsearch directly, not the Admin API, so wire it in as its own step alongside the database and backlog reads. On the first few runs leave DRY_RUN on so the script only reports the finding: the database count, the Elasticsearch alias count, the target index name, and the queue backlog. When you turn it off, the only allowed write is shelling out to bin/console es:create:alias, which is idempotent and safe to run again. The script must never delete an index or touch the alias directly against Elasticsearch, and never runs the fix while the queue backlog is still nonzero.

Run it safe

Always start with DRY_RUN=true. Detection only reads. The only allowed repair action is running bin/console es:create:alias, which is safe to re-run, and only after confirming the queue backlog is zero. Never delete an index or write to the Elasticsearch alias API directly, since switching mid index would point the storefront at a half populated index. Run bin/console es:index:cleanup separately, by hand, once you have confirmed the new alias is serving correctly.

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 only ever reports or runs the idempotent es:create:alias command, never a direct alias write or an index delete.

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

check_alias_stale.py
"""Detect a Shopware Elasticsearch alias that never refreshed after a full reindex.
Only reads: compares the database's active product count against the Elasticsearch
alias count, and checks the indexing message queue backlog. Flags the alias as stale
only when the backlog is empty (nothing still indexing) and the counts disagree beyond
tolerance. Run on a schedule. Safe to run again and again.

When DRY_RUN is false, the only write this script performs is shelling out to the
idempotent `bin/console es:create:alias`, and only when the backlog is confirmed empty.
It never deletes an index and never touches the Elasticsearch alias API directly.
"""
import os
import logging
import subprocess
import requests

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

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")
TOLERANCE_PCT = float(os.environ.get("TOLERANCE_PCT", "1.0"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CONSOLE_PATH = os.environ.get("SHOPWARE_CONSOLE_PATH", "bin/console")


def is_alias_stale(db_count, es_alias_count, queue_backlog, tolerance_pct=1.0):
    if queue_backlog > 0:
        return False
    divergence_pct = abs(db_count - es_alias_count) / max(db_count, 1) * 100
    return divergence_pct > tolerance_pct


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 db_active_product_count(token):
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/product",
        json={
            "page": 1,
            "limit": 1,
            "filter": [{"type": "equals", "field": "active", "value": True}],
            "total-count-mode": 1,
        },
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["total"]


def alias_task_status(token):
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/scheduled-task",
        json={
            "filter": [{"type": "equals", "field": "name",
                        "value": "shopware.elasticsearch.create.alias"}],
            "page": 1,
            "limit": 1,
        },
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()
    data = r.json()["data"]
    return data[0] if data else None


def message_queue_backlog(token):
    r = requests.get(
        f"{SHOPWARE_URL}/api/_action/message-queue/stats",
        headers=auth_headers(token),
        timeout=30,
    )
    r.raise_for_status()
    stats = r.json()
    return sum(row.get("size", 0) for row in stats if "ElasticsearchIndexingMessage" in row.get("name", ""))


def es_alias_count(es_url, index_pattern="*product*"):
    """Ops-level check against Elasticsearch directly, not the Admin API."""
    r = requests.get(f"{es_url}/_alias/{index_pattern}", timeout=30)
    r.raise_for_status()
    aliases = r.json()
    if not aliases:
        return None, 0
    target_index = next(iter(aliases))
    count_r = requests.get(f"{es_url}/{target_index}/_count", timeout=30)
    count_r.raise_for_status()
    return target_index, count_r.json().get("count", 0)


def run():
    token = get_token()
    db_count = db_active_product_count(token)
    backlog = message_queue_backlog(token)
    task = alias_task_status(token)

    es_url = os.environ.get("ELASTICSEARCH_URL")
    target_index, es_count = (None, db_count)
    if es_url:
        target_index, es_count = es_alias_count(es_url)
        if target_index is None:
            es_count = 0

    stale = is_alias_stale(db_count, es_count, backlog, TOLERANCE_PCT)

    log.info(
        "DB active products: %d | ES alias count: %d | queue backlog: %d | task status: %s",
        db_count, es_count, backlog, task.get("status") if task else "unknown",
    )

    if not stale:
        log.info("Alias looks current. Nothing to do.")
        return

    log.warning(
        "Alias appears stale. target_index=%s db_count=%d es_count=%d backlog=%d. %s",
        target_index, db_count, es_count, backlog,
        "dry run, would run es:create:alias" if DRY_RUN else "running es:create:alias",
    )

    if not DRY_RUN:
        if backlog > 0:
            raise RuntimeError("Refusing to switch alias while indexing messages are still in flight.")
        subprocess.run([CONSOLE_PATH, "es:create:alias"], check=True)
        log.info("es:create:alias completed. Run es:index:cleanup by hand once you confirm it serves correctly.")


if __name__ == "__main__":
    run()
check-alias-stale.js
/**
 * Detect a Shopware Elasticsearch alias that never refreshed after a full reindex.
 * Only reads: compares the database's active product count against the Elasticsearch
 * alias count, and checks the indexing message queue backlog. Flags the alias as stale
 * only when the backlog is empty (nothing still indexing) and the counts disagree beyond
 * tolerance. Run on a schedule. Safe to run again and again.
 *
 * When DRY_RUN is false, the only write this script performs is shelling out to the
 * idempotent `bin/console es:create:alias`, and only when the backlog is confirmed empty.
 * It never deletes an index and never touches the Elasticsearch alias API directly.
 */
import { pathToFileURL } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

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 TOLERANCE_PCT = Number(process.env.TOLERANCE_PCT || 1.0);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CONSOLE_PATH = process.env.SHOPWARE_CONSOLE_PATH || "bin/console";

export function isAliasStale(dbCount, esAliasCount, queueBacklog, tolerancePct = 1.0) {
  if (queueBacklog > 0) return false;
  const divergencePct = (Math.abs(dbCount - esAliasCount) / Math.max(dbCount, 1)) * 100;
  return divergencePct > tolerancePct;
}

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 dbActiveProductCount(token) {
  const res = await fetch(`${SHOPWARE_URL}/api/search/product`, {
    method: "POST",
    headers: { ...authHeaders(token), "Content-Type": "application/json" },
    body: JSON.stringify({
      page: 1,
      limit: 1,
      filter: [{ type: "equals", field: "active", value: true }],
      "total-count-mode": 1,
    }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const body = await res.json();
  return body.total;
}

async function aliasTaskStatus(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: "shopware.elasticsearch.create.alias" }],
      page: 1,
      limit: 1,
    }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const body = await res.json();
  return body.data[0] || null;
}

async function messageQueueBacklog(token) {
  const res = await fetch(`${SHOPWARE_URL}/api/_action/message-queue/stats`, {
    headers: authHeaders(token),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const stats = await res.json();
  return stats
    .filter((row) => (row.name || "").includes("ElasticsearchIndexingMessage"))
    .reduce((sum, row) => sum + (row.size || 0), 0);
}

async function esAliasCount(esUrl, indexPattern = "*product*") {
  // Ops-level check against Elasticsearch directly, not the Admin API.
  const res = await fetch(`${esUrl}/_alias/${indexPattern}`);
  if (!res.ok) throw new Error(`Elasticsearch ${res.status}`);
  const aliases = await res.json();
  const keys = Object.keys(aliases);
  if (keys.length === 0) return { targetIndex: null, count: 0 };
  const targetIndex = keys[0];
  const countRes = await fetch(`${esUrl}/${targetIndex}/_count`);
  if (!countRes.ok) throw new Error(`Elasticsearch ${countRes.status}`);
  const countBody = await countRes.json();
  return { targetIndex, count: countBody.count || 0 };
}

export async function run() {
  const token = await getToken();
  const dbCount = await dbActiveProductCount(token);
  const backlog = await messageQueueBacklog(token);
  const task = await aliasTaskStatus(token);

  let targetIndex = null;
  let esCount = dbCount;
  const esUrl = process.env.ELASTICSEARCH_URL;
  if (esUrl) {
    const result = await esAliasCount(esUrl);
    targetIndex = result.targetIndex;
    esCount = targetIndex === null ? 0 : result.count;
  }

  const stale = isAliasStale(dbCount, esCount, backlog, TOLERANCE_PCT);

  console.log(
    `DB active products: ${dbCount} | ES alias count: ${esCount} | queue backlog: ${backlog} | task status: ${task ? task.status : "unknown"}`
  );

  if (!stale) {
    console.log("Alias looks current. Nothing to do.");
    return;
  }

  console.warn(
    `Alias appears stale. target_index=${targetIndex} db_count=${dbCount} es_count=${esCount} backlog=${backlog}. ${DRY_RUN ? "dry run, would run es:create:alias" : "running es:create:alias"}`
  );

  if (!DRY_RUN) {
    if (backlog > 0) throw new Error("Refusing to switch alias while indexing messages are still in flight.");
    await execFileAsync(CONSOLE_PATH, ["es:create:alias"]);
    console.log("es:create:alias completed. Run es:index:cleanup by hand once you confirm it serves correctly.");
  }
}

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 reports a real problem or a normal in progress reindex. Because is_alias_stale is pure and takes plain numbers, the test needs no network, no Shopware store, and no Elasticsearch cluster. It just feeds in counts and a backlog size and checks the answer.

test_alias_stale.py
from check_alias_stale import is_alias_stale


def test_stale_when_backlog_empty_and_counts_diverge():
    assert is_alias_stale(1000, 950, 0, 1.0) is True


def test_not_stale_when_backlog_still_draining():
    # 5% divergence would normally flag, but indexing is still running
    assert is_alias_stale(1000, 950, 42, 1.0) is False


def test_not_stale_when_counts_match():
    assert is_alias_stale(1000, 1000, 0, 1.0) is False


def test_not_stale_within_tolerance():
    # 0.5% divergence, tolerance is 1.0%
    assert is_alias_stale(1000, 995, 0, 1.0) is False


def test_stale_just_past_tolerance_boundary():
    # just over 1.0% divergence
    assert is_alias_stale(1000, 988, 0, 1.0) is True


def test_handles_zero_db_count_without_division_error():
    assert is_alias_stale(0, 0, 0, 1.0) is False


def test_stale_when_es_count_is_zero_and_db_has_products():
    assert is_alias_stale(500, 0, 0, 1.0) is True
alias-stale.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isAliasStale } from "./check-alias-stale.js";

test("stale when backlog empty and counts diverge", () => {
  assert.equal(isAliasStale(1000, 950, 0, 1.0), true);
});

test("not stale when backlog still draining", () => {
  assert.equal(isAliasStale(1000, 950, 42, 1.0), false);
});

test("not stale when counts match", () => {
  assert.equal(isAliasStale(1000, 1000, 0, 1.0), false);
});

test("not stale within tolerance", () => {
  assert.equal(isAliasStale(1000, 995, 0, 1.0), false);
});

test("stale just past tolerance boundary", () => {
  assert.equal(isAliasStale(1000, 988, 0, 1.0), true);
});

test("handles zero db count without division error", () => {
  assert.equal(isAliasStale(0, 0, 0, 1.0), false);
});

test("stale when es count is zero and db has products", () => {
  assert.equal(isAliasStale(500, 0, 0, 1.0), true);
});

Case studies

One off shell reindex

The price update that never showed up

A store ran a seasonal price change, then triggered bin/console es:index from a one off SSH session to push it live faster. The session closed a few minutes later, and with it went the only process consuming the message queue. The new index finished building in the database's view, but the finish and alias switch messages sat queued with nobody home.

Support tickets started coming in about the storefront still showing the old price two days later. Running the detection script found the exact shape of the problem: zero queue backlog, meaning indexing had actually finished, but the database and Elasticsearch counts were miles apart. One run of es:create:alias fixed it in seconds.

Admin worker only

The host that never ran messenger:consume

A smaller shop relied only on the built in admin worker in a browser tab, with no messenger:consume process and no cron entry at all. A full reindex after a catalog import looked fine right up until the admin tab was closed, at which point the alias switch step queued up and simply waited, along with everything else the message queue was supposed to handle.

The team added the detection script on an hourly schedule, dry run first. It reported the stale alias along with a nonzero general backlog, which pointed them at the real root cause. Once they added a proper messenger:consume systemd unit, both the immediate stale alias and the underlying missing consumer were fixed together.

What good looks like

After this runs on a schedule, a reindex that finished in the database but never made it to the storefront gets caught within the hour instead of surfacing as confused support tickets days later. The script only ever reports or runs the same idempotent alias command Shopware's own scheduled task would have run. Pair it with a real check that messenger:consume is actually running, since a stale alias caught once but not addressed at the source will happen again on the next reindex.

FAQ

Why does the Shopware storefront still show old data after I ran es:index?

Shopware builds a brand new timestamped Elasticsearch index alongside the live one and only flips the read alias to it once indexing finishes, normally through a message queue step. If the queue is not being consumed while you ran es:index, or the process died mid run, the messages that trigger the alias switch never execute, so the storefront keeps reading the old, now stale index with no error shown anywhere.

Is it safe to detect a stale Elasticsearch alias with a script?

Yes, detection only reads data. The script compares the database count of active, visible products against the Elasticsearch alias count and checks the message queue backlog, then reports whether the two have drifted apart with no indexing still in flight. It makes no writes unless you explicitly turn off dry run for the repair step.

How do I fix a stale Elasticsearch alias once I find one?

Run bin/console es:create:alias to flip the alias to the finished index, or restart messenger:consume so the queued indexing and finish messages drain and the automatic switch fires on its own. Afterward run bin/console es:index:cleanup to drop the orphaned old index. Never move the alias directly against Elasticsearch while an indexing message might still be in flight, or the storefront can end up reading a half populated index.

Related field notes

Citations

On the problem:

  1. Shopware Developer Docs: debugging and troubleshooting Elasticsearch. developer.shopware.com elasticsearch-debugging
  2. GitHub shopware/shopware issue 528: Elasticsearch index lifecycle. github.com/shopware/shopware issues/528
  3. Shopware Community Forum: issues with Elastic Search in Shopware 6. forum.shopware.com issues-with-elastic-search

On the solution:

  1. Shopware Developer Docs: Elasticsearch concepts and framework. developer.shopware.com concepts/framework/elasticsearch
  2. Shopware Developer Docs: set up Elasticsearch. developer.shopware.com elasticsearch-setup
  3. Shopware Developer Docs: scheduled task infrastructure and hosting guide. developer.shopware.com hosting/infrastructure/scheduled-task

Stuck on a tricky one?

If you have a problem in Shopware orders, the message queue, search infrastructure, 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 catch your stale alias?

If this saved you a confusing few days of support tickets about missing products or old prices, 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