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:
@@ -9,14 +9,25 @@ settings = get_settings()
|
||||
|
||||
# SQLite (used in tests) needs a special connect arg; Postgres does not.
|
||||
connect_args = {}
|
||||
pool_args: dict[str, object] = {}
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
connect_args = {"check_same_thread": False}
|
||||
else:
|
||||
# FastAPI faehrt Routen ohne ``async`` in einem Arbeitsvorrat von 40
|
||||
# Threads. Der Standard-Vorrat von SQLAlchemy fasst aber nur 5 (+10)
|
||||
# Verbindungen – ab der 16. gleichzeitigen Anfrage wartet eine Route
|
||||
# stillschweigend bis zu 30 Sekunden auf eine freie Verbindung. Von aussen
|
||||
# sieht das aus wie ein haengender Server. Deshalb mehr Verbindungen und
|
||||
# eine kurze Frist: Ist wirklich keine frei, soll es KRACHEN statt zaeh zu
|
||||
# werden – ein Fehler ist auffindbar, eine halbe Minute Stille nicht.
|
||||
pool_args = {"pool_size": 10, "max_overflow": 20, "pool_timeout": 10}
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args=connect_args,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
**pool_args,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
@@ -209,6 +209,9 @@ def _ensure_schema() -> None:
|
||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_base VARCHAR(16)",
|
||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_count DOUBLE PRECISION",
|
||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_amount DOUBLE PRECISION",
|
||||
# Vorschaubild (Briefmarkengröße) neben dem Original. Bestehende Bilder
|
||||
# bekommen es beim ersten Abruf; deshalb nullbar und ohne Umzug.
|
||||
"ALTER TABLE product_images ADD COLUMN IF NOT EXISTS thumb BYTEA",
|
||||
]
|
||||
with engine.begin() as conn:
|
||||
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
|
||||
|
||||
@@ -468,6 +468,11 @@ class ProductImage(Base):
|
||||
|
||||
``source_url`` merkt sich, woher das Bild kam – daran ist erkennbar, ob eine
|
||||
geänderte ``Product.image_url`` ein neues Bild bedeutet.
|
||||
|
||||
``thumb`` ist dasselbe Bild auf Briefmarkengröße. Listen zeigen 34x34 Pixel
|
||||
je Zeile – ohne diese Spalte lud eine Liste mit 40 Einträgen 40 Fotos in
|
||||
voller Auflösung, also Dutzende Megabyte für ein paar Briefmarken. Nullbar,
|
||||
weil ältere Zeilen noch keine haben; sie wird beim ersten Abruf nachgezogen.
|
||||
"""
|
||||
|
||||
__tablename__ = "product_images"
|
||||
@@ -477,6 +482,7 @@ class ProductImage(Base):
|
||||
)
|
||||
content_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
thumb: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
source_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import difflib
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
File,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
|
||||
from ..crud import product_to_out, product_tracking, products_to_out_bulk
|
||||
from ..database import SessionLocal, get_db
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import (
|
||||
Barcode,
|
||||
@@ -340,6 +349,9 @@ def off_vergleich(
|
||||
@router.get("/{product_id}/image")
|
||||
def get_product_image(
|
||||
product_id: int,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
thumb: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
@@ -347,22 +359,38 @@ def get_product_image(
|
||||
|
||||
Anders als das Logo verlangt diese Route eine Anmeldung: Aus den Bildern
|
||||
liesse sich sonst ohne Konto ablesen, was im Vorrat liegt.
|
||||
|
||||
Diese Route wird je Tabellenzeile einmal aufgerufen. Sie muss deshalb
|
||||
schnell sein und darf unter keinen Umstaenden warten – siehe Modulkopf von
|
||||
``services/images.py``. Drei Zusagen:
|
||||
|
||||
* ``thumb=true`` liefert das Vorschaubild (~3 KB) statt des Originals.
|
||||
* Eine unveraenderte Kopie beantwortet sie mit 304, ohne Daten zu senden.
|
||||
* Fehlt die lokale Kopie, wird sie NACH der Antwort im Hintergrund geholt.
|
||||
"""
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
|
||||
bild = images.ensure(db, product)
|
||||
bild = db.get(ProductImage, product_id)
|
||||
if bild is None:
|
||||
if product.image_url and images.nachholen_faellig(product_id):
|
||||
background_tasks.add_task(images.nachholen, product_id, product.image_url)
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Bild vorhanden")
|
||||
return Response(
|
||||
content=bild.data,
|
||||
media_type=bild.content_type,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=300",
|
||||
"ETag": f'"bild-{product_id}-{int(bild.updated_at.timestamp())}"',
|
||||
},
|
||||
)
|
||||
|
||||
# Vorschau und Original brauchen VERSCHIEDENE Marken – sonst liefert der
|
||||
# Zwischenspeicher des Browsers die Briefmarke fuer das grosse Bild aus.
|
||||
marke = f'"bild-{product_id}-{int(bild.updated_at.timestamp())}{"-v" if thumb else ""}"'
|
||||
kopf = {"Cache-Control": "private, max-age=300", "ETag": marke}
|
||||
if request.headers.get("if-none-match") == marke:
|
||||
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=kopf)
|
||||
|
||||
daten, typ = bild.data, bild.content_type
|
||||
if thumb:
|
||||
klein = bild.thumb if bild.thumb is not None else images.vorschau_nachziehen(db, bild)
|
||||
if klein is not None:
|
||||
daten, typ = klein, images.THUMB_TYPE
|
||||
return Response(content=daten, media_type=typ, headers=kopf)
|
||||
|
||||
|
||||
@router.put("/{product_id}/image", response_model=ProductOut)
|
||||
@@ -396,14 +424,17 @@ async def upload_product_image(
|
||||
f"Das Bild ist zu groß ({len(data) // 1024} KB). "
|
||||
f"Erlaubt sind höchstens {images.MAX_BYTES // 1024} KB.",
|
||||
)
|
||||
klein = images.vorschau(data, file.content_type)
|
||||
bild = db.get(ProductImage, product.id)
|
||||
if bild is None:
|
||||
bild = ProductImage(
|
||||
product_id=product.id, content_type=file.content_type, data=data, source_url=None
|
||||
product_id=product.id, content_type=file.content_type, data=data,
|
||||
thumb=klein, source_url=None,
|
||||
)
|
||||
db.add(bild)
|
||||
else:
|
||||
bild.content_type, bild.data, bild.source_url = file.content_type, data, None
|
||||
bild.thumb = klein
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product_to_out(db, product)
|
||||
@@ -422,19 +453,6 @@ def delete_product_image(
|
||||
db.commit()
|
||||
|
||||
|
||||
def _store_product_image_bg(product_id: int, url: str) -> None:
|
||||
"""Bild nach dem Anlegen im Hintergrund holen (eigene Session), damit das
|
||||
Anlegen nicht auf den (langsamen) Netzwerk-Abruf wartet."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
product = db.get(Product, product_id)
|
||||
if product is not None:
|
||||
images.store(db, product, url)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _pruefe_zweiteinheit(base_unit, basis, anzahl, menge) -> None:
|
||||
"""Die Zweiteinheit muss vollstaendig und auf eine ANDERE Art zeigen.
|
||||
|
||||
@@ -520,7 +538,7 @@ def create_product(
|
||||
# Bild erst NACH dem Anlegen im Hintergrund holen – der Netzwerk-Abruf
|
||||
# soll das Anlegen nicht mehrere Sekunden blockieren. Schlägt er fehl,
|
||||
# bleibt der Artikel trotzdem angelegt (nur ohne Bild).
|
||||
background_tasks.add_task(_store_product_image_bg, product.id, product.image_url)
|
||||
background_tasks.add_task(images.nachholen, product.id, product.image_url)
|
||||
return product_to_out(db, product)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Artikelbilder holen und lokal vorhalten.
|
||||
"""Artikelbilder holen, verkleinern und lokal vorhalten.
|
||||
|
||||
Open Food Facts liefert nur eine Bild-*Adresse*. Würde die Oberfläche direkt
|
||||
dorthin verlinken, hinge jede Artikelseite an einem fremden Dienst: Das Bild
|
||||
@@ -9,11 +9,23 @@ eigenen Datenbank ausgeliefert.
|
||||
|
||||
Die Bilder liegen in einer eigenen Tabelle und nicht als Spalte an ``products``:
|
||||
Sonst zöge jede Artikelliste die Blobs mit.
|
||||
|
||||
**Der Abruf gehört NIE in eine laufende Anfrage.** Er hing früher in ``ensure``
|
||||
mitten in ``GET /products/{id}/image`` – und diese Route wird je Tabellenzeile
|
||||
einmal aufgerufen. Ein toter Bildlink kostete damit 15 Sekunden pro Zeile, bei
|
||||
jedem Aufruf aufs Neue; die ~6 Verbindungen, die ein Browser je Host offen hält,
|
||||
waren belegt, und alle übrigen Abfragen der Seite standen dahinter Schlange. Die
|
||||
Seite lud dann „nur zur Hälfte". Nachgeholt wird deshalb nur noch im
|
||||
Hintergrund, und ein Fehlschlag wird gemerkt (siehe ``nachholen_faellig``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Product, ProductImage
|
||||
@@ -28,6 +40,79 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
|
||||
TIMEOUT_SECONDS = 15.0
|
||||
|
||||
# Vorschaubilder erscheinen mit 34 CSS-Pixeln Kantenlaenge. 96 deckt auch einen
|
||||
# dreifach aufloesenden Bildschirm ab und bleibt bei ~3 KB je Bild.
|
||||
THUMB_PIXEL = 96
|
||||
THUMB_TYPE = "image/jpeg"
|
||||
|
||||
# Ein toter Bildlink darf nicht bei jedem Seitenaufruf erneut ins Netz gehen.
|
||||
# Der Merker liegt im Arbeitsspeicher: Er darf ruhig beim Neustart verfallen –
|
||||
# dann wird eben einmal erneut versucht, was genau richtig ist, wenn der Dienst
|
||||
# zwischenzeitlich wieder da ist.
|
||||
SPERRE_SEKUNDEN = 3600.0
|
||||
_gesperrt: dict[int, float] = {}
|
||||
|
||||
|
||||
def sperre_zuruecksetzen() -> None:
|
||||
"""Nur fuer Tests – der Merker ist sonst modulweit und ueberlebt sie."""
|
||||
_gesperrt.clear()
|
||||
|
||||
|
||||
def nachholen_faellig(product_id: int) -> bool:
|
||||
"""Darf fuer diesen Artikel (wieder) ins Netz gegriffen werden?"""
|
||||
bis = _gesperrt.get(product_id)
|
||||
return bis is None or time.monotonic() >= bis
|
||||
|
||||
|
||||
def vorschau(daten: bytes, content_type: str) -> bytes | None:
|
||||
"""Kleines Vorschaubild erzeugen. ``None``, wenn es nicht geht.
|
||||
|
||||
Ohne das laedt eine Liste mit 40 Zeilen bis zu 40 Handy-Fotos in voller
|
||||
Aufloesung – Dutzende Megabyte, um 34x34 Pixel je Zeile darzustellen.
|
||||
|
||||
Pillow wird erst hier importiert und jeder Fehler geschluckt: Ein Bild, das
|
||||
sich nicht verkleinern laesst, darf die Seite nicht mitreissen. Der Aufrufer
|
||||
liefert dann eben das Original aus – langsam, aber richtig.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
except ImportError: # pragma: no cover
|
||||
return None
|
||||
try:
|
||||
with Image.open(io.BytesIO(daten)) as roh:
|
||||
# draft() verkleinert JPEG schon beim Dekodieren – aus 2 MB werden
|
||||
# so Millisekunden statt eines vollen Bildaufbaus im Speicher.
|
||||
roh.draft("RGB", (THUMB_PIXEL, THUMB_PIXEL))
|
||||
# Handy-Fotos tragen ihre Ausrichtung in den EXIF-Daten. Beim neuen
|
||||
# Kodieren gehen die verloren – ohne diesen Schritt laege jedes
|
||||
# zweite Vorschaubild quer.
|
||||
klein = ImageOps.exif_transpose(roh).convert("RGB")
|
||||
klein.thumbnail((THUMB_PIXEL, THUMB_PIXEL))
|
||||
puffer = io.BytesIO()
|
||||
klein.save(puffer, format="JPEG", quality=80, optimize=True)
|
||||
except Exception: # noqa: BLE001 – s.o.
|
||||
return None
|
||||
return puffer.getvalue()
|
||||
|
||||
|
||||
def vorschau_nachziehen(db: Session, bild: ProductImage) -> bytes | None:
|
||||
"""Fehlende Vorschau einmalig erzeugen und ablegen.
|
||||
|
||||
Fuer Bilder, die vor dieser Funktion gespeichert wurden. ``updated_at``
|
||||
bleibt bewusst stehen: Das Bild hat sich nicht geaendert, und der
|
||||
Zeitstempel ist die Marke, an der Browser und App ihre Kopie erkennen.
|
||||
"""
|
||||
neu = vorschau(bild.data, bild.content_type)
|
||||
if neu is None:
|
||||
return None
|
||||
db.execute(
|
||||
update(ProductImage)
|
||||
.where(ProductImage.product_id == bild.product_id)
|
||||
.values(thumb=neu, updated_at=bild.updated_at)
|
||||
)
|
||||
db.commit()
|
||||
return neu
|
||||
|
||||
|
||||
def fetch(url: str) -> tuple[bytes, str] | None:
|
||||
"""Bild herunterladen. Gibt (Daten, Inhaltstyp) zurück oder None.
|
||||
@@ -68,27 +153,35 @@ def store(db: Session, product: Product, url: str) -> ProductImage | None:
|
||||
bild = db.get(ProductImage, product.id)
|
||||
if bild is None:
|
||||
bild = ProductImage(
|
||||
product_id=product.id, content_type=typ, data=daten, source_url=url
|
||||
product_id=product.id, content_type=typ, data=daten,
|
||||
thumb=vorschau(daten, typ), source_url=url,
|
||||
)
|
||||
db.add(bild)
|
||||
else:
|
||||
bild.content_type, bild.data, bild.source_url = typ, daten, url
|
||||
bild.thumb = vorschau(daten, typ)
|
||||
return bild
|
||||
|
||||
|
||||
def ensure(db: Session, product: Product) -> ProductImage | None:
|
||||
"""Lokale Kopie zurückgeben und bei Bedarf einmalig nachholen.
|
||||
def nachholen(product_id: int, url: str) -> None:
|
||||
"""Bild nach der Antwort im Hintergrund holen – mit eigener Sitzung.
|
||||
|
||||
So bekommen auch Artikel ein lokales Bild, die vor dieser Funktion angelegt
|
||||
wurden – ohne Wanderung über alle Datensätze. Bezahlt wird das mit einer
|
||||
einmaligen Verzögerung beim ersten Aufruf.
|
||||
Laeuft als Hintergrundaufgabe: beim Anlegen eines Artikels und beim ersten
|
||||
Abruf eines Bildes, dessen lokale Kopie fehlt. Schlaegt der Abruf fehl, wird
|
||||
der Artikel fuer eine Weile gesperrt – sonst klopft jede Tabellenzeile bei
|
||||
jedem Seitenaufruf erneut bei einem Dienst an, der nicht antwortet.
|
||||
"""
|
||||
bild = db.get(ProductImage, product.id)
|
||||
if bild is not None:
|
||||
return bild
|
||||
if not product.image_url:
|
||||
return None
|
||||
bild = store(db, product, product.image_url)
|
||||
if bild is not None:
|
||||
db.commit()
|
||||
return bild
|
||||
from ..database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
return
|
||||
if store(db, product, url) is None:
|
||||
_gesperrt[product_id] = time.monotonic() + SPERRE_SEKUNDEN
|
||||
else:
|
||||
_gesperrt.pop(product_id, None)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user