← Alle Beiträge

Shopware 6 mit OpenSearch: „Result window is too large, from + size must be less than or equal to: [10000]“

Shopware 6 mit OpenSearch: „Result window is too large, from + size must be less than or equal to: [10000]“

Im Log stehen plötzlich Hunderte illegal_argument_exception-Einträge von OpenSearch, und keiner davon hat mit einer Suche zu tun, die ein Mensch abgeschickt hat. Der Shop läuft, die Kategorien laden, Kunden merken nichts. Trotzdem wächst der Fehlerzähler jeden Tag weiter. In den Shops, die wir überwachen, gehört diese Meldung zu den häufigsten OpenSearch-Fehlern überhaupt.

Die Fehlermeldung

Je nach Einstellung landet sie in zwei Varianten im Log. Einmal als geloggter Fehler im Channel elasticsearch:

elasticsearch.CRITICAL: illegal_argument_exception: Result window is too large,
from + size must be less than or equal to: [10000] but was [46728]. See the scroll
api for a more efficient way to request large data sets. This limit can be set by
changing the [index.max_result_window] index level setting.

Und einmal als ungefangene Exception im Request:

request.CRITICAL: Uncaught PHP Exception OpenSearch\Exception\BadRequestHttpException:
"illegal_argument_exception: Result window is too large, from + size must be less
than or equal to: [10000] but was [78504]. ..." at HttpExceptionFactory.php line 45

Bei älteren Clients heißt die Klasse OpenSearch\Common\Exceptions\BadRequest400Exception, und der Text steckt in einem JSON-Block mit search_phase_execution_exception und all shards failed. Gemeint ist dasselbe.

Was dahintersteckt

OpenSearch und Elasticsearch blättern nicht beliebig tief. Eine Suche mit from und size darf zusammen höchstens index.max_result_window Treffer abdecken, Standard sind 10.000. Die Elasticsearch-Doku erklärt den Grund: Jeder Shard muss für Seite 400 auch alle Treffer der Seiten 1 bis 399 in den Speicher laden. Das Limit ist eine Schutzschranke, kein Bug.

Shopware rechnet den Offset für Listings in PagingListingProcessor ganz simpel aus: (p - 1) * limit. Bei 24 Artikeln pro Seite ergibt ?p=1947 also from + size = 46.728. Genau diese Zahl steht oben in der Meldung.

Schau dir die Werte aus but was [...] an. Sie sind praktisch immer glatt durch 24 teilbar, also die Standard-Seitengröße von Shopware. Bei einem Limit von 10.000 ist 10.008 der kleinste mögliche Wert, das ist Seite 417. Typisch sind Werte, die Seiten im vierstelligen Bereich entsprechen. Kein Kunde blättert in einer Kategorie auf Seite 8.000. Das sind Crawler, die ?p= hochzählen oder alte Pagination-URLs abarbeiten.

Was danach passiert, hängt von SHOPWARE_ES_THROW_EXCEPTION ab. Im ElasticsearchEntitySearcher wird jeder Fehler an ElasticsearchHelper::logAndThrowException() übergeben:

  • Steht die Variable auf 1, fliegt die Exception durch, der Bot bekommt einen 500er, und im Log steht der request.CRITICAL-Eintrag.
  • Steht sie auf 0 (so empfiehlt es die Shopware-Doku für Produktion), wird der Fehler nur geloggt und Shopware stellt dieselbe Suche noch einmal an MySQL. Mit einem Offset von 46.728.

Die zweite Variante sieht harmloser aus, ist aber die teurere. MySQL muss für einen großen OFFSET alle übersprungenen Zeilen trotzdem lesen, und das passiert bei jedem Bot-Request erneut.

Prüfen, ob es wirklich das ist

Erst die Zahlen aus dem Log ziehen:

grep -ho 'but was \[[0-9]*\]' var/log/prod-*.log | sort | uniq -c | sort -rn | head

Teile die Werte durch deine Artikelanzahl pro Seite (Einstellungen → Stammdaten → Produkte, Standard 24). Kommen glatte, dreistellige oder höhere Seitenzahlen heraus, ist es die Pagination.

Dann im Access-Log nachsehen, wer diese Seiten anfragt:

grep -E '[?&]p=[0-9]{3,}' /var/log/nginx/access.log | tail -n 50

User-Agent und Referrer zeigen, ob es ein bekannter Crawler ist, ein SEO-Tool oder irgendein Scraper. Den Pfad zum Access-Log musst du an deinen Server anpassen.

Und zuletzt das tatsächliche Limit im Index:

curl -s "$OPENSEARCH_URL/sw_product/_settings?include_defaults=true" | grep -o '"max_result_window":"[0-9]*"'

sw ist der Standard-Prefix aus SHOPWARE_ES_INDEX_PREFIX. Seit Shopware 6.6.3.0 setzt Shopware den Wert max_result_window: 10000 selbst in elasticsearch.index_settings, davor galt der OpenSearch-Standard, der ebenfalls 10.000 beträgt.

Warum max_result_window hochsetzen nicht hilft

Das ist der erste Reflex, und die Fehlermeldung schlägt ihn ja sogar vor. Auch Shops, die das Limit auf 50.000 oder 100.000 erhöht haben, sehen den Fehler weiter, nur bei höheren Seitenzahlen. Die Bots blättern einfach weiter, als jedes Limit reicht. Gleichzeitig darf jetzt jeder Request mit einem Offset von 99.000 ungebremst auf den Cluster, und genau davor sollte die Schranke schützen.

Hochsetzen ergibt nur Sinn, wenn eine Kategorie tatsächlich mehr als 10.000 Produkte hat und Menschen dort wirklich so tief blättern. Dann den Wert knapp über die echte Produktanzahl setzen, nicht pauschal auf 100.000. Wie in der Shopware-Doku beschrieben, in config/packages/elasticsearch.yml:

elasticsearch:
    index_settings:
        max_result_window: 15000

Danach bin/console cache:clear und neu indexieren, weil der Wert beim Anlegen des Index gesetzt wird. index.max_result_window ist laut OpenSearch-Doku zwar eine dynamische Einstellung und ließe sich per PUT _settings auch am laufenden Index ändern, der nächste es:index überschreibt das aber wieder mit dem Wert aus der Config.

Die Lösung: tiefe Seiten vor Shopware abfangen

Rechne aus, ab welcher Seite die Suche ohnehin scheitert: max_result_window / Artikel pro Seite. Bei 10.000 und 24 sind das 416 Seiten. Alles ab Seite 417 kann OpenSearch nicht liefern. Solche Requests müssen weder PHP noch den Cluster noch MySQL erreichen.

Mit nginx geht das im server-Block:

# p >= 417 (10000 / 24 Artikel pro Seite). Regex an deine Seitengröße anpassen.
if ($arg_p ~ "^0*(41[7-9]|4[2-9][0-9]|[5-9][0-9]{2}|[1-9][0-9]{3,})$") {
    return 404;
}

if mit return ist in nginx unproblematisch, die bekannten Fallstricke betreffen andere Direktiven innerhalb von if. Wenn du eine andere Seitengröße nutzt, passt du die Grenze an. Die Regex ist hässlich, aber sie läuft vor PHP und kostet nichts.

Das ist bewusst ein Workaround an der Grenze des Systems. Er greift nur für Storefront-Requests mit ?p= in der URL. Die Store API nimmt p auch im POST-Body entgegen, dort hilft eine nginx-Regel nicht.

Wenn ein einzelner Crawler das Problem ist, lohnt zusätzlich ein Blick auf robots.txt oder eine Rate-Limit-Regel für seinen User-Agent. Woher er die URLs hat, zeigt der Referrer im Access-Log.

Was Shopware selbst inzwischen macht

Seit Shopware 6.7.11.0 (PR #16575) beantwortet die Storefront Seiten hinter der letzten Listing-Seite mit einem 301 auf die kanonische URL ohne p, die Store API mit einem 404. Vorher kam ein 200 mit leerer Liste. Das ist gut für SEO und schickt Crawler schneller weg.

Den Fehler im Log beseitigt es nach unserem Verständnis des Codes nicht. Die Prüfung auf die letzte Seite läuft in PagingListingProcessor::process(), also nach der Suche. Die zu tiefe OpenSearch-Anfrage wird vorher trotzdem abgeschickt, scheitert und wird geloggt. Mit SHOPWARE_ES_THROW_EXCEPTION=0 folgt dann noch der MySQL-Fallback, erst danach greift der Redirect. Laut PR wurde die Änderung nicht nach 6.6 zurückportiert. Die nginx-Regel bleibt also auch auf aktuellen Versionen sinnvoll.

Wenn das nicht hilft

Taucht der Fehler mit Werten auf, die nicht durch deine Seitengröße teilbar sind, kommt die Anfrage woanders her. Typisch sind glatte Werte wie but was [50000] oder [10100]. Das sieht nach einem Plugin oder Export aus, der mit festen Limits arbeitet und from/size statt search_after benutzt. Für Massenabfragen empfiehlt die Elasticsearch-Doku search_after mit Point in Time. Das muss dann im jeweiligen Plugin passieren, nicht in Shopware.

Von außen sieht man diesem Fehler nichts an. Der Shop antwortet, nur die Last auf OpenSearch und MySQL steigt, und im Log stapeln sich Einträge, die keiner liest. ShopSignal zählt Fehler und Warnungen pro Shop, macht die Logs durchsuchbar und behält den Zustand des Elasticsearch-Clusters im Blick. Eine Alert-Regel auf steigende Fehlerzahlen meldet so einen Crawler, bevor er sich durch zehntausend Kategorieseiten gearbeitet hat.