From 05a35955adacacedbf1fc8646c3ba3aa74b6edc0 Mon Sep 17 00:00:00 2001 From: jtrzupek Date: Fri, 26 Jun 2026 16:25:29 +0200 Subject: [PATCH] fix(api): cap list_scenes filter sizes to prevent DB OOM (Fixes GOON-1M) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single request with 194 studio_slugs + 23 tag filters (each tag = a correlated EXISTS) plus an ILIKE search built a query heavy enough that the OOM killer killed the Postgres backend, triggering a full crash-recovery (~1s prod-wide outage, all in-flight connections dropped). Any user could do this with a big enough filter. Cap studios to 50, tags to 15, performers to 15 (far above any real UI usage) and return 422 instead of executing — bounding query complexity regardless of the planner's choice. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/scenes.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/api/scenes.py b/app/api/scenes.py index 5b00fe3..3d0b915 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -167,9 +167,18 @@ def list_scenes( if q: base = base.where(Scene.title_normalized.ilike(f"%{q.lower()}%")) + # Cap rozmiarów filtrów. Bez tego pojedynczy request z setkami studio_slugs + + # dziesiątkami tagów (każdy tag = osobny correlated EXISTS) + ILIKE budował zapytanie, + # które OOM-killer ubijał → PG crash-recovery = ~1s globalnej przerwy (GOON-1M, + # 2026-06-26: 194 studios + 23 tagi). Realny UI nigdy nie wysyła tylu. 422 zamiast + # wywalania bazy. Limity hojne (>> normalne użycie), ale ograniczają złożoność query. + _MAX_STUDIOS, _MAX_TAGS, _MAX_PERFORMERS = 50, 15, 15 + studio_slug_list = _split_csv(studio_slugs) if studio_slug: studio_slug_list.append(studio_slug) + if len(studio_slug_list) > _MAX_STUDIOS: + raise HTTPException(status_code=422, detail=f"too many studio filters (max {_MAX_STUDIOS})") if studio_slug_list: base = base.where( Scene.studio_id.in_( @@ -178,6 +187,8 @@ def list_scenes( ) tag_slug_list = _split_csv(tags) + if len(tag_slug_list) > _MAX_TAGS: + raise HTTPException(status_code=422, detail=f"too many tag filters (max {_MAX_TAGS})") # AND między tagami: scena musi mieć WSZYSTKIE zaznaczone tagi. Każdy slug → osobny # exists() — zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika. # @@ -207,6 +218,8 @@ def list_scenes( ) perf_id_strings = _split_csv(performer_ids) + if len(perf_id_strings) > _MAX_PERFORMERS: + raise HTTPException(status_code=422, detail=f"too many performer filters (max {_MAX_PERFORMERS})") if perf_id_strings: try: perf_ids = [uuid.UUID(s) for s in perf_id_strings]