Skip to content

Add in-process LRU cache for graph analysis results (MOO-72 Commit 2) - #10

Merged
OwenTanzer merged 2 commits into
mainfrom
moo-72/commit-2-centralized-cache
Jul 28, 2026
Merged

Add in-process LRU cache for graph analysis results (MOO-72 Commit 2)#10
OwenTanzer merged 2 commits into
mainfrom
moo-72/commit-2-centralized-cache

Conversation

@OwenTanzer

Copy link
Copy Markdown
Owner

Summary

Implements a centralized, in-process LRU cache for GraphIR analysis results across all three graph layers (repository, file, and function). This cache skips expensive GitHub fetches and analysis work for repeat requests, while maintaining correctness through precise cache key identity that accounts for all dimensions affecting the response.

Key Changes

Cache Infrastructure

  • New GraphCache class (server/lib/graph-cache.js) implementing an LRU cache with dual constraints: maximum item count and maximum byte size, plus TTL-based expiration
  • Cache is process-local and cleared on restart/redeploy; not shared across replicas
  • Integrated into server initialization (server/index.js) with configurable bounds

Cache Key Identity (MOO-72 Commit 2 core)

  • Added cacheKeyRequestIdentity() function to distinguish requests that resolve to the same commit but require different responses (e.g., default-branch vs explicit ref: 'main' requests)
  • Includes requestMode and requestRef (for branch mode) in cache keys to prevent serving wrong graph.context values
  • Normalized exclude patterns through normalizeExcludePatterns() so requests differing only in case, order, or whitespace collapse to the same key
  • Python tree-sitter grammar capability (PYTHON_TREE_SITTER_CAPABLE) probed at import time and included in repository layer keys

Route Handler Integration

  • graph-repository.js: Ref resolution moved to Phase 0 (before cache lookup) to compute the resolved SHA needed for the cache key; cache checked after validation but before expensive tree/blob fetching
  • graph-file.js and graph-function.js: Cache lookup positioned after cheap GitHub fetches and symbol resolution but before expensive analysis work (workspace staging, pyan3, CodeVisualizer)
  • All handlers build fresh AdapterResult responses from cached graphs, ensuring timing and cache.hit fields are never stale

Configuration

  • New environment variables: CACHE_ENABLED, CACHE_MAX_ITEMS, CACHE_MAX_BYTES, CACHE_TTL_MS
  • Defaults: enabled, 200 items, 256 MB, 1 hour TTL
  • Strict validation of CACHE_ENABLED (must be exactly "true" or "false") to prevent silent misconfiguration

Testing

  • tests/graph-cache.test.mjs: Unit tests for LRU eviction, TTL expiration, byte budgeting, and entry integrity
  • tests/graph-cache-routes.test.mjs: Cache identity tests verifying no collisions between semantically different requests and proper collapse of equivalent ones; pre-network ordering tests confirming validation/allowlist rejections never reach the cache
  • tests/graph-cache-e2e.test.mjs: End-to-end tests through real route handlers with stubbed GitHub API, verifying repeat requests skip expensive work (measured by GitHub call count)

Notable Implementation Details

  • Cache stores raw GraphIR graph objects, not full AdapterResult envelopes, so request-specific fields (timing, cache metadata) are always fresh
  • LRU tracking uses Map insertion order: get() refreshes recency by delete-and-reinsert; set() evicts the first Map entry until constraints are met
  • Entries larger than maxBytes are rejected without flushing the cache
  • Ref resolution happens before cache lookup so the key can include the resolved SHA without fetching repository content first
  • Python tree-sitter capability is actively probed (real Language.load) at startup, not inferred from runtime availability, ensuring the cache key accurately reflects what analysis actually used

https://claude.ai/code/session_01Pu8rYHKSahwRh9mu7LcdwC

Commit 1A had all three graph routes computing a canonical cache key and
returning it as a permanent miss. This wires a real store behind that hook
so a repeat request skips the work rather than just reporting a hit.

server/lib/graph-cache.js is a Map-based LRU (insertion order gives O(1)
recency) bounded by BOTH item count and bytes -- a handful of large
repository graphs can exhaust a Railway container well before 200 entries.
Overwrites delete before reinserting so recency actually refreshes, and an
entry too large to ever fit is rejected rather than flushing the cache on
its way out.

Cache placement is per-layer, always ahead of that layer's expensive step:

- repository: github-analyzer-bridge.js gains resolveGithubRef (ref
  resolution only) split from fetchAndAnalyzeRepo (tree + blobs +
  analysis), so the key can be built from one cheap call and the whole
  fetch/parse phase skipped. analyzeGithubRepo remains as the composed
  form for /api/analyze-repo.
- file: after the GitHub fetch, before workspace staging and pyan3.
- function: after symbol resolution, before CodeVisualizer.

Correctness details worth calling out:

Cache identity now includes request mode and ref. contextIdentityKey()
keys on sourceOwner/sourceRepo@sha and deliberately omits both -- correct
for "is this the same content", but not sufficient for caching a whole
response: a default-branch request and an explicit ref request can resolve
to the same SHA while needing different graph.context values. Without this
they would share an entry and one would be served the other's context.

Python tree-sitter capability is now an active startup probe (a real
Language.load of the grammar) rather than a runtime check that the shim
loaded. A loaded runtime does not prove the grammar is usable, and the old
signal would report "capable" while every Python parse silently fell back
to the regex heuristic. The probe is scoped so a missing Python grammar
does not disable tree-sitter for every other language.

The file layer keys on request.depth ?? 'auto' rather than the resolved
depth mode, since chooseDepthMode needs a built graph -- exactly the work a
hit must skip. Request-derived means lookup and storage always agree.

Responses are rebuilt on every hit rather than replayed: the store holds
the GraphIR, not the AdapterResult, so cache.hit and timing reflect the
current request and the stored graph is never mutated to carry per-request
metadata.

Degraded pyan3 output is never cached -- those failures are typically
transient, and storing the tree-sitter-only fallback would keep serving it
for the full TTL after pyan3 recovered. Tree-sitter degradation is
process-permanent and already in the key, so it is cached.

CACHE_ENABLED validates strictly as "true"/"false"; a typo is a startup
error rather than silently leaving caching on.

The cache is process-local: cleared on restart/deploy, not shared across
replicas, and assumes a single Railway instance. Stated in the startup log
so an operator debugging a stale result sees it. Concurrent identical
misses still duplicate work; single-flight is deferred to Commit 4.

Tests: +53 (492 -> 545, all passing). Store unit tests cover TTL
boundaries, LRU by count and by bytes, overwrite recency, and oversized
rejection. Route tests cover key identity across every dimension --
including the mode/ref collision above -- and that rejected requests never
reach the cache. An end-to-end suite drives the real handler with a stubbed
global fetch and asserts on GitHub call counts, so it proves a hit skips
the fetching rather than merely reporting hit: true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pu8rYHKSahwRh9mu7LcdwC
@linear-code

linear-code Bot commented Jul 27, 2026

Copy link
Copy Markdown

MOO-72

Copy link
Copy Markdown
Owner Author

Cache identity currently collapses requests whose returned graphs are not actually identical.

normalizeExcludePatterns() lowercases and sorts patterns before building the cache key, so requests such as ['DIST', 'node_modules'] and ['node_modules', 'dist'] intentionally share an entry. However, fetchAndAnalyzeRepo() preserves the normalized-but-original display strings in analysisData.excludePatterns, and adaptRepositoryAnalysis() returns those values as graph.metadata.excludePatterns.

Consequently, the second request can hit the first request's cache entry and receive the first request's case/order in its response metadata. The current equivalent-pattern E2E test checks only key equality and cache.hit; it does not compare the returned graph or metadata.excludePatterns.

Please make the cache identity and returned representation obey the same equivalence rule—either canonicalize metadata.excludePatterns before caching, preserve the exact representation in the key, or rebuild that request-specific metadata on each response—and add an E2E assertion covering the returned metadata.

…on cache hit

A cache entry shared by requests differing only in exclude-pattern
case/order/whitespace was serving the first request's original casing in
graph.metadata.excludePatterns to all of them, since that field came from
whichever request populated the entry rather than the one being served.
displayExcludePatterns() rebuilds the display form fresh from the current
request on every response, hit or miss.
@OwenTanzer

Copy link
Copy Markdown
Owner Author

Fixed in c0545f3.

Added displayExcludePatterns() in server/lib/github-analyzer-bridge.js — mirrors normalizeExcludePatterns()'s trim/dedupe (reusing the analyzer's own compileExcludePatterns) but preserves the current request's casing/order instead of canonicalizing it. graph-repository.js's cache-hit path now rebuilds metadata.excludePatterns from this request before responding (via a shallow clone, so the stored entry is never mutated), rather than trusting whatever the entry's original request happened to store. The miss path was already correct (its own analysisData.excludePatterns is already this request's display form).

Added an assertion to the existing "differing only in case/order/whitespace" E2E test confirming both requests get back their own casing (['DIST', 'node_modules'] vs ['node_modules', 'dist']) despite sharing a cache entry. Full suite (545 tests) passes.

@OwenTanzer
OwenTanzer merged commit 9e527be into main Jul 28, 2026
3 checks passed
@OwenTanzer
OwenTanzer deleted the moo-72/commit-2-centralized-cache branch July 28, 2026 09:08
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.

2 participants