feat: deduplicate casting requests
This commit is contained in:
parent
2b99dc8ccc
commit
fc0361bc71
@ -53,6 +53,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
limits with oldest-first eviction, and oversized individual clips are rejected.
|
limits with oldest-first eviction, and oversized individual clips are rejected.
|
||||||
- **Voice Design Idempotency.** Repeated voice-design submissions with the same `Idempotency-Key` now reuse the
|
- **Voice Design Idempotency.** Repeated voice-design submissions with the same `Idempotency-Key` now reuse the
|
||||||
original temporary result; reusing a key for different request data is rejected.
|
original temporary result; reusing a key for different request data is rejected.
|
||||||
|
- **Casting Idempotency.** Character-to-voice matching now applies the same bounded, expiring idempotency contract,
|
||||||
|
preventing repeated clicks from issuing duplicate LLM casting requests.
|
||||||
- **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.**
|
||||||
Shared structured prompts now version consistent pairs as v3 and fingerprint canonical appearance fields; add
|
Shared structured prompts now version consistent pairs as v3 and fingerprint canonical appearance fields; add
|
||||||
cross-record validation so mismatched profile/reference files are detected even when both are current-version.
|
cross-record validation so mismatched profile/reference files are detected even when both are current-version.
|
||||||
- [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference and voice-design jobs now
|
- [x] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference, voice-design, and casting
|
||||||
deduplicate repeated requests with a bounded, expiring `Idempotency-Key` contract; casting jobs remain.
|
jobs now deduplicate repeated requests with bounded, expiring `Idempotency-Key` contracts.
|
||||||
- [ ] **M — Improve large-table performance.** Character productions and the flat voice library now paginate at 50
|
- [ ] **M — Improve large-table performance.** Character productions and the flat voice library now paginate at 50
|
||||||
visible rows; updating only affected rows and disabling preloading on low-memory devices remain.
|
visible rows; updating only affected rows and disabling preloading on low-memory devices remain.
|
||||||
- [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses and persisted synthesized
|
- [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses and persisted synthesized
|
||||||
|
|||||||
@ -62,6 +62,8 @@ _consistent_image_jobs: dict[str, dict] = {}
|
|||||||
_consistent_image_jobs_lock = threading.Lock()
|
_consistent_image_jobs_lock = threading.Lock()
|
||||||
_consistent_image_idempotency: dict[str, str] = {}
|
_consistent_image_idempotency: dict[str, str] = {}
|
||||||
_consistent_image_slots = threading.BoundedSemaphore(2)
|
_consistent_image_slots = threading.BoundedSemaphore(2)
|
||||||
|
_CAST_IDEMPOTENCY: dict[str, tuple[str, dict, float]] = {}
|
||||||
|
_CAST_IDEMPOTENCY_LOCK = threading.Lock()
|
||||||
_CHARACTER_VISUAL_PROMPT_VERSION = "v3"
|
_CHARACTER_VISUAL_PROMPT_VERSION = "v3"
|
||||||
_CHARACTER_IDENTITY_FIELDS = (
|
_CHARACTER_IDENTITY_FIELDS = (
|
||||||
"age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species",
|
"age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species",
|
||||||
@ -536,6 +538,21 @@ async def match_characters_voices(request: Request):
|
|||||||
Returns: {"assignments": [{name, voice_id, reason}]}
|
Returns: {"assignments": [{name, voice_id, reason}]}
|
||||||
"""
|
"""
|
||||||
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")
|
||||||
|
request_fingerprint = hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest()
|
||||||
|
if idempotency_key:
|
||||||
|
now = time.time()
|
||||||
|
with _CAST_IDEMPOTENCY_LOCK:
|
||||||
|
for old_key, (_, _, created) in list(_CAST_IDEMPOTENCY.items()):
|
||||||
|
if now - created > 1800:
|
||||||
|
_CAST_IDEMPOTENCY.pop(old_key, None)
|
||||||
|
previous = _CAST_IDEMPOTENCY.get(idempotency_key)
|
||||||
|
if previous:
|
||||||
|
if previous[0] != request_fingerprint:
|
||||||
|
raise HTTPException(409, "Idempotency-Key was already used for a different request")
|
||||||
|
return {**previous[1], "deduplicated": True}
|
||||||
script: str = (data.get("script") or "").strip()
|
script: str = (data.get("script") or "").strip()
|
||||||
names: list = data.get("names") or []
|
names: list = data.get("names") or []
|
||||||
voices: list = data.get("voices") or []
|
voices: list = data.get("voices") or []
|
||||||
@ -638,7 +655,14 @@ async def match_characters_voices(request: Request):
|
|||||||
continue
|
continue
|
||||||
used_ids.add(vid)
|
used_ids.add(vid)
|
||||||
clean.append(a)
|
clean.append(a)
|
||||||
return {"assignments": clean}
|
result = {"assignments": clean}
|
||||||
|
if idempotency_key:
|
||||||
|
with _CAST_IDEMPOTENCY_LOCK:
|
||||||
|
_CAST_IDEMPOTENCY[idempotency_key] = (request_fingerprint, result, time.time())
|
||||||
|
if len(_CAST_IDEMPOTENCY) > 256:
|
||||||
|
oldest = min(_CAST_IDEMPOTENCY, key=lambda k: _CAST_IDEMPOTENCY[k][2])
|
||||||
|
_CAST_IDEMPOTENCY.pop(oldest, None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _extract_json_block(text: str) -> str:
|
def _extract_json_block(text: str) -> str:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user