perf: bound fish audio response cache

This commit is contained in:
mARTin-B78 2026-09-04 07:53:17 +02:00
parent 885fa9b065
commit 4a0101f4a4
4 changed files with 43 additions and 2 deletions

View File

@ -27,6 +27,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
existing job for repeated submissions; completed job mappings are evicted with the job cache.
- **Image Concurrency Bound.** Consistent image generation is limited to two active GPU jobs; additional requests
remain observable as queued jobs and fail clearly if no slot becomes available within 15 minutes.
- **Fish.audio Cache Eviction.** Offline search responses are now evicted by configurable file-count and byte-size
limits, preventing an unbounded cache from consuming the configuration volume.
- **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL
rejection and oversized JSON request rejection.
- **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary.

View File

@ -55,8 +55,8 @@ the first block before investing in architecture or polish.
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
tables, update only affected rows, and disable preloading on low-memory devices.
- [ ] **M — Add explicit cache invalidation and eviction.** Cache `/api/voices` safely
and add LRU cleanup for audio/image entries and object URLs.
- [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses now have bounded eviction;
add equivalent LRU cleanup for audio/image entries and object URLs.
- [ ] **M — Add constrained-device validation.** Test with Chrome CPU/memory throttling,
a 4 GB VM/container, and measure browser, Docker and temporary-disk usage.
- [ ] **L — Add background jobs and bounded queues.** Give voice design, transcription,

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import glob
import json
import os
import re
import uuid
from datetime import datetime, timezone
@ -29,6 +30,26 @@ from core.voice_index import upsert_voice_in_index
router = APIRouter()
_FISH_CACHE_MAX_FILES = int(os.environ.get("FISHAUDIO_CACHE_MAX_FILES", "200"))
_FISH_CACHE_MAX_BYTES = int(os.environ.get("FISHAUDIO_CACHE_MAX_MB", "256")) * 1024 * 1024
def _evict_fish_cache(cache_dir: Path) -> None:
"""Keep the offline search cache bounded by count and total bytes."""
try:
files = [p for p in cache_dir.glob("*.json") if p.is_file()]
total = sum(p.stat().st_size for p in files)
files.sort(key=lambda p: p.stat().st_mtime)
while files and (len(files) > _FISH_CACHE_MAX_FILES or total > _FISH_CACHE_MAX_BYTES):
old = files.pop(0)
size = old.stat().st_size
old.unlink()
total -= size
except OSError:
# Cache maintenance must never turn a successful provider response
# into an API failure.
return
async def _prepare_imported_voice(audio: Path, settings: dict) -> None:
"""Make a downloaded reference usable for Qwen clone playback.
@ -798,6 +819,7 @@ async def fishaudio_voices(request: Request):
try:
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(result), encoding="utf-8")
_evict_fish_cache(cache_dir)
except Exception:
pass
return result

View File

@ -78,6 +78,23 @@ class SecurityValidationTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(_load_settings().get("llm_api_key"), "keep-this-secret")
def test_fish_cache_eviction_is_bounded(self):
from pathlib import Path
from tempfile import TemporaryDirectory
import routes.sources as sources
with TemporaryDirectory() as directory:
cache = Path(directory)
old_count, old_bytes = sources._FISH_CACHE_MAX_FILES, sources._FISH_CACHE_MAX_BYTES
try:
sources._FISH_CACHE_MAX_FILES = 2
sources._FISH_CACHE_MAX_BYTES = 1024
for index in range(4):
(cache / f"{index}.json").write_text("{}")
sources._evict_fish_cache(cache)
self.assertLessEqual(len(list(cache.glob("*.json"))), 2)
finally:
sources._FISH_CACHE_MAX_FILES, sources._FISH_CACHE_MAX_BYTES = old_count, old_bytes
if __name__ == "__main__":
unittest.main()