Bilder blockieren nicht mehr die halbe Seite

Warum eine angeklickte Artikelseite nur zur Haelfte lud: Die Bild-Route wird je
Tabellenzeile einmal aufgerufen, und ein Browser haelt je Host nur rund sechs
Verbindungen offen. Was diese Route aufhaelt, legt die ganze Oberflaeche lahm.
Sie hielt gleich dreifach auf:

1. Fehlte die lokale Kopie, holte ``images.ensure`` das Bild MITTEN in der
   Anfrage von der fremden Adresse - bis zu 15 Sekunden, je Zeile, und bei
   jedem Aufruf erneut, weil ein Fehlschlag nirgends vermerkt wurde. Artikel
   aus einem Backup-Import haben genau diese Ausgangslage. Gemessen: 2,01 s
   je Aufruf, dreimal hintereinander. Jetzt: 0,01 s, das Nachholen laeuft nach
   der Antwort im Hintergrund und ein toter Link wird eine Stunde gesperrt.

2. Ein Vorschaubild ist 34x34 Pixel gross - ausgeliefert wurde das Original.
   Gemessen: 2530 KB je Briefmarke, bei 40 Zeilen 99 MB fuer eine Liste. Neu
   erzeugt die Bild-Route (Pillow) eine Vorschau und legt sie daneben ab;
   ``?thumb=1`` liefert sie aus. Gemessen: 0,6 KB.

3. If-None-Match wurde ignoriert. Nach den 5 Minuten Cache-Frist lud der
   Browser jedes Bild komplett neu - das erklaert, warum es "alle 20 Minuten"
   wieder losging. Jetzt 304 ohne Daten.

Dazu im Web: laufende Bildabrufe werden beim Seitenwechsel abgebrochen (sonst
steht die neue Seite hinter Bildern Schlange, die niemand mehr sieht), und wo
``image_version`` bekannt ist, entfaellt die Anfrage fuer bildlose Artikel ganz.

Und der Verbindungsvorrat der Datenbank: 5 (+10) gegen 40 Arbeits-Threads von
FastAPI. Ab der 16. gleichzeitigen Anfrage wartete eine Route stillschweigend
30 Sekunden - von aussen ein haengender Server. Jetzt 10 (+20) mit 10 Sekunden
Frist: lieber ein Fehler als eine halbe Minute Stille.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-18 21:14:43 +02:00
parent 4e2dd3e3eb
commit 8d4954610b
13 changed files with 416 additions and 72 deletions

View File

@@ -0,0 +1,167 @@
"""Die Bild-Route ist die einzige, die je Tabellenzeile einmal aufgerufen wird.
Was hier schiefgeht, vervielfacht sich also mit der Länge der Liste und weil
ein Browser nur ~6 Verbindungen je Host offen hält, warten alle übrigen
Abfragen der Seite dahinter. Genau daran lag das halb geladene Bild der
Artikelseite. Deshalb halten diese Tests drei Zusagen fest:
1. Die Auslieferung geht **nie** ins Netz.
2. Ein unverändertes Bild wird **nicht erneut** übertragen.
3. Ein Vorschaubild ist ein **kleines** Bild, nicht das Original.
"""
import io
import pytest
from fastapi import BackgroundTasks, HTTPException
from app.models import BaseUnit, Product, ProductImage
from app.routers.products import get_product_image
from app.services import images
class FakeRequest:
"""Nur die Kopfzeilen mehr braucht die Route nicht."""
def __init__(self, **kopf):
self.headers = {k.lower().replace("_", "-"): v for k, v in kopf.items()}
def _artikel(db, url=None):
p = Product(name="Pesto", base_unit=BaseUnit.gram, image_url=url)
db.add(p)
db.commit()
db.refresh(p)
return p
def _jpeg(breite=1600, hoehe=1200) -> bytes:
"""Ein Bild in Handy-Größe kein Platzhalter, sonst misst der Test nichts."""
Image = pytest.importorskip("PIL.Image")
bild = Image.new("RGB", (breite, hoehe))
# Rauschen, damit JPEG nicht auf ein paar Byte zusammenfällt.
bild.putdata([(x * 7 % 256, x * 13 % 256, x * 29 % 256) for x in range(breite * hoehe)])
puffer = io.BytesIO()
bild.save(puffer, format="JPEG", quality=90)
return puffer.getvalue()
def _hole(db, artikel, **kwargs):
return get_product_image(
artikel.id,
request=kwargs.pop("request", FakeRequest()),
background_tasks=kwargs.pop("background_tasks", BackgroundTasks()),
thumb=kwargs.pop("thumb", False),
db=db,
_=None,
)
def test_auslieferung_geht_nie_ins_netz(db, monkeypatch):
"""Der eigentliche Fehler: ``ensure`` holte das Bild MITTEN in der Anfrage.
Bei einem toten Link kostete das bis zu 15 Sekunden je Tabellenzeile, und
bei jedem Aufruf aufs Neue, weil ein Fehlschlag nirgends vermerkt wurde.
"""
monkeypatch.setattr(
images, "fetch", lambda url: pytest.fail("Die Auslieferung darf nicht ins Netz")
)
artikel = _artikel(db, "https://example.org/pesto.jpg")
with pytest.raises(HTTPException) as fehler:
_hole(db, artikel)
assert fehler.value.status_code == 404
def test_fehlende_kopie_wird_im_hintergrund_nachgeholt(db, monkeypatch):
"""Nachgeholt wird weiterhin nur eben nach der Antwort."""
monkeypatch.setattr(images, "fetch", lambda url: (b"x", "image/png"))
artikel = _artikel(db, "https://example.org/pesto.jpg")
aufgaben = BackgroundTasks()
with pytest.raises(HTTPException):
_hole(db, artikel, background_tasks=aufgaben)
assert [t.func for t in aufgaben.tasks] == [images.nachholen]
def test_ohne_bildadresse_gibt_es_nichts_nachzuholen(db, monkeypatch):
monkeypatch.setattr(
images, "fetch", lambda url: pytest.fail("Es gibt keine Adresse zum Holen")
)
aufgaben = BackgroundTasks()
with pytest.raises(HTTPException):
_hole(db, _artikel(db), background_tasks=aufgaben)
assert aufgaben.tasks == []
def test_toter_link_wird_nicht_bei_jedem_aufruf_erneut_versucht(db, monkeypatch):
"""Ohne Sperre klopft jede Tabellenzeile bei jedem Seitenaufruf erneut an."""
images.sperre_zuruecksetzen()
monkeypatch.setattr(images, "fetch", lambda url: None) # Abruf schlägt fehl
artikel = _artikel(db, "https://example.org/weg.jpg")
erste = BackgroundTasks()
with pytest.raises(HTTPException):
_hole(db, artikel, background_tasks=erste)
assert len(erste.tasks) == 1
# Die Hintergrundaufgabe oeffnet sonst eine eigene Sitzung hier soll es
# die des Fixtures sein, und schliessen darf sie sie nicht.
monkeypatch.setattr(db, "close", lambda: None)
monkeypatch.setattr("app.database.SessionLocal", lambda: db)
images.nachholen(artikel.id, artikel.image_url) # …und scheitert
zweite = BackgroundTasks()
with pytest.raises(HTTPException):
_hole(db, artikel, background_tasks=zweite)
assert zweite.tasks == []
def test_unveraendertes_bild_wird_nicht_erneut_uebertragen(db):
"""Ohne 304 lädt der Browser nach Ablauf der Frist jedes Bild komplett neu."""
artikel = _artikel(db)
db.add(ProductImage(product_id=artikel.id, content_type="image/jpeg", data=_jpeg()))
db.commit()
erste = _hole(db, artikel)
marke = erste.headers["etag"]
assert len(erste.body) > 0
zweite = _hole(db, artikel, request=FakeRequest(if_none_match=marke))
assert zweite.status_code == 304
assert zweite.body == b""
def test_vorschaubild_ist_ein_bruchteil_des_originals(db):
"""34x34 Pixel auf dem Schirm dürfen nicht 2 MB auf der Leitung sein."""
original = _jpeg()
artikel = _artikel(db)
db.add(ProductImage(product_id=artikel.id, content_type="image/jpeg", data=original))
db.commit()
gross = _hole(db, artikel)
klein = _hole(db, artikel, thumb=True)
assert len(gross.body) == len(original)
assert len(klein.body) < len(original) / 20
# Verschiedene Inhalte brauchen verschiedene Marken, sonst liefert der
# Zwischenspeicher des Browsers das eine fuer das andere aus.
assert klein.headers["etag"] != gross.headers["etag"]
def test_vorschau_bleibt_erhalten_und_ruehrt_den_zeitstempel_nicht_an(db):
"""Sie wird beim ersten Abruf nachgezogen aber das Bild ist nicht neu."""
artikel = _artikel(db)
bild = ProductImage(product_id=artikel.id, content_type="image/jpeg", data=_jpeg())
db.add(bild)
db.commit()
db.refresh(bild)
vorher = bild.updated_at
_hole(db, artikel, thumb=True)
db.expire_all()
frisch = db.get(ProductImage, artikel.id)
assert frisch.thumb is not None
assert frisch.updated_at == vorher