@@ -0,0 +1,87 @@ |
| | 1 | +# Caching |
| | 2 | + |
| | 3 | +The S36 perf-pass standardises on an in-process LRU |
| | 4 | +(`internal/cache/lru`) with optional TTL and a single-flight |
| | 5 | +wrapper for hot-key dogpile prevention. This doc tracks every |
| | 6 | +cross-request cache and its invalidation contract. |
| | 7 | + |
| | 8 | +The invariant is: **every cached value has a documented |
| | 9 | +invalidation trigger**. If you can't name the trigger, the cache |
| | 10 | +is a bug factory. |
| | 11 | + |
| | 12 | +## Active caches |
| | 13 | + |
| | 14 | +| Cache | Key | Value | Invalidator | Bound | |
| | 15 | +|---|---|---|---|---| |
| | 16 | +| `repos/git.AheadBehindCached` | (repo_id, base_oid, head_oid) | (ahead, behind) | OID change ⇒ different key; LRU eviction | 4096 entries | |
| | 17 | + |
| | 18 | +Concrete uses: |
| | 19 | +- `branchesList` (S20 deferral H4) — replaces N `git rev-list` |
| | 20 | + invocations per page load with one cached lookup per branch. |
| | 21 | + Single-flight collapses concurrent misses on hot branches. |
| | 22 | + |
| | 23 | +## Planned caches (next iterations) |
| | 24 | + |
| | 25 | +These are listed in the S36 spec; they land as the surfaces they |
| | 26 | +back grow large enough to bench-justify the cache. |
| | 27 | + |
| | 28 | +| Cache | Key | Value | Invalidator | |
| | 29 | +|---|---|---|---| |
| | 30 | +| Tree at root | (repo_id, ref_oid) | rendered ls-tree result | push:process bumps default-OID | |
| | 31 | +| Ref list | (repo_id) | branches + tags | push:process | |
| | 32 | +| File list (finder) | (repo_id, ref_oid) | flat path slice | push:process | |
| | 33 | +| Default-branch OID | (repo_id) | OID string | push:process + default-branch swap | |
| | 34 | +| Markdown render | (markdown_pipeline_version, body_hash) | rendered HTML | bump pipeline version on goldmark/policy change | |
| | 35 | +| Effective-team-set | (actor, org) | team-id slice | team-membership change | |
| | 36 | + |
| | 37 | +## Single-flight: when to wrap |
| | 38 | + |
| | 39 | +Wrap with `lru.Group` whenever: |
| | 40 | + |
| | 41 | +1. The upstream is non-trivial (subprocess, FS walk, multi-row DB read), AND |
| | 42 | +2. The key is hot (one popular repo, one busy user), AND |
| | 43 | +3. Concurrent misses are realistic (HTTP burst, worker fanout). |
| | 44 | + |
| | 45 | +Without single-flight, a cache miss under load triggers a stampede |
| | 46 | +that defeats the cache's purpose. The `lru.Group` wrapper collapses |
| | 47 | +N concurrent misses into one upstream call. |
| | 48 | + |
| | 49 | +## Errors are NOT cached |
| | 50 | + |
| | 51 | +`lru.Group.Do` deliberately returns errors without caching them. A |
| | 52 | +transient upstream failure (DB blip, git fork EAGAIN) shouldn't |
| | 53 | +poison subsequent reads. Negative caching (caching the absence of |
| | 54 | +a key) is a separate concern; callers add their own sentinel value |
| | 55 | +when they want it. |
| | 56 | + |
| | 57 | +## TTL: when to use one |
| | 58 | + |
| | 59 | +Default to no-TTL with explicit invalidation. Use TTL only when: |
| | 60 | + |
| | 61 | +- The data is fully public + anonymous-cacheable (rendered HTML for |
| | 62 | + a public repo's README). |
| | 63 | +- Staleness is measured-low-impact (≤ 60s for hot reads). |
| | 64 | +- An explicit invalidator is wired in addition (TTL is the safety |
| | 65 | + net, not the primary correctness mechanism). |
| | 66 | + |
| | 67 | +Avoid TTL on personalized content. The "you see your friend's old |
| | 68 | +comment for 30s" UX surprise is not worth the cache hit-rate. |
| | 69 | + |
| | 70 | +## Reading hit-rates |
| | 71 | + |
| | 72 | +Every cache exposes `Stats() lru.Stats{Hits, Misses, Evictions}`. |
| | 73 | +The `/metrics` surface (S37 deploy) will scrape these. CI baseline |
| | 74 | +asserts hit-rate above a per-cache target on the bench run. |
| | 75 | + |
| | 76 | +## Invalidation patterns |
| | 77 | + |
| | 78 | +The push:process worker is the canonical invalidation source for |
| | 79 | +git-shaped caches. After updating refs: |
| | 80 | + |
| | 81 | +```go |
| | 82 | +git.InvalidateAheadBehind(git.AheadBehindKey{...}) |
| | 83 | +// + future: tree, refs, default-branch caches |
| | 84 | +``` |
| | 85 | + |
| | 86 | +The (repo_id, ...) key shape lets us scope invalidation to one |
| | 87 | +repo's slice without scanning the whole cache. |