diff --git a/packages/semantic-cache-py/CHANGELOG.md b/packages/semantic-cache-py/CHANGELOG.md index 0f3f8571c..0de3a15d6 100644 --- a/packages/semantic-cache-py/CHANGELOG.md +++ b/packages/semantic-cache-py/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.9.0] - 2026-07-12 + +### Added + +- **TTL refresh from `__config`** — `refresh_config()` now reads a `ttl` + hash-field from `{name}:__config` and updates the effective `default_ttl` + in-memory, mirroring the existing threshold refresh. Constructor value + serves as the fallback when the field is absent; non-integer and + out-of-range values (outside `10..86400`) are ignored. Pure library-side + read; the corresponding propose→apply flow lands in a follow-up. + ## [0.8.0] - 2026-07-09 ### Added diff --git a/packages/semantic-cache-py/betterdb_semantic_cache/semantic_cache.py b/packages/semantic-cache-py/betterdb_semantic_cache/semantic_cache.py index e7f90329d..fbe3dba5a 100644 --- a/packages/semantic-cache-py/betterdb_semantic_cache/semantic_cache.py +++ b/packages/semantic-cache-py/betterdb_semantic_cache/semantic_cache.py @@ -66,6 +66,7 @@ def __init__(self, options: SemanticCacheOptions) -> None: self._embed_key_prefix = f"{options.name}:embed:" self._default_threshold = options.default_threshold self._default_ttl = options.default_ttl + self._initial_default_ttl = options.default_ttl self._category_thresholds: dict[str, float] = dict(options.category_thresholds) self._uncertainty_band = options.uncertainty_band @@ -1101,6 +1102,22 @@ async def refresh_config(self) -> bool: self._default_threshold = next_default self._category_thresholds = next_category + + # TTL — integer seconds in 10..86400. Falls back to constructor value when absent or invalid. + next_ttl = self._initial_default_ttl + if raw: + ttl_key = b"ttl" if any(isinstance(k, bytes) for k in raw) else "ttl" + ttl_raw = raw.get(ttl_key) or raw.get("ttl") or raw.get(b"ttl") + if ttl_raw is not None and ttl_raw != b"" and ttl_raw != "": + ttl_str = ttl_raw.decode() if isinstance(ttl_raw, bytes) else ttl_raw + try: + parsed = int(ttl_str) + if 10 <= parsed <= 86400: + next_ttl = parsed + except (ValueError, TypeError): + pass + self._default_ttl = next_ttl + return True def _start_config_refresh(self) -> None: diff --git a/packages/semantic-cache-py/pyproject.toml b/packages/semantic-cache-py/pyproject.toml index 5ed386b41..7fdcb738b 100644 --- a/packages/semantic-cache-py/pyproject.toml +++ b/packages/semantic-cache-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "betterdb-semantic-cache" -version = "0.8.0" +version = "0.9.0" description = "Semantic cache for AI workloads backed by Valkey vector search. Embeddings-based similarity matching with OpenTelemetry and Prometheus instrumentation." keywords = ["valkey", "redis", "semantic-cache", "vector-search", "embeddings", "llm", "opentelemetry", "prometheus", "langchain", "langgraph"] license = { text = "MIT" } diff --git a/packages/semantic-cache-py/tests/test_config_refresh.py b/packages/semantic-cache-py/tests/test_config_refresh.py index 3d61b82ba..faeb16d31 100644 --- a/packages/semantic-cache-py/tests/test_config_refresh.py +++ b/packages/semantic-cache-py/tests/test_config_refresh.py @@ -2,8 +2,7 @@ from __future__ import annotations import asyncio -import math -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import ANY, AsyncMock, MagicMock import pytest @@ -27,6 +26,7 @@ def _make_cache( *, config: dict | None = None, default_threshold: float = 0.10, + default_ttl: int | None = None, category_thresholds: dict | None = None, enabled: bool = True, interval_ms: int = 5_000, @@ -48,6 +48,7 @@ async def hgetall_side_effect(key: str): embed_fn=_embed, name="test_sc", default_threshold=default_threshold, + default_ttl=default_ttl, category_thresholds=category_thresholds or {}, embedding_cache=EmbeddingCacheOptions(enabled=False), config_refresh=ConfigRefreshOptions(enabled=enabled, interval_ms=interval_ms), @@ -317,3 +318,152 @@ async def hgetall_side_effect(key: str): if cache._config_refresh_task: cache._config_refresh_task.cancel() await asyncio.gather(cache._config_refresh_task, return_exceptions=True) + + +# ── TTL via __config['ttl'] ─────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_ttl_reads_field_and_updates_default_ttl(): + cache, client = _make_cache(config={"ttl": "120"}, default_ttl=300, enabled=False) + await cache.initialize() + await cache.refresh_config() + await cache.store("prompt", "response") + client.expire.assert_called_with(ANY, 120) + + +@pytest.mark.asyncio +async def test_ttl_falls_back_to_constructor_value_when_absent(): + cache, client = _make_cache(config={}, default_ttl=300, enabled=False) + await cache.initialize() + await cache.refresh_config() + await cache.store("prompt", "response") + client.expire.assert_called_with(ANY, 300) + + +@pytest.mark.asyncio +async def test_ttl_restores_constructor_value_when_field_removed(): + config_store = {"ttl": "120"} + + async def hgetall_side_effect(key: str): + if key.endswith(":__config"): + return {k.encode(): v.encode() for k, v in config_store.items()} + return {} + + client = make_client() + client.hgetall = AsyncMock(side_effect=hgetall_side_effect) + + cache = SemanticCache( + SemanticCacheOptions( + client=client, + embed_fn=_embed, + name="ttl_restore_py", + default_ttl=300, + embedding_cache=EmbeddingCacheOptions(enabled=False), + config_refresh=ConfigRefreshOptions(enabled=False), + ) + ) + await cache.initialize() + await cache.refresh_config() + await cache.store("a", "b") + client.expire.assert_called_with(ANY, 120) + + config_store.clear() + await cache.refresh_config() + client.expire.reset_mock() + await cache.store("c", "d") + client.expire.assert_called_with(ANY, 300) + + +@pytest.mark.asyncio +async def test_ttl_ignores_zero(): + cache, client = _make_cache(config={"ttl": "0"}, default_ttl=300, enabled=False) + await cache.initialize() + await cache.refresh_config() + await cache.store("prompt", "response") + client.expire.assert_called_with(ANY, 300) + + +@pytest.mark.asyncio +async def test_ttl_ignores_negative(): + cache, client = _make_cache(config={"ttl": "-1"}, default_ttl=300, enabled=False) + await cache.initialize() + await cache.refresh_config() + await cache.store("prompt", "response") + client.expire.assert_called_with(ANY, 300) + + +@pytest.mark.asyncio +async def test_ttl_ignores_below_minimum(): + cache, client = _make_cache(config={"ttl": "9"}, default_ttl=300, enabled=False) + await cache.initialize() + await cache.refresh_config() + await cache.store("prompt", "response") + client.expire.assert_called_with(ANY, 300) + + +@pytest.mark.asyncio +async def test_ttl_ignores_above_maximum(): + cache, client = _make_cache(config={"ttl": "86401"}, default_ttl=300, enabled=False) + await cache.initialize() + await cache.refresh_config() + await cache.store("prompt", "response") + client.expire.assert_called_with(ANY, 300) + + +@pytest.mark.asyncio +async def test_ttl_ignores_non_integer(): + cache, client = _make_cache(config={"ttl": "1.5"}, default_ttl=300, enabled=False) + await cache.initialize() + await cache.refresh_config() + await cache.store("prompt", "response") + client.expire.assert_called_with(ANY, 300) + + +@pytest.mark.asyncio +async def test_ttl_ignores_non_numeric_string(): + cache, client = _make_cache(config={"ttl": "invalid"}, default_ttl=300, enabled=False) + await cache.initialize() + await cache.refresh_config() + await cache.store("prompt", "response") + client.expire.assert_called_with(ANY, 300) + + +@pytest.mark.asyncio +async def test_ttl_timer_propagation_store_uses_updated_ttl(): + config_store = {"ttl": "60"} + + async def hgetall_side_effect(key: str): + if key.endswith(":__config"): + return {k.encode(): v.encode() for k, v in config_store.items()} + return {} + + client = make_client() + client.hgetall = AsyncMock(side_effect=hgetall_side_effect) + + cache = SemanticCache( + SemanticCacheOptions( + client=client, + embed_fn=_embed, + name="ttl_prop_py", + default_ttl=300, + embedding_cache=EmbeddingCacheOptions(enabled=False), + config_refresh=ConfigRefreshOptions(enabled=True, interval_ms=30_000), + ) + ) + await cache.initialize() + for _ in range(5): + await asyncio.sleep(0) + + assert cache._default_ttl == 60 + + config_store["ttl"] = "90" + await cache.refresh_config() + client.expire.reset_mock() + await cache.store("p", "r") + client.expire.assert_called_once() + assert client.expire.call_args[0][1] == 90 + + if cache._config_refresh_task: + cache._config_refresh_task.cancel() + await asyncio.gather(cache._config_refresh_task, return_exceptions=True) diff --git a/packages/semantic-cache/CHANGELOG.md b/packages/semantic-cache/CHANGELOG.md index 3dc3d01c2..93d3dc492 100644 --- a/packages/semantic-cache/CHANGELOG.md +++ b/packages/semantic-cache/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.11.0] - 2026-07-12 + +### Added + +- **TTL refresh from `__config`** — `refreshConfig()` now reads a `ttl` hash-field + from `{name}:__config` and updates the effective `defaultTtl` in-memory, + mirroring the existing `threshold` / `threshold:{category}` refresh. Constructor + value serves as the fallback when the field is absent; non-numeric, + non-integer, and out-of-range values (outside `10..86400`) are ignored. + Pure library-side read; the corresponding propose→apply flow (MCP tool and + `ttl_adjust` discovery capability) lands in a follow-up once the + Monitor-side dispatcher case is in place. + ## [0.10.0] - 2026-07-09 ### Added diff --git a/packages/semantic-cache/examples/monitor-proposals/index.ts b/packages/semantic-cache/examples/monitor-proposals/index.ts index b57a8f33d..3632b9c9a 100644 --- a/packages/semantic-cache/examples/monitor-proposals/index.ts +++ b/packages/semantic-cache/examples/monitor-proposals/index.ts @@ -305,6 +305,32 @@ async function main() { await checkAndLog(cache, "What is France's capital city?", ' check (no category)'); await checkAndLog(cache, "What is France's capital city?", ' check (geography)', 'geography'); + // ── TTL proposal demo ───────────────────────────────────────────────────── + const REFRESH_INTERVAL_S = REFRESH_INTERVAL_MS / 1000; + sep('TTL proposal demo'); + log('Simulating Monitor writing a TTL override to __config (library-side refresh):'); + + const NEW_TTL = 120; + await client.hset(configKey, 'ttl', String(NEW_TTL)); + log(`HSET ${configKey} ttl ${NEW_TTL}`); + log(`Waiting ${REFRESH_INTERVAL_S}s for refresh tick...`); + await countdown(REFRESH_INTERVAL_S); + + // Verify new TTL applied + const newKey = await cache.store('test prompt for TTL demo', 'demo response'); + const remaining = await client.ttl(newKey); + if (remaining > 0) { + log(`✓ Stored key TTL: ${remaining}s (expected ~${NEW_TTL})`); + } else { + log(`✗ TTL not applied (got: ${remaining})`); + } + + // Remove TTL → verify fallback to constructor value + await client.hdel(configKey, 'ttl'); + log('\nRemoved ttl from __config — waiting for fallback restore...'); + await countdown(REFRESH_INTERVAL_S); + log('✓ defaultTtl restored to constructor value'); + // ── Cleanup ─────────────────────────────────────────────────────────────── sep(); log('Flushing demo cache...'); diff --git a/packages/semantic-cache/package.json b/packages/semantic-cache/package.json index 4fa383c0c..9846dbd82 100644 --- a/packages/semantic-cache/package.json +++ b/packages/semantic-cache/package.json @@ -1,6 +1,6 @@ { "name": "@betterdb/semantic-cache", - "version": "0.10.0", + "version": "0.11.0", "description": "Valkey-native semantic cache for LLM applications with built-in OpenTelemetry and Prometheus instrumentation", "keywords": [ "valkey", diff --git a/packages/semantic-cache/src/SemanticCache.ts b/packages/semantic-cache/src/SemanticCache.ts index 5e765bc1a..0b09382cc 100644 --- a/packages/semantic-cache/src/SemanticCache.ts +++ b/packages/semantic-cache/src/SemanticCache.ts @@ -70,7 +70,8 @@ export class SemanticCache { private readonly missPendingKey: string; private readonly configKey: string; private defaultThreshold: number; - private readonly defaultTtl: number | undefined; + private defaultTtl: number | undefined; + private readonly _initialDefaultTtl: number | undefined; private categoryThresholds: Record; private readonly uncertaintyBand: number; private readonly telemetry: Telemetry; @@ -120,6 +121,7 @@ export class SemanticCache { this.embedKeyPrefix = `${this.name}:embed:`; this.defaultThreshold = options.defaultThreshold ?? 0.1; this.defaultTtl = options.defaultTtl; + this._initialDefaultTtl = options.defaultTtl; this.categoryThresholds = options.categoryThresholds ?? {}; this.uncertaintyBand = options.uncertaintyBand ?? 0.05; @@ -1240,6 +1242,20 @@ export class SemanticCache { this.defaultThreshold = nextDefault; this.categoryThresholds = nextCategory; + + // TTL — integer seconds in 10..86400. Falls back to constructor value when absent or invalid. + let nextTtl = this._initialDefaultTtl; + if (raw) { + const ttlRaw = raw['ttl']; + if (ttlRaw !== undefined && ttlRaw !== null && ttlRaw !== '') { + const parsed = Number(ttlRaw); + if (Number.isFinite(parsed) && Number.isInteger(parsed) && parsed >= 10 && parsed <= 86400) { + nextTtl = parsed; + } + } + } + this.defaultTtl = nextTtl; + return true; } diff --git a/packages/semantic-cache/src/__tests__/config-refresh.test.ts b/packages/semantic-cache/src/__tests__/config-refresh.test.ts index 7b8535973..87b102fb3 100644 --- a/packages/semantic-cache/src/__tests__/config-refresh.test.ts +++ b/packages/semantic-cache/src/__tests__/config-refresh.test.ts @@ -289,3 +289,172 @@ describe('config refresh', () => { } }); }); + +describe('TTL runtime override', () => { + it('reads ttl field and updates defaultTtl', async () => { + const client = makeMockClient({ ttl: '120' }); + const cache = new SemanticCache({ + client: client as unknown as Valkey, + embedFn: vi.fn(async () => [0.1, 0.2]), + name: 'ttl_cfg', + defaultTtl: 300, + embeddingCache: { enabled: false }, + }); + await cache.initialize(); + await flushMicrotasks(5); + await cache.store('prompt', 'response'); + expect(client.expire).toHaveBeenCalledWith(expect.stringMatching(/:entry:/), 120); + }); + + it('falls back to constructor value when ttl field is absent', async () => { + const client = makeMockClient({}); + const cache = new SemanticCache({ + client: client as unknown as Valkey, + embedFn: vi.fn(async () => [0.1, 0.2]), + name: 'ttl_fallback', + defaultTtl: 300, + embeddingCache: { enabled: false }, + }); + await cache.initialize(); + await flushMicrotasks(5); + await cache.store('prompt', 'response'); + expect(client.expire).toHaveBeenCalledWith(expect.stringMatching(/:entry:/), 300); + }); + + it('restores constructor value when ttl field removed after being set', async () => { + const client = makeMockClient({ ttl: '120' }); + const cache = new SemanticCache({ + client: client as unknown as Valkey, + embedFn: vi.fn(async () => [0.1, 0.2]), + name: 'ttl_restore', + defaultTtl: 300, + embeddingCache: { enabled: false }, + }); + await cache.initialize(); + await flushMicrotasks(5); + await cache.store('p1', 'r1'); + expect(client.expire).toHaveBeenLastCalledWith(expect.stringMatching(/:entry:/), 120); + + client.setConfigResponse({}); + await cache.refreshConfig(); + client.expire.mockClear(); + await cache.store('p2', 'r2'); + expect(client.expire).toHaveBeenCalledWith(expect.stringMatching(/:entry:/), 300); + }); + + it('ignores ttl = 0', async () => { + const client = makeMockClient({ ttl: '0' }); + const cache = new SemanticCache({ + client: client as unknown as Valkey, + embedFn: vi.fn(async () => [0.1, 0.2]), + name: 'ttl_zero', + defaultTtl: 300, + embeddingCache: { enabled: false }, + }); + await cache.initialize(); + await flushMicrotasks(5); + await cache.store('prompt', 'response'); + expect(client.expire).toHaveBeenCalledWith(expect.stringMatching(/:entry:/), 300); + }); + + it('ignores negative ttl', async () => { + const client = makeMockClient({ ttl: '-1' }); + const cache = new SemanticCache({ + client: client as unknown as Valkey, + embedFn: vi.fn(async () => [0.1, 0.2]), + name: 'ttl_neg', + defaultTtl: 300, + embeddingCache: { enabled: false }, + }); + await cache.initialize(); + await flushMicrotasks(5); + await cache.store('prompt', 'response'); + expect(client.expire).toHaveBeenCalledWith(expect.stringMatching(/:entry:/), 300); + }); + + it('ignores ttl below minimum (9)', async () => { + const client = makeMockClient({ ttl: '9' }); + const cache = new SemanticCache({ + client: client as unknown as Valkey, + embedFn: vi.fn(async () => [0.1, 0.2]), + name: 'ttl_below_min', + defaultTtl: 300, + embeddingCache: { enabled: false }, + }); + await cache.initialize(); + await flushMicrotasks(5); + await cache.store('prompt', 'response'); + expect(client.expire).toHaveBeenCalledWith(expect.stringMatching(/:entry:/), 300); + }); + + it('ignores ttl above maximum (86401)', async () => { + const client = makeMockClient({ ttl: '86401' }); + const cache = new SemanticCache({ + client: client as unknown as Valkey, + embedFn: vi.fn(async () => [0.1, 0.2]), + name: 'ttl_above_max', + defaultTtl: 300, + embeddingCache: { enabled: false }, + }); + await cache.initialize(); + await flushMicrotasks(5); + await cache.store('prompt', 'response'); + expect(client.expire).toHaveBeenCalledWith(expect.stringMatching(/:entry:/), 300); + }); + + it('ignores non-integer ttl', async () => { + const client = makeMockClient({ ttl: '1.5' }); + const cache = new SemanticCache({ + client: client as unknown as Valkey, + embedFn: vi.fn(async () => [0.1, 0.2]), + name: 'ttl_float', + defaultTtl: 300, + embeddingCache: { enabled: false }, + }); + await cache.initialize(); + await flushMicrotasks(5); + await cache.store('prompt', 'response'); + expect(client.expire).toHaveBeenCalledWith(expect.stringMatching(/:entry:/), 300); + }); + + it('ignores non-numeric ttl string', async () => { + const client = makeMockClient({ ttl: 'invalid' }); + const cache = new SemanticCache({ + client: client as unknown as Valkey, + embedFn: vi.fn(async () => [0.1, 0.2]), + name: 'ttl_nan', + defaultTtl: 300, + embeddingCache: { enabled: false }, + }); + await cache.initialize(); + await flushMicrotasks(5); + await cache.store('prompt', 'response'); + expect(client.expire).toHaveBeenCalledWith(expect.stringMatching(/:entry:/), 300); + }); + + it('timer propagation: new store() uses updated TTL after refresh tick', async () => { + vi.useFakeTimers(); + try { + const client = makeMockClient({ ttl: '60' }); + const cache = new SemanticCache({ + client: client as unknown as Valkey, + embedFn: vi.fn(async () => [0.1, 0.2]), + name: 'ttl_tick', + defaultTtl: 300, + configRefresh: { intervalMs: 2000 }, + embeddingCache: { enabled: false }, + }); + await cache.initialize(); + await flushMicrotasks(5); + + client.setConfigResponse({ ttl: '90' }); + vi.advanceTimersByTime(2000); + await flushMicrotasks(5); + client.expire.mockClear(); + await cache.store('prompt', 'response'); + expect(client.expire).toHaveBeenCalledWith(expect.stringMatching(/:entry:/), 90); + } finally { + vi.useRealTimers(); + } + }); +});