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:
167
backend/tests/test_bild_auslieferung.py
Normal file
167
backend/tests/test_bild_auslieferung.py
Normal 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
|
||||
Reference in New Issue
Block a user