From 5104a9a96710f682d641a1e10c4977ae196ab912 Mon Sep 17 00:00:00 2001 From: Boody Date: Tue, 28 Jul 2026 21:34:03 +0300 Subject: [PATCH 1/8] feat(tts): implement TTS cache size limit and eviction policy --- .env.example | 1 + services/tts/tts_service.py | 35 +++++++++ tests/test_tts_service_enforce_cache_limit.py | 73 +++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 tests/test_tts_service_enforce_cache_limit.py diff --git a/.env.example b/.env.example index d23276eb8..4eb4695e0 100644 --- a/.env.example +++ b/.env.example @@ -189,6 +189,7 @@ SEARXNG_INSTANCE=http://localhost:8080 # ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB) # ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB) # ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB) +# ODYSSEUS_TTS_CACHE_MAX_BYTES=52428800 # TTS cache (500 MB) # ============================================================ # Host Docker access (explicit opt-in) diff --git a/services/tts/tts_service.py b/services/tts/tts_service.py index 2120d7720..e1c67d4da 100644 --- a/services/tts/tts_service.py +++ b/services/tts/tts_service.py @@ -2,6 +2,7 @@ """Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser.""" import io +import os import wave import logging import hashlib @@ -41,6 +42,11 @@ class TTSService: self.cache_dir = Path(cache_dir) self.cache_dir.mkdir(parents=True, exist_ok=True) self._kokoro = None # lazy-init + + try: + self.max_cache_bytes = int(os.getenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", 500 * 1024 * 1024)) + except ValueError: + self.max_cache_bytes = 500 * 1024 * 1024 # ── Settings ── @@ -89,6 +95,35 @@ class TTSService: ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav" (self.cache_dir / f"{key}{ext}").write_bytes(data) + self._enforce_cache_limit() + + def _enforce_cache_limit(self): + """Evicts oldest files if the cache exceeds the configured byte limit.""" + if self.max_cache_bytes <= 0: + return + + files = [f for f in self.cache_dir.glob("*.*") if f.is_file()] + total_size = sum(f.stat().st_size for f in files) + + if total_size > self.max_cache_bytes: + logger.info(f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files.") + + # Sort files by modification time (oldest first) + files.sort(key=lambda f: f.stat().st_mtime) + + # Trim down to 80% of max capacity so we aren't constantly triggering this on every new generation + target_size = self.max_cache_bytes * 0.8 + + while files and total_size > target_size: + f = files.pop(0) + try: + size = f.stat().st_size + f.unlink() + total_size -= size + except FileNotFoundError: + # File was deleted by another process + continue + def clear_cache(self): count = 0 for f in self.cache_dir.glob("*.*"): diff --git a/tests/test_tts_service_enforce_cache_limit.py b/tests/test_tts_service_enforce_cache_limit.py new file mode 100644 index 000000000..f4a31c75b --- /dev/null +++ b/tests/test_tts_service_enforce_cache_limit.py @@ -0,0 +1,73 @@ +import os +import time +from pathlib import Path +import pytest + +# Adjust the import path if your file is directly in ./services instead of ./services/tts +from services.tts.tts_service import TTSService + +def test_cache_under_limit(tmp_path, monkeypatch): + """Test that writing a file under the size limit does not trigger eviction.""" + # Set a tiny limit: 100 bytes + monkeypatch.setenv("TTS_CACHE_MAX_BYTES", "100") + + # Initialize service with pytest's temporary directory + service = TTSService(cache_dir=str(tmp_path)) + + # Write a 40-byte file (under the 100-byte limit) + service._put_cache("test_key", b"x" * 40) + + # Verify the file was written and nothing was deleted + files = list(tmp_path.glob("*.*")) + assert len(files) == 1 + assert sum(f.stat().st_size for f in files) == 40 + +def test_cache_exceeds_limit_triggers_eviction(tmp_path, monkeypatch): + """Test that exceeding the limit evicts the oldest files down to 80% capacity.""" + # Set limit to 100 bytes. 80% target capacity will be 80 bytes. + monkeypatch.setenv("TTS_CACHE_MAX_BYTES", "100") + service = TTSService(cache_dir=str(tmp_path)) + + # 1. Setup: Manually create two older files (40 bytes each) + file1 = tmp_path / "oldest.wav" + file2 = tmp_path / "middle.wav" + + file1.write_bytes(b"a" * 40) + file2.write_bytes(b"b" * 40) + + # Spoof timestamps so file1 is explicitly older than file2 + now = time.time() + os.utime(file1, (now - 100, now - 100)) # 100 seconds ago + os.utime(file2, (now - 50, now - 50)) # 50 seconds ago + + # 2. Action: Write a 3rd file using the service method (40 bytes) + # Total cache is now 120 bytes, which exceeds 100. + # It should delete oldest (file1) to drop to 80 bytes (which matches the 80% target). + service._put_cache("newest", b"c" * 40) + + # 3. Assertions + # The newest file should exist (saved as .wav because it lacks MP3 magic bytes) + newest_file = tmp_path / "newest.wav" + + assert not file1.exists(), "The oldest file should have been evicted." + assert file2.exists(), "The middle file should still exist." + assert newest_file.exists(), "The newest file should have been saved." + + # Verify the final directory size is <= 80 bytes + total_size = sum(f.stat().st_size for f in tmp_path.glob("*.*")) + assert total_size <= 80 + +def test_cache_limit_disabled(tmp_path, monkeypatch): + """Test that setting max bytes to 0 disables eviction.""" + monkeypatch.setenv("TTS_CACHE_MAX_BYTES", "0") + service = TTSService(cache_dir=str(tmp_path)) + + # Write 3 large files that would normally trigger eviction + service._put_cache("file1", b"x" * 1000) + service._put_cache("file2", b"x" * 1000) + service._put_cache("file3", b"x" * 1000) + + # Ensure nothing was deleted + files = list(tmp_path.glob("*.*")) + assert len(files) == 3 + assert sum(f.stat().st_size for f in files) == 3000 \ No newline at end of file From 98e4d8451bdcdf2f682bb39a54cccfeb0299ab9a Mon Sep 17 00:00:00 2001 From: Boody Date: Tue, 28 Jul 2026 22:00:54 +0300 Subject: [PATCH 2/8] fix(tests): update environment variable for TTS cache limit to include ODYSSEUS prefix --- tests/test_tts_service_enforce_cache_limit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_tts_service_enforce_cache_limit.py b/tests/test_tts_service_enforce_cache_limit.py index f4a31c75b..8a726557b 100644 --- a/tests/test_tts_service_enforce_cache_limit.py +++ b/tests/test_tts_service_enforce_cache_limit.py @@ -9,7 +9,7 @@ from services.tts.tts_service import TTSService def test_cache_under_limit(tmp_path, monkeypatch): """Test that writing a file under the size limit does not trigger eviction.""" # Set a tiny limit: 100 bytes - monkeypatch.setenv("TTS_CACHE_MAX_BYTES", "100") + monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "100") # Initialize service with pytest's temporary directory service = TTSService(cache_dir=str(tmp_path)) @@ -25,7 +25,7 @@ def test_cache_under_limit(tmp_path, monkeypatch): def test_cache_exceeds_limit_triggers_eviction(tmp_path, monkeypatch): """Test that exceeding the limit evicts the oldest files down to 80% capacity.""" # Set limit to 100 bytes. 80% target capacity will be 80 bytes. - monkeypatch.setenv("TTS_CACHE_MAX_BYTES", "100") + monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "100") service = TTSService(cache_dir=str(tmp_path)) # 1. Setup: Manually create two older files (40 bytes each) @@ -59,7 +59,7 @@ def test_cache_exceeds_limit_triggers_eviction(tmp_path, monkeypatch): def test_cache_limit_disabled(tmp_path, monkeypatch): """Test that setting max bytes to 0 disables eviction.""" - monkeypatch.setenv("TTS_CACHE_MAX_BYTES", "0") + monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "0") service = TTSService(cache_dir=str(tmp_path)) # Write 3 large files that would normally trigger eviction From 61c138d9e7f50eaaf73185d1249f8debe809094b Mon Sep 17 00:00:00 2001 From: Boody Date: Wed, 29 Jul 2026 12:26:09 +0300 Subject: [PATCH 3/8] fixed .env.example ODYSSEUS_TTS_CACHE_MAX_BYTES into correct 500 MBs --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 4eb4695e0..2d1be3373 100644 --- a/.env.example +++ b/.env.example @@ -189,7 +189,7 @@ SEARXNG_INSTANCE=http://localhost:8080 # ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB) # ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB) # ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB) -# ODYSSEUS_TTS_CACHE_MAX_BYTES=52428800 # TTS cache (500 MB) +# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 MB) # ============================================================ # Host Docker access (explicit opt-in) From 46905ab9b0f4a2ecf85996b23f47f678501792e4 Mon Sep 17 00:00:00 2001 From: Boody Date: Wed, 29 Jul 2026 12:32:43 +0300 Subject: [PATCH 4/8] added ODYSSEUS_TTS_CACHE_MAX_BYTES env variable to docker compose files --- docker-compose.gpu-amd.yml | 1 + docker-compose.gpu-nvidia.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 91e223e05..9699fc038 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -67,6 +67,7 @@ services: - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} + - ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index e8c2fd032..804a0a14e 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -66,6 +66,7 @@ services: - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} + - ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} From 9914651cc9f1756fd1610952af42de8848b6015d Mon Sep 17 00:00:00 2001 From: Boody Date: Wed, 29 Jul 2026 12:41:31 +0300 Subject: [PATCH 5/8] improve cache eviction logic to handle file access errors and ensure stability --- services/tts/tts_service.py | 64 ++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/services/tts/tts_service.py b/services/tts/tts_service.py index e1c67d4da..c7f787954 100644 --- a/services/tts/tts_service.py +++ b/services/tts/tts_service.py @@ -98,31 +98,49 @@ class TTSService: self._enforce_cache_limit() def _enforce_cache_limit(self): - """Evicts oldest files if the cache exceeds the configured byte limit.""" - if self.max_cache_bytes <= 0: - return + """Evicts oldest files if the cache exceeds the configured byte limit.""" + if self.max_cache_bytes <= 0: + return - files = [f for f in self.cache_dir.glob("*.*") if f.is_file()] - total_size = sum(f.stat().st_size for f in files) + try: + files = [] + total_size = 0 - if total_size > self.max_cache_bytes: - logger.info(f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files.") - - # Sort files by modification time (oldest first) - files.sort(key=lambda f: f.stat().st_mtime) - - # Trim down to 80% of max capacity so we aren't constantly triggering this on every new generation - target_size = self.max_cache_bytes * 0.8 - - while files and total_size > target_size: - f = files.pop(0) - try: - size = f.stat().st_size - f.unlink() - total_size -= size - except FileNotFoundError: - # File was deleted by another process - continue + # Safely scan files and sum sizes, ignoring files deleted mid-scan + for f in self.cache_dir.glob("*.*"): + try: + if f.is_file(): + files.append(f) + total_size += f.stat().st_size + except OSError: + continue + + if total_size > self.max_cache_bytes: + logger.info( + f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files." + ) + + # Sort files by modification time (oldest first) + try: + files.sort(key=lambda f: f.stat().st_mtime) + except OSError as e: + logger.warning(f"Failed to sort cache files by mtime: {e}") + + # Trim down to 80% of max capacity + target_size = self.max_cache_bytes * 0.8 + + while files and total_size > target_size: + f = files.pop(0) + try: + size = f.stat().st_size + f.unlink() + total_size -= size + except OSError as e: + logger.warning(f"Failed to evict cache file {f}: {e}") + continue + + except Exception as e: + logger.warning(f"Error enforcing TTS cache limit: {e}", exc_info=True) def clear_cache(self): count = 0 From d183fe545b25766badbf5ada731ccd0fe7b0c594 Mon Sep 17 00:00:00 2001 From: Boody Date: Wed, 29 Jul 2026 12:42:20 +0300 Subject: [PATCH 6/8] add test for cache eviction handling unlink errors gracefully --- tests/test_tts_service_enforce_cache_limit.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_tts_service_enforce_cache_limit.py b/tests/test_tts_service_enforce_cache_limit.py index 8a726557b..1da9d16c0 100644 --- a/tests/test_tts_service_enforce_cache_limit.py +++ b/tests/test_tts_service_enforce_cache_limit.py @@ -70,4 +70,28 @@ def test_cache_limit_disabled(tmp_path, monkeypatch): # Ensure nothing was deleted files = list(tmp_path.glob("*.*")) assert len(files) == 3 - assert sum(f.stat().st_size for f in files) == 3000 \ No newline at end of file + assert sum(f.stat().st_size for f in files) == 3000 + +def test_cache_eviction_handles_unlink_error_gracefully(tmp_path, monkeypatch): + """Test that if unlinking a file fails, _put_cache still succeeds without raising.""" + service = TTSService(cache_dir=str(tmp_path)) + service.max_cache_bytes = 50 + + # Create a file to evict + old_file = tmp_path / "old.wav" + old_file.write_bytes(b"x" * 40) + + # Monkeypatch unlink on Path objects to simulate a PermissionError / file-lock failure + def mock_unlink(self_path): + raise OSError("Permission denied / file locked") + + monkeypatch.setattr(Path, "unlink", mock_unlink) + + # Writing a new file triggers eviction which encounters the mocked unlink error + try: + service._put_cache("new_key", b"y" * 40) + except Exception as e: + pytest.fail(f"_put_cache raised an exception during failed eviction: {e}") + + # The new file should still be written successfully + assert (tmp_path / "new_key.wav").exists() \ No newline at end of file From 2e631ad8160c76c42f757471d372cffa59ac88e4 Mon Sep 17 00:00:00 2001 From: Boody Date: Wed, 29 Jul 2026 12:47:55 +0300 Subject: [PATCH 7/8] improve cache size calculation by filtering file types --- services/tts/tts_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/tts/tts_service.py b/services/tts/tts_service.py index c7f787954..dd37865a7 100644 --- a/services/tts/tts_service.py +++ b/services/tts/tts_service.py @@ -107,9 +107,9 @@ class TTSService: total_size = 0 # Safely scan files and sum sizes, ignoring files deleted mid-scan - for f in self.cache_dir.glob("*.*"): + for f in self.cache_dir.iterdir(): try: - if f.is_file(): + if f.is_file() and f.suffix.lower() in (".mp3", ".wav"): files.append(f) total_size += f.stat().st_size except OSError: From 9297bed5b9574ea5ba13039821a51d77a2afaccd Mon Sep 17 00:00:00 2001 From: Boody Date: Wed, 29 Jul 2026 12:54:55 +0300 Subject: [PATCH 8/8] add ODYSSEUS_TTS_CACHE_MAX_BYTES environment variable to docker-compose --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index b1f2c37ee..b0efb4439 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,7 @@ services: - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} + - ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}