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

@@ -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)