Skip to content

feat(semantic-cache): per-entry hit analytics and cold-entry diagnostics#215

Open
amitkojha05 wants to merge 1 commit into
BetterDB-inc:masterfrom
amitkojha05:feat/entry-hit-analytics
Open

feat(semantic-cache): per-entry hit analytics and cold-entry diagnostics#215
amitkojha05 wants to merge 1 commit into
BetterDB-inc:masterfrom
amitkojha05:feat/entry-hit-analytics

Conversation

@amitkojha05
Copy link
Copy Markdown
Contributor

@amitkojha05 amitkojha05 commented May 20, 2026

What this PR does

Monitor can already answer "what is this cache's hit rate?" and "where do similarity scores cluster?" It cannot answer "of 40,000 stored prompts, how many have ever been returned as a hit — and which ones are dead weight?"
Never-hit entries sit silently in the HNSW index consuming memory and slowing every FT.SEARCH. Without per-entry signals there is no data-driven TTL sizing, no cold-entry dashboard, and no usage-based invalidation.
This PR moves observability from cache-level → entry-level, in the same spirit as the discovery-marker work (v0.3.0) moved it from nothing → cache-level.

Changes

Per-entry state (store / storeMultipart)

Every new entry gets two additional hash fields: hit_count: '0' and last_accessed_at: '0'. Pre-existing entries automatically start tracking on their first post-upgrade hit via HINCRBY auto-creation — no migration required.

Hit path (check / checkBatch)

On a genuine returned hit only (not judge-rejected, not stale-evicted), a pipeline atomically increments hit_count and sets last_accessed_at. This is batched with the existing TTL EXPIRE into a single round trip — no extra latency on the hot path. Best-effort: a failure never breaks check().

FT index schema

hit_count NUMERIC SORTABLE and last_accessed_at NUMERIC SORTABLE added to FT.CREATE. Existing indexes keep working — _hasUsageFields is detected via FT.INFO on initialize(), mirroring the _hasBinaryRefs pattern. Run flush() + initialize() to rebuild and enable the fast analytics path.

entryAnalytics(options?) — new public method

const a = await cache.entryAnalytics({ topN: 5, coldAfterDays: 14 });

console.log(`${a.neverHitCount} of ${a.totalEntries} entries have never been hit`);

console.log(
  'hottest:',
  a.topEntries.map((e) => `${e.key} (${e.hitCount})`),
);

Returns totalEntries, neverHitCount, hitAtLeastOnceCount, coldEntryCount, topEntries (sorted by hitCount desc, capped at topN), and coldAfterDays.

Two collection paths:

  • Fast path (_hasUsageFields = true): three parallel FT.SEARCH … LIMIT 0 0 count queries (no document materialization, exact on any index size) + one SORTBY hit_count DESC LIMIT 0 topN query for the hot list. Counts and top entries are independent queries — counts are never derived from a sampled hot list.

  • Slow path (_hasUsageFields = false, legacy index): clusterScan + HGETALL, capped at 10,000 entries. hgetall calls stop at the cap; a limitReached flag prevents even callback overhead beyond the limit. totalEntries reflects the sample size, documented in JSDoc and README.

Discovery capability

'entry_analytics' added to the capability array. Monitor can gate new endpoints/tools on this the same way it gates threshold_adjust.

New exported types

EntryAnalyticsOptions, EntryAnalyticsResult, EntrySummary — all exported from the package root.

Testing

Validated with:

pnpm --filter @betterdb/semantic-cache test

Integration tests pass successfully using Redis Stack / RediSearch setup.


Note

Medium Risk
Adds write-side behavior on every cache hit (pipelined HINCRBY/HSET and optional EXPIRE) and extends the FT index schema, which can affect performance and error handling on the hot path and requires index rebuild to enable fast analytics.

Overview
Adds per-entry usage tracking by storing hit_count and last_accessed_at on each entry, incrementing/updating them on cache hits (and batching with TTL refresh); hit-side updates are now best-effort and occur even when defaultTtl is unset.

Introduces cache.entryAnalytics() plus exported types (EntryAnalyticsOptions, EntryAnalyticsResult, EntrySummary) to report total/never-hit/cold entry counts and top hot entries, using FT.SEARCH LIMIT 0 0/SORTBY hit_count when the index has the new sortable fields and falling back to a capped SCAN + pipelined HMGET sampling path for legacy indexes.

Updates discovery markers to advertise a new entry_analytics capability, extends FT.CREATE schema with hit_count/last_accessed_at, and adds tests covering both the search-based and scan-based analytics paths.

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

Comment thread packages/semantic-cache/src/SemanticCache.ts
Comment thread packages/semantic-cache/src/SemanticCache.ts
@amitkojha05 amitkojha05 force-pushed the feat/entry-hit-analytics branch 2 times, most recently from a8a4fdc to ebc046e Compare May 20, 2026 20:56
Comment thread packages/semantic-cache/src/SemanticCache.ts Outdated
@amitkojha05 amitkojha05 force-pushed the feat/entry-hit-analytics branch from ebc046e to b764e1c Compare May 20, 2026 21:34
Comment thread packages/semantic-cache/src/SemanticCache.ts
@amitkojha05 amitkojha05 force-pushed the feat/entry-hit-analytics branch from b764e1c to 1210123 Compare May 20, 2026 21:50
@amitkojha05
Copy link
Copy Markdown
Contributor Author

Hi @KIvanow and @jamby77 ,Please review the PR ,would love to hear your feedback

@amitkojha05 amitkojha05 force-pushed the feat/entry-hit-analytics branch from 1210123 to e1b3704 Compare May 20, 2026 23:09
Comment thread packages/semantic-cache/src/SemanticCache.ts Outdated
@amitkojha05 amitkojha05 force-pushed the feat/entry-hit-analytics branch from e1b3704 to a0de39f Compare May 21, 2026 00:07
await pipeline.exec();
} catch {
// best-effort: usage tracking must never fail a cache hit
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TTL refresh errors now silently swallowed on hits

Medium Severity

The recordEntryUsage and recordEntryUsageBatch methods wrap the expire call (TTL refresh) in a try/catch that swallows all errors. Previously, the expire call in check() and checkBatch() was standalone and would propagate failures. By bundling TTL refresh with the new usage-tracking pipeline under a blanket catch, a transient Redis error now silently skips the TTL refresh, potentially causing active entries to expire prematurely. The "best-effort" comment is appropriate for hit_count/last_accessed_at tracking, but the pre-existing TTL refresh is a correctness concern that lost its error signal.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a0de39f. Configure here.

Comment thread packages/semantic-cache/src/SemanticCache.ts Outdated
@amitkojha05 amitkojha05 force-pushed the feat/entry-hit-analytics branch from a0de39f to 79bdc43 Compare May 21, 2026 00:40
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 79bdc43. Configure here.

const [totalEntries, neverHitCount, coldEntryCount] = await Promise.all([
countOf('*'),
countOf('@hit_count:[0 0]'),
countOf(`@last_accessed_at:[0 ${coldCutoff}]`),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cold entry count boundary differs between search and scan

Low Severity

The fast path (FT.SEARCH) and slow path (SCAN) use different comparison semantics for the coldCutoff boundary. The search query @last_accessed_at:[0 ${coldCutoff}] uses an inclusive upper bound (<=), while the scan path filter e.lastAccessedAt < coldCutoff uses a strict comparison (<). An entry whose lastAccessedAt equals coldCutoff exactly would be counted as cold by the search path but not by the scan path. RediSearch supports exclusive bounds via [( syntax, so matching the scan path's < semantics is straightforward.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 79bdc43. Configure here.

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.

1 participant