feat: deduplicate image generation jobs

This commit is contained in:
mARTin-B78 2026-09-04 07:51:31 +02:00
parent c6ce3e4803
commit ebf7b8baa1
3 changed files with 19 additions and 2 deletions

View File

@ -23,6 +23,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Server-side Provider Credentials.** Provider keys are now masked even for same-origin Settings responses, are - **Server-side Provider Credentials.** Provider keys are now masked even for same-origin Settings responses, are
preserved when masked/empty fields are saved, and model/health probes resolve configured keys inside the server. preserved when masked/empty fields are saved, and model/health probes resolve configured keys inside the server.
Browser localStorage is no longer used as a credential store. Browser localStorage is no longer used as a credential store.
- **Image Job Deduplication.** Consistent portrait/reference generation accepts an `Idempotency-Key` and returns the
existing job for repeated submissions; completed job mappings are evicted with the job cache.
- **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL
rejection and oversized JSON request rejection. rejection and oversized JSON request rejection.
- **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary.

View File

@ -51,8 +51,8 @@ the first block before investing in architecture or polish.
- [ ] **M — Make profile portraits and character sheets use one canonical identity.** - [ ] **M — Make profile portraits and character sheets use one canonical identity.**
Share age, hair, eyes, face, build, skin tone, marks and era; version the prompt and Share age, hair, eyes, face, build, skin tone, marks and era; version the prompt and
detect stale or inconsistent images. detect stale or inconsistent images.
- [ ] **M — Add bounded duplicate-generation protection.** Ensure double-clicks and - [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference jobs now deduplicate
retried requests cannot create duplicate voice, image or casting jobs. repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs.
- [ ] **M — Improve large-table performance.** Virtualize/paginate character and voice - [ ] **M — Improve large-table performance.** Virtualize/paginate character and voice
tables, update only affected rows, and disable preloading on low-memory devices. tables, update only affected rows, and disable preloading on low-memory devices.
- [ ] **M — Add explicit cache invalidation and eviction.** Cache `/api/voices` safely - [ ] **M — Add explicit cache invalidation and eviction.** Cache `/api/voices` safely

View File

@ -60,6 +60,7 @@ _attribution_llm_lock = threading.Lock()
# entries are pruned an hour after completion so this can't grow unbounded. # entries are pruned an hour after completion so this can't grow unbounded.
_consistent_image_jobs: dict[str, dict] = {} _consistent_image_jobs: dict[str, dict] = {}
_consistent_image_jobs_lock = threading.Lock() _consistent_image_jobs_lock = threading.Lock()
_consistent_image_idempotency: dict[str, str] = {}
def _cij_new(job_id: str, name: str) -> None: def _cij_new(job_id: str, name: str) -> None:
@ -72,6 +73,9 @@ def _cij_new(job_id: str, name: str) -> None:
cutoff = now - 3600 cutoff = now - 3600
for stale_id in [j for j, v in _consistent_image_jobs.items() if v.get("done") and v.get("updated", 0) < cutoff]: for stale_id in [j for j, v in _consistent_image_jobs.items() if v.get("done") and v.get("updated", 0) < cutoff]:
_consistent_image_jobs.pop(stale_id, None) _consistent_image_jobs.pop(stale_id, None)
for key, mapped_id in list(_consistent_image_idempotency.items()):
if mapped_id == stale_id:
_consistent_image_idempotency.pop(key, None)
def _cij_set(job_id: str, **fields) -> None: def _cij_set(job_id: str, **fields) -> None:
@ -3014,6 +3018,14 @@ async def character_generate_consistent_images(request: Request):
the face consistent across both deliverables. the face consistent across both deliverables.
""" """
data = await request.json() data = await request.json()
idempotency_key = str(request.headers.get("idempotency-key") or "").strip()
if len(idempotency_key) > 200:
raise HTTPException(400, "Idempotency-Key is too long")
if idempotency_key:
with _consistent_image_jobs_lock:
previous_job_id = _consistent_image_idempotency.get(idempotency_key)
if previous_job_id and previous_job_id in _consistent_image_jobs:
return {"job_id": previous_job_id, "deduplicated": True}
name = str(data.get("name") or "").strip() name = str(data.get("name") or "").strip()
book = str(data.get("book") or "").strip() book = str(data.get("book") or "").strip()
sheet: dict = data.get("sheet") or {} sheet: dict = data.get("sheet") or {}
@ -3068,6 +3080,9 @@ async def character_generate_consistent_images(request: Request):
# fetch that a page reload would then throw away mid-render. # fetch that a page reload would then throw away mid-render.
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
_cij_new(job_id, name or "Character") _cij_new(job_id, name or "Character")
if idempotency_key:
with _consistent_image_jobs_lock:
_consistent_image_idempotency[idempotency_key] = job_id
def _worker() -> None: def _worker() -> None:
try: try: