Skip to content

feat(cache): expose per-cache usage counters as CacheStats - #814

Open
paoValle wants to merge 1 commit into
redis:mainfrom
paoValle:feat/semantic-cache-stats
Open

paoValle wants to merge 1 commit into
redis:mainfrom
paoValle:feat/semantic-cache-stats

Conversation

@paoValle

@paoValle paoValle commented Nov 2, 2026 •

Copy link
Copy Markdown

Motivation

A semantic cache saves latency and money, but RedisVL gives no built-in way to see whether it is working: the index statistics it exposes say how many entries exist, not how many checks hit, how many missed, or how long a lookup took. Today every deployment writes its own counters around check()/store(). Closes #684.

Changes

A CacheStats snapshot on every cache

BaseLLMCache.stats returns a frozen CacheStats with checks, hits, misses, errors, stores, lookup_seconds, and the derived hit_rate and avg_lookup_seconds. Absent from hits + misses + errors is impossible by construction: a check that raises increments checks and errors and is never counted as a miss.

cache.check(prompt="What is the capital of France?")
cache.store(prompt="What is the capital of Italy?", response="Rome")
cache.stats.hit_rate        # 0.0
cache.stats.avg_lookup_seconds

Counting in decorators, applied once per method

The counters live in redisvl/extensions/cache/llm/stats.py as observe_check/observe_acheck/count_store/count_astore, applied to check, acheck, store and astore on SemanticCache and LangCacheSemanticCache. functools.wraps keeps the public signatures, docstrings and iscoroutinefunction intact, and there is a test that says so.

Per process, per object, no content

Nothing is written to Redis, so two workers sharing a cache name do not aggregate, and no prompt, response or metadata is recorded. The increments are not locked, so a cache object shared across threads counts best-effort.

Release Notes

Every LLM cache now exposes cache.stats, with hits, misses, errors, completed stores, hit rate and average lookup time. The counters are per cache object and per process; nothing changes on the wire, and sharing a cache object across threads makes the totals approximate.

Reviewer Brief

Start at stats.py — the four decorators and the CacheStats dataclass — then base.py for _record_check/_record_error and the stats property. The risk is in the wrappers rather than the arithmetic: an async wrapper that swallowed an exception would look fine in the happy-path tests, so tests/unit/test_cache_stats.py pins the async error path and the preserved signature separately.

Not verified: the integration test in tests/integration/test_llmcache.py needs a live Redis and was not run here; the unit tests mock the LangCache client and ran green (pytest tests/unit — 1360 passed, with the mcp and google-genai modules failing to collect for missing optional dependencies, as they do on main).


Note

Low Risk
Additive observability around existing check/store methods; cache semantics and Redis behavior are unchanged aside from lightweight in-process counting.

Overview
Adds built-in, in-process usage metrics for LLM caches so callers can read hit/miss behavior without wrapping every check/store call.

BaseLLMCache.stats returns a frozen CacheStats snapshot: checks, hits, misses, errors, stores, total lookup time, plus derived hit_rate (errors excluded from the denominator) and avg_lookup_seconds. Counters live on each cache instance only—nothing is persisted to Redis and no prompt/response content is recorded.

Instrumentation is applied via observe_check / observe_acheck / count_store / count_astore on SemanticCache and LangCacheSemanticCache. Failed checks increment errors (not misses); stores count only on successful completion. CacheStats is exported from the public LLM cache module and documented in docs/api/cache.rst.

Integration tests cover SemanticCache; unit tests (mocked LangCache client) cover error paths, snapshot immutability, per-instance isolation, and preserved method signatures.

Reviewed by Cursor Bugbot for commit 27393bf. Bugbot is set up for automated code reviews on this repo. Configure here.

Semantic cache users have no built-in way to see whether the cache is
working: the index statistics RedisVL exposes do not say how many checks hit,
how many missed, or how long the lookup took.

Every BaseLLMCache now counts the check/acheck calls the bundled caches make
(hit, miss, error) and the stores that complete, plus the wall-clock time spent
in checks, and exposes a frozen CacheStats snapshot with hit_rate and
avg_lookup_seconds derived from them. Counting lives in decorators on the
concrete caches, applied once each; an override that calls super() is counted,
one that does not is not. The counters are per process and per object, not
locked, and nothing is written to Redis or recorded from prompts and responses.

@vishal-bala vishal-bala left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking up #684! There's an open OpenTelemetry stack for the same issue (#755, #776, #777). It already records checks, hits, misses, errors, stores and lookup latency for SemanticCache, and it decorates the same four methods, so this PR would duplicate it and conflict with it.

What's still missing is LangCacheSemanticCache, which the stack doesn't instrument yet. Could you rework this PR to cover just that, on top of #777?

  • Drop stats.py, BaseLLMCache.stats and the SemanticCache decorators.
  • Decorate the LangCache check/acheck with _telemetry.operation("check", lookup=True), store/astore with operation("store"), and the delete_by_* methods with operation("drop").
  • Skip the retrieval span and the distance histogram, since LangCache's search is an HTTP call and its misses return no candidates.
  • Test against an in-memory metric reader, as tests/unit/test_cache_telemetry.py does. Your mocked LangCache fixture should still work there.
  • Add LangCache to the observability guide.

Keep targeting main, and rebase once the stack merges.

@paoValle

paoValle commented Nov 6, 2026

Copy link
Copy Markdown
Author

The rework is written and passes locally: LangCache check/acheck as lookups, store/astore, and the four delete_by_* as drops, with no retrieval span and no distance point, a unit test that reads the spans and duration points back through an in-memory reader, and a LangCache section in the observability guide.

I am holding the push until #777 merges. Building on a module that is not on main yet would put the whole OTel stack in this PR's diff, which is what your review asked me to avoid. The moment it lands I will rebase this branch onto main and push.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make semantic cache observability easier out of the box

2 participants