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. 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 - **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. 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 - **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

@ -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. 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.** Fish.audio search responses now have bounded eviction;
and add LRU cleanup for audio/image entries and object URLs. add equivalent LRU cleanup for audio/image entries and object URLs.
- [ ] **M — Add constrained-device validation.** Test with Chrome CPU/memory throttling, - [ ] **M — Add constrained-device validation.** Test with Chrome CPU/memory throttling,
a 4 GB VM/container, and measure browser, Docker and temporary-disk usage. a 4 GB VM/container, and measure browser, Docker and temporary-disk usage.
- [ ] **L — Add background jobs and bounded queues.** Give voice design, transcription, - [ ] **L — Add background jobs and bounded queues.** Give voice design, transcription,

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import glob import glob
import json import json
import os
import re import re
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@ -29,6 +30,26 @@ from core.voice_index import upsert_voice_in_index
router = APIRouter() 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: async def _prepare_imported_voice(audio: Path, settings: dict) -> None:
"""Make a downloaded reference usable for Qwen clone playback. """Make a downloaded reference usable for Qwen clone playback.
@ -798,6 +819,7 @@ async def fishaudio_voices(request: Request):
try: try:
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(result), encoding="utf-8") cache_file.write_text(json.dumps(result), encoding="utf-8")
_evict_fish_cache(cache_dir)
except Exception: except Exception:
pass pass
return result return result

View File

@ -78,6 +78,23 @@ class SecurityValidationTests(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(_load_settings().get("llm_api_key"), "keep-this-secret") 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__": if __name__ == "__main__":
unittest.main() unittest.main()