feat: deduplicate image generation jobs
This commit is contained in:
parent
c6ce3e4803
commit
ebf7b8baa1
@ -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.
|
||||||
|
|||||||
4
TODO.md
4
TODO.md
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user