Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/semantic-cache-py/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/semantic-cache-py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
154 changes: 152 additions & 2 deletions packages/semantic-cache-py/tests/test_config_refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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)
13 changes: 13 additions & 0 deletions packages/semantic-cache/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/semantic-cache/examples/monitor-proposals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...');
Expand Down
2 changes: 1 addition & 1 deletion packages/semantic-cache/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 17 additions & 1 deletion packages/semantic-cache/src/SemanticCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
private readonly uncertaintyBand: number;
private readonly telemetry: Telemetry;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading