diff --git a/.gitignore b/.gitignore index 2361f00..1e1be74 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,14 @@ traces/ *.spc.bz2 cluster0* .DS_Store + +# Working material that is not part of the library. These files stay on +# disk -- they are the working notes and the article's exhibits -- but the +# repository does not ship them. Documentation a reader or an agent should +# rely on lives in docs/. +plan/ +CLAUDE.md +codemaps/ +.reports/ +.vscode/ +metrics/zzz_refute_probe_test.go diff --git a/.reports/codemap-diff.txt b/.reports/codemap-diff.txt deleted file mode 100644 index a9e3da5..0000000 --- a/.reports/codemap-diff.txt +++ /dev/null @@ -1,52 +0,0 @@ -codemap update report -generated: 2026-07-26 - -METHOD - The command template specifies TypeScript/Node.js analysis and a - frontend/backend/data file set. This repository is a Go multi-module library - with no frontend and no data schemas. Analysis therefore uses `go doc`, - authoritative for a Go module's API, rather than a JS parser; and no - frontend codemap was generated because there is nothing to describe. - Symbol matching uses word boundaries: a substring test wrongly counts - generic names such as Get/New/Add as documented wherever they appear. - -BEFORE (codemaps dated 2026-02-23, predating milestones 1-6) - exported symbols 106 total, 27 unnamed -> 25.5% - root source files 15 total, 9 unnamed -> 60.0% - modules 9 total, 4 unnamed -> 44.4% - worst dimension 60.0%, above the 30% threshold -> approval requested - user approved: rewrite architecture.md, backend.md, data.md - -AFTER - exported symbols 106 total, 19 unnamed -> 17.9% - . 43 / 6 - lfu 18 / 7 - policies 23 / 2 - policies/arc 2 / 0 - policies/tinylfu 14 / 2 - metrics 6 / 2 - root source files 15 total, 1 unnamed -> 6.7% - generate.go - modules 9 total, 2 unnamed -> 22.2% - examples/basic - examples/migration - -REMAINING UNNAMED SYMBOLS - deliberate - All are methods on types the codemaps already describe (Close, Contains, - Peek, GetType, String, ContainsOrAdd, GetOldest, PeekOrAdd, RemoveOldest) - plus two low-level identifiers. The command asks for high-level structure, - not implementation details, and `go doc` is authoritative and always current - for method sets. Entry-point constructors and sentinel errors WERE added, - because those are API surface a reader needs to get started. - Unnamed: Close, Contains, ContainsOrAdd, DefaultEvictedBufferSize, GetOldest, GetType, Name, Peek, PeekOrAdd, PolicySnapshot, RemoveOldest, String - - generate.go is a go:generate directive only; examples/* are internal demos. - -FILES WRITTEN - codemaps/architecture.md module graph, request path, epoch lifecycle, - ordering invariant, concurrency model - codemaps/backend.md file-by-file, call graphs, entry points, - 'looks wrong but is deliberate' notes - codemaps/data.md enums, Settings, errors, stats scopes, Advice, - core invariant, capacity model - (no frontend.md - not applicable to this project) diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 082b194..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "makefile.configureOnOpen": false -} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 577e0bf..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,1074 +0,0 @@ -# CLAUDE.md - as-cache Project Guidelines - -## Project Overview - -**as-cache** (Adaptive Selection Cache) is a Go library that uses a Multi-Armed Bandit (MAB) statistical approach to select the cache replacement policy at runtime, measuring candidates against real traffic rather than asking the caller to guess. - -Instead of forcing users to choose a fixed eviction algorithm upfront, as-cache runs multiple policies in parallel (shadow caching), measures their hit/miss rates per epoch, and uses Thompson Sampling to select the best-performing policy dynamically. - -**Module:** `github.com/sshaplygin/as-cache` -**Go version:** 1.25+ -**Status:** Pre-1.0. API may change; measured against published traces (see -README "Evidence"); not yet tagged or published -- `make release-check`. - ---- - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ AdaptiveCache │ -│ (Manages active policy + shadow policies, runs Bandit) │ -└─────────────────────────────────────────────────────────────┘ - │ active │ shadows - ▼ ▼ - ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ - │ CacheWrapper│ │ CacheWrapper│ │ CacheWrapper │ - │ (LRU) │ │ (LFU) │ │ (future...) │ - └─────────────┘ └─────────────┘ └─────────────┘ - │ │ - ▼ ▼ - ┌─────────────┐ ┌─────────────┐ - │ hashicorp │ │ lfu/simple │ - │ LRU │ │ LFU │ - └─────────────┘ └─────────────┘ - │ - ▼ - ┌─────────────┐ - │ Bandit │ (Thompson Sampling via stitchfix/mab) - │ SelectPolicy│ - └─────────────┘ -``` - -**Key mechanism:** -1. Every epoch (configurable duration), the background goroutine calls `bandit.SelectPolicy()` -2. Each policy's hit/miss stats feed Beta distribution parameters -3. Bandit samples from distributions, picks the winner, switches active policy -4. Shadow caches receive dummy adds (no real data) to track comparative metrics - ---- - -## File Structure - -``` -as-cache/ -├── CLAUDE.md # This file -├── README.md # Landing page: pitch, quick start, API, doc index -├── docs/ # The long-form documentation the README links to -│ ├── design.md # mechanism, Bandit interface, what is not done -│ ├── configuration.md # Settings, migration, sampling, stability, tuning -│ ├── policies.md # ready-made arms and their caveats -│ ├── advisor-mode.md # ObserveOnly, Advice(), metrics module -│ ├── benchmarking.md # reproducible replays, benchclient, make evidence -│ ├── fleet.md # distributed bandit over Valkey/Redis -│ └── evidence.md # every measured claim and its table -├── go.mod / go.sum # Root module dependencies -├── generate.go # go:generate stringer directive -│ -├── interfaces.go # Core interface definitions -├── models.go # PolicyType, PolicyStats, ShadowStats, GlobalStats -├── errors.go # Sentinel errors returned by NewAdaptiveCache -├── settings.go # Settings + NewAdaptiveCache validation -├── cache.go # AdaptiveCache struct + public cache API -├── epoch.go # Epoch loop, bandit reporting, policy selection -├── migration.go # Migration strategies (cold/warm/gradual) -├── advice.go # ObserveOnly reporting: Advice, PolicyReport -├── shadow.go # Promotion/demotion, shadow duty, value dropping -├── sampling.go # keySampler + miniature capacity maths -├── stability.go # Switch gates (cool-down, min improvement) -├── wrapper.go # CacheWrapper (wraps any Cacher, adds stats) -├── policytype_string.go # Generated: PolicyType.String() via stringer -│ -├── policies/ # Separate module: ready-made policy adapters -│ ├── go.mod / go.sum # depends on root + hashicorp/golang-lru v2.0.6 -│ ├── adapters.go # NewLRU / NewTwoQueue / NewTTL / NewRandomPolicy -│ ├── adapt.go # PartialCacher + AdaptedCache (Resize-by-rebuild) -│ ├── random.go # RandomCache, implemented from scratch -│ ├── ttl.go # TTLCache: own expiry over plain LRU (see below) -│ └── conformance_test.go # shared Cacher/Policy contract suite -│ -├── policies/arc/ # Separate module, SOLELY for patent isolation -│ ├── go.mod / go.sum # depends on hashicorp/golang-lru/arc/v2 -│ └── arc.go # ARC adapter via policies.Adapt -│ -├── policies/fifo/ # Separate module: keeps golang-fifo isolated -│ ├── go.mod / go.sum # root + policies + scalalang2/golang-fifo -│ ├── fifo.go # S3-FIFO and SIEVE over one shared adapter -│ └── index.go # the key index upstream cannot enumerate for us -│ -├── policies/tinylfu/ # Separate module: keeps otter's deps isolated -│ ├── go.mod / go.sum # depends on maypok86/otter/v2 -│ └── tinylfu.go # W-TinyLFU adapter (natively resizable) -│ -├── metrics/ # Separate module: expvar export (stdlib only) -│ ├── go.mod / go.sum -│ └── metrics.go # Advisor, Snapshot, Take, Publish -│ -├── bandit/ # Separate module: ready-made Bandits (stdlib only) -│ ├── go.mod / go.sum # depends on root only -│ ├── thompson.go # Thompson + Greedy, promoted out of bench -│ ├── sample.go # Beta/Gamma draws (Marsaglia-Tsang) -│ ├── store.go # Store interface: Sync / Window / Decide -│ ├── memstore.go # In-process Store: tests and fleet simulation -│ ├── config.go # Config, Mode, EvidenceMode, defaults -│ ├── errors.go # Sentinel errors from NewDistributed -│ ├── fingerprint.go # regime: which replicas may pool with which -│ ├── window.go # decay weighting, evidence cap, Thompson draw -│ ├── distributed.go # the Bandit surface the cache calls (never blocks) -│ ├── coordinate.go # the sync goroutine: one round trip per epoch -│ └── snapshot.go # observability: fallback, leadership, fleet arms -│ -├── docker-compose.yml # Valkey 8 + Redis 7 for `make redis-test` -│ -├── bandit/redis/ # Separate module: keeps go-redis out of bandit -│ ├── go.mod / go.sum # depends on root + bandit + redis/go-redis/v9 -│ ├── TESTING.md # what the real-server runs verified, and did not -│ ├── store.go # bandit.Store over Valkey/Redis -│ ├── scripts.go # Lua: server-clock buckets, SET NX leadership -│ └── keys.go # key schema, hash tags, counter field encoding -│ -├── bench/ # Separate module: workloads + evidence harness -│ ├── workload.go # deterministic zipf/uniform/loop/scan/phase-shift -│ ├── harness.go # replay, Result, tables -│ ├── fleet.go # Shard/Split, paced replay, fleet comparison -│ ├── evidence_test.go # policy comparison + sampling-fidelity check -│ ├── fleet_test.go # does pooling beat deciding alone? -│ ├── timeline_test.go # ActivePolicy() plot over a phase-shift run -│ ├── trace.go # real-trace loaders (Twitter/LIRS/ARC formats) -│ ├── trace_msr.go # MSR Cambridge block I/O: byte ranges -> block keys -│ ├── trace_meta.go # Meta kvcache: header-mapped columns, op_count expansion -│ ├── trace_formats_test.go # fixture tests for both, run by `make test` -│ ├── memory_test.go # memory multiplier + allocations -│ └── tuning_test.go # epoch/migration configuration sweep -│ -├── lfu/ # Separate module: LFU cache -│ ├── go.mod / go.sum -│ ├── lfu.go # Thread-safe LFU wrapper with eviction callbacks -│ ├── lfu_test.go # (stub - needs tests) -│ ├── internal/ -│ │ └── list.go # Doubly-linked list for frequency buckets -│ └── simplelfu/ -│ ├── lfu.go # Core LFU algorithm (O(1) add/get/evict) -│ └── lfu_test.go # (stub - needs tests) -│ -├── site/ # GitHub Pages frontend; static, no build step -│ ├── index.html # landing page: pitch, evidence, docs links -│ ├── bandit-explorer.html # interactive phase-shift replay, self-contained -│ ├── active-policy-timeline.svg # static preview linking to the explorer -│ ├── og.png # 1200x630 social card (og:image, twitter:image) -│ ├── sitemap.xml # both pages; submit via Search Console -│ └── robots.txt # effective only on a custom domain (see file) -│ # deployed by .github/workflows/pages.yml -│ -└── examples/ - ├── basic/ - │ ├── go.mod / go.sum - │ └── main.go # HTTP server demo (GET/SET endpoints) - └── migration/ - ├── go.mod / go.sum - └── main.go # the three migration strategies, side by side -``` - ---- - -## Core Interfaces - -### `Cacher[K, V]` (interfaces.go) -Standard cache interface compatible with `hashicorp/golang-lru/v2`: -```go -Add(key K, value V) bool -Get(key K) (V, bool) -Remove(key K) bool -Keys() []K -Values() []V -Len() int -Peek(key K) (V, bool) -Purge() -Resize(size int) int -Contains(key K) bool -``` - -### `Policy[K, V]` (interfaces.go) -Extends `Cacher` with capacity tracking and stats: -```go -Cap() int -GetStats() PolicyStats -ResetStats() -GetType() PolicyType -``` - -### `Bandit` / `EpochBandit` (interfaces.go) -MAB strategy abstraction. **Both methods run under the cache's write lock, so -an implementation must not block** -- see the distributed-bandit notes below. -`EpochBandit` is an optional extension delivering one whole reporting epoch -per call instead of one arm at a time; a bandit implementing it receives -`RecordEpoch` and never `RecordStats`. - -```go -RecordStats(stats ShadowStats) -SelectPolicy() PolicyType -``` - ---- - -## Key Types - -| Type | Location | Purpose | -|---|---|---| -| `AdaptiveCache[K,V]` | cache.go | Main adaptive cache orchestrator | -| `CacheWrapper[K,V]` | wrapper.go | Wraps any Cacher, adds hit/miss tracking | -| `PolicyType` | models.go | Enum: Undefined, LRU, LFU, TwoQueue, ARC, Random, TTL, TinyLFU, S3FIFO, SIEVE | -| `MigrationStrategy` | models.go | Enum: MigrationCold, MigrationWarm, MigrationGradual | -| `PolicyStats` | models.go | Hits + Misses counters | -| `ShadowStats` | models.go | Per-epoch policy performance | -| `GlobalStats` | models.go | Aggregate statistics | - ---- - -## Dependencies - -| Package | Version | Role | -|---|---|---| -| `hashicorp/golang-lru/v2` | v2.0.6 | LRU/2Q/expirable (policies module). v2.0.7 also builds; the pin is inertia, not a defect | -| `stitchfix/mab` | v0.1.1 | Multi-Armed Bandit (Thompson Sampling) | -| `scalalang2/golang-fifo` | v1.2.0 | S3-FIFO and SIEVE (`policies/fifo` module only). MIT | -| `redis/go-redis/v9` | v9.21.0 | Valkey/Redis client (`bandit/redis` module only) | -| `alicebob/miniredis/v2` | v2.38.0 | Fake Redis for `bandit/redis` tests (test-only) | -| `gonum.org/v1/gonum` | v0.8.2 | Numerical computing (used by mab) | -| `golang.org/x/exp` | indirect | Used by gonum | -| `stretchr/testify` | v1.11.1 | Test assertions (root module, test-only) | - ---- - -## Code Patterns - -### Generics - All cache types use Go generics -```go -type AdaptiveCache[K comparable, V any] struct { ... } -type Cacher[K comparable, V any] interface { ... } -``` - -### Thread Safety -- `sync.RWMutex` in `AdaptiveCache` guards policy switching -- `sync.RWMutex` in LFU `Cache` guards all operations -- Eviction callbacks invoked outside critical sections - -### Context-Based Lifecycle -```go -func New[K comparable, V any](ctx context.Context, ...) *AdaptiveCache[K, V] -// Background goroutine stops on ctx.Done() -``` - -### Shadow Caching Pattern -- Active policy stores real key/value pairs -- Shadow policies receive `Add(key, zeroValue)` calls to track access patterns -- Stats are reset each epoch after bandit records them - ---- - -## Development Commands - -```bash -# Run root package tests -go test ./... - -# Run LFU package tests -cd lfu && go test ./... - -# Run example -cd examples/basic && go run main.go - -# Lint all modules (golangci-lint v2, config in .golangci.yml) -make lint # or: golangci-lint run ./... (per module) -make lint-fix # apply --fix and formatters -make install-tools # install the pinned golangci-lint version - -# Run the bandit/redis suite against real servers rather than miniredis. -# docker-compose.yml brings up both Valkey 8 and Redis 7; the suite runs -# against each, because the adapter claims to support both. -make redis-up # start them and wait for health -make redis-test # start, run against both, tear down -make redis-down # stop them -# or, against a server you already have: -AS_CACHE_REDIS_ADDR=127.0.0.1:6379 sh -c 'cd bandit/redis && go test ./...' - -# Regenerate stringer (after modifying PolicyType in models.go) -go generate ./... - -# Tidy dependencies -go mod tidy -cd lfu && go mod tidy -cd examples/basic && go mod tidy -``` - ---- - -## Current Status & Incomplete Features - -### Implemented -- [x] `AdaptiveCache.Add()` and `Get()` with shadow policy tracking -- [x] `AdaptiveCache.Remove()` - delegates to active policy, propagates to shadows -- [x] `AdaptiveCache.Purge()` - purges all policies -- [x] `AdaptiveCache.Resize()` - resizes all policies, returns total eviction count -- [x] `AdaptiveCache.Contains()` - delegates to active policy -- [x] `AdaptiveCache.Keys()` / `Values()` / `Len()` / `Peek()` - delegate to active policy -- [x] `AdaptiveCache.Stats()` - cumulative: completed epochs (`globalStats`) plus the active policy's in-progress epoch -- [x] Background epoch goroutine with bandit-based policy selection -- [x] `CacheWrapper` with hit/miss statistics -- [x] LFU implementation (simplelfu + thread-safe wrapper) — all methods implemented -- [x] `lfu.Cache`: `Resize`, `ContainsOrAdd`, `PeekOrAdd`, `RemoveOldest`, `GetOldest` -- [x] `simplelfu.LFU`: `Resize`, `GetOldest`, `RemoveOldest` -- [x] Basic example with HTTP server -- [x] Roadmap Milestone 1 (correctness): locked epoch switch in `runEpoch`; atomic - `CacheWrapper` counters; active policy's per-epoch stats reported to the bandit - alongside shadows with all counters reset each epoch (active counts folded into - `AdaptiveCache.globalStats` so `Stats()` stays cumulative and demotion never - leaks active-tenure stats into a shadow epoch); constructor validation with - sentinel errors in `errors.go` (`ErrNilSettings`, `ErrInvalidEpochDuration`, - `ErrNilBandit`, `ErrNilPolicy`, `ErrDuplicatePolicy`); idempotent `Close()` - (`sync.Once`) that waits for the epoch goroutine (`sync.WaitGroup`); explicit - `go vet` CI step and `TestAdaptiveCache_StressAcrossEpochBoundaries` (1ms - epochs, all three migration strategies, full public API under `-race`). - Migration strategies moved verbatim from `cache.go` to `migration.go` to keep - files under the 400-line rule. - -- [x] Roadmap Milestone 2 (overhead reduction), three of four items: - - **Sampled shadow caching** (`sampling.go`, `shadow.go`): `Settings.ShadowSampleRate` - gates the shadow fan-out on `maphash.Comparable(seed, key) < threshold`. One - sampler is shared by every policy so all arms measure the same substream -- - per-policy seeds would make their hit rates incomparable. Shadows resize to - `ceil(rate*Cap)` so each stays a faithful miniature; `MinShadowCapacity` - (default 256) floors that, raising the *effective rate* rather than only the - capacity, and disabling sampling when a cache is too small to host a useful - miniature. - - **Stats deviate from the roadmap wording deliberately**: sampled counts are - NOT scaled up by `1/rate`. That would restore magnitude while inventing - confidence, handing a Beta posterior 20x the evidence collected. Instead the - *active* arm is measured over the same sampled substream - (`activeSampledHits`/`activeSampledMisses`, reported by `selectPolicyLocked`), - so every arm carries equal, honest evidence. `Stats()` still reports real - unsampled traffic; only the bandit sees the sample. - - **Value dropping on demotion** (`demoteLocked`): a demoted policy keeps its - keys (that is its eviction bookkeeping) but its entries are rewritten to the - zero value, unsampled keys removed, then it shrinks to miniature capacity. - Rewriting in `Keys()` order preserves LRU recency and leaves LFU relative - order untouched. Deferred while a gradual window is open, since that window - promotes out of the source's real values. - - **Switch stability** (`stability.go`): `MinHitRateImprovement`, - `SwitchCooldownEpochs`, `MinEpochRequests`, all off at their zero values. - - **Ordering invariant** (documented on `switchLocked`): *every mutation of a - policy must happen while that policy is not the active one.* Mutate the - incoming policy before promoting it, the outgoing one after demoting it. - This is what keeps a caller from ever reading a policy mid-rewrite. - - **Test policies**: `mockPolicy` does not enforce capacity (its `Resize` only - records the new size). That blind spot hid three capacity bugs found in - review. `evictingPolicy` in `evicting_test.go` does enforce it -- use it for - anything whose subject is capacity, resizing, or eviction. - - Measured on an M1 Max with mutex-backed stub policies: `Get` 100 -> 36 ns/op - with one shadow, 145 -> 38 ns/op with three; `Add` 109 -> 52 and 189 -> 57; - mixed parallel 184 -> 85. The structural win is that cost becomes flat in the - number of shadows, so adding policies is nearly free. - - **Deferred: lock-free reads** (`atomic.Pointer` to the active policy). An - adversarial safety analysis found the usual seqlock-retry framing unsound and - surfaced five further hazards -- retry must wrap all six read delegations - (`Values()` on a stale post-drop state returns N zeros at the right length), - retries are not side-effect-free (double-counted stats, double-bumped - recency), the `GetStats`/`ResetStats` split silently discards hits once reads - are unlocked (fixing it changes the public `CacheStats` interface), and - `MigrationGradual` cannot go lock-free since `promoteLocked` mutates from - inside `Get`. Worth its own scoped change. - -- [x] Roadmap Milestone 4 (evidence), synthetic half. `make evidence` replays - the suite; see the README for the tables. The headline finding is negative and - should not be smoothed over: **adaptive selection never beats the best fixed - policy on these workloads**, including phase-shift, where W-TinyLFU wins by - 3.8 points. The `ActivePolicy()` timeline shows the bandit working correctly — - it explores, picks W-TinyLFU, and holds it 90% of the run — but there is no - crossover to exploit because W-TinyLFU is best in both regimes. What the - library does deliver is a bound on the cost of guessing wrong (77.5% vs LRU's - 0.0% on `loop`). That argues for Milestone 5 advisor mode as the primary - product rather than a stepping stone. - - Milestone 2's sampling was validated here: sampled shadows preserve the - policy ranking. (The "1-3 points pessimistic uniformly across arms" figure - once recorded here was measured before the shadow-insert fix and no longer - holds: with shadows filling themselves, every deterministic arm now matches - its standalone replay to within 0.005 at full size, and a sampled rate can - land either side of the true one depending on which slice of the keyspace - the seed selected.) - - Evidence tests are guarded by `testing.Short()` and excluded from - `make test`, which now passes `-short`. Under `-race` the epoch pacing - changes ~15x and the measurements become meaningless. Run `make evidence`. - - The root module ships no `Bandit`; `bench/bandit.go` has a Thompson - sampler (Beta posteriors via Marsaglia-Tsang gamma draws, with discounting - so it can change its mind) and a greedy control. Worth promoting if - advisor mode lands. - -- [x] Roadmap Milestone 4 (evidence), real traces. `./scripts/fetch-traces.sh` - downloads five published traces; none are committed (see `.gitignore`). - Loaders self-test against published record/distinct counts, and the ARC - layout's range expansion is asserted -- each record stands for `blockCount` - accesses, and reading it as one-key-per-line would silently produce a - workload incomparable with the literature. - - **The real traces overturned the synthetic conclusion.** The best fixed - policy varies by trace: 2Q wins on Twitter Twemcache and ARC OLTP, - W-TinyLFU on ARC P3 and the LIRS traces. On OLTP, W-TinyLFU is - second-*worst*. Tuned sensibly (50ms epoch, warm migration), adaptive lands - within ~1 point of the best fixed policy and beats it on P3 by 0.76. - - **Epoch duration is the setting that matters.** A 2ms epoch on a 20k cache - means copying the cache hundreds of times per replay: 13,476 ns/op and a - 7-point hit-rate loss on P3. `MigrationCold` costs 28 points on OLTP. The - stability gates cost 37 points on `loop`, which must re-adapt constantly. - - Memory: six policies cost 2.65x a single LRU, not 6x (shadows hold keys, - never values); 1.32x with sampling. The old README claim was wrong. - (Re-measured at eight policies once the FIFO arms joined the set: 3.92x - and 1.40x. Each is close to double an ordinary arm, because the adapter - keeps its own copy of the key set and S3-FIFO's ghost queue remembers - roughly a further cache's worth. The test now asserts the per-shadow - property - each shadow costs 0.42x a full cache - rather than a total - multiplier that moves every time an arm is added.) -- [x] Roadmap Milestone 5 (advisor mode). `Settings.ObserveOnly` measures every - arm while guaranteeing the cache behaves exactly like the policy it was built - with; `Advice()` reports which policy wins and by how much. The bandit may be - nil in this mode (implementing one is the fiddliest part of using the - library, and nothing is ever selected), and the `EvictPartialCapacityFilling` - capacity gate is bypassed, since it exists to avoid switching on thin - evidence and would otherwise suppress the very measurement being asked for. - The `metrics` module publishes a `Snapshot` through expvar, evaluated on - scrape, with a duplicate-name check so a registration mistake returns an - error instead of panicking the process. - - **`tenureStats`, not lifetime stats.** A policy's measurements are cleared - when it changes role. Accumulating across a role change pooled its active - tenure (full capacity, all traffic) with its shadow tenure (miniature - capacity, a sample), and left the outgoing policy's long history - outweighing the incoming one's short history -- so right after a correct - switch, `Advice` named the policy the cache had just moved away from as - best, for a number of epochs linear in the history length. Do not - "improve" this by accumulating for longer. - - **`Advice.Epochs` counts reporting epochs, not ticks.** The capacity gate - can skip measurement indefinitely, and `epochID` would report thousands of - epochs of evidence behind nothing. - - Ties in `Advice` are broken by `PolicyType`: ranging a map and sorting - stably on hit rate alone made `Best` flap between equally-performing arms - on an unchanged cache. - - `metrics.Publish` needs both the mutex and the `recover`: `expvar.Get` - followed by `expvar.Publish` is check-then-act, and expvar panics on a - duplicate name. - -- [x] LFU added to `policies` (`NewLFU`) and to the evidence suite. Putting it - through the shared conformance suite for the first time found a real bug: - it accepted entries at zero capacity, where every other policy holds nothing. - A pre-existing test asserted the buggy behaviour (that the entry stayed - retrievable) and was corrected -- its purpose was guarding a panic, and the - retrievability assertion was incidental. - - Evidence: LFU is the **best** policy on synthetic `zipf` (73.5%) and the - **worst** on both large real traces (41.4% Twitter, 45.4% OLTP). Synthetic - Zipf holds popularity stationary, which is exactly LFU's assumption; real - traffic shifts and stale frequency counts pin dead entries. This is the - clearest evidence in the repo that synthetic workloads mislead. - -- [x] Distributed bandit (`bandit`, `bandit/redis`). A fleet pools its - per-epoch counts through Valkey/Redis so replicas that individually see too - little traffic to rank their arms can still select. Five findings shaped it: - - **The `Bandit` interface runs under the cache's write lock.** `RecordStats` - and `SelectPolicy` are both called from `selectPolicyLocked`, inside - `runEpoch`'s `c.mu.Lock()`. Go's `RWMutex` queues new readers behind a - waiting writer, so a blocking call there stalls every `Get` in the process - for its duration -- a store timeout becomes a cache outage. `Distributed` - therefore buffers in `RecordEpoch` and serves `SelectPolicy` from an - `atomic.Int64`; all I/O is on its own goroutine. The non-blocking rule is - now documented on `Bandit` itself, and asserted by - `TestDistributed_RecordAndSelectNeverWaitOnTheStore`. - - **Two clocks.** The tuned single-node epoch is 50ms, which is below both a - round trip and any fleet-wide clock agreement. `CoordinationEpoch` is a - separate, seconds-scale clock. Buckets are derived from the *store's* clock - inside Lua (`redis.call('TIME')`), so no replica's clock is ever consulted - and a skewed machine cannot poison a window. Needs Redis 7 / Valkey 7.2 for - effects replication. - - **Decay cannot be applied in place by N writers.** Multiplying a shared - counter once per replica per epoch means a fleet of fifty forgets fifty - times faster than a fleet of one. The store holds plain per-bucket sums; - `aggregate` applies `decay^age` on read. Same arithmetic at any fleet size. - - **Pooling multiplies evidence by fleet size, which kills exploration.** A - Beta posterior narrows with the square root of its evidence, so a thousand - replicas produce draws that always return the same arm. `capEvidence` - bounds the effective sample size, preserving the rate exactly. - `TestCapEvidence_RestoresExploration` is the demonstration: uncapped, a - marginally worse arm is drawn zero times in 200. - - **The active/shadow role gap is why `ModeLeader` is the default.** The - active arm is measured at full capacity, shadows on miniatures whenever - sampling is on. Under leader election every replica has the same arm in - the flattering role so the bias cancels on summing; under - `ModeSharedPosterior` it is asymmetric and compounds with deployment share. - `EvidenceShadowOnly` removes it and is rejected under `ModeLeader`, where - the fleet-wide active policy is nobody's shadow and would have no evidence - at all (`ErrShadowOnlyUnderLeader`). - - **Replicas only pool when they measure the same thing.** `Namespace` is - suffixed with a fingerprint of arms + capacity + sample rate. Pooling a - 1000-entry cache's hit rate with a 100-entry one produces a number that - describes neither, and nothing in the counts would reveal it. Epoch - duration is deliberately *not* in the fingerprint: reporting twice as often - contributes twice the counts at the same rate, and rates are what is - compared. - - **The adapter suite runs against miniredis by default and against real - servers via `make redis-test`.** A fake can be too permissive about - precisely what this leans on -- `TIME` inside a script, `SET NX PX`, and - `HINCRBY` on a key the script names itself rather than declaring in `KEYS` - -- so `docker-compose.yml` brings up both Valkey 8 and Redis 7 and the - suite runs against each. Testing one engine while documenting two is how a - support claim quietly stops being true. Verified against Valkey 8.1.9 and - Redis 7.4.10: 20 tests, 0 skips, 0 failures on each. See - `bandit/redis/TESTING.md`, which also records what is *not* covered - (Redis Cluster, failover, Redis 6) and the zsh word-splitting trap that - makes a hand-run two-engine loop silently test the fake instead. - - Root-module changes this needed, both additive: the optional `EpochBandit` - interface (a bandit that must key evidence externally needs the epoch - boundary, the epoch id and which arm was active -- none of which the - per-arm `ShadowStats` stream carries), and `EpochReport.Capacity` / - `SampleRate` for the fingerprint. - -- [x] **Bug found and fixed: an unrecognised bandit selection panicked the - process.** `runEpoch` switched to whatever `SelectPolicy` returned without - checking the cache actually had it; `migrateData` then looked the missing - policy up in the map and dereferenced a nil interface. `Undefined` is the - natural return from a bandit that has not formed an opinion -- which a - distributed one does for every epoch before its first sync -- so this was - reachable in normal operation. Now guarded by `hasPolicy`; an unrecognised - selection means no change, which is what the docs already implied. - -- [x] **Bug found and fixed: the default migration strategy stopped purging - shadow zeros.** When `MigrationStrategy` was renumbered to `iota + 1`, its - zero value -- the documented default -- stopped matching any case in - `migrateData`'s switch, so a switch skipped purging the incoming policy's - zero-value shadow entries and served them to callers as real data. Cold is - now the `default:` arm rather than a named case, so no strategy value can - fall through the one step every strategy must take. - `TestMigration_DefaultStrategyNeverServesAShadowZero` covers all four values. - The same renumbering hit `bandit.Mode`, where a zero value claimed no - leadership and followed no leader; `validate` now resolves it explicitly. - -- [x] Evidence: **pooling helps only in the regime it was built for.** Paced to - ~8 requests per cache epoch per replica, a pooled fleet gains 3.6 points over - replicas deciding alone (58.17% vs 54.52%), and the mechanism is visible in - the endings: starved replicas scatter across 7 policies, the pooled fleet - holds 2. Unstarved, pooling *loses* -- about a point on zipf, 3.1 points on a - fleet whose replicas serve different workloads, where one fleet-wide policy - is a compromise nobody wanted. Re-measured after the shadow-insert fix; the - coordination sweep changed sign at the fast end (10ms +0.25, 25ms +0.47, 50ms - -0.46, 200ms -2.52 vs local), so coordinating often is now worth a fraction - of a point rather than always costing. - - **The unpaced fleet tests measure the wrong regime.** `Replay` runs flat - out, so it delivers thousands of requests per epoch however small the - workload; a smaller workload just finishes sooner. `ReplayPaced` holds a - request rate, which is the only way to reproduce thin traffic, and it costs - wall-clock time to do so. Do not "speed up" the paced test by unpacing it. - - The coordination-epoch sweep shows most of the unstarved loss is the fleet - getting fewer chances to change its mind, and the fastest settings are the - ones a real round trip makes most expensive. These replays use `MemStore`, - so coordination is free in a way it will not be in production. - - `bench/bandit.go` is gone; `bench` imports the `bandit` module. - -### Incomplete / TODO - -- [x] Data migration between policies on switch — `MigrationStrategy` in `Settings` (`MigrationCold` default, `MigrationWarm` copies all keys from old active to new active) -- [x] Unit tests for LFU packages (simplelfu: 98.3% coverage, lfu wrapper: 100% coverage) -- [x] Unit tests for root package (cache_test.go: 93.6% coverage -- CacheWrapper, AdaptiveCache delegated methods, tryChangePolicy, epoch-based switching, constructor edge cases, concurrent access) -- [x] Roadmap Milestone 3 (policy coverage), two of three items — LRU, 2Q, - Random, TTL and ARC adapters. Four findings shaped the design: - - **ARC is patented by IBM** (US 6,996,676). Upstream `hashicorp/golang-lru` - split it into its own module in v2 for that reason; `policies/arc` keeps - that split so importing `policies` never pulls a patented implementation - into a build. Do not merge it into `policies` for convenience. - - **Neither 2Q nor ARC satisfies `Cacher`**: their `Add`/`Remove` return - nothing and neither has `Resize`, which the miniature-shadow mechanism - depends on. One shared `policies.Adapt` covers both. Its `Resize` rebuilds - the cache, discarding the algorithm's learned adaptation state — so an - adapted policy resized every epoch would stay permanently unadapted and - under-report its own hit rate. - - **`TTLCache` deliberately does NOT use `expirable.LRU`.** Three defects - made that untenable, all found in review: its `Get`/`Peek` return - `(zeroValue, true)` for an expired-but-unreaped entry (a bare `return` over - an already-true named result) -- a zero-value leak, the one invariant this - library is built on; its `Values` returns a full-length slice padded with - trailing zeros that no longer line up with `Keys`; and `NewLRU` starts a - reaper goroutine per cache with no way to stop it, leaking the goroutine - and the whole cache forever. `TTLCache` instead stores a `ttlEntry{value, - expiresAt}` in a plain `lru.Cache` and expires lazily on read. It also - makes size 0 mean *empty*, where `expirable` documents 0 as *unlimited* -- - which, since shadows are resized automatically, would have turned a - bounded shadow into an unbounded one. - - **`Keys()` order is not portable.** 2Q returns frequent-then-recent, ARC - returns recent-then-frequent; neither is a global recency order. So - `AdaptedCache.Resize` replays every entry and lets the rebuilt cache pick - its own victims -- which entries survive a shrink is explicitly not - meaningful. Any "keep the tail" rule is right for one cache and exactly - backwards for the other. - - **`RandomCache.Add` must evict before inserting.** Inserting first puts the - caller's own write into the victim draw, losing it with probability - 1/(size+1) -- a write accepted and gone before the next read. No other - policy here does that, and no conformance test caught it until one was - added that fills a cache and then reads the fresh key back. - - **golang-lru v2.0.7 builds fine; an earlier note here said otherwise and - was wrong.** The claim was that its published `simplelru` imports - `.../simplelru/internal`, which the module does not contain. What actually - happened is that one machine's module cache held a corrupted extraction of - v2.0.7 whose `simplelru/lru.go` line 9 read - `github.com/hashicorp/golang-lru/v2/simplelru/internal`, where the - published file reads `github.com/hashicorp/golang-lru/v2/internal`. The - v2.0.6 and v2.0.7 zips have identical file lists and both contain - `internal/list.go`; a build against v2.0.7 with an empty `GOMODCACHE` - succeeds. The pin at v2.0.6 is therefore only inertia, not a defence. - - The lesson is about the evidence, not the version: "verified against the - checksum database" was asserted but never actually run. When a - dependency looks broken, compare the module zip from `proxy.golang.org` - against the extracted copy in `GOMODCACHE` before concluding anything is - upstream -- `go mod verify` does not re-hash an already-extracted tree. -- [x] W-TinyLFU arm (`policies/tinylfu`, over `maypok86/otter` v2) — the - baseline that actually needs beating. otter is natively resizable, so unlike - 2Q and ARC this arm never rebuilds and keeps its frequency sketch. Caveat: - otter reports an *approximate* size, so `Len()` is approximate and the - `EvictPartialCapacityFilling=false` capacity gate (which compares `Len()` to - `Cap()` for exact equality) may not fire — set it to true when this arm is in - play. -- [x] README Idea section, rewritten to describe the mechanism as built: - per-request (active serves, sampler gates, shadows take keys only) and - per-epoch (equal evidence per arm, bandit names an arm, stability gates and - migration). The stale `## TODO` section, whose two items both shipped in - 0.1.0, is replaced by `## What is not done` -- lock-free reads, unsteppable - wall-clock epochs, no adaptive sizing, no production use. - -- [x] CI covers every module. `bandit` and `bandit/redis` were absent from both - matrices in `.github/workflows/ci.yml`, so the newest and most - concurrency-sensitive module in the repo was checked by nothing but a local - `make test`. Both matrices now mirror the Makefile's `MODULES` (keep them in - step). A separate `redis` job runs the `bandit/redis` suite against Valkey 8 - and Redis 7 service containers -- the CI equivalent of `make redis-test` -- - because miniredis is exactly the kind of fake that is too permissive about - what this adapter leans on. `AS_CACHE_REDIS_ADDR` is set as a step `env` - rather than exported inline, which is the same word-splitting trap - `bandit/redis/TESTING.md` documents: get it wrong and the job silently tests - the fake while reporting that it tested a server. Verified green on the merge - commit: 24 jobs, both engines included. - -- [x] `release-check` runs in CI and in `make all`. It ran in neither: this - file claimed it was "part of `make all`" while the target was `fmt vet lint - test`, so the one gate that catches an unreleasable module was purely - opt-in, and the failure it guards against is invisible until a stranger's - `go get` fails. Both are now wired up, and the claim is true. - -- [x] **Request-counted epochs and the `benchclient` module**, so the cache can - enter a benchmark suite (`maypok86/benchmarks`) and produce a number worth - quoting. Four findings, in the order they surfaced: - - **`Settings.EpochRequests` ends an epoch every N `Get` calls.** A - wall-clock epoch makes adaptation a function of machine speed: replaying - one trace on a loaded machine re-evaluates fewer times and lands on a - different hit rate. `Get` is the unit because hits and misses are recorded - there and nowhere else. The counter subtracts the limit rather than - resetting to zero, so requests arriving mid-crossing are still counted, and - the trigger compares `== limit` so exactly one goroutine runs each epoch. - It runs on the caller's goroutine, outside every lock the `Get` took -- - `runEpoch` needs the write lock. - - **The Thompson bandit was not reproducible even when seeded.** - `SelectPolicy` drew one sample per arm while ranging a map, so map - iteration order decided which arm got which draw. The seed fixes the - sequence of numbers, not the recipients. This is the same defect as the - epoch-report ordering fixed in 0.2.0, in the module written to fix it. - Anything that consumes randomness while iterating a map has this bug. - - **Determinism is a property of the whole stack, and one arm breaks it.** - With the bandit fixed, LRU/LFU/2Q/Random replay identically; W-TinyLFU does - not. Measured with no cache above it, one trace gave three hit counts and - left 527, 504 and 545 entries at capacity 500 -- otter evicts - asynchronously and reports an approximate size. Hence `DefaultArms` - excludes it and `ArmsWithWindowTinyLFU` exists to make that trade - explicitly. Do not "improve" the default by adding the best arm to it. - - **`benchclient` imports nothing from any benchmark suite.** The contract is - five methods and Go interfaces are structural, so a local `contract` - interface plus a `var _` assertion pins it without a dependency, and breaks - the build rather than the registration if it ever drifts. - -- [x] **S3-FIFO and SIEVE arms** (`policies/fifo`, `ascache.S3FIFO` and - `ascache.SIEVE`), one module adapting scalalang2/golang-fifo v1.2.0 (MIT). - Yang, Zhang, Qiu, Yue & Rashmi, "FIFO Queues are All You Need for Cache - Eviction", SOSP '23; Zhang, Yang, Yue, Vigfusson & Rashmi, "SIEVE is Simpler - than LRU", NSDI '24. - - **One module, one adapter, two algorithms.** They come from one dependency - and need the same four missing methods supplied, so duplicating ~300 lines - of deadlock-sensitive glue to give each its own module would have been the - wrong split. `Cache` is parameterised by a constructor returning - `types.Cache`; `NewS3FIFOPolicy` and `NewSievePolicy` choose which. The - module is `policies/fifo`, not `policies/s3fifo`, for that reason. - - **The two fail differently at size zero**, which is why the adapter guards - rather than trusting either: SIEVE's `New` panics outright, S3-FIFO's `Set` - loops "while len >= size, evict" with nothing to evict and never returns. - - **Only S3-FIFO's `Len` is racy** (it reads its queues without locking); - SIEVE's locks. The adapter answers `Len` from its own index for both, so - the hazard is out of reach rather than dependent on which is wrapped. - - **Both arms are driven through a real AdaptiveCache, not just measured.** - `TestArmsDriveAnAdaptiveCacheThroughSwitches` forces a switch every half - round through all four migration strategies, which is the only thing that - exercises what the adaptive layer does to this adapter specifically: - demotion walks `Keys()` and rewrites every sampled key to the zero value - (removing the unsampled ones), promotion resizes back to full capacity, and - warm migration copies out through `Keys()` + `Peek()`. All three lean on - the adapter's own key index and on its rebuild-to-resize. The invariant - under test is the repository's central one: a caller must never read a - shadow's zero value as data, so stored values are `i+1` and any zero read - back is unambiguously a leak. - - **That test must use `EpochRequests`, not `EpochDuration`.** Written with - a 1ms wall-clock epoch it passed alone and failed under `-race` in the - full suite: the race detector slows the epoch goroutine enough that the - run switched too few times to reach both arms. Request-counted epochs - make the number of switches identical on any machine. Anything asserting - "a switch happened" needs them. - - **`bench.TestEveryArmDrivesAnAdaptiveCache`** asserts the whole arm set - builds, reports distinct `PolicyType`s and constructs a real cache. A - duplicate type takes out every adaptive measurement at once (the - constructor rejects the set), and an arm in `FixedPolicies` that is not - reachable through `AdaptiveArms` would be measured alone and silently left - out of the comparison it exists for. - - Its capacity assertion is deliberately loose (1.5x). W-TinyLFU is in the - set and runs over its limit while otter's maintenance catches up; a tight - bound here fails intermittently and measures that arm rather than the - wiring. - - **It is the best fixed policy on one of the six real traces** -- Meta - kvcache, at 69.05% -- and within 0.05 points of the winner on Twitter - (SIEVE 59.78%) and 0.46 of it on OLTP (2Q 68.25%). It is among the cheapest - per operation, because it never reorders anything on a hit. An earlier note - here claimed three of six; that was read off a run predating SIEVE. It also serves **0.00% on LIRS - `loop`**, tied with LRU, LFU, 2Q, TTL, ARC and SIEVE. That is the number worth - remembering about it: **the ghost queue is a hard horizon.** `loop` cycles - 1011 keys through a 500-entry cache, so every reuse distance is 1011; a key - must return while it is still in the small queue or still in the ghost - queue to be promoted, and at 1011 it is neither. On the synthetic `loop`, - whose cycle is 550, it serves ~80%. The entire difference is reuse distance. - - **Written from scratch first, then replaced with the library on request.** - The from-scratch version is gone; what it taught is not: - - **A differential test against a second implementation found a bug nothing - else did.** The hand-rolled ghost queue used the paper's §4.2 - virtual-timestamp approximation, whose window is *positional*, so an - entry removed from it left a dead position - and a ghost hit removes one - on every second-chance admission. The queue steadily held fewer keys than - its capacity claimed and dropped them just before they came back. Every - property test passed: promotion threshold, freq cap, reinsertion, ghost - re-admission, capacity, resize, invariants. Each property held in - isolation; the hit stream did not. **If a policy is ever hand-written - here again, write the differential test first.** - - The paper's `evict()` (`S.size >= 0.1 * cache size`) and the reference - implementation's (main when it overruns or small is empty) agree on every - case a differential run exercised. - - **The adapter has to supply four methods upstream does not have**: `Keys`, - `Values`, `Resize`, `Cap`, plus `Add`'s evicted flag. Four hazards came out - of that, all of them load-bearing: - - **`Resize` rebuilds, discarding the ghost queue and every counter.** - Upstream cannot resize. AdaptiveCache resizes a policy on every promotion - and demotion, so this arm is permanently re-learning if it changes role - often - the exact cost `AdaptedCache.Resize` documents for 2Q and ARC, - now paid by the strongest cheap arm too. This is the single biggest - difference from the from-scratch version, which resized in place. - - **The eviction callback runs under the library's mutex.** golang-fifo - calls it from inside `removeEntry` with its own lock held, on paths this - adapter entered with its own lock held. `onEvicted` therefore must NOT - take the adapter's mutex - a `sync.Mutex` is not reentrant and it would - deadlock on the first eviction. That reasoning holds only because the - cache is built with a TTL of zero and so has no background expiry - goroutine. Do not add a TTL to this adapter. - - **Upstream's `Len()` takes no lock**, so calling it concurrently with a - write is a data race the detector will report. The adapter answers `Len` - from its own index and never calls it. - - **Upstream cannot be built at size zero**: `Set` loops "while len >= - size, evict" with nothing to evict, and never returns. The adapter holds - no cache at all in that state and answers every method itself. - - **Upstream counts a write as an access** (`Set` on a live key raises its - counter), which the paper's algorithm does not. Shadow policies here are - driven with `Add`, so this arm's counters rise faster on shadow duty than - the algorithm specifies. - - **Adding a strong arm made adaptive selection look worse, not better.** - Arms are not free: each thins the evidence every other arm gets per epoch. - It also showed up as a test failure - - `TestFleet_HeterogeneousShardsAreWherePoolingShouldHurt` asserted that - leader election leaves the fleet on exactly one policy, which held 8 runs - out of 8 with seven arms and fails about 1 run in 5 once S3-FIFO and SIEVE - brought the set to nine. The - assertion now covers the property the test is named for (pooling loses to - deciding alone on a heterogeneous fleet) and bounds the convergence claim - instead of pinning it. Do not add arms to the default sets without - re-running the evidence suite. - - It is in `benchclient.DefaultArms`: that set's criteria are deterministic - and unencumbered, and this arm is both. - -- [x] **MSR Cambridge and Meta kvcache trace loaders** (`bench/trace_msr.go`, - `bench/trace_meta.go`), plus a shared `openTraceScanner` the older loaders - now use. - - **Both layouts expand, and that is the whole difficulty.** MSR carries a - byte offset and a byte length and stands for as many block accesses as fit - in the length (a 64 KiB read is 128 accesses at the default 512-byte block, - not one). Meta collapses runs of identical operations into one row and an - `op_count` that CacheBench replays that many times: on the 2M-request - slice, 942,355 read rows expand to 2,000,000 requests. Read either as - one-record-one-request and you get the same keys, far less reuse, and hit - rates that are wrong and entirely plausible. Same trap as `LoadARCTrace`. - - **MSR keys are namespaced by host and disk.** The set covers thirteen - servers whose volumes are numbered from zero independently, so block 40 - means a different thing on each and pooling by block number alone invents - reuse that never happened. Block size defaults to 512 to match Caffeine's - `CambridgeTraceReader`, which makes the numbers comparable with what is - published from that simulator; changing it changes the workload. - - **Meta columns are located by name, not position.** The 2022 release is - `key,op,size,op_count,key_size` and the 2024 one is - `op_time,key,key_size,op,op_count,size,cache_hits,ttl,usecase,sub_usecase`. - Read at the 2022 offsets, the 2024 file's key column is the key size and - every row is discarded as an unrecognised op -- a failure that looks - exactly like an empty trace, which is why an unknown header is an error - rather than a skip. - - **`GET` and `GET_LEASE` are the reads.** SETs are excluded by default: - almost every SET in these files is immediately followed by a GET of the - same key, so replaying both hands every policy a free hit per write. - - **SNIA is not scriptable and the script says so.** `iotta.snia.org` serves - the MSR files behind a click-through licence and a cookie check (and was - unreachable from this machine entirely while this was written), so - `fetch-traces.sh` prints how to fetch them by hand instead of pretending. - Any `msr_.csv[.gz]` in the trace directory is picked up by a glob, - so which volumes somebody takes is their choice. - - **The Meta files are 5-10 GB, and a byte-range request over plain HTTPS is - enough.** No AWS credentials and no CLI: the bucket answers `Range`. The - slice ends mid-line, so every loader here skips a row it cannot parse - rather than failing -- which is also what makes a truncated download - usable. - - **The loader tests run in `make test`, not under `make evidence`.** A - format misread is a correctness bug, not evidence. They are pinned against - fixtures copied out of the real files. - -- [x] **Bug found and fixed: a gradual migration window served shadow zeros as - real data.** Introduced by the shadow-insert fix above and caught by an - adversarial review agent, not by the suite. - - While a `MigrationGradual` window is open, `switchLocked` deliberately does - NOT demote the source: it holds the only copy of everything not yet - promoted. But it is not the active policy either, so `fanOutReadLocked` - treated it as a shadow and filled it with the zero value on a miss. - `promoteLocked` then `Peek`s the source and cannot tell that zero from a - real pending value, so it promoted the zero into the active policy and the - caller got it as a hit. - - **"Not active" is not the same as "is a shadow."** That is the general - lesson: for the duration of a gradual window there are three roles, not - two, and anything iterating `c.policies` and skipping only `activePolicy` - has to decide what it means to do to `migrateFrom`. - - `Add`'s fan-out writes to the source too, but compensates by deleting the - key from `migrationRealKeys`, so the poisoned entry is never promoted. Only - the read path was wrong. - - **The precondition is eviction from the source during the window**, so a - test whose working set fits inside the capacity cannot catch it however - many switches it drives - which is exactly why - `TestArmsDriveAnAdaptiveCacheThroughSwitches/gradual` (500 keys, capacity - 500) passed throughout. `TestGradualMigration_NeverServesAZeroFromTheSource` - uses a working set three times the capacity and fails without the guard. - -- [x] **Bug found and fixed: a shadow policy could only ever acquire a key the - active policy had missed**, which made every shadow measurement meaningless - whenever the active policy was performing well. - - `AdaptiveCache.get` fanned out only `policy.Get(key)` to the shadows. The - single shadow-insert path was `AdaptiveCache.Add` - and a read-through - caller calls `Add` only when the *active* policy missed. So a shadow was - fed from the incumbent's miss stream rather than from the traffic, its - contents went static behind a strong incumbent, and a static cache covering - most of a small keyspace scores extremely well. - - **Measured on `loop` with W-TinyLFU active (94% standalone): LFU, LRU, - SIEVE and TTL all serve 0.00% and were reported at over 90%.** `Advice()` - ranked the arm serving 0% above the arm serving 94% and recommended - switching to it. The distortion's sign and size depend on which arm is - incumbent - Random on zipf went the other way, truly 62.6% and reported at - 3.3% - so it never cancelled in the comparison. - - The fix is `fanOutReadLocked`: a shadow that misses fills itself. After it, - every deterministic arm's shadow figure equals its standalone replay to - within 0.005. `TestShadowsMeasureWhatThePolicyWouldActuallyServe` pins that, - deliberately with the strongest arm active, since that is the only - condition under which the defect appears. - - **The `Add` fan-out now skips a key the shadow already holds.** A shadow's - value is always the zero value, so re-adding a key it has carries no - information - but it is not free: a write counts as an access in several - policies, and SIEVE would mark every freshly filled key as visited, - defeating precisely the one-hit-wonder filtering it is carried for. - `Peek` rather than `Contains`/`Get` so the check disturbs nothing. - - `docs/design.md` had stated the opposite ("Selected keys go to every shadow - as `Add(key, zeroValue)`"), which was true of writes and false of reads. - - It was found by an independent review agent, not by the test suite. Nothing - in the suite compared a shadow's measured rate against the same policy - replayed on its own - the one property a shadow exists to have. - -### Release blocker for the S3-FIFO/SIEVE work - -`benchclient/go.mod` requires `github.com/sshaplygin/as-cache/policies/fifo -v0.3.1`, and **no `policies/fifo/*` tag has ever existed** - the module is new -on this branch. `policies/fifo/go.mod` likewise requires root `v0.3.1`, which -does not contain `ascache.S3FIFO`. Both are correct as pre-release placeholders -under the bottom-up procedure below, which rewrites every `require` at tag -time; neither is safe to tag as-is. - -**`release-check` reports green on both, and should not.** Its version filter -only rejects `^v0\.0\.0`, so a require naming a plausible-but-nonexistent tag -passes. That is precisely the "builds here, unusable by a stranger" failure the -script exists to catch, reached through a hole in it. Verified: resolving -`benchclient` from the proxy fails with "module ... found, but does not contain -package .../policies/fifo". Fix the check to confirm each intra-repo require -resolves to a tag that exists before tagging anything. - -### Releasing 0.2.0 - -Root was tagged `v0.1.0` and `v0.1.1`; **no submodule had ever been tagged**, so -`policies`, `metrics` and the rest were unconsumable at any version. 0.2.0 is -the first release to tag the whole graph, uniformly, including modules that did -not change -- each sibling's `require` has to move to the root's new version -anyway. All eight are published and were verified by resolving them from -proxy.golang.org into a module with no `replace` directives. - -Three things learned doing it, none of which are obvious from the outside. - -- **`release-check` passed a module it should have failed.** Its grep was - anchored to requires written inside a `require (` block, so `policies`' - standalone `require github.com/sshaplygin/as-cache/lfu v0.0.0-000101...` - line was never examined. It reads `go mod edit -json` now, which is the - parse the go command itself uses, so no layout can hide an entry. -- **Verify the chain before pushing a tag, not after.** A bare clone plus - `GOPROXY=direct` and a `GIT_CONFIG_*` url rewrite resolves the require lines - exactly as a stranger would, with no global git config touched and nothing - published. Module versions are immutable once the proxy fetches them. -- **A message-only amend is safe after tagging; a tree change is not.** The - module zip is built from the file tree, so rewriting a commit message and - moving the tags leaves every `h1:` hash identical (checked against the - checksum database before and after). Adding so much as a file under `docs/` - would not -- that is inside the root module, and its hash is already - recorded. Anything landing after a tag has to be a commit after the tag. - -The CHANGELOG now also carries a `[0.1.1]` section. The MPL licence work was -filed under `[0.1.0]`, which shipped before the LICENSE files existed. - ---- - -## Implementation Plan - -### Phase 1: Test Coverage -Priority: fill empty test stubs before adding new features. - -**`lfu/simplelfu/lfu_test.go`** -- DONE (98.3% coverage) -- Test Add/Get/Contains/Peek/Remove/Purge/Keys/Values/Len -- Test eviction behavior (least-frequently-used item removed) -- Test frequency increment on repeated access -- Edge cases: empty cache, single item, duplicate keys -- Bug fixes applied: removed double Freq increment in Add, fixed Keys/Values slice init -- Bucket-index invariant enforced: every bucket in `evictList` holds at least one - entry, and `minFreq` always addresses a live bucket while the cache is non-empty. - `Add`'s eviction path and `removeElement` previously left an emptied bucket in - the map, so a later `minFreq` recompute selected it and panicked on a nil - dereference (`GetOldest`/`RemoveOldest`/`Resize`). Entry removal now funnels - through `detach`/`recomputeMinFreq`, `Add` only evicts when the cache is - non-empty (so `Resize(0)` + `Add` cannot panic), and the lookup helpers degrade - to a miss instead of panicking if the index is ever corrupted. - -**`lfu/lfu_test.go`** -- DONE (100% coverage) - -- `Resize`, `ContainsOrAdd`, `PeekOrAdd`, `RemoveOldest` and `GetOldest` are - covered directly; they are fully implemented (there are no panic stubs) but - were previously untested, which is why the simplelfu bucket-index panics went - unnoticed -- those methods are their public entry points. -- Test thread-safe wrapper around simplelfu -- Test eviction callbacks (buffered channel, DefaultEvictedBufferSize=16) -- Test concurrent Add/Get under race detector -- Concurrent tests for mixed operations, purge-while-reading, keys/values - -**Root package tests (`cache_test.go`)** -- DONE (93.6% coverage) -- `CacheWrapper`: hit/miss stats tracking, GetStats/ResetStats, Cap, Name, GetType, delegated methods -- `AdaptiveCache`: Stats, Resize, Contains, Keys, Values, Len, Peek, ActivePolicy -- `AdaptiveCache`: Add/Get with epoch-based switching, tryChangePolicy (switch, no-switch, skip-when-not-full) -- Test bandit integration with mock bandit (recordingBandit verifies shadow stats delivery) -- Test context cancellation stops background goroutine (Close) -- Constructor edge cases: empty policies, nil policies -- Remove propagates to shadows, Purge clears all policies -- Concurrent access to all delegated read methods under race detector -- Migration strategy tests: Cold, Warm, Gradual (promotion, drain, zero-value safety, concurrent) -- All assertions use `testify/require` (fatal) and `testify/assert` (non-fatal) — no bare `t.Fatal`/`t.Error` -- Note: `tryChangePolicy()` returns `PolicyType` (the bandit's selection), not `bool`; tests compare against `LRU`/`LFU` constants - -### Phase 2: Complete AdaptiveCache Methods -Implement the missing methods that currently return zero values: -- `Remove(key K)`: delegate to active policy, propagate to shadows -- `Purge()`: purge all policies -- `Contains(key K)`: check active policy -- `Keys()` / `Values()` / `Len()` / `Peek()`: delegate to active policy -- `Resize(size int)`: resize all policies -- `Stats()`: aggregate hit/miss from all wrappers - -### Phase 3: Policy Migration on Switch -- DONE - -`MigrationStrategy` enum added to `models.go`; `Settings.MigrationStrategy` field controls behaviour: - -- `MigrationCold` (default, 0): start fresh — simple, causes temporary miss spike -- `MigrationWarm`: on switch, purge zero-value shadow entries from new active policy, then copy all key/value pairs from old active via `Keys()`+`Peek()` - -- `MigrationGradual`: Get-time promotion (during the window, `Get` takes the write lock and promotes the eligible key from the old policy into the new active BEFORE the counted lookup, so promoted requests register as active-policy hits — not misses) + Add-time drain (one key migrated per Add call). Migration window closes when no eligible keys remain (drained, promoted, or removed), on `Purge()`, on the next policy switch, and unconditionally at the next epoch boundary (`runEpoch` calls `closeMigrationLocked` first, so a workload that stops touching pending keys cannot leave the source pinned at full capacity holding real values). Switching away from an empty policy never opens a window. - -Bug fix applied during implementation: all three strategies now purge shadow zero-value entries from the new active policy at switch time, so callers never observe a shadow zero as a real cached value. - -### Phase 4: Additional Policies -Add wrappers for: -- `hashicorp/golang-lru/v2/expirable` (TTL-based) -- `hashicorp/golang-lru/v2` 2Q variant -- Random eviction policy - -Each new policy only needs to implement the `Cacher` interface and be wrapped by `CacheWrapper`. - ---- - -## Testing Guidelines - -- Use `go test -race ./...` to catch race conditions (mandatory given concurrent design) -- Mock the `Bandit` interface to test policy switching deterministically -- Use table-driven tests for cache operation coverage -- Test epoch transitions with short durations (e.g., 1ms) in tests -- Minimum 80% coverage target -- Use `require.*` for assertions that must stop the test on failure (setup, preconditions) -- Use `assert.*` for assertions where the test can continue (value checks, multiple independent conditions) -- Do not use bare `t.Fatal`, `t.Fatalf`, `t.Error`, or `t.Errorf` — use testify instead -- All three test packages (`ascache`, `lfu`, `simplelfu`) use `github.com/stretchr/testify` - ---- - -## Releasing - -This is a multi-module repository, which has one trap that will bite anyone who -tags without knowing about it. - -Every sibling module depends on the others through a `replace` directive -pointing at a local path. That is what makes local development work -- and -**`replace` directives are ignored when a module is consumed as a dependency**. -So `policies/go.mod` requiring `github.com/sshaplygin/as-cache v0.0.0` builds -and tests perfectly here while being impossible for anyone else to use: - - reading github.com/sshaplygin/as-cache/go.mod at revision v0.0.0: - unknown revision v0.0.0 - -`make release-check` catches this; it is part of `make all`. Do not tag until it -passes. - -The licence is MPL 2.0, and **every publishable module carries its own copy**. -A Go module zip contains only its own directory, so a root-only LICENSE never -reaches someone fetching `policies/arc` or any other submodule. Upstream -`hashicorp/golang-lru` ships a LICENSE inside its `arc` submodule for the same -reason. `release-check` verifies this per module. - -Releasing therefore goes bottom-up through the dependency graph -- root, then -`lfu`, then `policies`, then `policies/arc`, `policies/tinylfu` and `metrics` -- -updating each module's `require` to the version its dependency was just tagged -at. `bench` and `examples/*` are internal and are never tagged. - -Go module versions are immutable once the module proxy has fetched them. A -broken `v0.1.0` cannot be replaced, only superseded, so verify the whole chain -resolves before pushing any tag. - -## Rules - -1. No emojis in code, comments, or documentation -2. All public types must have godoc comments -3. Run `go vet ./...` before committing -4. Keep each file under 400 lines; split by responsibility -5. No `panic` except in initialization failures or truly unimplemented stubs -6. Eviction callbacks must not be called while holding a mutex -7. All new policies must implement `Cacher[K, V]` and be wrapped via `CacheWrapper` -8. Update `PolicyType` enum and regenerate stringer when adding new policies diff --git a/codemaps/architecture.md b/codemaps/architecture.md deleted file mode 100644 index e27d3bb..0000000 --- a/codemaps/architecture.md +++ /dev/null @@ -1,144 +0,0 @@ -# Architecture Codemap - -**Last Updated:** 2026-07-26 -**Root module:** `github.com/sshaplygin/as-cache` -**Go version:** 1.25.2 -**Modules:** 9 (6 publishable, 3 internal) - -## Overview - -as-cache selects a cache eviction policy at runtime instead of asking the caller -to choose one. Candidate policies run side by side against the same key stream; -one serves real data while the rest measure comparative hit rates. A Multi-Armed -Bandit picks the active policy each epoch, or -- in `ObserveOnly` mode -- the -cache never switches and simply reports which policy would have served better. - -The core module has no dependencies. Policies and integrations live in companion -modules so a consumer pulls in only what it uses. - -## Module graph - -```text - as-cache (core, no deps) - / | \ - / | \ - policies metrics lfu - / | \ ^ - / | \ | - arc tinylfu (policies also depends on lfu) - \ - bench (internal: depends on all of the above) -``` - -| Module | Role | External deps | LOC | -| --- | --- | --- | --- | -| `.` (root) | cache, epochs, sampling, advice | none | 1592 | -| `lfu` | O(1) LFU implementation | none | 682 | -| `policies` | LRU/LFU/2Q/Random/TTL adapters | `hashicorp/golang-lru/v2` | 1101 | -| `policies/arc` | ARC adapter (patent-isolated) | `hashicorp/golang-lru/arc/v2` | 50 | -| `policies/tinylfu` | W-TinyLFU adapter | `maypok86/otter/v2` | 207 | -| `metrics` | expvar export | none | 171 | -| `bench` | workloads, traces, evidence (internal) | all of the above | 808 | -| `examples/*` | runnable demos (internal) | -- | -- | - -`policies/arc` is a separate module **solely** because ARC is patented by IBM -(US 6,996,676); importing `policies` must never pull a patented implementation -into a build. Upstream `hashicorp/golang-lru` made the same split. - -## Request path - -```text -Caller - | - v -AdaptiveCache.Get(key) - | - |-- sampler.sampled(key)? ---no--> skip shadows entirely - | |yes - | v - | for each shadow: policy.Get(key) (measurement only) - | - v -activePolicy.Get(key) ------------------> value to caller - | - +-- recordActiveSample(hit) (counts only sampled keys, so every - arm is judged on the same substream) -``` - -`Add` mirrors this: shadows receive `Add(key, zeroValue)`, the active policy -receives the real value. **A shadow never holds a real value**, which is what -makes running seven policies cost 2.65x one rather than 7x. - -## Epoch lifecycle - -Driven by a background goroutine on `Settings.EpochDuration`. - -```text -runEpoch() [holds the write lock throughout] - | - 1. closeMigrationLocked() end any gradual window; demote its source - 2. selectPolicyLocked() report every arm to the bandit, reset - | counters, accumulate tenureStats - 3. ObserveOnly? --yes--> stop here; the active policy never changes - | - 4. allowSwitchLocked() stability gates (improvement, cooldown, - | minimum requests) - 5. switchLocked(from, to) promote capacity -> migrate -> activate - | -> demote the outgoing policy - 6. epochID++ -``` - -### The ordering invariant - -Documented on `switchLocked` and load-bearing: - -> Every mutation of a policy must happen while that policy is not the active -> one. - -The incoming policy is resized and migrated into *before* it becomes active; the -outgoing policy has its values dropped only *after* it stops being active. -Reversing either half would let a caller read a policy mid-rewrite and take a -dropped zero for real data. Any future move to lock-free reads rests on this. - -## Key mechanisms - -**Sampled shadows** (`sampling.go`). One `keySampler` per cache, shared by every -policy so all arms measure the same substream. Shadows shrink to -`ceil(rate * Cap)` so each stays a faithful miniature; `MinShadowCapacity` floors -that by raising the *effective rate*, not just the capacity. - -**Migration** (`migration.go`). Cold discards, Warm copies at switch time, -Gradual promotes on read and drains one key per `Add`. A gradual window closes at -the next epoch at the latest. - -**Advice** (`advice.go`). `tenureStats` accumulates per policy and is cleared -when a policy changes role, so a comparison never pools a policy's active tenure -(full capacity, all traffic) with its shadow tenure (miniature, sampled). - -## Concurrency model - -- One `sync.RWMutex` guards the cache. Reads take `RLock`; `Add`, `Remove`, - `Purge`, `Resize` and the epoch take `Lock`. -- Wrapper hit/miss counters and the active-sample counters are `atomic.Int64`, - because reads mutate them while holding only a read lock. -- Underlying policies carry their own locks; the cache lock orders policy - *switching*, not individual policy operations. -- Reads are **not** lock-free. That work is deferred; see `CLAUDE.md`. - -## Layering rules - -- The root module must stay dependency-free. Anything needing a third-party - cache goes in a companion module. -- New policies implement `Cacher[K, V]` and are wrapped by `CacheWrapper`; they - need no change to the core. -- `PolicyType` is a closed enum in the root; adding a policy means extending it - and regenerating the stringer. - -## Related documents - -- `codemaps/backend.md` -- file-by-file structure -- `codemaps/data.md` -- types, enums and their invariants -- `CLAUDE.md` -- development rules and the multi-module release procedure -- `README.md` -- landing page: what it is, when to use it, quick start, API -- `docs/` -- design, configuration, policies, advisor mode, benchmarking, - fleets and the measured evidence behind every claim diff --git a/codemaps/backend.md b/codemaps/backend.md deleted file mode 100644 index c552cf6..0000000 --- a/codemaps/backend.md +++ /dev/null @@ -1,184 +0,0 @@ -# Implementation Codemap - -**Last Updated:** 2026-07-26 - -File-by-file structure. Types and their invariants are in `data.md`; the -component and epoch diagrams are in `architecture.md`. - -## Root module -- `github.com/sshaplygin/as-cache` - -No external dependencies. `testify` is test-only. - -| File | Responsibility | -| --- | --- | -| `doc.go` | package documentation (the pkg.go.dev landing page) | -| `interfaces.go` | `Cacher`, `CacheStats`, `Policy`, `Bandit` | -| `models.go` | `PolicyType`, `MigrationStrategy`, stats structs | -| `errors.go` | sentinel errors returned by the constructor | -| `settings.go` | `Settings` + `NewAdaptiveCache` validation | -| `cache.go` | `AdaptiveCache` struct and the public cache API | -| `epoch.go` | epoch loop, bandit reporting, policy selection | -| `shadow.go` | promote/demote, shadow duty, value dropping, switch | -| `migration.go` | cold/warm/gradual migration | -| `sampling.go` | `keySampler`, miniature capacity maths | -| `stability.go` | switch gates (improvement, cooldown, min requests) | -| `advice.go` | `Advice`, `PolicyReport`, observe-only reporting | -| `wrapper.go` | `CacheWrapper`: hit/miss tracking around any `Cacher` | -| `policytype_string.go` | generated by `stringer`; do not edit | - -### Call graph, hot path - -```go -Get(key) // cache.go - sampler.sampled(key) // sampling.go - [shadows].Get(key) // measurement only - activePolicy.Get(key) - recordActiveSample(hit) // cache.go, atomic counters - // gradual window only: promoteLocked -> migration.go -``` - -### Call graph, control plane - -```go -runAdaptiveSelect() // epoch.go, background goroutine - runEpoch() - closeMigrationLocked() // shadow.go -> demoteLocked - selectPolicyLocked() // epoch.go - policy.GetStats/ResetStats - bandit.RecordStats // every arm, active included - bandit.SelectPolicy - allowSwitchLocked() // stability.go - switchLocked(from, to) // shadow.go - promoteLockedCapacity(to) - migrateData(from, to) // migration.go - activePolicy = to - demoteLocked(from) -``` - -### Things that look wrong but are deliberate - -- `selectPolicyLocked` reports the **active** policy from `activeSampledHits`, - not from its own counters, so every arm is judged on the same substream. -- `demoteLocked` rewrites entries to the zero value in `Keys()` order rather - than purging: that preserves LRU recency and leaves LFU relative order intact. -- `Advice.Epochs` comes from `reportingEpochs`, not `epochID`. The capacity gate - can skip measurement for many ticks, and reporting those as evidence would - overstate it. -- `AdaptedCache.Resize` replays *every* entry and lets the rebuilt cache evict. - `Keys()` order is not portable (2Q returns frequent-then-recent, ARC returns - recent-then-frequent), so no "keep the tail" rule is correct for both. - -## `lfu` -- O(1) LFU - -| File | Responsibility | -| --- | --- | -| `lfu.go` | thread-safe wrapper, eviction callbacks | -| `simplelfu/lfu.go` | core algorithm, frequency buckets | -| `internal/list.go` | doubly-linked list per frequency bucket | - -Invariant: every bucket in `evictList` holds at least one entry, and `minFreq` -always addresses a live bucket while the cache is non-empty. Entry removal -funnels through `detach`/`recomputeMinFreq`. `Add` refuses entries at zero -capacity. - -## `policies` -- ready-made arms - -Depends on the root module, `lfu`, and `hashicorp/golang-lru/v2` (held at -**v2.0.6**; v2.0.7 builds too, see `CLAUDE.md`). - -| File | Provides | -| --- | --- | -| `adapters.go` | `NewLRU`, `NewLFU`, `NewTwoQueue`, `NewTTL`, `NewRandomPolicy` | -| `adapt.go` | `PartialCacher`, `AdaptedCache`, `Adapt` | -| `random.go` | `RandomCache`, from scratch | -| `ttl.go` | `TTLCache`, own expiry over plain LRU | -| `conformance_test.go` | shared contract suite every policy must satisfy | -| `regression_test.go` | guards for specific past defects | - -`Adapt` exists because 2Q and ARC both lack `Resize` and report neither -evictions nor removals. `TTLCache` does **not** use `expirable.LRU`: that type -returns `(zero, true)` for an expired-but-unreaped entry, pads `Values` with -zeros, and leaks a reaper goroutine per cache. - -## `policies/arc` -- patent isolation - -One file wrapping `hashicorp/golang-lru/arc/v2` through `policies.Adapt`. -Separate module so that importing `policies` never pulls in a patented -algorithm. Do not merge it into `policies` for convenience. - -## `policies/tinylfu` -- W-TinyLFU - -Wraps `maypok86/otter/v2`. Natively resizable, so unlike the adapted policies it -never rebuilds and keeps its frequency sketch. `Len()` is **approximate** -(otter reports an estimate), so the `EvictPartialCapacityFilling=false` capacity -gate may not fire; set it true when this arm is in play. - -## `metrics` -- observability - -Standard library only. `Advisor` is an interface rather than a generic -parameter so any `AdaptiveCache[K, V]` satisfies it. `Publish` registers an -`expvar.Func` (evaluated on scrape) behind a mutex plus a `recover`, because -`expvar.Get` then `expvar.Publish` is check-then-act and expvar panics on a -duplicate name. - -## `bench` -- evidence (internal, never tagged) - -| File | Provides | -| --- | --- | -| `workload.go` | deterministic zipf/uniform/loop/scan/phase-shift generators | -| `trace.go` | loaders for Twitter, LIRS and ARC trace formats | -| `bandit.go` | Thompson (Beta posteriors, discounted) and greedy bandits | -| `harness.go` | `Replay`, `Result`, `FixedPolicies`, tables | -| `evidence_test.go` | policy comparison, sampling-fidelity sweep | -| `timeline_test.go` | `ActivePolicy()` plot over a phase shift | -| `memory_test.go` | memory multiplier and allocations | -| `tuning_test.go` | epoch/migration configuration sweep | -| `trace_test.go` | real-trace evidence + parser self-tests | - -Evidence tests are guarded by `testing.Short()` and excluded from `make test`, -which passes `-short`. Under `-race` epoch pacing changes enough to make the -measurements meaningless. Run `make evidence`. - -The root module ships no `Bandit`; `bench/bandit.go` has one short enough to -copy. - -## Entry points - -What to call, per module. Methods on these types are deliberately not -enumerated here -- `go doc` is authoritative and always current. - -```go -// root -ascache.NewAdaptiveCache(policies, bandit, settings) (*AdaptiveCache, error) -ascache.NewCache(cacher, policyType, size) *CacheWrapper // wrap your own - -// policies -policies.NewLRU / NewLFU / NewTwoQueue(size) (Policy, error) -policies.NewTTL(size, ttl) Policy -policies.NewRandomPolicy(size) Policy -policies.Adapt(size, build) (*AdaptedCache, error) // adapt your own -policies.NewRandom(size) *RandomCache // concrete types -policies.NewTTLCache(size, ttl) *TTLCache - -// separate modules -arc.NewPolicy(size) (Policy, error) -tinylfu.NewPolicy(size) (Policy, error) - -// lfu -lfu.New(size) (*Cache, error) -lfu.NewWithEvict(size, onEvicted) (*Cache, error) - -// metrics -metrics.Take(cache) Snapshot -metrics.Publish(name, cache) error -``` - -## Build and checks - -```bash -make all # fmt, vet, lint, test, release-check -make test # go test -race -short, every module -make evidence # replay workloads, print the comparison tables -make release-check # licence + unresolvable sibling requires -make lint # golangci-lint v2, shared root config -``` diff --git a/codemaps/data.md b/codemaps/data.md deleted file mode 100644 index 55240d5..0000000 --- a/codemaps/data.md +++ /dev/null @@ -1,178 +0,0 @@ -# Data Models Codemap - -**Last Updated:** 2026-07-26 - -Types, their zero values, and the invariants that hold them together. File -structure is in `backend.md`. - -## Enums - -### PolicyType (`models.go`) - -```go -type PolicyType uint - -const ( - Undefined PolicyType = iota // 0 -- zero value, names no policy - LRU // 1 - LFU // 2 - TwoQueue // 3 -- scan-resistant - ARC // 4 -- patented; adapter is a separate module - Random // 5 -- the control arm worth beating - TTL // 6 -- expiry as well as recency - TinyLFU // 7 -- W-TinyLFU -) -``` - -Closed enum. Adding a policy means extending it **and** running -`go generate ./...` to regenerate `policytype_string.go`. - -`Undefined` is meaningful: a bandit returning it selects nothing, which is how -`observerBandit` works in `ObserveOnly` mode. - -### MigrationStrategy (`models.go`) - -```go -type MigrationStrategy uint - -const ( - MigrationCold MigrationStrategy = iota // 0 -- start empty - MigrationWarm // 1 -- copy at switch time - MigrationGradual // 2 -- promote on read, drain on Add -) -``` - -Every strategy purges the incoming policy first: it arrives from shadow duty -holding zero values, and serving one as real data would break the core -invariant. - -Measured cost: `MigrationCold` loses 28 points of hit rate on the ARC OLTP -trace. `MigrationWarm` is the sane default. - -## Configuration - -### Settings (`settings.go`) - -Every field is inactive at its zero value, so `Settings{EpochDuration: d}` is a -complete configuration. - -| Field | Type | Zero means | -| --- | --- | --- | -| `EpochDuration` | `time.Duration` | **required**, must be > 0 | -| `EvictPartialCapacityFilling` | `bool` | only switch once the cache is full | -| `MigrationStrategy` | `MigrationStrategy` | `MigrationCold` | -| `MinHitRateImprovement` | `float64` | no improvement gate | -| `SwitchCooldownEpochs` | `int64` | may switch every epoch | -| `MinEpochRequests` | `int64` | no minimum evidence | -| `ShadowSampleRate` | `float64` | 1 -- shadows mirror every key | -| `MinShadowCapacity` | `int` | `DefaultMinShadowCapacity` (256) | -| `ObserveOnly` | `bool` | the cache may switch | - -`MinHitRateImprovement` is a **fraction** in [0,1], matching `Advice.Improvement` -(0.02 = two points), not a percentage. - -Under `ShadowSampleRate`, `MinEpochRequests` counts *sampled* requests: at rate -0.05 a threshold of 100 is reached after roughly 2000 real requests. - -## Constructor errors (`errors.go`) - -`NewAdaptiveCache` returns these as sentinels; compare with `errors.Is`. - -| Error | Cause | -| --- | --- | -| `ErrEmptyPolicies` | no policies supplied | -| `ErrNilSettings` | `settings` is nil | -| `ErrInvalidEpochDuration` | `EpochDuration` <= 0 (`time.NewTicker` would panic) | -| `ErrNilBandit` | `bandit` is nil and `ObserveOnly` is false | -| `ErrNilPolicy` | a nil entry in the policies slice | -| `ErrDuplicatePolicy` | two policies report the same `PolicyType` | - -Validation order matters: settings is checked before the bandit, because a nil -bandit is legal when `settings.ObserveOnly` is set. - -## Statistics - -```go -type PolicyStats struct{ Hits, Misses int64 } // one policy -type GlobalStats struct{ Hits, Misses int64 } // what the cache served -type ShadowStats struct { // one epoch report to the bandit - Policy PolicyType - Hits, Misses int64 -} -``` - -Three different scopes, easily confused: - -| Source | Covers | Sampled? | -| --- | --- | --- | -| `AdaptiveCache.Stats()` | all traffic the cache served, cumulative | never | -| `ShadowStats` (to bandit) | one policy, since its last report | yes, if enabled | -| `PolicyReport` (in `Advice`) | one policy, current role only | yes, if enabled | - -`Stats()` is cumulative across epochs because per-policy counters are reset -after each report; the active policy's totals fold into `globalStats` first. - -## Advice - -```go -type Advice struct { - Epochs int64 // REPORTING epochs, not elapsed ticks - Active PolicyType - Best PolicyType - Improvement float64 // fraction; 0 when Best == Active - Sampled bool - SampleRate float64 - Reports []PolicyReport // best hit rate first, ties broken by PolicyType -} - -type PolicyReport struct { - Policy PolicyType - Hits, Misses int64 // current role only - Active bool -} -``` - -Invariants worth knowing before changing this: - -- `Reports` is sorted by hit rate with a **deterministic tie-break** on - `PolicyType`. Ranging a map and sorting stably on rate alone made `Best` flap - between equally-performing arms on an unchanged cache. -- `tenureStats` is cleared for both policies on a switch. Pooling a policy's - active tenure with its shadow tenure mixes full capacity over all traffic with - a miniature over a sample, and lets a long history outweigh a short one -- so - advice recommended reverting a correct switch. -- `Epochs` counts epochs that measured something. The capacity gate can skip - measurement indefinitely. - -## Metrics snapshot (`metrics` module) - -`Snapshot` is JSON-serialised for expvar. `Hits`/`Misses`/`HitRate` are real -unsampled traffic; the per-policy figures inside `Policies` are sampled when -sampling is on. The series worth graphing is `active_policy`; the one worth -alerting on is `improvement`. - -## Core invariant - -> A caller must never observe a zero value, or any value never stored, as if it -> were real cached data. - -Everything below serves it: - -- Shadows receive `Add(key, zeroValue)` and hold no real values. -- Every migration purges the incoming policy before it serves anyone. -- Demotion drops values only *after* the policy stops being active. -- Gradual migration promotes a key *before* the counted lookup, so a served - request is a hit rather than a miss against a zero. - -## Capacity model - -Each policy has two capacities: - -- `nominalCap` -- as the caller built it; restored on promotion. -- `shadowCap` -- `ceil(effectiveRate * nominalCap)`; used while shadowing. - -`shadowCapacity` applies the `MinShadowCapacity` floor by raising the effective -*rate*, preserving `shadowCap/nominalCap == rate`. `scaledCapacity` (used by -`Resize`) does **not** apply the floor: the sampler's rate is fixed for the -cache's life, so flooring capacity alone would leave shadows simulating a larger -cache than the real one and bias every arm against the active policy. diff --git a/lfu/simplelfu/lfu_test.go b/lfu/simplelfu/lfu_test.go index ff3a0d8..43b9184 100644 --- a/lfu/simplelfu/lfu_test.go +++ b/lfu/simplelfu/lfu_test.go @@ -585,7 +585,7 @@ func TestRemove_RepairsMinFreqWhenBucketEmpties(t *testing.T) { // The bucket index is an internal invariant that the code above is responsible // for upholding. These white-box cases corrupt it deliberately to prove the // lookup helpers degrade to a miss rather than dereferencing a nil bucket, -// keeping the package free of panics (CLAUDE.md rule 5). +// keeping the package free of panics outside initialisation. func TestCorruptedIndex_DegradesToMissInsteadOfPanic(t *testing.T) { t.Run("minFreq addresses a missing bucket", func(t *testing.T) { c, err := NewLFU[string, int](2, nil) diff --git a/metrics/zzz_refute_probe_test.go b/metrics/zzz_refute_probe_test.go deleted file mode 100644 index fb59a77..0000000 --- a/metrics/zzz_refute_probe_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package metrics_test - -import ( - "testing" - "time" - - "github.com/stretchr/testify/require" - - ascache "github.com/sshaplygin/as-cache" - "github.com/sshaplygin/as-cache/metrics" - "github.com/sshaplygin/as-cache/policies" -) - -// TestProbe_IdleCacheSnapshot prints what an observe-only cache that has served -// NOTHING publishes, after several epochs have elapsed. -func TestProbe_IdleCacheSnapshot(t *testing.T) { - lru, err := policies.NewLRU[string, int](1000) - require.NoError(t, err) - twoQ, err := policies.NewTwoQueue[string, int](1000) - require.NoError(t, err) - - cache, err := ascache.NewAdaptiveCache[string, int]( - []ascache.Policy[string, int]{lru, twoQ}, nil, - &ascache.Settings{EpochDuration: 5 * time.Millisecond, ObserveOnly: true}, - ) - require.NoError(t, err) - t.Cleanup(func() { _ = cache.Close() }) - - // Snapshot BEFORE any epoch has run at all. - t.Logf("t=0 (no epoch yet): %s", metrics.Take(cache).String()) - - // Let several epochs elapse with zero traffic. - deadline := time.Now().Add(300 * time.Millisecond) - for time.Now().Before(deadline) { - if cache.Advice().Epochs > 3 { - break - } - } - - snap := metrics.Take(cache) - t.Logf("idle (epochs=%d): %s", snap.Epochs, snap.String()) - t.Logf("advice struct: %+v", cache.Advice()) - t.Logf("advice string:\n%s", cache.Advice().String()) - - // Is the "nothing measured" state distinguishable from a genuine 0%? - t.Logf("hits=%d misses=%d hits+misses=%d best=%q active=%q improvement=%v", - snap.Hits, snap.Misses, snap.Hits+snap.Misses, - snap.BestPolicy, snap.ActivePolicy, snap.Improvement) -} - -// TestProbe_GenuineZeroHitRate shows what a cache that genuinely misses on -// everything publishes, for comparison with the idle case. -func TestProbe_GenuineZeroHitRate(t *testing.T) { - lru, err := policies.NewLRU[string, int](10) - require.NoError(t, err) - twoQ, err := policies.NewTwoQueue[string, int](10) - require.NoError(t, err) - - cache, err := ascache.NewAdaptiveCache[string, int]( - []ascache.Policy[string, int]{lru, twoQ}, nil, - &ascache.Settings{EpochDuration: 5 * time.Millisecond, ObserveOnly: true}, - ) - require.NoError(t, err) - t.Cleanup(func() { _ = cache.Close() }) - - // Every key distinct -> every Get is a miss. - for i := 0; i < 5000; i++ { - cache.Get(string(rune(i)) + "-never-stored") - } - - snap := metrics.Take(cache) - t.Logf("all-miss: %s", snap.String()) - t.Logf("hits=%d misses=%d hit_rate=%v", snap.Hits, snap.Misses, snap.HitRate) -}