diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a80516..d7f8c28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,9 @@ jobs: strategy: fail-fast: false matrix: - # Every module has its own go.mod and is linted independently. - module: [".", "lfu", "policies", "policies/arc", "policies/tinylfu", "metrics", "bench", "examples/basic", "examples/migration"] + # Every module has its own go.mod and is linted independently. Keep + # this in step with MODULES in the Makefile. + module: [".", "lfu", "policies", "policies/arc", "policies/tinylfu", "metrics", "bandit", "bandit/redis", "bench", "examples/basic", "examples/migration"] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -36,7 +37,7 @@ jobs: strategy: fail-fast: false matrix: - module: [".", "lfu", "policies", "policies/arc", "policies/tinylfu", "metrics", "bench", "examples/basic", "examples/migration"] + module: [".", "lfu", "policies", "policies/arc", "policies/tinylfu", "metrics", "bandit", "bandit/redis", "bench", "examples/basic", "examples/migration"] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -49,3 +50,52 @@ jobs: - name: go test working-directory: ${{ matrix.module }} run: go test -race -short -count=1 ./... + + # The test job above runs bandit/redis against miniredis, which is a fake. + # This one runs the same suite against real servers, because the adapter + # leans on three things a fake can be too permissive about: TIME called + # inside a Lua script, SET with NX and PX, and HINCRBY on a key the script + # names itself rather than declaring in KEYS. + # + # Both engines run, because both are documented as supported. Testing one + # while documenting two is how that claim quietly stops being true. This is + # the CI equivalent of `make redis-test`; docker-compose.yml is the local one. + redis: + name: test bandit/redis (${{ matrix.engine.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + engine: + # Deriving the bucket from the server's clock inside a script needs + # effects replication, so the floor is Redis 7 / Valkey 7.2. + - name: valkey 8 + image: valkey/valkey:8-alpine + ping: valkey-cli ping + - name: redis 7 + image: redis:7-alpine + ping: redis-cli ping + services: + server: + image: ${{ matrix.engine.image }} + ports: + - 6379:6379 + options: >- + --health-cmd "${{ matrix.engine.ping }}" + --health-interval 1s + --health-timeout 3s + --health-retries 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: bandit/redis/go.sum + - name: go test + working-directory: bandit/redis + # Set, so the suite uses the service container rather than miniredis. + # The suite fails rather than skips if this address is unreachable, so + # a broken service container cannot pass as a green fake run. + env: + AS_CACHE_REDIS_ADDR: 127.0.0.1:6379 + run: go test -race -count=1 ./... diff --git a/CLAUDE.md b/CLAUDE.md index 23291c2..251a30f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,13 +2,14 @@ ## Project Overview -**as-cache** (Adaptive Selection Cache) is an experimental Go library that uses a Multi-Armed Bandit (MAB) statistical approach to automatically select the optimal cache replacement policy at runtime. +**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:** Experimental +**Status:** Pre-1.0. API may change; measured against published traces (see +README "Evidence"); not yet tagged or published -- `make release-check`. --- @@ -90,11 +91,35 @@ as-cache/ │ ├── 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 -│ ├── bandit.go # Thompson + greedy bandits (root ships none) │ ├── 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) │ ├── memory_test.go # memory multiplier + allocations @@ -144,8 +169,13 @@ ResetStats() GetType() PolicyType ``` -### `Bandit` (interfaces.go) -MAB strategy abstraction: +### `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 @@ -173,6 +203,8 @@ SelectPolicy() PolicyType |---|---|---| | `hashicorp/golang-lru/v2` | v2.0.6 | LRU/2Q/expirable (policies module). NOT v2.0.7: that release does not build | | `stitchfix/mab` | v0.1.1 | Multi-Armed Bandit (Thompson Sampling) | +| `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) | @@ -222,6 +254,15 @@ 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 ./... @@ -385,6 +426,106 @@ cd examples/basic && go mod tidy 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 running 1-3 + points pessimistic. 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 2.3-3.9 points + over replicas deciding alone (58-59% vs 55.5%), and the mechanism is visible + in the endings: starved replicas scatter across 5 policies, the pooled fleet + holds 1. Unstarved, pooling *loses* -- 1-2 points on uniform traffic, 5.1 + points on a fleet whose replicas serve different workloads, where one + fleet-wide policy is a compromise nobody wanted. + - **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 (10/25/50/200ms: -0.41/-0.67/-2.21/-3.47 vs + local) shows most of the unstarved loss is the fleet getting fewer chances + to change its mind -- but it closes towards break-even, never past it, and + the fastest setting is the one 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) diff --git a/Makefile b/Makefile index 0b7b246..b0b6b1a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Each of these directories is a separate Go module (own go.mod), so tooling is # run once per module. The root .golangci.yml is shared by all of them. -MODULES := . lfu policies policies/arc policies/tinylfu metrics bench examples/basic examples/migration +MODULES := . lfu policies policies/arc policies/tinylfu metrics bandit bandit/redis bench examples/basic examples/migration GOLANGCI_LINT_VERSION := v2.8.0 @@ -50,6 +50,33 @@ release-check: ## Check the repository could actually be released today evidence: ## Replay the workload suite and print the policy comparison tables ( cd bench && go test -count=1 -timeout 20m -v ./... ) +# The bandit/redis tests run against miniredis by default, which is a fake. +# The Lua the adapter depends on - TIME inside a script, SET NX PX, HINCRBY on +# a key the script names itself - is exactly what a fake can be too permissive +# about, so the same suite runs against real servers here. See +# docker-compose.yml for why both engines are covered rather than one. +COMPOSE ?= docker compose + +.PHONY: redis-up +redis-up: ## Start the local Valkey and Redis containers and wait for them + $(COMPOSE) up -d --wait + +.PHONY: redis-down +redis-down: ## Stop the local Valkey and Redis containers + $(COMPOSE) down -v + +.PHONY: redis-test +redis-test: ## Run the bandit/redis suite against real Valkey and Redis + @$(MAKE) redis-up + @status=0; \ + for target in "valkey 127.0.0.1:63799" "redis 127.0.0.1:63798"; do \ + set -- $$target; \ + echo "==> bandit/redis against $$1 ($$2)"; \ + ( cd bandit/redis && AS_CACHE_REDIS_ADDR=$$2 go test -race -count=1 ./... ) || status=1; \ + done; \ + $(MAKE) redis-down; \ + exit $$status + .PHONY: tidy tidy: ## Run go mod tidy across all modules @set -e; for m in $(MODULES); do \ diff --git a/README.md b/README.md index 13d1fe7..87bee4f 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,15 @@ real traffic instead of asking you to guess. ## Status -Experimental, but the claims here are measured rather than asserted -- see -[Evidence](#evidence). Two things are worth knowing before adopting it. +Pre-1.0: the API may still change, and nothing here has been run in production +that I know of. What has been done is measurement -- every claim below comes +from a reproducible run against published traces, not from intuition, and the +concurrency has been exercised under the race detector and adversarially +reviewed. Read [Evidence](#evidence) and decide for yourself; the numbers are +there so you do not have to take "experimental" or "production-ready" on +trust. + +Three things are worth knowing before adopting it. **No single policy wins everywhere, and that is the point.** On real traces the best fixed policy changes: 2Q wins on the Twitter and OLTP traces, W-TinyLFU on @@ -50,8 +57,8 @@ throughout): - You do not know which policy suits your traffic, and cannot easily find out. - Your traffic changes shape and you would rather not re-tune. -- You want the measurement more than the switching -- see advisor mode on the - roadmap. +- You want the measurement more than the switching. `ObserveOnly` mode gives + you that at zero risk -- see [Advisor mode](#advisor-mode). ### When not to use it @@ -60,8 +67,15 @@ throughout): - The hot path is latency-critical at single-digit nanoseconds. Even sampled, the adaptive layer costs several times a bare LRU per operation. - You need a hard memory ceiling. The multiplier is modest but real. -- You cannot give it enough traffic per epoch to measure anything. The bandit - needs many requests per epoch to tell arms apart. +- You cannot give it enough traffic per epoch to measure anything. Arms that + are within noise of each other reorder run to run, so a cache seeing a + handful of requests per epoch will pick essentially at random. `Advice()` + reports `Epochs` so you can tell whether it has seen enough. If the reason + is that your traffic is spread across many replicas rather than genuinely + thin, see [Running a fleet](#running-a-fleet). +- Your keyspace is small enough to fit in the cache. Every policy scores the + same when nothing is ever evicted, and you are paying for shadows that can + never tell you anything. ## Problem @@ -387,6 +401,130 @@ For Prometheus, wrap `metrics.Take` in a collector -- how metrics are named and labelled belongs to your application, not to a cache library, so this package does not impose a dependency on it. +## Running a fleet + +One replica of a service sees one replica's traffic. If you run fifty of them +behind a load balancer, each cache sees a fiftieth of the requests, and the +"you cannot give it enough traffic per epoch to measure anything" caveat above +stops being about your traffic and starts being about how it was divided. + +The `bandit` module pools that evidence back together through Valkey or Redis. +Each replica publishes its per-epoch counts; one replica per coordination epoch +reads the fleet's aggregate, chooses, and publishes the choice for the others +to apply. + +```go +client := goredis.NewClient(&goredis.Options{Addr: "valkey:6379"}) +store, err := redisstore.New(redisstore.Options{Client: client}) +if err != nil { + return err +} +defer store.Close() + +b, err := bandit.NewDistributed(bandit.Config{ + Store: store, + Namespace: "sessions", + CoordinationEpoch: time.Second, +}) +if err != nil { + return err +} +defer b.Close() + +cache, err := ascache.NewAdaptiveCache(arms, b, &ascache.Settings{ + EpochDuration: 50 * time.Millisecond, +}) +``` + +Read the fleet evidence below before reaching for it. The answer is yes in one +specific regime -- replicas individually too starved of traffic to rank their +own arms -- and no everywhere else, and which case you are in is measurable in +advance. + +**Two clocks, not one.** `EpochDuration` is how often each cache measures; +`CoordinationEpoch` is how often the fleet decides. They are deliberately +different scales. Cache epochs are tuned in tens of milliseconds, which is +below both a round trip to the store and any clock agreement a fleet can be +assumed to have. Measure on the fast clock, coordinate on the slow one; a +second is a sensible starting point. + +**No replica's clock is ever consulted.** Buckets are derived from the store's +clock inside a Lua script, so a fleet needs no clock synchronisation at all and +a machine with a skewed clock cannot write its counts into a window nobody +reads. + +**Nothing touches the network on the cache's path.** The cache calls its bandit +while holding its write lock, so a round trip there would stall every `Get` in +the process — and Go's `RWMutex` queues readers behind a waiting writer, so a +store that hangs would hang the cache. `RecordEpoch` folds numbers into a +buffer and `SelectPolicy` is an atomic load; all I/O happens on the bandit's +own goroutine, once per coordination epoch. + +**When the store is unreachable**, each replica falls back to a local Thompson +bandit fed by its own reports, which is exactly the behaviour of a cache that +was never distributed. Nothing fails and nothing blocks. Counts measured during +the outage are discarded rather than replayed on recovery — evidence that +arrives in the wrong window is worse than no evidence. `Snapshot().Fallback` is +the field to alert on: the cache looks entirely healthy either way. + +**Only integers cross the wire.** Per-policy hit and miss counts, a node id and +a policy name. No cache keys and no cache values ever leave the process. +Everything written carries a TTL, so a fleet that stops running leaves nothing +behind. + +Requires Redis 7.0 or Valkey 7.2 and above. `docker-compose.yml` brings up both +for local testing; `make redis-test` runs the store suite against each. + +### Which replicas pool with which + +Pooling is only meaningful between caches measuring the same thing. A hit rate +from a 1000-entry cache says nothing about a 100-entry one, and averaging them +describes neither — with nothing in the numbers to show it happened. + +So `Namespace` is not the whole key. A fingerprint of each cache's measurement +regime — its arms, its capacity and its sample rate — is appended to it, and +replicas that share a name without sharing a regime pool separately rather than +pooling wrongly. `Snapshot().Namespace` and `Snapshot().Regime` are where to +look when a fleet has unexpectedly split in two. Epoch duration deliberately is +not part of it: a replica reporting twice as often contributes twice the +counts at the same rate, and rates are what the comparison is made on. + +### The two modes + +`ModeLeader` (the default) elects one replica per coordination epoch to decide +for everyone, so the fleet runs one policy at a time. `ModeSharedPosterior` +has every replica draw its own selection from the pooled evidence, so no +election happens and replicas may run different policies indefinitely. + +Leader election is the default for a reason that is not obvious. The active +arm on a replica is measured at full capacity while every shadow runs on a +miniature, and shadows measure a point or two pessimistic. Under leader +election every replica has the *same* arm in the flattering role, so the bias +applies uniformly and largely cancels when the counts are summed. Under +shared-posterior selection it does not: an arm active on most of the fleet is +mostly measured in the flattering role, so it accumulates an advantage in +proportion to how widely it is already deployed. `EvidenceShadowOnly` removes +that feedback by discarding active-role counts, which is why it is available +under shared-posterior selection and rejected under leader election — where +the fleet-wide active policy is nobody's shadow and would have no evidence at +all. + +### Pooling changes how much evidence a posterior sees + +A Beta posterior narrows with the square root of what it has seen, and a fleet +supplies evidence in proportion to its size. A thousand replicas produce +posteriors sharp enough that every Thompson draw returns the same arm — the +bandit stops exploring precisely at the scale where missing a workload change +is most expensive. `MaxEvidence` caps the effective sample size, keeping the +measured rate and discarding the surplus certainty. The default puts an arm's +posterior standard deviation at about a sixth of a percentage point. + +Decay works the same way for the same reason: a shared counter cannot be +decayed in place, because every replica applying the multiplication would +compound it once per replica and a fleet of fifty would forget fifty times +faster than a fleet of one. The store holds plain per-bucket sums and the +weighting happens on read, so the arithmetic is identical at any fleet size. + ## Evidence `make evidence` replays a suite of deterministic workloads against every policy @@ -555,6 +693,71 @@ default. Raise it if your keyspace is small enough that 5% of it is only a handful of keys -- `MinShadowCapacity` guards the degenerate end by raising the effective rate rather than letting a miniature shrink into noise. +### Does pooling across a fleet help? + +**Only in the regime it was built for, and it is worth checking you are in that +regime before turning it on.** All figures are 8 replicas, cache capacity 300 +to 500, `make evidence`. + +The case it exists for is a replica that sees too little traffic per epoch to +rank its own arms. Reproducing that requires holding each replica to a request +rate — an unpaced replay delivers thousands of requests per epoch however small +the workload is, it just finishes sooner. Paced to roughly 8 requests per cache +epoch per replica: + +| Setup | Hit rate | Policies in use at the end | +| --- | --- | --- | +| best fixed (ARC) | 62.8% | 1 | +| pooled, leader-elected | 58.3-59.5% | 1-2 | +| each replica deciding alone | 55.5-55.9% | 5 | + +**Pooling gains 2.3 to 3.9 points** over independent replicas, across four +runs. The last column is the mechanism: a replica with eight requests an epoch +cannot tell its arms apart, so the fleet scatters across five different +policies, several of them poor. Pooled, the fleet has 64 requests an epoch of +evidence and stays on one. + +Now the same comparison where replicas are *not* starved — the unpaced replays +every other measurement here uses: + +| Workload | Pooled | Deciding alone | Best fixed | +| --- | --- | --- | --- | +| zipf, split evenly | 68.2% | 70.4% | 73.0% | +| zipf, sharded by key | 86.2% | 87.4% | LFU 88.2% | +| phase-shift | 82.0% | 82.0% | 2Q 83.0% | +| mixed fleet (half loop, half zipf) | 36.6% | 41.7% | — | + +**Pooling loses whenever the replicas could already measure for themselves**, +by 1 to 2 points on uniform traffic and by 5.1 points on a fleet whose replicas +serve different workloads. The mixed-fleet row is the clearest: a fleet-wide +decision is a compromise, and when half your replicas want the policy the other +half are worst served by, forcing agreement costs more than the disagreement +did. + +Most of the loss on uniform traffic is the fleet simply getting fewer chances +to change its mind: + +```text +local (no coordination): 70.42% +coordination epoch 10ms: 70.01% (-0.41) +coordination epoch 25ms: 69.75% (-0.67) +coordination epoch 50ms: 68.20% (-2.21) +coordination epoch 200ms: 66.95% (-3.47) +``` + +The gap closes monotonically as coordination speeds up — but it closes towards +break-even, never past it, and a 10ms coordination epoch is where a real round +trip stops being negligible. Note that these replays coordinate through an +in-process store, so coordination is free in a way it will not be for you: the +setting that looks best here is the one that costs most to run. + +**So the rule is:** pool when your replicas are individually starved of +traffic, run the same workload shape as each other, and are numerous enough +that the pooled evidence is meaningfully thicker. Otherwise let each replica +decide alone — it is simpler, it needs no store, and on this evidence it is +also better. `Advice()` in observe-only mode will tell you which case you are +in before you deploy anything. + ## TODO - [ ] Trace-driven benchmarks (ARC paper traces, `twitter/cache-trace`) @@ -567,6 +770,8 @@ effective rate rather than letting a miniature shrink into noise. - [Ristretto (dgraph-io)](https://github.com/dgraph-io/ristretto) — inspiration for adaptive selection - [hashicorp/golang-lru](https://github.com/hashicorp/golang-lru) — LRU/2Q/ARC implementations - [stitchfix/mab](https://github.com/stitchfix/mab) — Multi-Armed Bandit (Thompson Sampling) +- [redis/go-redis](https://github.com/redis/go-redis) — the client behind the Valkey/Redis store +- [Valkey](https://valkey.io/) — the store the distributed bandit was built against ## License diff --git a/bandit/LICENSE b/bandit/LICENSE new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/bandit/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/bandit/config.go b/bandit/config.go new file mode 100644 index 0000000..08c9fb6 --- /dev/null +++ b/bandit/config.go @@ -0,0 +1,277 @@ +package bandit + +import ( + "fmt" + "math/rand/v2" + "os" + "time" +) + +// Mode selects how a fleet turns pooled evidence into a policy choice. +type Mode uint8 + +const ( + // ModeLeader has one replica per coordination epoch read the fleet's + // aggregate, choose, and publish that choice for every other replica to + // apply. The fleet therefore runs one policy at a time. + // + // This is the default, and it is the mode that keeps the pooled numbers + // honest. Every replica measures the same arm in the active role and every + // other arm in the shadow role, so the systematic gap between the two + // roles applies equally to every replica's report and largely cancels when + // they are summed. See Role. + ModeLeader Mode = iota + 1 + + // ModeSharedPosterior has every replica draw its own selection from the + // pooled posterior. No leader, no election, and a replica that loses the + // store keeps working without any handover. + // + // The cost is that replicas may run different policies indefinitely, which + // makes the role gap asymmetric: an arm active on most of the fleet is + // mostly measured in the flattering role, so it accumulates an advantage + // in proportion to how widely it is already deployed. Pair it with + // EvidenceShadowOnly to remove that feedback, at the price of ignoring + // what the serving policies measured. + ModeSharedPosterior +) + +// EvidenceMode selects which measurements feed the pooled posterior. +type EvidenceMode uint8 + +const ( + // EvidenceAll pools active-role and shadow-role counts together. It + // matches what a single-node bandit sees exactly, and it is the default. + EvidenceAll EvidenceMode = iota + 1 + + // EvidenceShadowOnly discards active-role counts and compares arms purely + // on shadow measurements, so every arm is measured the same way. It is + // only meaningful under ModeSharedPosterior, where a diverse fleet leaves + // every arm shadowing somewhere; under ModeLeader it is rejected. + EvidenceShadowOnly +) + +// Config configures a Distributed bandit. +type Config struct { + // Store is where the fleet coordinates. Required. + Store Store + + // Namespace names the fleet. Required, and it must be the same string on + // every replica that should pool with each other, and different on any + // that should not. + // + // It is not the whole key: a fingerprint of the cache's measurement regime + // - its arms, its capacity and its sample rate - is appended, so replicas + // that share a name but are not measuring the same thing pool separately + // instead of pooling wrongly. A hit rate from a 1000-entry cache says + // nothing about a 100-entry one, and averaging them describes neither. + Namespace string + + // NodeID identifies this replica within the fleet, and needs only to be + // unique. Defaults to hostname-pid-random, which is unique enough and + // stays readable in a leadership key. + NodeID string + + // CoordinationEpoch is how often this replica syncs with the store, and + // therefore how often the fleet can change its mind. Required. + // + // It is a separate, much slower clock than Settings.EpochDuration, and + // deliberately so. Cache epochs are tuned in tens of milliseconds, which + // is below the round trip to the store and below the clock agreement a + // fleet can be assumed to have. Measure on the fast clock, coordinate on + // the slow one. A second is a sensible starting point. + CoordinationEpoch time.Duration + + // Window is how many past buckets the leader reads. Defaults to + // DefaultWindow. Zero after defaulting means only the previous bucket. + Window int + + // Decay weights each bucket by Decay^age before summing, so recent + // evidence counts for more and old evidence fades out of the decision + // rather than sitting in it forever. Defaults to DefaultDecay. A value of + // 1 weights the whole window equally. + // + // A shared counter cannot be decayed in place - every replica applying the + // multiplication would compound it once per replica - so the store holds + // plain per-bucket sums and the weighting happens here, on read. + Decay float64 + + // MaxEvidence caps how many observations an arm's posterior is allowed to + // rest on, after weighting. Defaults to DefaultMaxEvidence; a negative + // value disables the cap. + // + // It exists because pooling multiplies evidence by the size of the fleet. + // A Beta posterior narrows with the square root of what it has seen, so a + // thousand replicas produce posteriors sharp enough that every Thompson + // draw returns the same arm - the bandit stops exploring precisely at the + // scale where a workload change is most expensive to miss. Capping keeps + // the measured rate and discards the surplus certainty. + MaxEvidence float64 + + // Mode selects leader-elected or shared-posterior selection. Defaults to + // ModeLeader. + Mode Mode + + // Evidence selects which roles' counts feed the posterior. Defaults to + // EvidenceAll. + Evidence EvidenceMode + + // FallbackAfter is how long the store may go unreachable before this + // replica stops waiting for the fleet and decides locally. Defaults to + // three coordination epochs. + FallbackAfter time.Duration + + // SyncTimeout bounds a single round trip to the store. Defaults to the + // coordination epoch. It bounds how far behind a hung store can push this + // replica's ticks; it never affects the cache, which is not waiting on any + // of this. + SyncTimeout time.Duration + + // LocalDiscount is the discount factor of the local Thompson bandit that + // takes over when the store is unreachable. Defaults to + // DefaultLocalDiscount. + LocalDiscount float64 + + // Jitter spreads each replica's sync across a fraction of the coordination + // epoch, so a large fleet does not arrive at the store in lockstep on + // every bucket boundary. Defaults to DefaultJitter. Buckets come from the + // store's clock, so jitter can never put a replica in the wrong one. + Jitter float64 + + // Seed seeds the local fallback bandit and the jitter. Zero draws a random + // one, which is what you want: seeding a fleet identically would + // synchronise the jitter it exists to break up. + Seed uint64 + + // Now is the clock used for staleness and jitter, a seam for tests. + // Defaults to time.Now. It is never used to derive a bucket - that is the + // store's job precisely so a replica's clock cannot matter. + Now func() time.Time +} + +// Defaults applied to a zero-valued Config field. +const ( + // DefaultWindow is how many buckets of history the leader reads. Ten + // buckets of a one-second epoch is ten seconds of fleet evidence, which is + // enough to be stable and short enough to still track a workload that + // moves. + DefaultWindow = 10 + // DefaultDecay weights each bucket at 0.8 of the one after it, so the + // oldest bucket of a default window carries about a seventh of the weight + // of the newest. + DefaultDecay = 0.8 + // DefaultLocalDiscount is the fallback bandit's discount factor. + DefaultLocalDiscount = 0.7 + // DefaultJitter spreads syncs over a tenth of the coordination epoch. + DefaultJitter = 0.1 + // DefaultMaxEvidence caps an arm's posterior at a hundred thousand + // weighted observations, which puts its standard deviation at roughly a + // sixth of a percentage point: enough certainty to separate arms that + // differ by half a point, little enough to keep exploring arms that do + // not. + DefaultMaxEvidence = 100_000.0 +) + +// validate fills in defaults and reports whatever cannot be defaulted. +func (c *Config) validate() error { + if c.Store == nil { + return ErrNilStore + } + if c.Namespace == "" { + return ErrEmptyNamespace + } + if c.CoordinationEpoch <= 0 { + return fmt.Errorf("%w: got %s", ErrInvalidCoordinationEpoch, c.CoordinationEpoch) + } + if c.Window < 0 { + return fmt.Errorf("%w: got %d", ErrInvalidWindow, c.Window) + } + if c.Decay < 0 || c.Decay > 1 { + return fmt.Errorf("%w: got %v", ErrInvalidDecay, c.Decay) + } + if c.Jitter < 0 || c.Jitter >= 0.5 { + return fmt.Errorf("%w: got %v", ErrInvalidJitter, c.Jitter) + } + + // Both enums number from one, so their zero value names no mode at all + // rather than happening to name the default. That has to be resolved here, + // before anything compares against it: an unresolved zero Mode is neither + // ModeLeader nor ModeSharedPosterior, so the replica would claim no + // leadership and follow no leader, and the fleet would sync forever + // without ever deciding anything. + if c.Mode == 0 { + c.Mode = ModeLeader + } + if c.Evidence == 0 { + c.Evidence = EvidenceAll + } + + if c.Mode == ModeLeader && c.Evidence == EvidenceShadowOnly { + return ErrShadowOnlyUnderLeader + } + + if c.Window == 0 { + c.Window = DefaultWindow + } + if c.Decay == 0 { + c.Decay = DefaultDecay + } + if c.Jitter == 0 { + c.Jitter = DefaultJitter + } + if c.LocalDiscount <= 0 || c.LocalDiscount > 1 { + c.LocalDiscount = DefaultLocalDiscount + } + if c.MaxEvidence == 0 { + c.MaxEvidence = DefaultMaxEvidence + } + if c.FallbackAfter <= 0 { + c.FallbackAfter = 3 * c.CoordinationEpoch + } + if c.SyncTimeout <= 0 { + c.SyncTimeout = c.CoordinationEpoch + } + if c.Now == nil { + c.Now = time.Now + } + if c.Seed == 0 { + // Not a secret: it only has to differ between replicas, so that a + // fleet's jitter is not synchronised. + c.Seed = rand.Uint64() //nolint:gosec // see above + } + if c.NodeID == "" { + c.NodeID = defaultNodeID(c.Seed) + } + + return nil +} + +// counterTTL is how long a bucket's counters outlive the bucket. The leader +// reads Window buckets back, so anything shorter would leave holes in the +// window; the margin covers a leader that ticks late. +func (c *Config) counterTTL() time.Duration { + return time.Duration(c.Window+2) * c.CoordinationEpoch +} + +// leaderTTL is how long a leadership claim survives. Leadership is per-bucket +// and produces one immutable decision, so an over-long claim costs nothing +// beyond that bucket; two epochs covers a leader whose sync is slow. +func (c *Config) leaderTTL() time.Duration { + return 2 * c.CoordinationEpoch +} + +// decisionTTL is how long a published decision remains readable. It outlives +// its bucket so a replica that ticks late still finds it. +func (c *Config) decisionTTL() time.Duration { + return 3 * c.CoordinationEpoch +} + +// defaultNodeID builds an identifier that is unique enough for leadership and +// still legible in a key. +func defaultNodeID(seed uint64) string { + host, err := os.Hostname() + if err != nil || host == "" { + host = "unknown" + } + + return fmt.Sprintf("%s-%d-%x", host, os.Getpid(), seed&0xffffff) +} diff --git a/bandit/config_test.go b/bandit/config_test.go new file mode 100644 index 0000000..949a6a9 --- /dev/null +++ b/bandit/config_test.go @@ -0,0 +1,195 @@ +package bandit + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ascache "github.com/sshaplygin/as-cache" +) + +func validConfig() Config { + return Config{ + Store: NewMemStore(), + Namespace: "test", + CoordinationEpoch: time.Second, + } +} + +func TestConfig_RejectsWhatCannotBeDefaulted(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + wantErr error + }{ + { + name: "no store", + mutate: func(c *Config) { c.Store = nil }, + wantErr: ErrNilStore, + }, + { + name: "no namespace", + mutate: func(c *Config) { c.Namespace = "" }, + wantErr: ErrEmptyNamespace, + }, + { + name: "zero coordination epoch", + mutate: func(c *Config) { c.CoordinationEpoch = 0 }, + wantErr: ErrInvalidCoordinationEpoch, + }, + { + name: "negative coordination epoch", + mutate: func(c *Config) { c.CoordinationEpoch = -time.Second }, + wantErr: ErrInvalidCoordinationEpoch, + }, + { + name: "negative window", + mutate: func(c *Config) { c.Window = -1 }, + wantErr: ErrInvalidWindow, + }, + { + name: "decay above one", + mutate: func(c *Config) { c.Decay = 1.5 }, + wantErr: ErrInvalidDecay, + }, + { + name: "decay below zero", + mutate: func(c *Config) { c.Decay = -0.1 }, + wantErr: ErrInvalidDecay, + }, + { + name: "jitter of half an epoch", + mutate: func(c *Config) { c.Jitter = 0.5 }, + wantErr: ErrInvalidJitter, + }, + { + name: "negative jitter", + mutate: func(c *Config) { c.Jitter = -0.1 }, + wantErr: ErrInvalidJitter, + }, + { + name: "shadow-only evidence under leader election", + mutate: func(c *Config) { + c.Mode = ModeLeader + c.Evidence = EvidenceShadowOnly + }, + wantErr: ErrShadowOnlyUnderLeader, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig() + tt.mutate(&cfg) + + _, err := NewDistributed(cfg) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestConfig_ShadowOnlyIsAllowedUnderSharedPosterior(t *testing.T) { + cfg := validConfig() + cfg.Mode = ModeSharedPosterior + cfg.Evidence = EvidenceShadowOnly + + bandit, err := NewDistributed(cfg) + require.NoError(t, err) + assert.NoError(t, bandit.Close()) +} + +func TestConfig_ZeroModeLeadsRatherThanDoingNothing(t *testing.T) { + // Mode and EvidenceMode number from one, so their zero value names no mode + // at all. Left unresolved it matches neither branch: the replica claims no + // leadership and follows no leader, so the fleet syncs forever and never + // decides anything - a configuration that looks healthy and selects + // nothing. + cfg := validConfig() + require.Zero(t, uint8(cfg.Mode)) + require.NoError(t, cfg.validate()) + + assert.Equal(t, ModeLeader, cfg.Mode) + assert.Equal(t, EvidenceAll, cfg.Evidence) +} + +func TestConfig_ZeroModeActuallyClaimsLeadership(t *testing.T) { + store, clock := newFleetStore(t) + + subject := fleet(t, 1, store, clock, func(cfg *Config) { + cfg.Mode = 0 + })[0] + + subject.report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.2, ascache.TinyLFU: 0.9}) + subject.bandit.sync() + clock.advance(testEpoch) + subject.report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.2, ascache.TinyLFU: 0.9}) + subject.bandit.sync() + + snapshot := subject.bandit.Snapshot() + assert.Positive(t, snapshot.Leaderships) + assert.Equal(t, ascache.TinyLFU.String(), snapshot.Selection) +} + +func TestConfig_FillsInDefaults(t *testing.T) { + cfg := validConfig() + require.NoError(t, cfg.validate()) + + assert.Equal(t, DefaultWindow, cfg.Window) + assert.InDelta(t, DefaultDecay, cfg.Decay, 1e-9) + assert.InDelta(t, DefaultJitter, cfg.Jitter, 1e-9) + assert.InDelta(t, DefaultLocalDiscount, cfg.LocalDiscount, 1e-9) + assert.InDelta(t, DefaultMaxEvidence, cfg.MaxEvidence, 1e-9) + assert.Equal(t, 3*time.Second, cfg.FallbackAfter) + assert.Equal(t, time.Second, cfg.SyncTimeout) + assert.NotEmpty(t, cfg.NodeID) + assert.NotZero(t, cfg.Seed) + assert.NotNil(t, cfg.Now) +} + +func TestConfig_UnseededReplicasDoNotShareJitter(t *testing.T) { + // Seeding a fleet identically would synchronise the very jitter that + // exists to keep it from arriving at the store in lockstep. + seeds := make(map[uint64]struct{}) + for range 20 { + cfg := validConfig() + require.NoError(t, cfg.validate()) + seeds[cfg.Seed] = struct{}{} + } + + assert.Greater(t, len(seeds), 15) +} + +func TestConfig_ExplicitValuesSurviveValidation(t *testing.T) { + cfg := validConfig() + cfg.Window = 3 + cfg.Decay = 1 + cfg.Jitter = 0.25 + cfg.MaxEvidence = -1 + cfg.NodeID = "chosen" + cfg.FallbackAfter = time.Minute + + require.NoError(t, cfg.validate()) + + assert.Equal(t, 3, cfg.Window) + assert.InDelta(t, 1.0, cfg.Decay, 1e-9) + assert.InDelta(t, 0.25, cfg.Jitter, 1e-9) + assert.InDelta(t, -1.0, cfg.MaxEvidence, 1e-9, "a negative cap disables it and must not be defaulted away") + assert.Equal(t, "chosen", cfg.NodeID) + assert.Equal(t, time.Minute, cfg.FallbackAfter) +} + +func TestConfig_DerivedTTLsOutliveTheirWindow(t *testing.T) { + cfg := validConfig() + cfg.Window = 10 + require.NoError(t, cfg.validate()) + + // A counter TTL shorter than the window the leader reads back would leave + // holes in it, and the fleet would silently decide on less evidence than + // it was configured for. + assert.Greater(t, cfg.counterTTL(), time.Duration(cfg.Window)*cfg.CoordinationEpoch) + assert.Positive(t, cfg.leaderTTL()) + assert.Greater(t, cfg.decisionTTL(), cfg.CoordinationEpoch, + "a decision must outlive its bucket so a replica that ticks late still finds it") +} diff --git a/bandit/coordinate.go b/bandit/coordinate.go new file mode 100644 index 0000000..a7e065a --- /dev/null +++ b/bandit/coordinate.go @@ -0,0 +1,271 @@ +// This file holds everything that runs on the coordination goroutine: the sync +// loop and the one round trip it makes per coordination epoch. It is separate +// from distributed.go, which holds the surface the cache itself calls, because +// the two run under completely different constraints - nothing here is allowed +// to be slow, and nothing there is allowed to block at all. + +package bandit + +import ( + "context" + "time" + + ascache "github.com/sshaplygin/as-cache" +) + +// coordinate runs the sync loop until Close. +func (d *Distributed) coordinate() { + defer d.wg.Done() + + timer := time.NewTimer(d.nextInterval()) + defer timer.Stop() + + for { + select { + case <-d.ctx.Done(): + return + case <-timer.C: + d.sync() + timer.Reset(d.nextInterval()) + } + } +} + +// nextInterval returns the coordination epoch with jitter applied, so a large +// fleet spreads its round trips across the epoch instead of arriving together +// on every boundary. Buckets are assigned by the store's clock, so jitter can +// never put a replica's counts in the wrong window. +func (d *Distributed) nextInterval() time.Duration { + if d.cfg.Jitter <= 0 { + return d.cfg.CoordinationEpoch + } + + spread := float64(d.cfg.CoordinationEpoch) * d.cfg.Jitter + offset := (d.rng.Float64()*2 - 1) * spread + + return time.Duration(float64(d.cfg.CoordinationEpoch) + offset) +} + +// sync performs one coordination round: publish this replica's counts, then +// work out what the fleet should be running. +func (d *Distributed) sync() { + req, ok := d.drain() + if !ok { + // No report has arrived yet, so the measurement regime is unknown and + // there is nothing to publish or to key by. + return + } + + ctx, cancel := context.WithTimeout(d.ctx, d.cfg.SyncTimeout) + defer cancel() + + result, err := d.cfg.Store.Sync(ctx, req) + if err != nil { + d.recordFailure(err) + return + } + + decision, decided := result.Decision, result.HasDecision + + switch { + case d.cfg.Mode == ModeSharedPosterior: + // Every replica reads the window and draws for itself. No leader is + // elected and the decision key is never written or read. + decision, decided = d.drawShared(ctx, req.Namespace, result.Bucket) + + case result.Leader: + // This replica won the bucket. It decides for the fleet, and applies + // its own decision now rather than reading it back a round later. + if choice, ok := d.leadBucket(ctx, req.Namespace, result.Bucket); ok { + decision, decided = choice, true + } + } + + d.applyResult(result.Bucket, decision, decided) +} + +// drain takes everything buffered since the last sync and builds the request. +// The buffer is cleared whether or not the sync goes on to succeed: counts +// that missed their window are not worth carrying forward. +func (d *Distributed) drain() (SyncRequest, bool) { + d.mu.Lock() + defer d.mu.Unlock() + + if !d.haveShape { + return SyncRequest{}, false + } + + counts := make([]ArmCounts, 0, len(d.pending)) + for key, stats := range d.pending { + counts = append(counts, ArmCounts{ + Policy: key.Policy, + Role: key.Role, + Hits: stats.Hits, + Misses: stats.Misses, + }) + } + clear(d.pending) + + return SyncRequest{ + Namespace: d.namespace, + NodeID: d.cfg.NodeID, + Counts: counts, + EpochMillis: d.cfg.CoordinationEpoch.Milliseconds(), + CounterTTL: d.cfg.counterTTL(), + LeaderTTL: d.cfg.leaderTTL(), + Lead: d.cfg.Mode == ModeLeader, + }, true +} + +// readPooled reads the fleet's recent buckets and returns the pooled, decayed, +// evidence-capped posterior per arm. +// +// The window stops one bucket short of the current one: the fleet is still +// writing into that bucket, so including it would weight whichever replicas +// happened to have synced already. +func (d *Distributed) readPooled( + ctx context.Context, + namespace string, + bucket Bucket, +) (map[ascache.PolicyType]weighted, bool) { + newest := bucket - 1 + first := newest - Bucket(d.cfg.Window) + 1 + + window, err := d.cfg.Store.Window(ctx, namespace, first, newest) + if err != nil { + d.recordFailure(err) + return nil, false + } + + pooled := aggregate(window, newest, d.cfg.Decay, d.cfg.Evidence) + capEvidence(pooled, d.cfg.MaxEvidence) + d.recordFleet(pooled) + + return pooled, true +} + +// leadBucket decides for the fleet and publishes the decision. A failure +// anywhere leaves the bucket without one, which every replica reads as "keep +// doing what you are doing". +func (d *Distributed) leadBucket( + ctx context.Context, + namespace string, + bucket Bucket, +) (ascache.PolicyType, bool) { + d.mu.Lock() + d.state.leaderships++ + d.mu.Unlock() + + pooled, ok := d.readPooled(ctx, namespace, bucket) + if !ok { + return ascache.Undefined, false + } + + choice := draw(d.rng, pooled) + if choice == ascache.Undefined { + // Nothing in the window: the fleet has published no evidence yet. + return ascache.Undefined, false + } + + // The decision in force is what the store reports, not what was drawn: if + // another replica somehow published first, the leader follows the fleet + // rather than being the one machine running something else. + inForce, err := d.cfg.Store.Decide(ctx, namespace, bucket, choice, d.cfg.decisionTTL()) + if err != nil { + d.recordFailure(err) + return ascache.Undefined, false + } + + return inForce, inForce != ascache.Undefined +} + +// drawShared reads the fleet's window and draws this replica's own selection +// from it. Replicas share the evidence but not the draw, so they explore +// independently and may run different policies at the same time. +func (d *Distributed) drawShared( + ctx context.Context, + namespace string, + bucket Bucket, +) (ascache.PolicyType, bool) { + pooled, ok := d.readPooled(ctx, namespace, bucket) + if !ok { + return ascache.Undefined, false + } + + choice := draw(d.rng, pooled) + + return choice, choice != ascache.Undefined +} + +// applyResult folds a successful sync into the selection and the observable +// state. +func (d *Distributed) applyResult(bucket Bucket, decision ascache.PolicyType, decided bool) { + d.mu.Lock() + defer d.mu.Unlock() + + d.state.lastSync = d.cfg.Now() + d.state.lastBucket = bucket + d.state.lastErr = nil + d.state.syncs++ + d.state.fallback = false + + switch { + case !decided: + // No decision this round - the leader has not published yet, or the + // window was empty. Keeping the previous selection is the mode's one + // coordination epoch of built-in staleness, and it is bounded: the + // decision will be there on the next sync. + + case !d.knownArmLocked(decision): + // Something is publishing decisions for a policy this cache does not + // have. The fingerprint in the namespace is supposed to make that + // impossible, so this is a real misconfiguration rather than a race: + // refuse it, count it, and keep serving. + d.state.rejected++ + + default: + d.state.decisions++ + d.selection.Store(uint64(decision)) + } +} + +func (d *Distributed) knownArmLocked(policy ascache.PolicyType) bool { + _, ok := d.arms[policy] + + return ok +} + +// recordFleet stores the pooled posterior for Snapshot to report. +func (d *Distributed) recordFleet(pooled map[ascache.PolicyType]weighted) { + d.mu.Lock() + defer d.mu.Unlock() + + d.state.fleet = pooled +} + +// recordFailure notes a failed round trip and, once the store has been +// unreachable for longer than FallbackAfter, hands selection to the local +// bandit. +func (d *Distributed) recordFailure(err error) { + d.mu.Lock() + defer d.mu.Unlock() + + d.state.lastErr = err + d.state.syncFailures++ + + // A replica that has never synced falls back immediately rather than + // waiting out a grace period measured from a sync that never happened. + stale := d.state.lastSync.IsZero() || + d.cfg.Now().Sub(d.state.lastSync) > d.cfg.FallbackAfter + if !stale { + return + } + + d.state.fallback = true + + choice := d.local.SelectPolicy() + if choice == ascache.Undefined || !d.knownArmLocked(choice) { + return + } + d.selection.Store(uint64(choice)) +} diff --git a/bandit/distributed.go b/bandit/distributed.go new file mode 100644 index 0000000..683e3c9 --- /dev/null +++ b/bandit/distributed.go @@ -0,0 +1,235 @@ +package bandit + +import ( + "context" + "math/rand/v2" + "sync" + "sync/atomic" + "time" + + ascache "github.com/sshaplygin/as-cache" +) + +var ( + _ ascache.Bandit = (*Distributed)(nil) + _ ascache.EpochBandit = (*Distributed)(nil) +) + +// Distributed pools every replica's measurements through a shared store, so a +// fleet of caches selects on the fleet's evidence rather than on each +// replica's own. +// +// It exists for the case the README gives as a reason not to use this library +// at all: a cache that sees too few requests per epoch to tell its arms apart. +// That is usually not a property of the traffic but of how it was divided - a +// fleet of a hundred replicas each sees a hundredth of it. Pooling puts the +// evidence back together without moving the caches. +// +// # Nothing here runs on the cache's path +// +// RecordEpoch folds numbers into a buffer and returns; SelectPolicy is an +// atomic load. Both are called by the cache while it holds its write lock, +// where a round trip to a store would stall every Get in the process for its +// duration - and Go's RWMutex queues readers behind a waiting writer, so a +// store that hangs would hang the cache. All I/O happens on this type's own +// goroutine, once per coordination epoch. +// +// # When the store is unreachable +// +// Selection falls back to a local Thompson bandit fed by this replica's own +// reports, which is exactly the behaviour of a cache that was never +// distributed. Nothing fails and nothing blocks; [Distributed.Snapshot] +// reports that it happened. Counts measured during the outage are discarded +// rather than replayed on recovery: evidence that arrives in the wrong window +// is worse than no evidence, because the whole scheme rests on recent buckets +// describing recent traffic. +type Distributed struct { + cfg Config + + // local is the fallback, and it is fed on every report rather than only + // during an outage - a bandit that started learning at the moment it was + // needed would spend the outage exploring from scratch. + local *Thompson + + // selection is what SelectPolicy returns, held as the numeric value of a + // PolicyType. It is written only by the coordination goroutine and read on + // the cache's epoch path, so it is atomic rather than mutex-guarded: + // SelectPolicy must never wait on the goroutine, which may be + // mid-round-trip. + selection atomic.Uint64 + + // rng is confined to the coordination goroutine: jitter and, under + // ModeSharedPosterior, this replica's own draw. It is deliberately not + // shared with local, which has its own. + rng *rand.Rand + + mu sync.Mutex + // pending accumulates what the cache has reported since the last sync. + pending map[ArmKey]ascache.PolicyStats + // shape is the measurement regime the reports describe and namespace is + // the fingerprinted namespace derived from it. Both are set by the first + // report and updated if the cache's shape ever changes - a resize moves + // this replica to a different namespace, because its numbers stopped being + // comparable with the fleet's. + shape regime + haveShape bool + namespace string + // arms is the set of policies this cache actually has. A decision naming + // anything else is refused: it means something with a different build or + // configuration is publishing into this namespace. + arms map[ascache.PolicyType]struct{} + state state + + // ctx is cancelled by Close, and every round trip derives its context from + // it. Without that a Close arriving while a sync is in flight would wait + // out the whole SyncTimeout against a store that has stopped answering - + // so shutting down behind an unreachable store would take as long as the + // store was allowed to take. + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + stopOnce sync.Once +} + +// state is the observable part, guarded by mu. +type state struct { + lastSync time.Time + lastBucket Bucket + lastErr error + syncs int64 + syncFailures int64 + leaderships int64 + decisions int64 + rejected int64 + fallback bool + fleet map[ascache.PolicyType]weighted +} + +// NewDistributed starts a distributed bandit and its coordination goroutine. +// Callers must call Close to stop it. +func NewDistributed(cfg Config) (*Distributed, error) { + d, err := newDistributed(cfg) + if err != nil { + return nil, err + } + + d.wg.Add(1) + go d.coordinate() + + return d, nil +} + +// newDistributed builds the bandit without starting its goroutine, so a test +// can drive sync rounds itself instead of racing a timer. +func newDistributed(cfg Config) (*Distributed, error) { + if err := cfg.validate(); err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(context.Background()) + + d := &Distributed{ + cfg: cfg, + local: NewThompson(cfg.LocalDiscount, cfg.Seed), + pending: make(map[ArmKey]ascache.PolicyStats), + arms: make(map[ascache.PolicyType]struct{}), + //nolint:gosec // deliberate: a seeded, reproducible source, not a secret + rng: rand.New(rand.NewPCG(cfg.Seed, cfg.Seed^0x2545f4914f6cdd1d)), + ctx: ctx, + cancel: cancel, + } + d.selection.Store(uint64(ascache.Undefined)) + + return d, nil +} + +// RecordStats folds a single arm's report into the buffer. +// +// The cache calls RecordEpoch instead, since this type implements +// [ascache.EpochBandit]. This exists so a Distributed can still stand in +// wherever a plain [ascache.Bandit] is expected; the role is unknowable on +// this path, so counts are attributed to RoleShadow. +func (d *Distributed) RecordStats(stats ascache.ShadowStats) { + d.local.RecordStats(stats) + + d.mu.Lock() + defer d.mu.Unlock() + + d.arms[stats.Policy] = struct{}{} + d.addLocked(ArmKey{Policy: stats.Policy, Role: RoleShadow}, stats) +} + +// RecordEpoch folds one reporting epoch into the buffer and into the local +// fallback bandit. It performs no I/O and holds one uncontended mutex for the +// length of a few map writes, because the cache is holding its write lock +// while it runs. +func (d *Distributed) RecordEpoch(report ascache.EpochReport) { + shape := regimeOf(report) + + for _, stats := range report.Stats { + d.local.RecordStats(stats) + } + + d.mu.Lock() + defer d.mu.Unlock() + + if !d.haveShape || !d.shape.equal(shape) { + // The cache's measurement regime changed - a Resize, most likely. Its + // numbers are no longer comparable with what it published before, nor + // with a fleet still running the old shape, so it moves to a different + // namespace and starts accumulating there. Whatever was buffered was + // measured under the old regime and goes with it. + d.shape = shape + d.haveShape = true + d.namespace = scopedNamespace(d.cfg.Namespace, shape) + clear(d.pending) + + d.arms = make(map[ascache.PolicyType]struct{}, len(report.Stats)) + for _, stats := range report.Stats { + d.arms[stats.Policy] = struct{}{} + } + } + + for _, stats := range report.Stats { + role := RoleShadow + if stats.Policy == report.Active { + role = RoleActive + } + + d.addLocked(ArmKey{Policy: stats.Policy, Role: role}, stats) + } +} + +func (d *Distributed) addLocked(key ArmKey, stats ascache.ShadowStats) { + counts := d.pending[key] + counts.Hits += stats.Hits + counts.Misses += stats.Misses + d.pending[key] = counts +} + +// SelectPolicy returns the policy the fleet has settled on, or - while the +// store is unreachable - the one this replica's own evidence favours. It +// returns [ascache.Undefined] until the first coordination round completes, +// which the cache reads as "no change". +// +// It is a single atomic load. The answer changes at the coordination cadence +// rather than the cache's epoch cadence, so a cache on a 50ms epoch and a +// bandit on a 1s one will see the same answer twenty times over. That is the +// design: a fleet-wide decision moves at the fleet's pace. +func (d *Distributed) SelectPolicy() ascache.PolicyType { + // Only a PolicyType is ever stored here, by this type, so the round trip + // through uint64 is lossless. + return ascache.PolicyType(d.selection.Load()) +} + +// Close stops the coordination goroutine and waits for it to exit, cancelling +// any round trip in flight. It is idempotent. It does not close the store, +// which the caller supplied and may still be using. +func (d *Distributed) Close() error { + d.stopOnce.Do(func() { + d.cancel() + d.wg.Wait() + }) + + return nil +} diff --git a/bandit/distributed_test.go b/bandit/distributed_test.go new file mode 100644 index 0000000..caeffe5 --- /dev/null +++ b/bandit/distributed_test.go @@ -0,0 +1,673 @@ +package bandit + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ascache "github.com/sshaplygin/as-cache" +) + +// replica is one simulated cache: a bandit driven by hand, plus the traffic +// pattern its arms measure. +type replica struct { + bandit *Distributed + active ascache.PolicyType +} + +// report feeds one epoch's measurements, expressed as a hit rate per arm. +func (r *replica) report(t *testing.T, requests int64, rates map[ascache.PolicyType]float64) { + t.Helper() + + arms := make([]ascache.PolicyType, 0, len(rates)) + for policy := range rates { + arms = append(arms, policy) + } + // EpochReport documents its Stats as PolicyType-ordered, and the regime + // fingerprint depends on that order, so a test must honour it too. + sortPolicies(arms) + + stats := make([]ascache.ShadowStats, 0, len(arms)) + for _, policy := range arms { + hits := int64(float64(requests) * rates[policy]) + stats = append(stats, ascache.ShadowStats{ + Policy: policy, + Hits: hits, + Misses: requests - hits, + }) + } + + r.bandit.RecordEpoch(ascache.EpochReport{ + Active: r.active, + Stats: stats, + Capacity: 1000, + SampleRate: 1, + }) +} + +func sortPolicies(arms []ascache.PolicyType) { + for i := 1; i < len(arms); i++ { + for j := i; j > 0 && arms[j] < arms[j-1]; j-- { + arms[j], arms[j-1] = arms[j-1], arms[j] + } + } +} + +// fleet builds n replicas sharing one store, each with its own bandit, and +// with the coordination goroutine left unstarted so the test drives sync +// rounds itself. +func fleet(t *testing.T, n int, store Store, clock *testClock, tune func(*Config)) []*replica { + t.Helper() + + replicas := make([]*replica, 0, n) + for i := range n { + cfg := Config{ + Store: store, + Namespace: "test", + NodeID: string(rune('a' + i)), + CoordinationEpoch: testEpoch, + Seed: uint64(i + 1), + Now: clock.now, + Jitter: 0, + } + if tune != nil { + tune(&cfg) + } + + bandit, err := newDistributed(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = bandit.Close() }) + + replicas = append(replicas, &replica{bandit: bandit, active: ascache.LRU}) + } + + return replicas +} + +func newFleetStore(t *testing.T) (*MemStore, *testClock) { + t.Helper() + + clock := newTestClock() + store := NewMemStore() + store.SetClock(clock.now) + t.Cleanup(func() { _ = store.Close() }) + + return store, clock +} + +// --------------------------------------------------------------------------- +// The contract that matters most: nothing here touches the network +// --------------------------------------------------------------------------- + +// blockingStore blocks forever on every call. A bandit that did its I/O on the +// cache's path would deadlock the cache against it; this one does not, so the +// test simply completes. +type blockingStore struct { + entered chan struct{} + once sync.Once +} + +func (s *blockingStore) enter(ctx context.Context) error { + s.once.Do(func() { close(s.entered) }) + <-ctx.Done() + + return ctx.Err() +} + +func (s *blockingStore) Sync(ctx context.Context, _ SyncRequest) (SyncResult, error) { + return SyncResult{}, s.enter(ctx) +} + +func (s *blockingStore) Window(ctx context.Context, _ string, _, _ Bucket) ([]WindowCounts, error) { + return nil, s.enter(ctx) +} + +func (s *blockingStore) Decide( + ctx context.Context, + _ string, + _ Bucket, + _ ascache.PolicyType, + _ time.Duration, +) (ascache.PolicyType, error) { + return ascache.Undefined, s.enter(ctx) +} + +func (s *blockingStore) Close() error { return nil } + +func TestDistributed_RecordAndSelectNeverWaitOnTheStore(t *testing.T) { + // The cache calls both of these while holding its write lock, so a round + // trip on either path stalls every Get in the process. This is the + // invariant the whole design is arranged around. + store := &blockingStore{entered: make(chan struct{})} + clock := newTestClock() + + bandit, err := newDistributed(Config{ + Store: store, + Namespace: "test", + CoordinationEpoch: testEpoch, + SyncTimeout: time.Hour, + Now: clock.now, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = bandit.Close() }) + + r := &replica{bandit: bandit, active: ascache.LRU} + r.report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.9}) + + // Start a sync and wait until it is genuinely inside the store and stuck. + syncing := make(chan struct{}) + go func() { + defer close(syncing) + bandit.sync() + }() + <-store.entered + + done := make(chan struct{}) + go func() { + defer close(done) + for range 1000 { + r.report(t, 10, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.9}) + bandit.SelectPolicy() + } + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("RecordEpoch or SelectPolicy blocked behind an in-flight store call") + } + + require.NoError(t, bandit.Close()) + <-syncing +} + +// --------------------------------------------------------------------------- +// Leader mode +// --------------------------------------------------------------------------- + +func TestDistributed_FleetConvergesOnTheBetterArm(t *testing.T) { + store, clock := newFleetStore(t) + replicas := fleet(t, 8, store, clock, nil) + + // Each replica sees a twentieth of the traffic: 50 requests an epoch is + // too thin to rank arms locally, which is the whole reason to pool. + rates := map[ascache.PolicyType]float64{ascache.LRU: 0.40, ascache.TinyLFU: 0.75} + + for range 12 { + for _, r := range replicas { + r.report(t, 50, rates) + r.bandit.sync() + } + clock.advance(testEpoch) + } + + for _, r := range replicas { + assert.Equal(t, ascache.TinyLFU, r.bandit.SelectPolicy(), + "every replica should be running the arm the fleet's evidence favours") + } +} + +func TestDistributed_FleetRunsOnePolicyAtATime(t *testing.T) { + store, clock := newFleetStore(t) + replicas := fleet(t, 6, store, clock, nil) + + // Arms close enough together that exploration keeps moving the decision. + rates := map[ascache.PolicyType]float64{ascache.LRU: 0.50, ascache.TinyLFU: 0.51} + + for range 15 { + for _, r := range replicas { + r.report(t, 200, rates) + r.bandit.sync() + } + clock.advance(testEpoch) + + // Everyone syncs again in the new bucket so the leader's decision has + // reached them all. + for _, r := range replicas { + r.bandit.sync() + } + + selections := make(map[ascache.PolicyType]int) + for _, r := range replicas { + selections[r.bandit.SelectPolicy()]++ + } + assert.Len(t, selections, 1, + "under leader election the fleet applies one decision: %v", selections) + } +} + +func TestDistributed_ExactlyOneReplicaLeadsEachBucket(t *testing.T) { + store, clock := newFleetStore(t) + replicas := fleet(t, 10, store, clock, nil) + + const buckets = 6 + for range buckets { + for _, r := range replicas { + r.report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.6}) + r.bandit.sync() + } + clock.advance(testEpoch) + } + + total := int64(0) + for _, r := range replicas { + total += r.bandit.Snapshot().Leaderships + } + assert.Equal(t, int64(buckets), total) +} + +func TestDistributed_LeaderAppliesItsOwnDecisionImmediately(t *testing.T) { + store, clock := newFleetStore(t) + replicas := fleet(t, 3, store, clock, nil) + + for range 4 { + for _, r := range replicas { + r.report(t, 500, map[ascache.PolicyType]float64{ascache.LRU: 0.2, ascache.TinyLFU: 0.9}) + r.bandit.sync() + } + clock.advance(testEpoch) + } + + // Whichever replica led last must already be on the decision it published, + // not waiting a further round to read it back. + led := false + for _, r := range replicas { + snapshot := r.bandit.Snapshot() + if snapshot.Leaderships == 0 { + continue + } + led = true + assert.Positive(t, snapshot.Decisions, "%s led but applied nothing", snapshot.NodeID) + } + require.True(t, led, "no replica ever led") +} + +// --------------------------------------------------------------------------- +// Shared-posterior mode +// --------------------------------------------------------------------------- + +func TestDistributed_SharedPosteriorSelectsWithoutALeader(t *testing.T) { + store, clock := newFleetStore(t) + replicas := fleet(t, 6, store, clock, func(cfg *Config) { + cfg.Mode = ModeSharedPosterior + }) + + rates := map[ascache.PolicyType]float64{ascache.LRU: 0.30, ascache.TinyLFU: 0.80} + + for range 10 { + for _, r := range replicas { + r.report(t, 100, rates) + r.bandit.sync() + } + clock.advance(testEpoch) + } + + for _, r := range replicas { + snapshot := r.bandit.Snapshot() + assert.Zero(t, snapshot.Leaderships, "shared-posterior mode elects nobody") + assert.Equal(t, ascache.TinyLFU.String(), snapshot.Selection) + assert.NotEmpty(t, snapshot.Fleet, "every replica reads the window for itself") + } +} + +func TestDistributed_SharedPosteriorRejectsShadowOnlyUnderLeader(t *testing.T) { + _, err := NewDistributed(Config{ + Store: NewMemStore(), + Namespace: "test", + CoordinationEpoch: testEpoch, + Mode: ModeLeader, + Evidence: EvidenceShadowOnly, + }) + assert.ErrorIs(t, err, ErrShadowOnlyUnderLeader) +} + +func TestDistributed_ShadowOnlyIgnoresTheActiveArmsFlattery(t *testing.T) { + store, clock := newFleetStore(t) + replicas := fleet(t, 4, store, clock, func(cfg *Config) { + cfg.Mode = ModeSharedPosterior + cfg.Evidence = EvidenceShadowOnly + }) + + // LRU is the active arm everywhere and reports far better than it measures + // as a shadow - the systematic role gap, exaggerated. Shadow-only evidence + // should see straight through it. + for range 10 { + for _, r := range replicas { + r.bandit.RecordEpoch(ascache.EpochReport{ + Active: ascache.LRU, + Stats: []ascache.ShadowStats{ + {Policy: ascache.LRU, Hits: 950, Misses: 50}, + {Policy: ascache.TinyLFU, Hits: 700, Misses: 300}, + }, + Capacity: 1000, + SampleRate: 1, + }) + r.bandit.sync() + } + clock.advance(testEpoch) + } + + for _, r := range replicas { + snapshot := r.bandit.Snapshot() + assert.Equal(t, ascache.TinyLFU.String(), snapshot.Selection, + "the incumbent's active-role numbers must not decide this") + } +} + +// --------------------------------------------------------------------------- +// Failure and recovery +// --------------------------------------------------------------------------- + +func TestDistributed_FallsBackToLocalWhenTheStoreIsUnreachable(t *testing.T) { + store, clock := newFleetStore(t) + replicas := fleet(t, 3, store, clock, nil) + + rates := map[ascache.PolicyType]float64{ascache.LRU: 0.2, ascache.TinyLFU: 0.9} + for range 3 { + for _, r := range replicas { + r.report(t, 200, rates) + r.bandit.sync() + } + clock.advance(testEpoch) + } + + subject := replicas[0] + require.False(t, subject.bandit.Snapshot().Fallback) + + store.Fail(errors.New("connection refused")) + + // Inside the grace period the replica holds the fleet's last decision + // rather than reacting to one failed round trip. + subject.report(t, 200, rates) + subject.bandit.sync() + assert.False(t, subject.bandit.Snapshot().Fallback, "one failure is not an outage") + + clock.advance(4 * testEpoch) + subject.report(t, 200, rates) + subject.bandit.sync() + + snapshot := subject.bandit.Snapshot() + assert.True(t, snapshot.Fallback) + assert.Contains(t, snapshot.LastError, "connection refused") + assert.Equal(t, ascache.TinyLFU.String(), snapshot.Selection, + "the local bandit has been learning all along, so it does not start from nothing") +} + +func TestDistributed_RecoversWhenTheStoreComesBack(t *testing.T) { + store, clock := newFleetStore(t) + replicas := fleet(t, 3, store, clock, nil) + subject := replicas[0] + + store.Fail(errors.New("down")) + subject.report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.6}) + subject.bandit.sync() + require.True(t, subject.bandit.Snapshot().Fallback) + + store.Fail(nil) + for range 4 { + for _, r := range replicas { + r.report(t, 200, map[ascache.PolicyType]float64{ascache.LRU: 0.2, ascache.TinyLFU: 0.9}) + r.bandit.sync() + } + clock.advance(testEpoch) + } + + snapshot := subject.bandit.Snapshot() + assert.False(t, snapshot.Fallback) + assert.Positive(t, snapshot.Syncs) + assert.Equal(t, ascache.TinyLFU.String(), snapshot.Selection) +} + +func TestDistributed_OutageCountsAreDiscardedNotReplayed(t *testing.T) { + store, clock := newFleetStore(t) + subject := fleet(t, 1, store, clock, nil)[0] + + store.Fail(errors.New("down")) + for range 5 { + subject.report(t, 1000, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.6}) + subject.bandit.sync() + } + store.Fail(nil) + + subject.report(t, 10, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.6}) + result, err := store.Sync(t.Context(), testSyncRequest(subject.bandit.namespace, "probe")) + require.NoError(t, err) + subject.bandit.sync() + + window, err := store.Window(t.Context(), subject.bandit.namespace, result.Bucket, result.Bucket) + require.NoError(t, err) + require.Len(t, window, 1) + + published := int64(0) + for _, stats := range window[0].Arms { + published += stats.Hits + stats.Misses + } + // 10 requests across two arms. The 5000 measured during the outage stayed + // out: evidence that missed its window would land in the wrong bucket and + // describe traffic that is no longer current. + assert.Equal(t, int64(20), published) +} + +func TestDistributed_NeverSyncedFallsBackWithoutWaiting(t *testing.T) { + store, clock := newFleetStore(t) + store.Fail(errors.New("down from the start")) + + subject := fleet(t, 1, store, clock, func(cfg *Config) { + cfg.FallbackAfter = time.Hour + })[0] + + subject.report(t, 500, map[ascache.PolicyType]float64{ascache.LRU: 0.2, ascache.TinyLFU: 0.9}) + subject.bandit.sync() + + snapshot := subject.bandit.Snapshot() + assert.True(t, snapshot.Fallback, + "a grace period measured from a sync that never happened would never expire") + assert.Equal(t, ascache.TinyLFU.String(), snapshot.Selection) +} + +func TestDistributed_RefusesADecisionForAnArmItDoesNotHave(t *testing.T) { + store, clock := newFleetStore(t) + subject := fleet(t, 1, store, clock, nil)[0] + + subject.report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.6}) + subject.bandit.sync() + before := subject.bandit.SelectPolicy() + + // Something else takes the next bucket's leadership and publishes a + // decision for a policy this cache was not built with. The namespace + // fingerprint is supposed to prevent it, so reaching here means a genuine + // misconfiguration - and applying it would hand the cache a policy it + // cannot look up. + clock.advance(testEpoch) + strangerReq := testSyncRequest(subject.bandit.namespace, "stranger") + strangerReq.Lead = true + result, err := store.Sync(t.Context(), strangerReq) + require.NoError(t, err) + require.True(t, result.Leader) + + inForce, err := store.Decide(t.Context(), subject.bandit.namespace, result.Bucket, ascache.ARC, time.Minute) + require.NoError(t, err) + require.Equal(t, ascache.ARC, inForce) + + subject.report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.6}) + subject.bandit.sync() + + snapshot := subject.bandit.Snapshot() + assert.Positive(t, snapshot.Rejected) + assert.Equal(t, before, subject.bandit.SelectPolicy()) + assert.NotEqual(t, ascache.ARC.String(), snapshot.Selection) +} + +// --------------------------------------------------------------------------- +// Regime changes +// --------------------------------------------------------------------------- + +func TestDistributed_ResizeMovesTheReplicaToItsOwnNamespace(t *testing.T) { + store, clock := newFleetStore(t) + subject := fleet(t, 1, store, clock, nil)[0] + + subject.report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.6}) + before := subject.bandit.Snapshot().Namespace + + subject.bandit.RecordEpoch(ascache.EpochReport{ + Active: ascache.LRU, + Stats: []ascache.ShadowStats{ + {Policy: ascache.LRU, Hits: 1, Misses: 1}, + {Policy: ascache.TinyLFU, Hits: 1, Misses: 1}, + }, + Capacity: 4000, // resized + SampleRate: 1, + }) + + after := subject.bandit.Snapshot().Namespace + assert.NotEqual(t, before, after, + "a hit rate measured at 4000 entries says nothing about one measured at 1000") + assert.Contains(t, after, "test:") +} + +func TestDistributed_ReplicasWithDifferentShapesDoNotPool(t *testing.T) { + store, clock := newFleetStore(t) + replicas := fleet(t, 2, store, clock, nil) + + replicas[0].report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.6}) + replicas[1].bandit.RecordEpoch(ascache.EpochReport{ + Active: ascache.LRU, + Stats: []ascache.ShadowStats{ + {Policy: ascache.LRU, Hits: 50, Misses: 50}, + {Policy: ascache.TinyLFU, Hits: 60, Misses: 40}, + }, + Capacity: 100, // a much smaller cache + SampleRate: 1, + }) + + assert.NotEqual(t, + replicas[0].bandit.Snapshot().Namespace, + replicas[1].bandit.Snapshot().Namespace) +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +func TestDistributed_CloseCancelsAnInFlightRoundTrip(t *testing.T) { + // Close waits for the coordination goroutine, which may be inside a store + // call. Deriving that call's context from Background rather than from the + // bandit's own would make shutting down behind an unreachable store take + // the whole SyncTimeout - an hour here, and in production however long the + // timeout was set to. + store := &blockingStore{entered: make(chan struct{})} + + bandit, err := newDistributed(Config{ + Store: store, + Namespace: "test", + CoordinationEpoch: testEpoch, + SyncTimeout: time.Hour, + }) + require.NoError(t, err) + + r := &replica{bandit: bandit, active: ascache.LRU} + r.report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.9}) + + bandit.wg.Add(1) + go func() { + defer bandit.wg.Done() + bandit.sync() + }() + <-store.entered + + closed := make(chan error, 1) + go func() { closed <- bandit.Close() }() + + select { + case err := <-closed: + assert.NoError(t, err) + case <-time.After(10 * time.Second): + t.Fatal("Close waited on an in-flight store call instead of cancelling it") + } +} + +func TestDistributed_CloseIsIdempotentAndStopsTheGoroutine(t *testing.T) { + store, _ := newFleetStore(t) + + bandit, err := NewDistributed(Config{ + Store: store, + Namespace: "test", + CoordinationEpoch: time.Millisecond, + }) + require.NoError(t, err) + + require.NoError(t, bandit.Close()) + require.NoError(t, bandit.Close()) + require.NoError(t, bandit.Close()) +} + +func TestDistributed_SyncBeforeAnyReportPublishesNothing(t *testing.T) { + store, clock := newFleetStore(t) + subject := fleet(t, 1, store, clock, nil)[0] + + // The measurement regime is unknown until the cache reports, so there is + // nothing to key the counts by. + subject.bandit.sync() + + snapshot := subject.bandit.Snapshot() + assert.Zero(t, snapshot.Syncs) + assert.Zero(t, snapshot.SyncFailures) + assert.Equal(t, ascache.Undefined.String(), snapshot.Selection) +} + +func TestDistributed_SelectPolicyIsUndefinedUntilTheFirstRound(t *testing.T) { + store, clock := newFleetStore(t) + subject := fleet(t, 1, store, clock, nil)[0] + + subject.report(t, 100, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.6}) + + // The cache reads Undefined as "no change", which is what stops it acting + // on a bandit that has not heard from the fleet yet. + assert.Equal(t, ascache.Undefined, subject.bandit.SelectPolicy()) +} + +func TestDistributed_RecordStatsStandsInForAPlainBandit(t *testing.T) { + store, clock := newFleetStore(t) + subject := fleet(t, 1, store, clock, nil)[0] + + // A Distributed used through the plain Bandit interface still works; it + // just cannot tell which arm was active. + for range 3 { + subject.bandit.RecordStats(ascache.ShadowStats{Policy: ascache.LRU, Hits: 20, Misses: 80}) + subject.bandit.RecordStats(ascache.ShadowStats{Policy: ascache.TinyLFU, Hits: 90, Misses: 10}) + } + + assert.Equal(t, ascache.TinyLFU, subject.bandit.local.SelectPolicy()) +} + +func TestDistributed_ConcurrentReportsAndSelections(t *testing.T) { + store, clock := newFleetStore(t) + subject := fleet(t, 1, store, clock, nil)[0] + + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for range 200 { + subject.report(t, 10, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.TinyLFU: 0.6}) + subject.bandit.SelectPolicy() + } + }() + } + wg.Add(1) + go func() { + defer wg.Done() + for range 50 { + subject.bandit.sync() + subject.bandit.Snapshot() + } + }() + + wg.Wait() +} diff --git a/bandit/doc.go b/bandit/doc.go new file mode 100644 index 0000000..a308cf2 --- /dev/null +++ b/bandit/doc.go @@ -0,0 +1,47 @@ +// Package bandit provides ready-made [ascache.Bandit] implementations. +// +// The core as-cache module deliberately ships none: which arm to pull is the +// interesting decision, and it depends on how fast the traffic moves. This +// module makes the common choices available without putting them, or their +// dependencies, in the core. +// +// # Local +// +// [Thompson] samples each arm's hit rate from a Beta posterior and picks the +// best draw, so an arm is chosen roughly as often as it is likely to be the +// best one. Evidence is discounted as it ages, which is what lets it change +// its mind when the workload does. [Greedy] always takes the best-measured arm +// and exists as a control: it shows what the adaptive layer achieves with no +// exploration at all. +// +// # Distributed +// +// [Distributed] pools evidence across a fleet of replicas through a shared +// store, so a cache seeing too little traffic to tell its arms apart can still +// benefit from selection. Each replica publishes its per-epoch counts; one +// replica per coordination epoch reads the fleet's aggregate and publishes the +// decision the others apply. +// +// b, err := bandit.NewDistributed(bandit.Config{ +// Store: store, // from .../bandit/redis +// Namespace: "sessions", +// CoordinationEpoch: time.Second, +// }) +// defer b.Close() +// +// The store is an interface, not a client: [MemStore] runs a whole fleet in +// one process for tests, and github.com/sshaplygin/as-cache/bandit/redis backs +// it with Valkey or Redis. +// +// # What crosses the wire +// +// Per-policy hit and miss integers, and a policy name. No cache keys and no +// cache values ever leave the process. +// +// # Cost +// +// One round trip per replica per coordination epoch, plus two more for +// whichever replica is leading. Nothing touches the network on the cache's hot +// path, or while the cache holds a lock: [Distributed.RecordEpoch] folds +// numbers into a buffer and [Distributed.SelectPolicy] is an atomic load. +package bandit diff --git a/bandit/errors.go b/bandit/errors.go new file mode 100644 index 0000000..28d6b58 --- /dev/null +++ b/bandit/errors.go @@ -0,0 +1,45 @@ +package bandit + +import "errors" + +// ErrNilStore is returned by NewDistributed when Config.Store is nil. There is +// no useful default: a distributed bandit with nowhere to coordinate is just a +// local one, and silently becoming one would hide a misconfiguration behind +// behaviour that looks fine. +var ErrNilStore = errors.New("bandit: store must not be nil") + +// ErrInvalidCoordinationEpoch is returned by NewDistributed when +// Config.CoordinationEpoch is zero or negative. +var ErrInvalidCoordinationEpoch = errors.New("bandit: coordination epoch must be positive") + +// ErrInvalidWindow is returned by NewDistributed when Config.Window is +// negative. +var ErrInvalidWindow = errors.New("bandit: window must not be negative") + +// ErrInvalidDecay is returned by NewDistributed when Config.Decay falls +// outside (0,1]. +var ErrInvalidDecay = errors.New("bandit: decay must be in (0,1]") + +// ErrInvalidJitter is returned by NewDistributed when Config.Jitter falls +// outside [0,0.5). Half an epoch of jitter would let one replica's tick +// overtake another's by a whole bucket. +var ErrInvalidJitter = errors.New("bandit: jitter must be in [0,0.5)") + +// ErrEmptyNamespace is returned by NewDistributed when Config.Namespace is +// empty. Two unrelated fleets sharing a store and pooling each other's +// evidence is a failure with no symptom other than bad decisions, so the name +// is required rather than defaulted. +var ErrEmptyNamespace = errors.New("bandit: namespace must not be empty") + +// ErrShadowOnlyUnderLeader is returned by NewDistributed for the combination +// of ModeLeader and EvidenceShadowOnly. +// +// Under leader election every replica runs the same active policy, so that +// policy is nobody's shadow and shadow-only evidence contains nothing about +// it. Its posterior would stay at the uniform prior forever: it could still be +// drawn, but only by chance, and never on the strength of how it is actually +// performing. Discarding evidence for the one arm that is serving all the +// traffic is not a tuning choice, it is a broken configuration. +var ErrShadowOnlyUnderLeader = errors.New( + "bandit: EvidenceShadowOnly cannot be used with ModeLeader: " + + "the fleet-wide active policy has no shadow measurements anywhere") diff --git a/bandit/fingerprint.go b/bandit/fingerprint.go new file mode 100644 index 0000000..7baf6ed --- /dev/null +++ b/bandit/fingerprint.go @@ -0,0 +1,82 @@ +package bandit + +import ( + "fmt" + "hash/fnv" + "strings" + + ascache "github.com/sshaplygin/as-cache" +) + +// regime is the identity of what a cache is measuring: which arms, at what +// capacity, over how much of the keyspace. +// +// Two replicas may only pool evidence when these match. A hit rate is a +// statement about a specific cache size against a specific substream, and +// summing one taken at 1000 entries with one taken at 100 produces a number +// that describes neither cache - it just looks like a hit rate. Nothing in the +// counts themselves reveals the mismatch, and the resulting decision looks +// entirely reasonable, so this is checked structurally rather than left to be +// noticed. +// +// Epoch duration is deliberately not part of it. A replica reporting twice as +// often contributes twice the counts, but at the same rate, and rates are what +// the comparison is made on. Fleets may be tuned per replica. +type regime struct { + arms []ascache.PolicyType + capacity int + sampleRate float64 +} + +// String renders the regime in the form that gets hashed. It is legible on +// purpose: when a fleet mysteriously splits into two namespaces, this is the +// string worth logging. +func (r regime) String() string { + names := make([]string, 0, len(r.arms)) + for _, arm := range r.arms { + names = append(names, arm.String()) + } + + // The rate is quantised before it is rendered. It arrives as a float that + // has been through a capacity-floor calculation, so two replicas + // configured identically can differ in the last bits and would otherwise + // fingerprint apart for no reason a caller could ever see. + return fmt.Sprintf("arms=%s;cap=%d;rate=%.4f", + strings.Join(names, ","), r.capacity, r.sampleRate) +} + +// fingerprint is the short form appended to the namespace. +func (r regime) fingerprint() string { + h := fnv.New64a() + // Hash.Write never returns an error, per the hash.Hash contract. + _, _ = h.Write([]byte(r.String())) + + return fmt.Sprintf("%012x", h.Sum64()&0xffffffffffff) +} + +// regimeOf reads the measurement regime out of an epoch report. The arms are +// already sorted by PolicyType - EpochReport documents that ordering - so the +// fingerprint does not depend on anything the cache could vary between runs. +func regimeOf(report ascache.EpochReport) regime { + arms := make([]ascache.PolicyType, 0, len(report.Stats)) + for _, stats := range report.Stats { + arms = append(arms, stats.Policy) + } + + return regime{ + arms: arms, + capacity: report.Capacity, + sampleRate: report.SampleRate, + } +} + +// equal reports whether two regimes may pool. Sample rates are compared at the +// precision they are fingerprinted at, so the two can never disagree. +func (r regime) equal(other regime) bool { + return r.String() == other.String() +} + +// scopedNamespace is the namespace a replica in this regime coordinates under. +func scopedNamespace(namespace string, r regime) string { + return namespace + ":" + r.fingerprint() +} diff --git a/bandit/fingerprint_test.go b/bandit/fingerprint_test.go new file mode 100644 index 0000000..aec5143 --- /dev/null +++ b/bandit/fingerprint_test.go @@ -0,0 +1,107 @@ +package bandit + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + ascache "github.com/sshaplygin/as-cache" +) + +func report(capacity int, rate float64, arms ...ascache.PolicyType) ascache.EpochReport { + stats := make([]ascache.ShadowStats, 0, len(arms)) + for _, arm := range arms { + stats = append(stats, ascache.ShadowStats{Policy: arm}) + } + + return ascache.EpochReport{Stats: stats, Capacity: capacity, SampleRate: rate} +} + +func TestRegime_SeparatesWhatCannotBePooled(t *testing.T) { + base := regimeOf(report(1000, 1, ascache.LRU, ascache.TinyLFU)) + + tests := []struct { + name string + other ascache.EpochReport + }{ + { + name: "different capacity", + other: report(100, 1, ascache.LRU, ascache.TinyLFU), + }, + { + name: "different sample rate", + other: report(1000, 0.05, ascache.LRU, ascache.TinyLFU), + }, + { + name: "different arms", + other: report(1000, 1, ascache.LRU, ascache.LFU), + }, + { + name: "extra arm", + other: report(1000, 1, ascache.LRU, ascache.TinyLFU, ascache.LFU), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + other := regimeOf(tt.other) + + assert.False(t, base.equal(other)) + assert.NotEqual(t, + scopedNamespace("app", base), + scopedNamespace("app", other)) + }) + } +} + +func TestRegime_IdenticalShapesPool(t *testing.T) { + left := regimeOf(report(1000, 0.05, ascache.LRU, ascache.TinyLFU)) + right := regimeOf(report(1000, 0.05, ascache.LRU, ascache.TinyLFU)) + + assert.True(t, left.equal(right)) + assert.Equal(t, scopedNamespace("app", left), scopedNamespace("app", right)) +} + +func TestRegime_ToleratesFloatNoiseInTheSampleRate(t *testing.T) { + // The rate reaches the bandit having been through a capacity-floor + // calculation, so two replicas configured identically can differ in the + // last bits. Splitting a fleet over that would be invisible and + // undiagnosable. + exact := regimeOf(report(1000, 0.05, ascache.LRU)) + noisy := regimeOf(report(1000, 0.05+1e-12, ascache.LRU)) + + assert.True(t, exact.equal(noisy)) +} + +func TestRegime_DistinguishesRatesThatActuallyDiffer(t *testing.T) { + // The tolerance must not be so wide that genuinely different sampling + // regimes pool with each other. + coarse := regimeOf(report(1000, 0.05, ascache.LRU)) + finer := regimeOf(report(1000, 0.06, ascache.LRU)) + + assert.False(t, coarse.equal(finer)) +} + +func TestRegime_StringIsLegible(t *testing.T) { + // When a fleet mysteriously splits, this is the string worth logging, so + // it has to say what actually differs. + r := regimeOf(report(2048, 0.25, ascache.LRU, ascache.TinyLFU)) + + assert.Equal(t, "arms=LRU,TinyLFU;cap=2048;rate=0.2500", r.String()) +} + +func TestRegime_FingerprintIsStableAndShort(t *testing.T) { + r := regimeOf(report(1000, 1, ascache.LRU, ascache.TinyLFU)) + + first := r.fingerprint() + assert.Len(t, first, 12) + for range 10 { + assert.Equal(t, first, r.fingerprint()) + } +} + +func TestScopedNamespace_KeepsTheCallersNameReadable(t *testing.T) { + r := regimeOf(report(1000, 1, ascache.LRU)) + + assert.Contains(t, scopedNamespace("sessions", r), "sessions:") +} diff --git a/bandit/go.mod b/bandit/go.mod new file mode 100644 index 0000000..f4d3c73 --- /dev/null +++ b/bandit/go.mod @@ -0,0 +1,16 @@ +module github.com/sshaplygin/as-cache/bandit + +go 1.25.2 + +require ( + github.com/sshaplygin/as-cache v0.0.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/sshaplygin/as-cache => .. diff --git a/bandit/go.sum b/bandit/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/bandit/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/bandit/integration_test.go b/bandit/integration_test.go new file mode 100644 index 0000000..2295eea --- /dev/null +++ b/bandit/integration_test.go @@ -0,0 +1,373 @@ +package bandit + +import ( + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ascache "github.com/sshaplygin/as-cache" +) + +// evictOrder selects which end of the insertion order a testPolicy discards. +type evictOrder int + +const ( + // evictOldest is LRU: discard the least recently used entry. + evictOldest evictOrder = iota + 1 + // evictNewest is MRU: discard the most recently used entry. It is the + // right policy for a cyclic scan just larger than the cache, which is + // exactly where LRU serves nothing at all - so the two make an + // unambiguous pair to test selection with. + evictNewest +) + +// testPolicy is a small recency-ordered cache. The bandit module depends on +// the core module alone, so it cannot reach for the ready-made policies; this +// is enough to drive a real AdaptiveCache. +type testPolicy struct { + mu sync.Mutex + entries map[string]int + order []string + capacity int + evict evictOrder + policy ascache.PolicyType + hits int64 + misses int64 +} + +func newTestPolicy(policy ascache.PolicyType, capacity int, order evictOrder) *testPolicy { + return &testPolicy{ + entries: make(map[string]int, capacity), + capacity: capacity, + evict: order, + policy: policy, + } +} + +func (p *testPolicy) touchLocked(key string) { + for i, existing := range p.order { + if existing == key { + p.order = append(p.order[:i], p.order[i+1:]...) + break + } + } + p.order = append(p.order, key) +} + +func (p *testPolicy) Add(key string, value int) bool { + p.mu.Lock() + defer p.mu.Unlock() + + _, existed := p.entries[key] + if !existed && p.capacity > 0 && len(p.entries) >= p.capacity { + victim := p.order[0] + if p.evict == evictNewest { + victim = p.order[len(p.order)-1] + } + delete(p.entries, victim) + p.removeFromOrderLocked(victim) + } + + if p.capacity <= 0 { + return false + } + + p.entries[key] = value + p.touchLocked(key) + + return !existed +} + +func (p *testPolicy) removeFromOrderLocked(key string) { + for i, existing := range p.order { + if existing == key { + p.order = append(p.order[:i], p.order[i+1:]...) + return + } + } +} + +func (p *testPolicy) Get(key string) (int, bool) { + p.mu.Lock() + defer p.mu.Unlock() + + value, ok := p.entries[key] + if ok { + p.hits++ + p.touchLocked(key) + } else { + p.misses++ + } + + return value, ok +} + +func (p *testPolicy) Peek(key string) (int, bool) { + p.mu.Lock() + defer p.mu.Unlock() + + value, ok := p.entries[key] + + return value, ok +} + +func (p *testPolicy) Contains(key string) bool { + p.mu.Lock() + defer p.mu.Unlock() + + _, ok := p.entries[key] + + return ok +} + +func (p *testPolicy) Remove(key string) bool { + p.mu.Lock() + defer p.mu.Unlock() + + _, ok := p.entries[key] + delete(p.entries, key) + p.removeFromOrderLocked(key) + + return ok +} + +func (p *testPolicy) Purge() { + p.mu.Lock() + defer p.mu.Unlock() + + p.entries = make(map[string]int, p.capacity) + p.order = nil +} + +func (p *testPolicy) Keys() []string { + p.mu.Lock() + defer p.mu.Unlock() + + keys := make([]string, len(p.order)) + copy(keys, p.order) + + return keys +} + +func (p *testPolicy) Values() []int { + p.mu.Lock() + defer p.mu.Unlock() + + values := make([]int, 0, len(p.order)) + for _, key := range p.order { + values = append(values, p.entries[key]) + } + + return values +} + +func (p *testPolicy) Len() int { + p.mu.Lock() + defer p.mu.Unlock() + + return len(p.entries) +} + +func (p *testPolicy) Cap() int { + p.mu.Lock() + defer p.mu.Unlock() + + return p.capacity +} + +func (p *testPolicy) Resize(size int) int { + p.mu.Lock() + defer p.mu.Unlock() + + p.capacity = size + + evicted := 0 + for len(p.entries) > size && len(p.order) > 0 { + victim := p.order[0] + delete(p.entries, victim) + p.order = p.order[1:] + evicted++ + } + + return evicted +} + +func (p *testPolicy) GetStats() ascache.PolicyStats { + p.mu.Lock() + defer p.mu.Unlock() + + return ascache.PolicyStats{Hits: p.hits, Misses: p.misses} +} + +func (p *testPolicy) ResetStats() { + p.mu.Lock() + defer p.mu.Unlock() + + p.hits, p.misses = 0, 0 +} + +func (p *testPolicy) GetType() ascache.PolicyType { return p.policy } + +// TestIntegration_FleetOfCachesSwitchesToTheBetterPolicy wires the whole thing +// together: real AdaptiveCaches with their own epoch goroutines, real bandits +// with their own coordination goroutines, one shared store. +// +// The workload is a cyclic scan slightly larger than the cache, where LRU +// serves nothing at all and MRU serves most of it. Each replica sees a +// fraction of the traffic. +func TestIntegration_FleetOfCachesSwitchesToTheBetterPolicy(t *testing.T) { + if testing.Short() { + t.Skip("timing-dependent: runs goroutines at millisecond epochs") + } + + const ( + replicas = 4 + capacity = 32 + workingSet = 40 + ) + + store := NewMemStore() + t.Cleanup(func() { _ = store.Close() }) + + caches := make([]*ascache.AdaptiveCache[string, int], 0, replicas) + bandits := make([]*Distributed, 0, replicas) + + for i := range replicas { + b, err := NewDistributed(Config{ + Store: store, + Namespace: "integration", + NodeID: string(rune('a' + i)), + CoordinationEpoch: 20 * time.Millisecond, + Window: 4, + Seed: uint64(i + 1), + }) + require.NoError(t, err) + t.Cleanup(func() { _ = b.Close() }) + bandits = append(bandits, b) + + cache, err := ascache.NewAdaptiveCache( + []ascache.Policy[string, int]{ + newTestPolicy(ascache.LRU, capacity, evictOldest), + newTestPolicy(ascache.TinyLFU, capacity, evictNewest), + }, + b, + &ascache.Settings{ + EpochDuration: 5 * time.Millisecond, + EvictPartialCapacityFilling: true, + MigrationStrategy: ascache.MigrationWarm, + }, + ) + require.NoError(t, err) + t.Cleanup(func() { _ = cache.Close() }) + caches = append(caches, cache) + } + + stop := make(chan struct{}) + var wg sync.WaitGroup + for _, cache := range caches { + wg.Go(func() { + for round := 0; ; round++ { + select { + case <-stop: + return + default: + } + + key := keyOf(round % workingSet) + if _, ok := cache.Get(key); !ok { + cache.Add(key, round) + } + } + }) + } + + // Every replica should end up on the arm the fleet's pooled evidence + // favours, through a decision none of them made alone. + require.Eventually(t, func() bool { + for _, cache := range caches { + if cache.ActivePolicy() != ascache.TinyLFU { + return false + } + } + + return true + }, 15*time.Second, 25*time.Millisecond) + + close(stop) + wg.Wait() + + for _, b := range bandits { + snapshot := b.Snapshot() + assert.False(t, snapshot.Fallback, "%s fell back despite a healthy store", snapshot.NodeID) + assert.Positive(t, snapshot.Syncs) + } + + // Leadership is claimed once per bucket across the fleet, so no single + // replica can have led every one of them. + led := 0 + for _, b := range bandits { + if b.Snapshot().Leaderships > 0 { + led++ + } + } + assert.Positive(t, led) +} + +func keyOf(i int) string { + return "k" + strconv.Itoa(i) +} + +// TestIntegration_CacheSurvivesABanditThatNeverReachesItsStore is the failure +// case that matters most: the store is down from the start, so the bandit is +// in fallback for the whole run. The cache must keep serving throughout. +func TestIntegration_CacheSurvivesABanditThatNeverReachesItsStore(t *testing.T) { + if testing.Short() { + t.Skip("timing-dependent: runs goroutines at millisecond epochs") + } + + store := NewMemStore() + store.Fail(assert.AnError) + t.Cleanup(func() { _ = store.Close() }) + + b, err := NewDistributed(Config{ + Store: store, + Namespace: "outage", + CoordinationEpoch: 5 * time.Millisecond, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = b.Close() }) + + cache, err := ascache.NewAdaptiveCache( + []ascache.Policy[string, int]{ + newTestPolicy(ascache.LRU, 16, evictOldest), + newTestPolicy(ascache.TinyLFU, 16, evictNewest), + }, + b, + &ascache.Settings{ + EpochDuration: 2 * time.Millisecond, + EvictPartialCapacityFilling: true, + }, + ) + require.NoError(t, err) + t.Cleanup(func() { _ = cache.Close() }) + + deadline := time.Now().Add(2 * time.Second) + for round := 0; time.Now().Before(deadline); round++ { + key := keyOf(round % 20) + if _, ok := cache.Get(key); !ok { + cache.Add(key, round) + } + } + + stats := cache.Stats() + assert.Positive(t, stats.Hits+stats.Misses, "the cache must keep serving through an outage") + + snapshot := b.Snapshot() + assert.True(t, snapshot.Fallback) + assert.Positive(t, snapshot.SyncFailures) + assert.NotEqual(t, ascache.Undefined, cache.ActivePolicy()) +} diff --git a/bandit/memstore.go b/bandit/memstore.go new file mode 100644 index 0000000..53811ae --- /dev/null +++ b/bandit/memstore.go @@ -0,0 +1,254 @@ +package bandit + +import ( + "context" + "sync" + "time" + + ascache "github.com/sshaplygin/as-cache" +) + +var _ Store = (*MemStore)(nil) + +// MemStore is a Store held in memory, shared by every replica in one process. +// +// It is not a stand-in for a real store in production - a fleet in one process +// is not a fleet - but it is the right thing for three jobs: testing a +// distributed bandit without a server, simulating a fleet to see whether +// pooling helps before deploying anything, and giving the store contract one +// executable definition that the Valkey and Redis adapters are checked +// against. +// +// It implements the same semantics the adapters must: buckets come from the +// store's clock and never a replica's, leadership is first-come per bucket, +// a published decision is immutable, and everything expires. +type MemStore struct { + mu sync.Mutex + + now func() time.Time + + counts map[bucketKey]*bucketEntry + leaders map[bucketKey]entry[string] + decisions map[bucketKey]entry[ascache.PolicyType] + + // failure, when non-nil, is returned by every call. It exists so a test + // can take the store away mid-run and watch replicas fall back. + failure error +} + +type bucketKey struct { + namespace string + bucket Bucket +} + +type entry[T any] struct { + value T + expires time.Time +} + +type bucketEntry struct { + arms map[ArmKey]ascache.PolicyStats + expires time.Time +} + +// NewMemStore returns an empty in-memory store using the wall clock. +func NewMemStore() *MemStore { + return &MemStore{ + now: time.Now, + counts: make(map[bucketKey]*bucketEntry), + leaders: make(map[bucketKey]entry[string]), + decisions: make(map[bucketKey]entry[ascache.PolicyType]), + } +} + +// SetClock replaces the store's clock, which is what assigns buckets. A test +// that drives this clock controls bucket boundaries exactly, with no sleeping +// and no dependence on how fast the machine is. +func (s *MemStore) SetClock(now func() time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + + s.now = now +} + +// Fail makes every subsequent call return err, or restores normal operation +// when err is nil. It is how a test simulates the store going away. +func (s *MemStore) Fail(err error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.failure = err +} + +// Sync publishes one replica's counts, claims the bucket if asked and if it is +// unclaimed, and reports any decision already published for it. +func (s *MemStore) Sync(ctx context.Context, req SyncRequest) (SyncResult, error) { + if err := ctx.Err(); err != nil { + return SyncResult{}, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.failure != nil { + return SyncResult{}, s.failure + } + + now := s.now() + s.expireLocked(now) + + bucket := bucketAt(now, req.EpochMillis) + key := bucketKey{namespace: req.Namespace, bucket: bucket} + + if len(req.Counts) > 0 { + state, ok := s.counts[key] + if !ok { + state = &bucketEntry{arms: make(map[ArmKey]ascache.PolicyStats)} + s.counts[key] = state + } + // The TTL is refreshed by every writer, so a bucket outlives its last + // contribution rather than its first. + state.expires = now.Add(req.CounterTTL) + + for _, count := range req.Counts { + armKey := ArmKey{Policy: count.Policy, Role: count.Role} + stats := state.arms[armKey] + stats.Hits += count.Hits + stats.Misses += count.Misses + state.arms[armKey] = stats + } + } + + result := SyncResult{Bucket: bucket} + + if req.Lead { + if _, taken := s.leaders[key]; !taken { + s.leaders[key] = entry[string]{value: req.NodeID, expires: now.Add(req.LeaderTTL)} + result.Leader = true + } + } + + if decision, ok := s.decisions[key]; ok { + result.Decision, result.HasDecision = decision.value, true + } + + return result, nil +} + +// Window returns the buckets in [first, last] that hold counts. Buckets that +// were never written, or that have expired, are omitted rather than reported +// as zero. +func (s *MemStore) Window(ctx context.Context, namespace string, first, last Bucket) ([]WindowCounts, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.failure != nil { + return nil, s.failure + } + + s.expireLocked(s.now()) + + // The span is caller-supplied, and sizing the slice from it would let a + // wide range allocate for buckets that were never written. Most windows + // hold a handful of buckets; append covers the rest. + window := make([]WindowCounts, 0, min(max(0, int(last-first)+1), 64)) + for bucket := first; bucket <= last; bucket++ { + state, ok := s.counts[bucketKey{namespace: namespace, bucket: bucket}] + if !ok { + continue + } + + arms := make(map[ArmKey]ascache.PolicyStats, len(state.arms)) + for armKey, stats := range state.arms { + arms[armKey] = stats + } + window = append(window, WindowCounts{Bucket: bucket, Arms: arms}) + } + + return window, nil +} + +// Decide publishes a decision for a bucket, leaving any existing one in place, +// and returns whichever decision is in force. +func (s *MemStore) Decide( + ctx context.Context, + namespace string, + bucket Bucket, + policy ascache.PolicyType, + ttl time.Duration, +) (ascache.PolicyType, error) { + if err := ctx.Err(); err != nil { + return ascache.Undefined, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.failure != nil { + return ascache.Undefined, s.failure + } + + now := s.now() + s.expireLocked(now) + + key := bucketKey{namespace: namespace, bucket: bucket} + if existing, exists := s.decisions[key]; exists { + // Immutable once published: replicas act on it, and one that changed + // underneath them would move the fleet mid-epoch for no reason. + return existing.value, nil + } + + s.decisions[key] = entry[ascache.PolicyType]{value: policy, expires: now.Add(ttl)} + + return policy, nil +} + +// Close discards everything the store holds. +func (s *MemStore) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + clear(s.counts) + clear(s.leaders) + clear(s.decisions) + + return nil +} + +// expireLocked drops everything past its TTL. A real store does this itself; +// doing it here keeps a long-running simulation from growing without bound and +// keeps the two implementations behaving the same when a leader reads back +// further than the counters were kept for. +func (s *MemStore) expireLocked(now time.Time) { + for key, state := range s.counts { + if now.After(state.expires) { + delete(s.counts, key) + } + } + for key, state := range s.leaders { + if now.After(state.expires) { + delete(s.leaders, key) + } + } + for key, state := range s.decisions { + if now.After(state.expires) { + delete(s.decisions, key) + } + } +} + +// bucketAt divides the store's clock into coordination epochs. Every replica +// gets its bucket from here rather than computing one locally, which is what +// makes the scheme immune to clock skew across the fleet: a replica's own +// clock is never consulted, so it cannot be wrong in a way that matters. +func bucketAt(now time.Time, epochMillis int64) Bucket { + if epochMillis <= 0 { + epochMillis = 1 + } + + return Bucket(now.UnixMilli() / epochMillis) +} diff --git a/bandit/memstore_test.go b/bandit/memstore_test.go new file mode 100644 index 0000000..a58cb94 --- /dev/null +++ b/bandit/memstore_test.go @@ -0,0 +1,298 @@ +package bandit + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ascache "github.com/sshaplygin/as-cache" +) + +// testClock is a clock a test drives by hand, so bucket boundaries are exact +// and nothing has to sleep. +type testClock struct { + mu sync.Mutex + at time.Time +} + +func newTestClock() *testClock { + // An arbitrary fixed instant. Buckets are derived from Unix milliseconds, + // so any starting point works as long as it does not move on its own. + return &testClock{at: time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)} +} + +func (c *testClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + + return c.at +} + +func (c *testClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.at = c.at.Add(d) +} + +const testEpoch = time.Second + +func testSyncRequest(namespace, node string, counts ...ArmCounts) SyncRequest { + return SyncRequest{ + Namespace: namespace, + NodeID: node, + Counts: counts, + EpochMillis: testEpoch.Milliseconds(), + CounterTTL: 12 * testEpoch, + LeaderTTL: 2 * testEpoch, + } +} + +func shadow(policy ascache.PolicyType, hits, misses int64) ArmCounts { + return ArmCounts{Policy: policy, Role: RoleShadow, Hits: hits, Misses: misses} +} + +func TestMemStore_BucketComesFromTheStoreClock(t *testing.T) { + clock := newTestClock() + store := NewMemStore() + store.SetClock(clock.now) + t.Cleanup(func() { _ = store.Close() }) + + first, err := store.Sync(t.Context(), testSyncRequest("ns", "a")) + require.NoError(t, err) + + clock.advance(testEpoch) + second, err := store.Sync(t.Context(), testSyncRequest("ns", "a")) + require.NoError(t, err) + + assert.Equal(t, first.Bucket+1, second.Bucket) +} + +func TestMemStore_SumsCountsAcrossReplicas(t *testing.T) { + store := NewMemStore() + t.Cleanup(func() { _ = store.Close() }) + + result, err := store.Sync(t.Context(), testSyncRequest("ns", "a", shadow(ascache.LRU, 10, 5))) + require.NoError(t, err) + _, err = store.Sync(t.Context(), testSyncRequest("ns", "b", shadow(ascache.LRU, 20, 15))) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "ns", result.Bucket, result.Bucket) + require.NoError(t, err) + require.Len(t, window, 1) + + assert.Equal(t, + ascache.PolicyStats{Hits: 30, Misses: 20}, + window[0].Arms[ArmKey{Policy: ascache.LRU, Role: RoleShadow}]) +} + +func TestMemStore_NamespacesDoNotPool(t *testing.T) { + store := NewMemStore() + t.Cleanup(func() { _ = store.Close() }) + + result, err := store.Sync(t.Context(), testSyncRequest("one", "a", shadow(ascache.LRU, 10, 0))) + require.NoError(t, err) + _, err = store.Sync(t.Context(), testSyncRequest("two", "b", shadow(ascache.LRU, 999, 0))) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "one", result.Bucket, result.Bucket) + require.NoError(t, err) + require.Len(t, window, 1) + + assert.Equal(t, int64(10), window[0].Arms[ArmKey{Policy: ascache.LRU, Role: RoleShadow}].Hits) +} + +func TestMemStore_OneLeaderPerBucket(t *testing.T) { + clock := newTestClock() + store := NewMemStore() + store.SetClock(clock.now) + t.Cleanup(func() { _ = store.Close() }) + + leaders := 0 + for _, node := range []string{"a", "b", "c", "d", "e"} { + req := testSyncRequest("ns", node) + req.Lead = true + result, err := store.Sync(t.Context(), req) + require.NoError(t, err) + if result.Leader { + leaders++ + } + } + assert.Equal(t, 1, leaders, "leadership of a bucket is claimed once") + + clock.advance(testEpoch) + req := testSyncRequest("ns", "f") + req.Lead = true + result, err := store.Sync(t.Context(), req) + require.NoError(t, err) + assert.True(t, result.Leader, "the next bucket is up for grabs again") +} + +func TestMemStore_LeadershipIsNotClaimedUnlessAsked(t *testing.T) { + store := NewMemStore() + t.Cleanup(func() { _ = store.Close() }) + + result, err := store.Sync(t.Context(), testSyncRequest("ns", "a")) + require.NoError(t, err) + assert.False(t, result.Leader) +} + +func TestMemStore_DecisionIsImmutable(t *testing.T) { + store := NewMemStore() + t.Cleanup(func() { _ = store.Close() }) + + first, err := store.Decide(t.Context(), "ns", 100, ascache.TinyLFU, time.Minute) + require.NoError(t, err) + assert.Equal(t, ascache.TinyLFU, first) + + // Republishing is not an error, and reports what is actually in force - + // which is how a leader that lost a race still ends up agreeing with the + // fleet instead of running its own draw alone. + second, err := store.Decide(t.Context(), "ns", 100, ascache.LRU, time.Minute) + require.NoError(t, err) + assert.Equal(t, ascache.TinyLFU, second) + + clock := newTestClock() + store.SetClock(clock.now) + + // Read it back through a sync landing in the same bucket. + req := testSyncRequest("ns", "a") + req.EpochMillis = 1 + result, err := store.Sync(t.Context(), req) + require.NoError(t, err) + + _, err = store.Decide(t.Context(), "ns", result.Bucket, ascache.LFU, time.Minute) + require.NoError(t, err) + + again, err := store.Sync(t.Context(), req) + require.NoError(t, err) + + require.True(t, again.HasDecision) + assert.Equal(t, ascache.LFU, again.Decision) +} + +func TestMemStore_ExpiredBucketsLeaveHolesRatherThanZeros(t *testing.T) { + clock := newTestClock() + store := NewMemStore() + store.SetClock(clock.now) + t.Cleanup(func() { _ = store.Close() }) + + req := testSyncRequest("ns", "a", shadow(ascache.LRU, 10, 0)) + req.CounterTTL = 2 * testEpoch + first, err := store.Sync(t.Context(), req) + require.NoError(t, err) + + clock.advance(5 * testEpoch) + latest, err := store.Sync(t.Context(), req) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "ns", first.Bucket, latest.Bucket) + require.NoError(t, err) + + require.Len(t, window, 1, "the expired bucket is absent, not present and empty") + assert.Equal(t, latest.Bucket, window[0].Bucket) +} + +func TestMemStore_WindowSkipsBucketsNeverWritten(t *testing.T) { + store := NewMemStore() + t.Cleanup(func() { _ = store.Close() }) + + result, err := store.Sync(t.Context(), testSyncRequest("ns", "a", shadow(ascache.LRU, 1, 1))) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "ns", result.Bucket-10, result.Bucket) + require.NoError(t, err) + assert.Len(t, window, 1) +} + +func TestMemStore_WindowCopiesItsCounts(t *testing.T) { + store := NewMemStore() + t.Cleanup(func() { _ = store.Close() }) + + result, err := store.Sync(t.Context(), testSyncRequest("ns", "a", shadow(ascache.LRU, 10, 0))) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "ns", result.Bucket, result.Bucket) + require.NoError(t, err) + require.Len(t, window, 1) + + // A caller mutating what it read must not corrupt the store's own state, + // or a fleet simulation would poison itself. + window[0].Arms[ArmKey{Policy: ascache.LRU, Role: RoleShadow}] = ascache.PolicyStats{Hits: 1 << 40} + + again, err := store.Window(t.Context(), "ns", result.Bucket, result.Bucket) + require.NoError(t, err) + assert.Equal(t, int64(10), again[0].Arms[ArmKey{Policy: ascache.LRU, Role: RoleShadow}].Hits) +} + +func TestMemStore_FailAffectsEveryCall(t *testing.T) { + store := NewMemStore() + t.Cleanup(func() { _ = store.Close() }) + + boom := errors.New("store is down") + store.Fail(boom) + + _, err := store.Sync(t.Context(), testSyncRequest("ns", "a")) + assert.ErrorIs(t, err, boom) + + _, err = store.Window(t.Context(), "ns", 0, 10) + assert.ErrorIs(t, err, boom) + + _, err = store.Decide(t.Context(), "ns", 1, ascache.LRU, time.Minute) + assert.ErrorIs(t, err, boom) + + store.Fail(nil) + _, err = store.Sync(t.Context(), testSyncRequest("ns", "a")) + assert.NoError(t, err) +} + +func TestMemStore_RespectsContextCancellation(t *testing.T) { + store := NewMemStore() + t.Cleanup(func() { _ = store.Close() }) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := store.Sync(ctx, testSyncRequest("ns", "a")) + assert.ErrorIs(t, err, context.Canceled) + + _, err = store.Window(ctx, "ns", 0, 1) + assert.ErrorIs(t, err, context.Canceled) + + _, err = store.Decide(ctx, "ns", 1, ascache.LRU, time.Minute) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestMemStore_ConcurrentReplicas(t *testing.T) { + store := NewMemStore() + t.Cleanup(func() { _ = store.Close() }) + + const replicas = 16 + const rounds = 50 + + var wg sync.WaitGroup + for node := range replicas { + wg.Add(1) + go func() { + defer wg.Done() + for range rounds { + req := testSyncRequest("ns", string(rune('a'+node)), shadow(ascache.LRU, 1, 1)) + req.Lead = true + if _, err := store.Sync(t.Context(), req); err != nil { + assert.NoError(t, err) + return + } + if _, err := store.Window(t.Context(), "ns", 0, 32); err != nil { + assert.NoError(t, err) + return + } + } + }() + } + wg.Wait() +} diff --git a/bandit/redis/LICENSE b/bandit/redis/LICENSE new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/bandit/redis/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/bandit/redis/TESTING.md b/bandit/redis/TESTING.md new file mode 100644 index 0000000..5c0679b --- /dev/null +++ b/bandit/redis/TESTING.md @@ -0,0 +1,136 @@ +# Testing the Valkey/Redis store + +This adapter is the only part of as-cache that depends on a server being there +and behaving. Everything else can be tested by calling it; this cannot. + +The suite runs against [miniredis](https://github.com/alicebob/miniredis) by +default so `make test` stays offline, and against real servers on demand. Both +matter, for different reasons. + +## Why a fake is not enough here + +The adapter leans on three things that a fake can accept while a real server +rejects them, or accept in a way a real server would not: + +1. **`redis.call('TIME')` inside a Lua script.** This is how a bucket is + derived from the *server's* clock rather than a replica's, which is what + frees a fleet from needing clock agreement. It requires effects replication; + on Redis before 7.0 a script calling a non-deterministic command before a + write was rejected outright. A fake with a permissive Lua interpreter will + happily run a script that a Redis 6 server refuses. +2. **Keys the script names itself.** A bucket is not known when the call is + made, so `syncScript` builds its key names from a base passed in `ARGV` + rather than declaring them in `KEYS`. That works because every key for a + namespace carries the same hash tag and therefore hashes to one slot. A + single-instance fake has no slots at all, so it cannot notice a mistake + here. +3. **`SET ... NX PX` truthiness and reply shapes.** Leadership depends on + distinguishing "I claimed it" from "someone else has it", and the sync reply + is a Lua table whose third element is deliberately an empty string rather + than a nil, because a nil inside a Lua table truncates the array Redis + builds from it and would silently take the bucket and leader values with it. + +Testing one engine while the documentation claims two is how a support claim +quietly stops being true, so both are covered. + +## Running it + +```sh +make redis-up # start Valkey and Redis, wait for their healthchecks +make redis-test # start both, run the suite against each, tear down +make redis-down # stop both and remove their volumes +``` + +Against a server you already have: + +```sh +AS_CACHE_REDIS_ADDR=127.0.0.1:6379 go test -race -count=1 ./... +``` + +[`docker-compose.yml`](../../docker-compose.yml) in the repository root defines +both servers. Ports are deliberately off the defaults — Valkey on `63799`, +Redis on `63798` — so this cannot collide with a Redis you are already running +for something else. Neither container persists anything. + +### A shell trap worth knowing + +Do not write the two-engine loop as + +```sh +for target in "valkey 127.0.0.1:63799" "redis 127.0.0.1:63798"; do + set -- $target + AS_CACHE_REDIS_ADDR=$2 go test ./... # WRONG under zsh +done +``` + +**zsh does not word-split unquoted parameter expansions**, so `$2` is empty, +`AS_CACHE_REDIS_ADDR` is unset, and the run silently falls back to miniredis +while printing every sign of having used a real server. It passes, quickly, and +proves nothing. The `redis-test` recipe in the Makefile is safe because make +runs recipes under `/bin/sh`, which does split. When running by hand, pass the +address literally. + +The tell is `TestStore_CountersExpire`: it waits out a real TTL on a real +server and takes about 0.6s, and fast-forwards a fake clock in 0.00s. + +## Verified + +Run on 2026-07-26, macOS 26.2 arm64, Go 1.25.5, `go test -race -count=1`. + +| Backend | Version | Result | +| --- | --- | --- | +| miniredis | v2.38.0 | 20 passed, 0 skipped, 0 failed | +| Valkey | 8.1.9 (reports `redis_version` 7.2.4) | 20 passed, 0 skipped, 0 failed | +| Redis | 7.4.10 | 20 passed, 0 skipped, 0 failed | + +```text +==> bandit/redis against valkey (127.0.0.1:63799) +ok github.com/sshaplygin/as-cache/bandit/redis 7.081s +==> bandit/redis against redis (127.0.0.1:63798) +ok github.com/sshaplygin/as-cache/bandit/redis 7.108s +``` + +Every test runs against every backend; nothing is skipped anywhere. The two +slow tests are slow on purpose: `TestStore_ReportsAnUnreachableServer` waits +out a dial timeout against a closed port, and `TestStore_CountersExpire` waits +out a real TTL. + +## What each test establishes + +| Test | What it would catch | +| --- | --- | +| `TestNew_RejectsANilClient` | A store constructed with nowhere to talk to | +| `TestStore_SyncReportsAServerDerivedBucket` | A script returning a constant instead of reading the server clock; the bucket is checked against the client's own clock, within tolerance | +| `TestStore_SumsCountsAcrossReplicas` | Counts overwriting rather than accumulating — the whole point of pooling | +| `TestStore_KeepsRolesApart` | Active-role and shadow-role counts merging, which would hide the measurement bias the two modes exist to manage | +| `TestStore_NamespacesDoNotPool` | Two fleets sharing a store and silently pooling each other's evidence | +| `TestStore_OneLeaderPerBucket` | Two replicas both believing they lead, which under `ModeLeader` means two decisions for one epoch | +| `TestStore_LeadershipIsNotClaimedUnlessAsked` | A shared-posterior replica claiming leadership it never wanted, starving whoever did | +| `TestStore_DecisionIsImmutableAndReportsWhatIsInForce` | A decision changing under replicas mid-epoch, and a leader that lost a race running its own draw alone | +| `TestStore_SyncReadsBackAPublishedDecision` | Followers never seeing what the leader decided | +| `TestStore_WindowOmitsBucketsThatHoldNothing` | A missing bucket read as a measured zero hit rate | +| `TestStore_WindowOfAnInvertedRangeIsEmpty` | A reversed range spinning or erroring instead of returning nothing | +| `TestStore_ZeroCountsCreateNoCounters` | An arm that measured nothing being recorded as an arm that measured badly | +| `TestStore_CountersExpire` | Counters outliving their window, so a leader decides on stale traffic | +| `TestStore_EveryKeyItWritesHasATTL` | Any key written without an expiry — a fleet that stops running must leave nothing behind in a shared store | +| `TestStore_EveryKeyForANamespaceSharesOneSlot` | A key missing its hash tag, which breaks the window pipeline and the scripts' computed key names on Redis Cluster | +| `TestStore_SurvivesAStrayFieldWrittenBySomethingElse` | A foreign field in a counter hash breaking a fleet's read of its own counters | +| `TestStore_ReportsAnUnreachableServer` | A store failure being swallowed, which would leave the bandit waiting instead of falling back to local selection | +| `TestStore_RespectsContextCancellation` | A call that ignores its context, which would stop `Close` from cancelling an in-flight round trip | +| `TestParseCountField_RoundTrips` | The field encoding and its parser drifting apart | +| `TestParseCountField_RejectsJunk` | Malformed fields being parsed into plausible-looking counts | + +## Not covered + +- **Redis Cluster.** The hash tags are verified against a real keyspace, so the + property clustering depends on holds, but nothing here runs against an actual + clustered deployment. Cross-slot behaviour is unproven. +- **Redis 6 and earlier.** Documented as unsupported rather than tested as + unsupported. The expected failure is the sync script being rejected for + calling `TIME` before a write. +- **Failover, replication lag and partitions.** A replica losing the store is + covered in the `bandit` module's own tests via a store that fails on demand; + what a real Valkey does mid-failover is not. +- **Load at fleet scale.** The cost model in the package documentation — one + round trip per replica per coordination epoch, two more for the leader — is + arithmetic, not a measurement. diff --git a/bandit/redis/doc.go b/bandit/redis/doc.go new file mode 100644 index 0000000..4c836db --- /dev/null +++ b/bandit/redis/doc.go @@ -0,0 +1,47 @@ +// Package redis backs a distributed bandit with Valkey or Redis. +// +// It implements github.com/sshaplygin/as-cache/bandit.Store over +// github.com/redis/go-redis/v9, which speaks to both. +// +// client := goredis.NewClient(&goredis.Options{Addr: "localhost:6379"}) +// store, err := redisstore.New(redisstore.Options{Client: client}) +// if err != nil { +// return err +// } +// defer store.Close() +// +// b, err := bandit.NewDistributed(bandit.Config{ +// Store: store, +// Namespace: "sessions", +// CoordinationEpoch: time.Second, +// }) +// +// # What it stores +// +// Per-policy hit and miss integers, a leader's node identifier, and a policy +// name. No cache keys and no cache values ever leave the process, so the store +// holds nothing that needs protecting beyond the usual. +// +// Everything written carries a TTL, sized from the bandit's window. A fleet +// that stops running leaves nothing behind. +// +// # Load +// +// One round trip per replica per coordination epoch, plus two more for the +// replica leading that epoch. At a one-second epoch a thousand replicas +// produce about a thousand small pipelined calls a second against a single +// key slot, which is not much - but it is the number to check before running a +// coordination epoch faster than a second. +// +// # Requirements +// +// Redis 7.0 or Valkey 7.2 and above. Buckets are derived from the server's +// clock inside a Lua script, which needs effects replication - the default +// since Redis 7 - and which is the point: no replica's clock is ever +// consulted, so a fleet needs no clock agreement at all. +// +// # Redis Cluster +// +// Every key for one namespace shares a hash tag, so a namespace lives in a +// single slot and the scripts and pipelines here work unchanged on a cluster. +package redis diff --git a/bandit/redis/go.mod b/bandit/redis/go.mod new file mode 100644 index 0000000..e7d3a66 --- /dev/null +++ b/bandit/redis/go.mod @@ -0,0 +1,24 @@ +module github.com/sshaplygin/as-cache/bandit/redis + +go 1.25.2 + +require ( + github.com/alicebob/miniredis/v2 v2.38.0 + github.com/redis/go-redis/v9 v9.21.0 + github.com/sshaplygin/as-cache v0.0.0 + github.com/sshaplygin/as-cache/bandit v0.0.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + go.uber.org/atomic v1.11.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/sshaplygin/as-cache => ../.. + +replace github.com/sshaplygin/as-cache/bandit => .. diff --git a/bandit/redis/go.sum b/bandit/redis/go.sum new file mode 100644 index 0000000..ccc390e --- /dev/null +++ b/bandit/redis/go.sum @@ -0,0 +1,30 @@ +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/bandit/redis/keys.go b/bandit/redis/keys.go new file mode 100644 index 0000000..ca72b2e --- /dev/null +++ b/bandit/redis/keys.go @@ -0,0 +1,93 @@ +package redis + +import ( + "strconv" + "strings" + + ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" +) + +// DefaultKeyPrefix is what every key this package writes begins with. +const DefaultKeyPrefix = "asc" + +// keyBase is the common prefix of every key belonging to a namespace, +// including the hash tag. +// +// The braces are what put a whole namespace in one Redis Cluster slot. That +// matters twice: the window read is a pipeline across many bucket keys, and +// the scripts compute key names themselves - a bucket comes from the server's +// clock and so cannot be known when the call is made, and Cluster refuses a +// script access outside the slot its declared keys hash to. +func (s *Store) keyBase(namespace string) string { + return s.prefix + ":{" + namespace + "}" +} + +// anchorKey is the key handed to a script as KEYS[1], so Cluster routes it to +// the slot the computed keys live in. Nothing is ever stored under it. +func (s *Store) anchorKey(namespace string) string { + return s.keyBase(namespace) + ":anchor" +} + +// countsKey holds one bucket's per-arm counters as a hash. It must match the +// expression the Lua builds. +func (s *Store) countsKey(namespace string, bucket bandit.Bucket) string { + return s.keyBase(namespace) + ":c:" + strconv.FormatInt(int64(bucket), 10) +} + +// countField names one counter within a bucket's hash. +// +// The policy is written as its numeric PolicyType rather than its name. Names +// come from stringer and are a presentation detail: renaming one would +// silently split a fleet's counters in two, with every replica still reporting +// and none of them agreeing, and nothing in the data would show why. The role +// is "a" for active or "s" for shadow, and the suffix is "h" for hits or "m" +// for misses. +func countField(policy ascache.PolicyType, role bandit.Role, hits bool) string { + kind := "m" + if hits { + kind = "h" + } + + roleTag := "s" + if role == bandit.RoleActive { + roleTag = "a" + } + + return strconv.FormatUint(uint64(policy), 10) + ":" + roleTag + ":" + kind +} + +// parseCountField reverses countField. An unrecognised field is reported as +// not ok rather than as an error: the store may be shared, and a stray field +// written by something else must not stop a fleet reading its own counters. +func parseCountField(field string) (policy ascache.PolicyType, role bandit.Role, hits, ok bool) { + parts := strings.Split(field, ":") + if len(parts) != 3 { + return 0, 0, false, false + } + + number, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + return 0, 0, false, false + } + + switch parts[1] { + case "a": + role = bandit.RoleActive + case "s": + role = bandit.RoleShadow + default: + return 0, 0, false, false + } + + switch parts[2] { + case "h": + hits = true + case "m": + hits = false + default: + return 0, 0, false, false + } + + return ascache.PolicyType(number), role, hits, true +} diff --git a/bandit/redis/scripts.go b/bandit/redis/scripts.go new file mode 100644 index 0000000..304e85c --- /dev/null +++ b/bandit/redis/scripts.go @@ -0,0 +1,83 @@ +package redis + +import goredis "github.com/redis/go-redis/v9" + +// syncScript publishes one replica's counts, optionally claims the bucket, and +// reads back whatever decision has been published for it - in one round trip, +// because every replica makes this call on every coordination epoch and it is +// the only one that scales with fleet size. +// +// The bucket comes from the server's clock. That is the whole reason this is a +// script rather than a pipeline: if each replica divided its own clock into +// buckets, a fleet would need clock agreement finer than its coordination +// epoch, and one machine with a skewed clock would write its counts into a +// window nobody reads while dragging the fleet's view of its traffic with it. +// Asking the server means there is one clock and no agreement to reach. +// +// KEYS[1] anchor key, present so Cluster routes the script to the slot +// the computed keys live in +// ARGV[1] key base, including the hash tag: "asc:{namespace}" +// ARGV[2] coordination epoch in milliseconds +// ARGV[3] counter TTL in milliseconds +// ARGV[4] leader TTL in milliseconds +// ARGV[5] node id +// ARGV[6] "1" to claim leadership of the bucket +// ARGV[7...] alternating field name and delta +var syncScript = goredis.NewScript(` +local base = ARGV[1] +local epoch = tonumber(ARGV[2]) + +local t = redis.call('TIME') +local nowMs = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000) +local bucket = math.floor(nowMs / epoch) + +if #ARGV >= 7 then + local countsKey = base .. ':c:' .. bucket + for i = 7, #ARGV, 2 do + redis.call('HINCRBY', countsKey, ARGV[i], ARGV[i + 1]) + end + -- Refreshed by every writer, so a bucket outlives its last contribution + -- rather than its first. + redis.call('PEXPIRE', countsKey, ARGV[3]) +end + +local leader = 0 +if ARGV[6] == '1' then + if redis.call('SET', base .. ':l:' .. bucket, ARGV[5], 'NX', 'PX', ARGV[4]) then + leader = 1 + end +end + +-- An empty string rather than a nil: a nil inside a Lua table terminates the +-- array Redis builds from it, so returning one here would truncate the reply +-- and take the bucket and leader values with it. +local decision = redis.call('GET', base .. ':d:' .. bucket) +if not decision then + decision = '' +end + +return {bucket, leader, decision} +`) + +// decideScript publishes the decision for a bucket if none has been published, +// and returns whichever decision is in force either way. +// +// It returns rather than acknowledges because the leader has to act on the +// same policy as everyone following it. A leader that applied its own draw +// while the fleet applied an earlier one would be the one replica running +// something different, and it would be the one making the decisions. +// +// KEYS[1] anchor key, for Cluster routing +// ARGV[1] key base, including the hash tag +// ARGV[2] bucket +// ARGV[3] policy +// ARGV[4] decision TTL in milliseconds +var decideScript = goredis.NewScript(` +local key = ARGV[1] .. ':d:' .. ARGV[2] + +if redis.call('SET', key, ARGV[3], 'NX', 'PX', ARGV[4]) then + return ARGV[3] +end + +return redis.call('GET', key) +`) diff --git a/bandit/redis/store.go b/bandit/redis/store.go new file mode 100644 index 0000000..1295731 --- /dev/null +++ b/bandit/redis/store.go @@ -0,0 +1,273 @@ +package redis + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + goredis "github.com/redis/go-redis/v9" + + ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" +) + +var _ bandit.Store = (*Store)(nil) + +// ErrNilClient is returned by New when Options.Client is nil. +var ErrNilClient = errors.New("redis: client must not be nil") + +// Options configures a Store. +type Options struct { + // Client is the connection to Valkey or Redis. Required. + // + // It is a UniversalClient so a plain client, a cluster client and a ring + // are all accepted, and it is supplied rather than dialled here because + // connection settings - timeouts, TLS, credentials, pool sizes - belong to + // the application, which almost always has a client already. + Client goredis.UniversalClient + + // KeyPrefix begins every key this store writes. Defaults to + // DefaultKeyPrefix. + KeyPrefix string +} + +// Store implements bandit.Store over Valkey or Redis. +type Store struct { + client goredis.UniversalClient + prefix string +} + +// New returns a Store over the given client. It does not take ownership of the +// client: Close leaves it open for the application to keep using. +func New(opts Options) (*Store, error) { + if opts.Client == nil { + return nil, ErrNilClient + } + + prefix := opts.KeyPrefix + if prefix == "" { + prefix = DefaultKeyPrefix + } + + return &Store{client: opts.Client, prefix: prefix}, nil +} + +// Sync publishes one replica's counts, claims the bucket if asked and if it is +// unclaimed, and reports any decision published for it - in a single round +// trip. +func (s *Store) Sync(ctx context.Context, req bandit.SyncRequest) (bandit.SyncResult, error) { + epochMillis := req.EpochMillis + if epochMillis <= 0 { + epochMillis = 1 + } + + lead := "0" + if req.Lead { + lead = "1" + } + + args := make([]any, 0, 6+4*len(req.Counts)) + args = append(args, + s.keyBase(req.Namespace), + strconv.FormatInt(epochMillis, 10), + strconv.FormatInt(millis(req.CounterTTL), 10), + strconv.FormatInt(millis(req.LeaderTTL), 10), + req.NodeID, + lead, + ) + + for _, count := range req.Counts { + // Zero deltas are dropped rather than sent. They would cost a HINCRBY + // each and create the counter field for an arm that measured nothing, + // which reads back as evidence of a zero hit rate rather than as an + // absence of evidence. + if count.Hits != 0 { + args = append(args, + countField(count.Policy, count.Role, true), + strconv.FormatInt(count.Hits, 10)) + } + if count.Misses != 0 { + args = append(args, + countField(count.Policy, count.Role, false), + strconv.FormatInt(count.Misses, 10)) + } + } + + raw, err := syncScript.Run(ctx, s.client, []string{s.anchorKey(req.Namespace)}, args...).Result() + if err != nil { + return bandit.SyncResult{}, fmt.Errorf("redis: sync: %w", err) + } + + return parseSyncReply(raw) +} + +// parseSyncReply converts the script's three-element reply. +func parseSyncReply(raw any) (bandit.SyncResult, error) { + values, ok := raw.([]any) + if !ok || len(values) != 3 { + return bandit.SyncResult{}, fmt.Errorf("redis: sync: unexpected reply %T %v", raw, raw) + } + + bucket, ok := values[0].(int64) + if !ok { + return bandit.SyncResult{}, fmt.Errorf("redis: sync: bucket is %T, want integer", values[0]) + } + + leader, ok := values[1].(int64) + if !ok { + return bandit.SyncResult{}, fmt.Errorf("redis: sync: leader flag is %T, want integer", values[1]) + } + + result := bandit.SyncResult{ + Bucket: bandit.Bucket(bucket), + Leader: leader == 1, + } + + decision, ok := values[2].(string) + if !ok { + return bandit.SyncResult{}, fmt.Errorf("redis: sync: decision is %T, want string", values[2]) + } + if decision != "" { + policy, err := parsePolicy(decision) + if err != nil { + return bandit.SyncResult{}, err + } + result.Decision, result.HasDecision = policy, true + } + + return result, nil +} + +// Window reads the aggregated counts for a range of buckets, pipelined into +// one round trip. Buckets that expired or were never written come back empty +// and are omitted, so a leader can tell a quiet epoch from a missing one. +func (s *Store) Window( + ctx context.Context, + namespace string, + first, last bandit.Bucket, +) ([]bandit.WindowCounts, error) { + if first > last { + return nil, nil + } + + buckets := make([]bandit.Bucket, 0, last-first+1) + commands := make([]*goredis.MapStringStringCmd, 0, last-first+1) + + pipe := s.client.Pipeline() + for bucket := first; bucket <= last; bucket++ { + buckets = append(buckets, bucket) + commands = append(commands, pipe.HGetAll(ctx, s.countsKey(namespace, bucket))) + } + + // redis.Nil surfaces here only if a command in the pipeline missed, which + // HGETALL never does - it returns an empty map. Any other error is real. + if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, goredis.Nil) { + return nil, fmt.Errorf("redis: window: %w", err) + } + + window := make([]bandit.WindowCounts, 0, len(commands)) + for i, cmd := range commands { + fields, err := cmd.Result() + if err != nil { + if errors.Is(err, goredis.Nil) { + continue + } + + return nil, fmt.Errorf("redis: window: bucket %d: %w", buckets[i], err) + } + if len(fields) == 0 { + continue + } + + arms := parseArms(fields) + if len(arms) == 0 { + continue + } + + window = append(window, bandit.WindowCounts{Bucket: buckets[i], Arms: arms}) + } + + return window, nil +} + +// parseArms turns a bucket's hash into per-arm counts, skipping anything it +// does not recognise. +func parseArms(fields map[string]string) map[bandit.ArmKey]ascache.PolicyStats { + arms := make(map[bandit.ArmKey]ascache.PolicyStats, len(fields)/2) + + for field, value := range fields { + policy, role, hits, ok := parseCountField(field) + if !ok { + continue + } + + count, err := strconv.ParseInt(value, 10, 64) + if err != nil { + continue + } + + key := bandit.ArmKey{Policy: policy, Role: role} + stats := arms[key] + if hits { + stats.Hits += count + } else { + stats.Misses += count + } + arms[key] = stats + } + + return arms +} + +// Decide publishes the policy the fleet should run for a bucket and returns +// the decision actually in force for it. +func (s *Store) Decide( + ctx context.Context, + namespace string, + bucket bandit.Bucket, + policy ascache.PolicyType, + ttl time.Duration, +) (ascache.PolicyType, error) { + raw, err := decideScript.Run(ctx, s.client, + []string{s.anchorKey(namespace)}, + s.keyBase(namespace), + strconv.FormatInt(int64(bucket), 10), + strconv.FormatUint(uint64(policy), 10), + strconv.FormatInt(millis(ttl), 10), + ).Result() + if err != nil { + return ascache.Undefined, fmt.Errorf("redis: decide: %w", err) + } + + text, ok := raw.(string) + if !ok { + return ascache.Undefined, fmt.Errorf("redis: decide: reply is %T, want string", raw) + } + + return parsePolicy(text) +} + +// Close releases the store. The client belongs to the caller and is left open. +func (s *Store) Close() error { return nil } + +// parsePolicy reads the numeric PolicyType a decision is stored as. +func parsePolicy(text string) (ascache.PolicyType, error) { + number, err := strconv.ParseUint(text, 10, 64) + if err != nil { + return ascache.Undefined, fmt.Errorf("redis: decision %q is not a policy: %w", text, err) + } + + return ascache.PolicyType(number), nil +} + +// millis rounds a TTL up to at least one millisecond. PEXPIRE with a +// non-positive argument deletes the key it was meant to keep alive. +func millis(d time.Duration) int64 { + if ms := d.Milliseconds(); ms > 0 { + return ms + } + + return 1 +} diff --git a/bandit/redis/store_test.go b/bandit/redis/store_test.go new file mode 100644 index 0000000..2a58044 --- /dev/null +++ b/bandit/redis/store_test.go @@ -0,0 +1,411 @@ +package redis + +import ( + "context" + "os" + "strconv" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + goredis "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" +) + +const testEpoch = time.Second + +// runID separates one run's keys from the last one's on a real server, which +// unlike miniredis is not thrown away between runs. +var runID = strconv.FormatInt(time.Now().UnixNano(), 36) + +// newStore returns a Store over miniredis, or over a real server when +// AS_CACHE_REDIS_ADDR is set. +// +// The fake covers the logic; the real server covers the assumption the logic +// rests on, which is that the Lua runs at all - TIME inside a script, SET NX +// with PX, and HINCRBY on a hash the script names itself. Those are exactly +// the things a fake can get wrong in the direction of being too permissive, +// so the same tests run against both. +func newStore(t *testing.T) (*Store, goredis.UniversalClient, *miniredis.Miniredis) { + t.Helper() + + if addr := os.Getenv("AS_CACHE_REDIS_ADDR"); addr != "" { + client := goredis.NewClient(&goredis.Options{Addr: addr}) + require.NoError(t, client.Ping(t.Context()).Err(), "AS_CACHE_REDIS_ADDR is set but unreachable") + + // A real server is shared and outlives the run, so each test gets its + // own key prefix rather than flushing someone else's data. The run id + // is part of it because buckets come from the clock: two runs within + // the same second would otherwise land in the same bucket and the + // second would find the first's leadership already claimed and its + // decisions already published. Everything written carries a TTL, so + // the extra prefixes expire on their own. + store, err := New(Options{ + Client: client, + KeyPrefix: "asctest:" + runID + ":" + t.Name(), + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = store.Close() + _ = client.Close() + }) + + return store, client, nil + } + + server := miniredis.RunT(t) + client := goredis.NewClient(&goredis.Options{Addr: server.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + + store, err := New(Options{Client: client}) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + + return store, client, server +} + +// expire advances past a TTL. A fake is fast-forwarded; a real server has to +// be waited out, which is why the TTLs in these tests are short. +func expire(t *testing.T, server *miniredis.Miniredis, d time.Duration) { + t.Helper() + + if server != nil { + server.FastForward(d) + + return + } + time.Sleep(d) +} + +func syncRequest(namespace, node string, counts ...bandit.ArmCounts) bandit.SyncRequest { + return bandit.SyncRequest{ + Namespace: namespace, + NodeID: node, + Counts: counts, + EpochMillis: testEpoch.Milliseconds(), + CounterTTL: 12 * testEpoch, + LeaderTTL: 2 * testEpoch, + } +} + +func shadow(policy ascache.PolicyType, hits, misses int64) bandit.ArmCounts { + return bandit.ArmCounts{Policy: policy, Role: bandit.RoleShadow, Hits: hits, Misses: misses} +} + +func TestNew_RejectsANilClient(t *testing.T) { + _, err := New(Options{}) + assert.ErrorIs(t, err, ErrNilClient) +} + +func TestStore_SyncReportsAServerDerivedBucket(t *testing.T) { + store, _, _ := newStore(t) + + result, err := store.Sync(t.Context(), syncRequest("ns", "a")) + require.NoError(t, err) + + // The bucket is the server's clock divided by the epoch. Checking it lands + // near the client's clock proves the script read a real time rather than + // returning a constant. + expected := bandit.Bucket(time.Now().UnixMilli() / testEpoch.Milliseconds()) + assert.InDelta(t, int64(expected), int64(result.Bucket), 5) +} + +func TestStore_SumsCountsAcrossReplicas(t *testing.T) { + store, _, _ := newStore(t) + + result, err := store.Sync(t.Context(), syncRequest("ns", "a", shadow(ascache.LRU, 10, 5))) + require.NoError(t, err) + _, err = store.Sync(t.Context(), syncRequest("ns", "b", shadow(ascache.LRU, 20, 15))) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "ns", result.Bucket, result.Bucket) + require.NoError(t, err) + require.Len(t, window, 1) + + assert.Equal(t, + ascache.PolicyStats{Hits: 30, Misses: 20}, + window[0].Arms[bandit.ArmKey{Policy: ascache.LRU, Role: bandit.RoleShadow}]) +} + +func TestStore_KeepsRolesApart(t *testing.T) { + store, _, _ := newStore(t) + + result, err := store.Sync(t.Context(), syncRequest("ns", "a", + bandit.ArmCounts{Policy: ascache.LRU, Role: bandit.RoleActive, Hits: 90, Misses: 10}, + bandit.ArmCounts{Policy: ascache.LRU, Role: bandit.RoleShadow, Hits: 40, Misses: 60}, + )) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "ns", result.Bucket, result.Bucket) + require.NoError(t, err) + require.Len(t, window, 1) + + assert.Equal(t, ascache.PolicyStats{Hits: 90, Misses: 10}, + window[0].Arms[bandit.ArmKey{Policy: ascache.LRU, Role: bandit.RoleActive}]) + assert.Equal(t, ascache.PolicyStats{Hits: 40, Misses: 60}, + window[0].Arms[bandit.ArmKey{Policy: ascache.LRU, Role: bandit.RoleShadow}]) +} + +func TestStore_NamespacesDoNotPool(t *testing.T) { + store, _, _ := newStore(t) + + result, err := store.Sync(t.Context(), syncRequest("one", "a", shadow(ascache.LRU, 10, 0))) + require.NoError(t, err) + _, err = store.Sync(t.Context(), syncRequest("two", "b", shadow(ascache.LRU, 999, 0))) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "one", result.Bucket, result.Bucket) + require.NoError(t, err) + require.Len(t, window, 1) + + assert.Equal(t, int64(10), + window[0].Arms[bandit.ArmKey{Policy: ascache.LRU, Role: bandit.RoleShadow}].Hits) +} + +func TestStore_OneLeaderPerBucket(t *testing.T) { + store, _, _ := newStore(t) + + leaders := 0 + for _, node := range []string{"a", "b", "c", "d", "e"} { + req := syncRequest("ns", node) + req.Lead = true + result, err := store.Sync(t.Context(), req) + require.NoError(t, err) + if result.Leader { + leaders++ + } + } + + assert.Equal(t, 1, leaders) +} + +func TestStore_LeadershipIsNotClaimedUnlessAsked(t *testing.T) { + store, _, _ := newStore(t) + + result, err := store.Sync(t.Context(), syncRequest("ns", "a")) + require.NoError(t, err) + assert.False(t, result.Leader) + + // And a later replica that does ask still gets it. + req := syncRequest("ns", "b") + req.Lead = true + result, err = store.Sync(t.Context(), req) + require.NoError(t, err) + assert.True(t, result.Leader) +} + +func TestStore_DecisionIsImmutableAndReportsWhatIsInForce(t *testing.T) { + store, _, _ := newStore(t) + + first, err := store.Decide(t.Context(), "ns", 42, ascache.TinyLFU, time.Minute) + require.NoError(t, err) + assert.Equal(t, ascache.TinyLFU, first) + + second, err := store.Decide(t.Context(), "ns", 42, ascache.LRU, time.Minute) + require.NoError(t, err) + assert.Equal(t, ascache.TinyLFU, second, + "a leader that lost the race must follow the fleet, not its own draw") +} + +func TestStore_SyncReadsBackAPublishedDecision(t *testing.T) { + store, _, _ := newStore(t) + + result, err := store.Sync(t.Context(), syncRequest("ns", "a")) + require.NoError(t, err) + assert.False(t, result.HasDecision) + + _, err = store.Decide(t.Context(), "ns", result.Bucket, ascache.TwoQueue, time.Minute) + require.NoError(t, err) + + again, err := store.Sync(t.Context(), syncRequest("ns", "b")) + require.NoError(t, err) + require.True(t, again.HasDecision) + assert.Equal(t, ascache.TwoQueue, again.Decision) +} + +func TestStore_WindowOmitsBucketsThatHoldNothing(t *testing.T) { + store, _, _ := newStore(t) + + result, err := store.Sync(t.Context(), syncRequest("ns", "a", shadow(ascache.LRU, 1, 1))) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "ns", result.Bucket-8, result.Bucket) + require.NoError(t, err) + + require.Len(t, window, 1, "a bucket nobody wrote is absent, not present and zero") + assert.Equal(t, result.Bucket, window[0].Bucket) +} + +func TestStore_WindowOfAnInvertedRangeIsEmpty(t *testing.T) { + store, _, _ := newStore(t) + + window, err := store.Window(t.Context(), "ns", 100, 10) + require.NoError(t, err) + assert.Empty(t, window) +} + +func TestStore_ZeroCountsCreateNoCounters(t *testing.T) { + store, _, _ := newStore(t) + + // An arm that measured nothing has produced no evidence. Writing a zero + // would read back as evidence of a zero hit rate. + result, err := store.Sync(t.Context(), syncRequest("ns", "a", shadow(ascache.LRU, 0, 0))) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "ns", result.Bucket, result.Bucket) + require.NoError(t, err) + assert.Empty(t, window) +} + +func TestStore_CountersExpire(t *testing.T) { + store, _, server := newStore(t) + + // A fleet that stops running must leave nothing behind in a store it + // shares with everything else, so this checks both halves of that: the TTL + // is actually attached, and the data really does go. + const ttl = 300 * time.Millisecond + + req := syncRequest("ns", "a", shadow(ascache.LRU, 10, 0)) + req.CounterTTL = ttl + result, err := store.Sync(t.Context(), req) + require.NoError(t, err) + + window, err := store.Window(t.Context(), "ns", result.Bucket, result.Bucket) + require.NoError(t, err) + require.Len(t, window, 1) + + expire(t, server, 2*ttl) + + window, err = store.Window(t.Context(), "ns", result.Bucket, result.Bucket) + require.NoError(t, err) + assert.Empty(t, window) +} + +func TestStore_EveryKeyItWritesHasATTL(t *testing.T) { + store, client, _ := newStore(t) + + req := syncRequest("ns", "a", shadow(ascache.LRU, 1, 1)) + req.Lead = true + result, err := store.Sync(t.Context(), req) + require.NoError(t, err) + + _, err = store.Decide(t.Context(), "ns", result.Bucket, ascache.LRU, time.Minute) + require.NoError(t, err) + + keys, err := client.Keys(t.Context(), store.prefix+":*").Result() + require.NoError(t, err) + require.NotEmpty(t, keys) + + // A key without an expiry is a key that outlives the fleet that wrote it. + for _, key := range keys { + ttl, err := client.PTTL(t.Context(), key).Result() + require.NoError(t, err) + assert.Positive(t, ttl, "key %q was written without a TTL", key) + } +} + +func TestStore_EveryKeyForANamespaceSharesOneSlot(t *testing.T) { + store, client, _ := newStore(t) + + req := syncRequest("ns", "a", shadow(ascache.LRU, 1, 1)) + req.Lead = true + result, err := store.Sync(t.Context(), req) + require.NoError(t, err) + + _, err = store.Decide(t.Context(), "ns", result.Bucket, ascache.LRU, time.Minute) + require.NoError(t, err) + + keys, err := client.Keys(t.Context(), store.prefix+":*").Result() + require.NoError(t, err) + require.NotEmpty(t, keys) + + // The hash tag is what keeps a namespace in one Redis Cluster slot, which + // is what lets the window pipeline and the scripts' computed key names + // work on a cluster at all. Nothing here runs against a cluster, so this + // checks the property the cluster behaviour rests on. + for _, key := range keys { + assert.Contains(t, key, "{ns}", "key %q is missing the hash tag", key) + } +} + +func TestStore_SurvivesAStrayFieldWrittenBySomethingElse(t *testing.T) { + store, client, _ := newStore(t) + + result, err := store.Sync(t.Context(), syncRequest("ns", "a", shadow(ascache.LRU, 10, 5))) + require.NoError(t, err) + + // The store may be shared. A field written by something else must not stop + // a fleet reading its own counters. + require.NoError(t, client.HSet(t.Context(), + store.countsKey("ns", result.Bucket), "not-a-field", "nonsense").Err()) + + window, err := store.Window(t.Context(), "ns", result.Bucket, result.Bucket) + require.NoError(t, err) + require.Len(t, window, 1) + + assert.Equal(t, ascache.PolicyStats{Hits: 10, Misses: 5}, + window[0].Arms[bandit.ArmKey{Policy: ascache.LRU, Role: bandit.RoleShadow}]) + assert.Len(t, window[0].Arms, 1) +} + +func TestStore_ReportsAnUnreachableServer(t *testing.T) { + client := goredis.NewClient(&goredis.Options{ + Addr: "127.0.0.1:1", + DialTimeout: 200 * time.Millisecond, + }) + t.Cleanup(func() { _ = client.Close() }) + + store, err := New(Options{Client: client}) + require.NoError(t, err) + + _, err = store.Sync(t.Context(), syncRequest("ns", "a")) + require.Error(t, err, "an unreachable store must report, so the bandit can fall back") + + _, err = store.Window(t.Context(), "ns", 0, 4) + assert.Error(t, err) + + _, err = store.Decide(t.Context(), "ns", 1, ascache.LRU, time.Minute) + assert.Error(t, err) +} + +func TestStore_RespectsContextCancellation(t *testing.T) { + store, _, _ := newStore(t) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := store.Sync(ctx, syncRequest("ns", "a")) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestParseCountField_RoundTrips(t *testing.T) { + policies := []ascache.PolicyType{ + ascache.LRU, ascache.LFU, ascache.TwoQueue, + ascache.ARC, ascache.Random, ascache.TTL, ascache.TinyLFU, + } + + for _, policy := range policies { + for _, role := range []bandit.Role{bandit.RoleActive, bandit.RoleShadow} { + for _, hits := range []bool{true, false} { + field := countField(policy, role, hits) + + gotPolicy, gotRole, gotHits, ok := parseCountField(field) + require.True(t, ok, "field %q did not parse", field) + assert.Equal(t, policy, gotPolicy) + assert.Equal(t, role, gotRole) + assert.Equal(t, hits, gotHits) + } + } + } +} + +func TestParseCountField_RejectsJunk(t *testing.T) { + for _, field := range []string{"", "1", "1:s", "1:s:h:x", "x:s:h", "1:z:h", "1:s:z"} { + _, _, _, ok := parseCountField(field) + assert.False(t, ok, "field %q should not have parsed", field) + } +} diff --git a/bandit/sample.go b/bandit/sample.go new file mode 100644 index 0000000..26aac4d --- /dev/null +++ b/bandit/sample.go @@ -0,0 +1,48 @@ +package bandit + +import ( + "math" + "math/rand/v2" +) + +// betaSample draws from Beta(a, b) as the ratio of two Gamma draws, which is +// the standard construction: if X ~ Gamma(a,1) and Y ~ Gamma(b,1) then +// X/(X+Y) ~ Beta(a,b). +func betaSample(rng *rand.Rand, a, b float64) float64 { + x := gammaSample(rng, a) + y := gammaSample(rng, b) + if x+y == 0 { + return 0 + } + + return x / (x + y) +} + +// gammaSample draws from Gamma(shape, 1) using Marsaglia and Tsang's method, +// with the standard boost for shapes below 1. +func gammaSample(rng *rand.Rand, shape float64) float64 { + if shape < 1 { + // Gamma(a) == Gamma(a+1) * U^(1/a) for a < 1. + return gammaSample(rng, shape+1) * math.Pow(rng.Float64(), 1/shape) + } + + d := shape - 1.0/3.0 + c := 1 / math.Sqrt(9*d) + + for { + x := rng.NormFloat64() + v := 1 + c*x + if v <= 0 { + continue + } + v = v * v * v + + u := rng.Float64() + if u < 1-0.0331*x*x*x*x { + return d * v + } + if math.Log(u) < 0.5*x*x+d*(1-v+math.Log(v)) { + return d * v + } + } +} diff --git a/bandit/snapshot.go b/bandit/snapshot.go new file mode 100644 index 0000000..28523a9 --- /dev/null +++ b/bandit/snapshot.go @@ -0,0 +1,162 @@ +package bandit + +import ( + "fmt" + "sort" + "strings" + "time" + + ascache "github.com/sshaplygin/as-cache" +) + +// ArmEvidence is one arm's pooled, decayed evidence as the fleet last reported +// it. +type ArmEvidence struct { + Policy string `json:"policy"` + // Hits and Misses are weighted sums over the window, so they are + // fractional and are not request counts. HitRate is what the decision was + // actually made on. + Hits float64 `json:"hits"` + Misses float64 `json:"misses"` + HitRate float64 `json:"hit_rate"` +} + +// Snapshot is what a distributed bandit is doing, for monitoring. +// +// A cache that changes its own eviction policy is only safe to run if you can +// see what it is doing, and one that takes that decision from other machines +// needs one more thing visible: whether it is actually hearing from them. +// Fallback is the field to alert on. It is not an error - the cache keeps +// working, deciding locally - but a fleet where every replica has quietly +// fallen back is no longer a fleet, and nothing else about the cache looks any +// different. +type Snapshot struct { + // Selection is the policy this replica's bandit is currently returning. + Selection string `json:"selection"` + // Mode is "leader" or "shared-posterior". + Mode string `json:"mode"` + // NodeID identifies this replica, and Namespace is the fingerprinted + // namespace it coordinates under - the plain namespace plus a hash of the + // cache's arms, capacity and sample rate. Two replicas that should be + // pooling but are not will differ here, and nowhere else. + NodeID string `json:"node_id"` + Namespace string `json:"namespace"` + // Regime is the human-readable form of what that fingerprint covers. + Regime string `json:"regime"` + + // Fallback reports that the store has been unreachable for longer than + // FallbackAfter and this replica is deciding on its own evidence. + Fallback bool `json:"fallback"` + // LastSyncAge is how long ago the last successful round trip completed. + LastSyncAge time.Duration `json:"last_sync_age_ns"` + // LastError is the most recent failure, empty if the last round trip + // succeeded. + LastError string `json:"last_error,omitempty"` + // LastBucket is the coordination bucket of the last successful sync, by + // the store's clock. + LastBucket int64 `json:"last_bucket"` + + // Syncs and SyncFailures count round trips. Leaderships counts the buckets + // this replica led, Decisions the fleet decisions it applied, and Rejected + // the ones it refused because they named a policy it does not have. + Syncs int64 `json:"syncs"` + SyncFailures int64 `json:"sync_failures"` + Leaderships int64 `json:"leaderships"` + Decisions int64 `json:"decisions"` + Rejected int64 `json:"rejected"` + + // Fleet holds the pooled evidence behind the last decision this replica + // computed, best hit rate first. + // + // It is only populated on a replica that read the window: always under + // ModeSharedPosterior, and only while leading under ModeLeader. A follower + // reporting an empty Fleet is working correctly - it is applying a + // decision, not making one - so read it from whichever replica has + // Leaderships climbing. + Fleet []ArmEvidence `json:"fleet"` +} + +// Snapshot reads the bandit's current state. It is safe to call at any time +// and does not disturb coordination. +func (d *Distributed) Snapshot() Snapshot { + d.mu.Lock() + defer d.mu.Unlock() + + mode := "leader" + if d.cfg.Mode == ModeSharedPosterior { + mode = "shared-posterior" + } + + snapshot := Snapshot{ + Selection: ascache.PolicyType(d.selection.Load()).String(), + Mode: mode, + NodeID: d.cfg.NodeID, + Namespace: d.namespace, + Fallback: d.state.fallback, + LastBucket: int64(d.state.lastBucket), + Syncs: d.state.syncs, + SyncFailures: d.state.syncFailures, + Leaderships: d.state.leaderships, + Decisions: d.state.decisions, + Rejected: d.state.rejected, + Fleet: make([]ArmEvidence, 0, len(d.state.fleet)), + } + + if d.haveShape { + snapshot.Regime = d.shape.String() + } + if d.state.lastErr != nil { + snapshot.LastError = d.state.lastErr.Error() + } + if !d.state.lastSync.IsZero() { + snapshot.LastSyncAge = d.cfg.Now().Sub(d.state.lastSync) + } + + for policy, arm := range d.state.fleet { + snapshot.Fleet = append(snapshot.Fleet, ArmEvidence{ + Policy: policy.String(), + Hits: arm.Hits, + Misses: arm.Misses, + HitRate: arm.hitRate(), + }) + } + + // Ties broken by name so an unchanged fleet renders identically on every + // scrape; ranging a map alone would reorder equal arms on each call. + sort.SliceStable(snapshot.Fleet, func(i, j int) bool { + if snapshot.Fleet[i].HitRate != snapshot.Fleet[j].HitRate { + return snapshot.Fleet[i].HitRate > snapshot.Fleet[j].HitRate + } + + return snapshot.Fleet[i].Policy < snapshot.Fleet[j].Policy + }) + + return snapshot +} + +// String renders the snapshot as a short human-readable summary. +func (s Snapshot) String() string { + var b strings.Builder + + fmt.Fprintf(&b, "%s running %s, %s mode", s.NodeID, s.Selection, s.Mode) + if s.Fallback { + fmt.Fprintf(&b, " (FALLBACK: store unreachable for %s)", s.LastSyncAge.Round(time.Millisecond)) + } + b.WriteString("\n") + + fmt.Fprintf(&b, "namespace %s [%s]\n", s.Namespace, s.Regime) + fmt.Fprintf(&b, "%d syncs, %d failures, %d led, %d decisions applied, %d rejected\n", + s.Syncs, s.SyncFailures, s.Leaderships, s.Decisions, s.Rejected) + + if len(s.Fleet) == 0 { + return b.String() + } + + fmt.Fprintf(&b, "\n%-10s %9s %14s %14s\n", "policy", "hit rate", "hits", "misses") + for _, arm := range s.Fleet { + fmt.Fprintf(&b, "%-10s %8.2f%% %14.0f %14.0f\n", + arm.Policy, arm.HitRate*100, arm.Hits, arm.Misses) + } + + return b.String() +} diff --git a/bandit/store.go b/bandit/store.go new file mode 100644 index 0000000..25e6d9b --- /dev/null +++ b/bandit/store.go @@ -0,0 +1,163 @@ +package bandit + +import ( + "context" + "time" + + ascache "github.com/sshaplygin/as-cache" +) + +// Bucket identifies one coordination epoch fleet-wide. It is derived from the +// store's clock, never from a replica's, so a machine with a skewed clock +// cannot write its counts into a window nobody else is reading. +type Bucket int64 + +// Role distinguishes how an arm's counts were measured. It matters because the +// two are not measured the same way: the active arm runs at the cache's full +// capacity, and every shadow runs on a miniature of it. The rates are +// comparable by design, but shadows measure 1 to 3 points pessimistic in +// practice, so pooling one arm's active-role counts with another's shadow-role +// counts hands the first a systematic advantage. +type Role uint8 + +const ( + // RoleShadow is an arm measured as a shadow: miniature capacity, no real + // values, no traffic served. + RoleShadow Role = iota + 1 + // RoleActive is the arm that was serving traffic on the reporting replica. + RoleActive +) + +// String names the role, for keys and for logs. +func (r Role) String() string { + if r == RoleActive { + return "active" + } + + return "shadow" +} + +// ArmCounts is one arm's measurements from one replica, in one role. +type ArmCounts struct { + Policy ascache.PolicyType + Role Role + Hits int64 + Misses int64 +} + +// SyncRequest is the once-per-coordination-epoch call every replica makes. +// +// It bundles three things that would otherwise be separate round trips, all of +// which every replica needs on every tick: publish what I measured, tell me +// which bucket that landed in, and let me lead this bucket if nobody has +// claimed it. +type SyncRequest struct { + // Namespace scopes every key this call touches. Replicas pool with each + // other exactly when their namespaces match, which is why it carries a + // fingerprint of the cache's shape - see Config.Namespace. + Namespace string + + // NodeID identifies the replica, for leadership. + NodeID string + + // Counts is what this replica measured since its last sync. It may be + // empty on a tick where the cache reported nothing. + Counts []ArmCounts + + // EpochMillis is the coordination epoch length. The store divides its own + // clock by it to derive the bucket, so every replica agrees on bucket + // boundaries without their clocks having to agree on anything. + EpochMillis int64 + + // CounterTTL is how long a bucket's counters should outlive it. It must + // comfortably exceed Window * the epoch length, or the leader will read a + // window with holes in it where buckets have already expired. + CounterTTL time.Duration + + // LeaderTTL is how long a leadership claim lasts. It bounds nothing + // important: leadership is per-bucket and the decision it produces is + // written once, so a claim that outlives its usefulness only means the + // bucket has no leader, and the next bucket gets one. + LeaderTTL time.Duration + + // Lead asks to claim leadership of the current bucket if it is unclaimed. + // A replica configured for shared-posterior mode never sets it. + Lead bool +} + +// SyncResult is what the store knew at the moment of the sync. +type SyncResult struct { + // Bucket is the bucket the counts were added to, by the store's clock. + Bucket Bucket + + // Leader reports whether this replica claimed leadership of Bucket. At + // most one replica per bucket ever sees true. + Leader bool + + // Decision is the policy published for Bucket, and HasDecision reports + // whether one had been published when the sync ran. A replica that syncs + // before its leader has decided sees no decision and keeps using the + // previous one, which is the mode's one epoch of built-in staleness. + Decision ascache.PolicyType + HasDecision bool +} + +// WindowCounts is the fleet's aggregated measurements for one bucket. +type WindowCounts struct { + Bucket Bucket + // Arms holds the summed counts across every replica that published into + // the bucket, keyed by policy and role. + Arms map[ArmKey]ascache.PolicyStats +} + +// ArmKey identifies one arm's counts in one role. +type ArmKey struct { + Policy ascache.PolicyType + Role Role +} + +// Store is the shared state a fleet of caches coordinates through. +// +// It is deliberately dumb: it counts, it claims, it reads back. Every decision +// about what the numbers mean - how far back to look, how much to discount, +// which arm wins - stays in this package, so backing it with a different store +// never changes the selection behaviour. +// +// Implementations must be safe for concurrent use, and must respect the +// context: a replica whose store has stopped responding falls back to deciding +// locally, and it can only do that if these calls actually return. +type Store interface { + // Sync publishes one replica's counts and reports the bucket they landed + // in, whether this replica leads that bucket, and any decision already + // published for it. + Sync(ctx context.Context, req SyncRequest) (SyncResult, error) + + // Window reads the aggregated counts for buckets first through last + // inclusive. Buckets that have expired or were never written are omitted + // rather than reported as zero, so a caller can tell a quiet epoch from a + // missing one. Only the leader of a bucket calls it. + Window(ctx context.Context, namespace string, first, last Bucket) ([]WindowCounts, error) + + // Decide publishes the policy the fleet should run for a bucket and + // returns the decision that is actually in force for it. + // + // It must not overwrite a decision already published for that bucket: the + // decision is what replicas act on, and one that changed underneath them + // would move the fleet mid-epoch for no reason. Publishing to an + // already-decided bucket is not an error, and returns the decision that + // was already there - which is why this returns a policy at all. A leader + // that acted on its own draw while the fleet acted on someone else's would + // put the one replica making the decisions out of step with every replica + // following them. + Decide( + ctx context.Context, + namespace string, + bucket Bucket, + policy ascache.PolicyType, + ttl time.Duration, + ) (ascache.PolicyType, error) + + // Close releases whatever the store holds. It does not close a connection + // the caller supplied. + Close() error +} diff --git a/bandit/thompson.go b/bandit/thompson.go new file mode 100644 index 0000000..a0bd392 --- /dev/null +++ b/bandit/thompson.go @@ -0,0 +1,146 @@ +package bandit + +import ( + "math/rand/v2" + "sync" + + ascache "github.com/sshaplygin/as-cache" +) + +var _ ascache.Bandit = (*Thompson)(nil) + +// Thompson picks a policy by Thompson sampling over Beta posteriors. +// +// Each arm's hit rate is modelled as a Beta distribution updated from the hits +// and misses reported for it. Selecting means drawing one sample per arm and +// taking the best draw, so an arm is chosen in proportion to the probability +// that it is genuinely the best - which explores uncertain arms without ever +// committing to a fixed exploration schedule. +// +// Evidence is discounted as it ages. Without that the posteriors only sharpen, +// and an arm that was best over the first ten thousand requests keeps winning +// long after the workload has moved on. Discounting is what makes the bandit +// able to change its mind, which is the entire point on a shifting workload. +type Thompson struct { + mu sync.Mutex + // hits and misses hold the discounted evidence per arm. + hits map[ascache.PolicyType]float64 + misses map[ascache.PolicyType]float64 + // discount multiplies existing evidence at each update, in (0,1]. A value + // of 1 never forgets. + discount float64 + rng *rand.Rand +} + +// NewThompson returns a bandit that discounts prior evidence by the given +// factor on each report. A discount of 1 never forgets; 0.7 is a reasonable +// starting point for a workload expected to change. A discount outside (0,1] +// is treated as 1. +func NewThompson(discount float64, seed uint64) *Thompson { + if discount <= 0 || discount > 1 { + discount = 1 + } + + return &Thompson{ + hits: map[ascache.PolicyType]float64{}, + misses: map[ascache.PolicyType]float64{}, + discount: discount, + //nolint:gosec // deliberate: a seeded, reproducible source, not a secret + rng: rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)), + } +} + +// RecordStats folds one policy's epoch result into its posterior. +func (b *Thompson) RecordStats(stats ascache.ShadowStats) { + b.mu.Lock() + defer b.mu.Unlock() + + b.recordLocked(stats) +} + +func (b *Thompson) recordLocked(stats ascache.ShadowStats) { + b.hits[stats.Policy] = b.hits[stats.Policy]*b.discount + float64(stats.Hits) + b.misses[stats.Policy] = b.misses[stats.Policy]*b.discount + float64(stats.Misses) +} + +// SelectPolicy draws one sample from each arm's posterior and returns the arm +// with the highest draw. It returns [ascache.Undefined] before any arm has +// reported, which the cache reads as "no change". +func (b *Thompson) SelectPolicy() ascache.PolicyType { + b.mu.Lock() + defer b.mu.Unlock() + + best := ascache.Undefined + bestSample := -1.0 + + for policy, hits := range b.hits { + // Beta(1+hits, 1+misses): the +1s are a uniform prior, so an arm with + // no evidence yet is sampled across the whole range rather than being + // pinned at zero and never tried. + sample := betaSample(b.rng, 1+hits, 1+b.misses[policy]) + if sample > bestSample || (sample == bestSample && policy < best) { + best, bestSample = policy, sample + } + } + + return best +} + +// Arms returns the arms the bandit has seen, in no particular order. +func (b *Thompson) Arms() []ascache.PolicyType { + b.mu.Lock() + defer b.mu.Unlock() + + arms := make([]ascache.PolicyType, 0, len(b.hits)) + for policy := range b.hits { + arms = append(arms, policy) + } + + return arms +} + +var _ ascache.Bandit = (*Greedy)(nil) + +// Greedy always selects the arm with the best hit rate so far. It is a useful +// control: it shows what the adaptive layer achieves without any exploration, +// and it makes switching behaviour deterministic in tests. +type Greedy struct { + mu sync.Mutex + rates map[ascache.PolicyType]float64 +} + +// NewGreedy returns a bandit that always picks the best-measured arm. +func NewGreedy() *Greedy { + return &Greedy{rates: map[ascache.PolicyType]float64{}} +} + +// RecordStats stores the arm's hit rate for the epoch just measured. An epoch +// in which an arm saw no requests leaves its previous rate in place rather +// than scoring it zero. +func (b *Greedy) RecordStats(stats ascache.ShadowStats) { + b.mu.Lock() + defer b.mu.Unlock() + + total := stats.Hits + stats.Misses + if total == 0 { + return + } + b.rates[stats.Policy] = float64(stats.Hits) / float64(total) +} + +// SelectPolicy returns the arm with the highest measured hit rate, ties broken +// by PolicyType so the answer does not depend on map iteration order. +func (b *Greedy) SelectPolicy() ascache.PolicyType { + b.mu.Lock() + defer b.mu.Unlock() + + best := ascache.Undefined + bestRate := -1.0 + for policy, rate := range b.rates { + if rate > bestRate || (rate == bestRate && policy < best) { + best, bestRate = policy, rate + } + } + + return best +} diff --git a/bandit/thompson_test.go b/bandit/thompson_test.go new file mode 100644 index 0000000..61a32ca --- /dev/null +++ b/bandit/thompson_test.go @@ -0,0 +1,140 @@ +package bandit + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + ascache "github.com/sshaplygin/as-cache" +) + +func feed(b ascache.Bandit, epochs int, rates map[ascache.PolicyType]float64, requests int64) { + for range epochs { + for policy, rate := range rates { + hits := int64(float64(requests) * rate) + b.RecordStats(ascache.ShadowStats{ + Policy: policy, + Hits: hits, + Misses: requests - hits, + }) + } + } +} + +func winner(b ascache.Bandit, draws int) ascache.PolicyType { + counts := make(map[ascache.PolicyType]int) + for range draws { + counts[b.SelectPolicy()]++ + } + + best, bestCount := ascache.Undefined, -1 + for policy, count := range counts { + if count > bestCount || (count == bestCount && policy < best) { + best, bestCount = policy, count + } + } + + return best +} + +func TestThompson_UndefinedBeforeAnyEvidence(t *testing.T) { + // The cache reads Undefined as "no change", which is what makes this a + // safe answer rather than a crash. + assert.Equal(t, ascache.Undefined, NewThompson(0.7, 1).SelectPolicy()) +} + +func TestThompson_FavoursTheBetterArm(t *testing.T) { + b := NewThompson(1, 42) + feed(b, 10, map[ascache.PolicyType]float64{ascache.LRU: 0.3, ascache.TinyLFU: 0.8}, 1000) + + assert.Equal(t, ascache.TinyLFU, winner(b, 200)) +} + +func TestThompson_ChangesItsMindWhenTheWorkloadDoes(t *testing.T) { + // Discounting is the whole point: without it the first ten thousand + // requests keep deciding long after the traffic has moved on. + b := NewThompson(0.5, 7) + feed(b, 20, map[ascache.PolicyType]float64{ascache.LRU: 0.9, ascache.TinyLFU: 0.2}, 1000) + assert.Equal(t, ascache.LRU, winner(b, 200)) + + feed(b, 20, map[ascache.PolicyType]float64{ascache.LRU: 0.2, ascache.TinyLFU: 0.9}, 1000) + assert.Equal(t, ascache.TinyLFU, winner(b, 200)) +} + +func TestThompson_NeverForgettingIsStuck(t *testing.T) { + // The control for the test above: with a discount of 1 the old evidence + // still outweighs the new, which is the failure discounting prevents. + b := NewThompson(1, 7) + feed(b, 200, map[ascache.PolicyType]float64{ascache.LRU: 0.9, ascache.TinyLFU: 0.2}, 1000) + feed(b, 5, map[ascache.PolicyType]float64{ascache.LRU: 0.2, ascache.TinyLFU: 0.9}, 1000) + + assert.Equal(t, ascache.LRU, winner(b, 200)) +} + +func TestThompson_ExploresAnArmWithNoEvidence(t *testing.T) { + // An arm nobody has measured must not be pinned at zero and never tried; + // the uniform prior is what keeps it reachable. + b := NewThompson(1, 3) + feed(b, 5, map[ascache.PolicyType]float64{ascache.LRU: 0.5}, 100) + b.RecordStats(ascache.ShadowStats{Policy: ascache.TinyLFU}) + + drawn := make(map[ascache.PolicyType]int) + for range 500 { + drawn[b.SelectPolicy()]++ + } + + assert.Positive(t, drawn[ascache.TinyLFU], "an unmeasured arm must still be explored") + assert.Positive(t, drawn[ascache.LRU]) +} + +func TestThompson_RejectsAnOutOfRangeDiscount(t *testing.T) { + for _, discount := range []float64{0, -1, 1.5} { + b := NewThompson(discount, 1) + assert.InDelta(t, 1.0, b.discount, 1e-9, "an out-of-range discount falls back to never forgetting") + } +} + +func TestThompson_ArmsReportsWhatItHasSeen(t *testing.T) { + b := NewThompson(1, 1) + feed(b, 1, map[ascache.PolicyType]float64{ascache.LRU: 0.5, ascache.LFU: 0.5}, 10) + + assert.ElementsMatch(t, []ascache.PolicyType{ascache.LRU, ascache.LFU}, b.Arms()) +} + +func TestGreedy_TakesTheBestMeasuredArm(t *testing.T) { + b := NewGreedy() + feed(b, 1, map[ascache.PolicyType]float64{ascache.LRU: 0.3, ascache.TinyLFU: 0.8}, 1000) + + assert.Equal(t, ascache.TinyLFU, b.SelectPolicy()) +} + +func TestGreedy_UndefinedBeforeAnyEvidence(t *testing.T) { + assert.Equal(t, ascache.Undefined, NewGreedy().SelectPolicy()) +} + +func TestGreedy_QuietEpochLeavesThePreviousRateAlone(t *testing.T) { + // An arm that saw no requests this epoch has not become bad, it has just + // not been measured. Scoring it zero would evict it from contention on the + // strength of no evidence at all. + b := NewGreedy() + feed(b, 1, map[ascache.PolicyType]float64{ascache.LRU: 0.3, ascache.TinyLFU: 0.8}, 1000) + + b.RecordStats(ascache.ShadowStats{Policy: ascache.TinyLFU}) + assert.Equal(t, ascache.TinyLFU, b.SelectPolicy()) +} + +func TestGreedy_TieBreaksDeterministically(t *testing.T) { + // Ranging a map alone would let equally-performing arms swap places on + // every call, so an unchanged cache would keep switching for no reason. + for range 50 { + b := NewGreedy() + feed(b, 1, map[ascache.PolicyType]float64{ + ascache.LRU: 0.5, + ascache.LFU: 0.5, + ascache.TwoQueue: 0.5, + ascache.TinyLFU: 0.5, + }, 100) + + assert.Equal(t, ascache.LRU, b.SelectPolicy()) + } +} diff --git a/bandit/window.go b/bandit/window.go new file mode 100644 index 0000000..48837f8 --- /dev/null +++ b/bandit/window.go @@ -0,0 +1,133 @@ +package bandit + +import ( + "math" + "math/rand/v2" + "slices" + + ascache "github.com/sshaplygin/as-cache" +) + +// weighted is one arm's pooled evidence after the window has been discounted. +// It is fractional because the weighting is, which is also why it is not +// ascache.PolicyStats. +type weighted struct { + Hits float64 + Misses float64 +} + +func (w weighted) total() float64 { return w.Hits + w.Misses } + +func (w weighted) hitRate() float64 { + if w.total() == 0 { + return 0 + } + + return w.Hits / w.total() +} + +// aggregate pools a window of per-bucket fleet counts into one posterior per +// arm, weighting each bucket by decay raised to its age relative to newest. +// +// Ageing is done here rather than in the store because the store has many +// writers. Decaying a shared counter in place would apply the multiplication +// once per replica per epoch, so a fleet of fifty would forget fifty times +// faster than a fleet of one, and the same configuration would mean something +// different at every scale. Plain sums in the store, weighting on read, and +// the arithmetic is identical whatever the fleet size. +func aggregate( + window []WindowCounts, + newest Bucket, + decay float64, + mode EvidenceMode, +) map[ascache.PolicyType]weighted { + pooled := make(map[ascache.PolicyType]weighted) + + for _, bucket := range window { + age := int64(newest - bucket.Bucket) + if age < 0 { + // A bucket newer than the reference is still being written by the + // rest of the fleet, so it is a partial count that would weight + // whichever replicas happen to have arrived already. + continue + } + + weight := math.Pow(decay, float64(age)) + if weight == 0 { + continue + } + + for key, stats := range bucket.Arms { + if mode == EvidenceShadowOnly && key.Role == RoleActive { + continue + } + + arm := pooled[key.Policy] + arm.Hits += weight * float64(stats.Hits) + arm.Misses += weight * float64(stats.Misses) + pooled[key.Policy] = arm + } + } + + return pooled +} + +// capEvidence scales each arm's counts down to at most maxEvidence +// observations, preserving its hit rate exactly. +// +// Pooling is what makes this necessary. A Beta posterior's width shrinks with +// the square root of the evidence behind it, and a fleet supplies evidence in +// proportion to its size: a thousand replicas hand Thompson sampling a +// posterior so sharp that every draw returns the same arm, and the bandit +// stops exploring at exactly the scale where it has the most to gain from +// noticing a change. Capping the effective sample size keeps the rate estimate +// and throws away the surplus certainty. +// +// At the default cap an arm sitting at a 50% hit rate has a posterior standard +// deviation of about 0.16 points, so arms half a point apart are reliably +// separated and arms within a tenth of a point keep being explored. +func capEvidence(pooled map[ascache.PolicyType]weighted, maxEvidence float64) { + if maxEvidence <= 0 { + return + } + + for policy, arm := range pooled { + total := arm.total() + if total <= maxEvidence { + continue + } + + scale := maxEvidence / total + pooled[policy] = weighted{Hits: arm.Hits * scale, Misses: arm.Misses * scale} + } +} + +// draw picks an arm by Thompson sampling over the pooled posteriors. +// +// Arms are visited in PolicyType order and ties are broken towards the lower +// PolicyType, so the only nondeterminism is the sampling itself. Ranging the +// map directly would make a fleet's decisions depend on Go's map seed, which +// is invisible, unreproducible, and would quietly break the tie-breaking that +// keeps an unchanged fleet on an unchanged policy. +func draw(rng *rand.Rand, pooled map[ascache.PolicyType]weighted) ascache.PolicyType { + arms := make([]ascache.PolicyType, 0, len(pooled)) + for policy := range pooled { + arms = append(arms, policy) + } + slices.Sort(arms) + + best := ascache.Undefined + bestSample := -1.0 + + for _, policy := range arms { + arm := pooled[policy] + // The +1s are a uniform prior: an arm with no evidence is sampled + // across the whole range rather than pinned at zero and never tried. + sample := betaSample(rng, 1+arm.Hits, 1+arm.Misses) + if sample > bestSample { + best, bestSample = policy, sample + } + } + + return best +} diff --git a/bandit/window_test.go b/bandit/window_test.go new file mode 100644 index 0000000..9e7aedd --- /dev/null +++ b/bandit/window_test.go @@ -0,0 +1,180 @@ +package bandit + +import ( + "math" + "math/rand/v2" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ascache "github.com/sshaplygin/as-cache" +) + +func TestAggregate_WeightsBucketsByAge(t *testing.T) { + window := []WindowCounts{ + {Bucket: 8, Arms: map[ArmKey]ascache.PolicyStats{ + {Policy: ascache.LRU, Role: RoleShadow}: {Hits: 100, Misses: 0}, + }}, + {Bucket: 10, Arms: map[ArmKey]ascache.PolicyStats{ + {Policy: ascache.LRU, Role: RoleShadow}: {Hits: 100, Misses: 0}, + }}, + } + + pooled := aggregate(window, 10, 0.5, EvidenceAll) + + // Bucket 10 is the newest so it is unweighted; bucket 8 is two buckets old + // and carries 0.5^2 of its counts. + assert.InDelta(t, 100+25.0, pooled[ascache.LRU].Hits, 1e-9) +} + +func TestAggregate_IgnoresBucketsNewerThanTheReference(t *testing.T) { + // The current bucket is still being written by the rest of the fleet. + // Counting it would weight whichever replicas happened to sync first. + window := []WindowCounts{ + {Bucket: 11, Arms: map[ArmKey]ascache.PolicyStats{ + {Policy: ascache.LRU, Role: RoleShadow}: {Hits: 999, Misses: 0}, + }}, + {Bucket: 10, Arms: map[ArmKey]ascache.PolicyStats{ + {Policy: ascache.LRU, Role: RoleShadow}: {Hits: 5, Misses: 5}, + }}, + } + + pooled := aggregate(window, 10, 1, EvidenceAll) + + assert.InDelta(t, 5.0, pooled[ascache.LRU].Hits, 1e-9) + assert.InDelta(t, 5.0, pooled[ascache.LRU].Misses, 1e-9) +} + +func TestAggregate_ShadowOnlyDropsActiveRole(t *testing.T) { + window := []WindowCounts{ + {Bucket: 1, Arms: map[ArmKey]ascache.PolicyStats{ + {Policy: ascache.LRU, Role: RoleActive}: {Hits: 90, Misses: 10}, + {Policy: ascache.LRU, Role: RoleShadow}: {Hits: 40, Misses: 60}, + {Policy: ascache.TinyLFU, Role: RoleShadow}: {Hits: 50, Misses: 50}, + }}, + } + + all := aggregate(window, 1, 1, EvidenceAll) + assert.InDelta(t, 0.65, all[ascache.LRU].hitRate(), 1e-9, + "pooling both roles mixes the flattering active measurement in") + + shadowOnly := aggregate(window, 1, 1, EvidenceShadowOnly) + assert.InDelta(t, 0.40, shadowOnly[ascache.LRU].hitRate(), 1e-9) + assert.InDelta(t, 0.50, shadowOnly[ascache.TinyLFU].hitRate(), 1e-9) +} + +func TestAggregate_EmptyWindowYieldsNoArms(t *testing.T) { + assert.Empty(t, aggregate(nil, 5, 0.8, EvidenceAll)) +} + +func TestCapEvidence_PreservesRateAndBoundsTotal(t *testing.T) { + pooled := map[ascache.PolicyType]weighted{ + ascache.LRU: {Hits: 9_000_000, Misses: 1_000_000}, + ascache.TinyLFU: {Hits: 50, Misses: 50}, + } + + capEvidence(pooled, 1000) + + assert.InDelta(t, 0.9, pooled[ascache.LRU].hitRate(), 1e-9, "the rate must survive the cap exactly") + assert.InDelta(t, 1000.0, pooled[ascache.LRU].total(), 1e-9) + + assert.InDelta(t, 100.0, pooled[ascache.TinyLFU].total(), 1e-9, + "an arm already under the cap must not be touched") +} + +func TestCapEvidence_NegativeCapDisablesIt(t *testing.T) { + pooled := map[ascache.PolicyType]weighted{ + ascache.LRU: {Hits: 1e9, Misses: 1e9}, + } + + capEvidence(pooled, -1) + + assert.InDelta(t, 2e9, pooled[ascache.LRU].total(), 1) +} + +// TestCapEvidence_RestoresExploration is the reason the cap exists: pooling +// multiplies evidence by the size of the fleet, and a posterior built on +// millions of observations stops moving. Two arms a fifth of a point apart +// should still both get drawn. +func TestCapEvidence_RestoresExploration(t *testing.T) { + fleetScale := func(cap float64) int { + pooled := map[ascache.PolicyType]weighted{ + ascache.LRU: {Hits: 5_000_000, Misses: 5_000_000}, + ascache.TinyLFU: {Hits: 5_020_000, Misses: 4_980_000}, + } + capEvidence(pooled, cap) + + rng := rand.New(rand.NewPCG(1, 2)) + lruWins := 0 + for range 200 { + if draw(rng, pooled) == ascache.LRU { + lruWins++ + } + } + + return lruWins + } + + assert.Zero(t, fleetScale(-1), + "uncapped, ten million observations make the marginally worse arm unreachable") + assert.Greater(t, fleetScale(DefaultMaxEvidence), 10, + "capped, the marginally worse arm is still explored") +} + +func TestDraw_PrefersTheBetterArm(t *testing.T) { + pooled := map[ascache.PolicyType]weighted{ + ascache.LRU: {Hits: 100, Misses: 900}, + ascache.TinyLFU: {Hits: 900, Misses: 100}, + } + + rng := rand.New(rand.NewPCG(42, 42)) + wins := 0 + for range 500 { + if draw(rng, pooled) == ascache.TinyLFU { + wins++ + } + } + + assert.Greater(t, wins, 495, "a nine-to-one better arm should win nearly always") +} + +func TestDraw_EmptyEvidenceIsUndefined(t *testing.T) { + rng := rand.New(rand.NewPCG(1, 1)) + assert.Equal(t, ascache.Undefined, draw(rng, nil)) +} + +func TestDraw_DoesNotDependOnMapIterationOrder(t *testing.T) { + // Two arms with identical evidence must produce the same sequence of draws + // from the same seed, however Go happens to order the map that run. + build := func() map[ascache.PolicyType]weighted { + return map[ascache.PolicyType]weighted{ + ascache.LRU: {Hits: 10, Misses: 10}, + ascache.LFU: {Hits: 10, Misses: 10}, + ascache.TwoQueue: {Hits: 10, Misses: 10}, + ascache.TinyLFU: {Hits: 10, Misses: 10}, + } + } + + sequence := func() []ascache.PolicyType { + rng := rand.New(rand.NewPCG(7, 7)) + pooled := build() + out := make([]ascache.PolicyType, 0, 50) + for range 50 { + out = append(out, draw(rng, pooled)) + } + + return out + } + + first := sequence() + for range 20 { + require.Equal(t, first, sequence()) + } +} + +func TestWeighted_HitRateOfNothingIsZero(t *testing.T) { + var empty weighted + assert.Zero(t, empty.hitRate()) + assert.False(t, math.IsNaN(empty.hitRate())) +} diff --git a/bench/bandit.go b/bench/bandit.go deleted file mode 100644 index 59a606c..0000000 --- a/bench/bandit.go +++ /dev/null @@ -1,183 +0,0 @@ -package bench - -import ( - "math" - "math/rand/v2" - "sync" - - ascache "github.com/sshaplygin/as-cache" -) - -// ThompsonBandit picks a policy by Thompson sampling over Beta posteriors. -// -// Each arm's hit rate is modelled as a Beta distribution updated from the -// hits and misses reported for it. Selecting means drawing one sample per arm -// and taking the best draw, so an arm is chosen in proportion to the -// probability that it is genuinely the best - which explores uncertain arms -// without ever committing to a fixed exploration schedule. -// -// Evidence is discounted as it ages. Without that the posteriors only sharpen, -// and an arm that was best over the first ten thousand requests keeps winning -// long after the workload has moved on. Discounting is what makes the bandit -// able to change its mind, which is the entire point on a shifting workload. -// -// The as-cache module deliberately ships no Bandit implementation, so this -// exists here to make the benchmarks runnable. It is small enough to copy. -type ThompsonBandit struct { - mu sync.Mutex - // hits and misses hold the discounted evidence per arm. - hits map[ascache.PolicyType]float64 - misses map[ascache.PolicyType]float64 - // discount multiplies existing evidence at each update, in (0,1]. A value - // of 1 never forgets. - discount float64 - rng *rand.Rand -} - -// NewThompsonBandit returns a bandit that discounts prior evidence by the -// given factor on each report. A discount of 1 never forgets; 0.7 is a -// reasonable starting point for a workload expected to change. -func NewThompsonBandit(discount float64, seed uint64) *ThompsonBandit { - if discount <= 0 || discount > 1 { - discount = 1 - } - - return &ThompsonBandit{ - hits: map[ascache.PolicyType]float64{}, - misses: map[ascache.PolicyType]float64{}, - discount: discount, - rng: newRNG(seed), - } -} - -// RecordStats folds one policy's epoch result into its posterior. -func (b *ThompsonBandit) RecordStats(stats ascache.ShadowStats) { - b.mu.Lock() - defer b.mu.Unlock() - - b.hits[stats.Policy] = b.hits[stats.Policy]*b.discount + float64(stats.Hits) - b.misses[stats.Policy] = b.misses[stats.Policy]*b.discount + float64(stats.Misses) -} - -// SelectPolicy draws one sample from each arm's posterior and returns the arm -// with the highest draw. -func (b *ThompsonBandit) SelectPolicy() ascache.PolicyType { - b.mu.Lock() - defer b.mu.Unlock() - - best := ascache.Undefined - bestSample := -1.0 - - for policy, hits := range b.hits { - // Beta(1+hits, 1+misses): the +1s are a uniform prior, so an arm with - // no evidence yet is sampled across the whole range rather than being - // pinned at zero and never tried. - sample := betaSample(b.rng, 1+hits, 1+b.misses[policy]) - if sample > bestSample { - best, bestSample = policy, sample - } - } - - return best -} - -// Arms returns the arms the bandit has seen, for reporting. -func (b *ThompsonBandit) Arms() []ascache.PolicyType { - b.mu.Lock() - defer b.mu.Unlock() - - arms := make([]ascache.PolicyType, 0, len(b.hits)) - for policy := range b.hits { - arms = append(arms, policy) - } - - return arms -} - -// betaSample draws from Beta(a, b) as the ratio of two Gamma draws, which is -// the standard construction: if X ~ Gamma(a,1) and Y ~ Gamma(b,1) then -// X/(X+Y) ~ Beta(a,b). -func betaSample(rng *rand.Rand, a, b float64) float64 { - x := gammaSample(rng, a) - y := gammaSample(rng, b) - if x+y == 0 { - return 0 - } - - return x / (x + y) -} - -// gammaSample draws from Gamma(shape, 1) using Marsaglia and Tsang's method, -// with the standard boost for shapes below 1. -func gammaSample(rng *rand.Rand, shape float64) float64 { - if shape < 1 { - // Gamma(a) == Gamma(a+1) * U^(1/a) for a < 1. - return gammaSample(rng, shape+1) * math.Pow(rng.Float64(), 1/shape) - } - - d := shape - 1.0/3.0 - c := 1 / math.Sqrt(9*d) - - for { - x := rng.NormFloat64() - v := 1 + c*x - if v <= 0 { - continue - } - v = v * v * v - - u := rng.Float64() - if u < 1-0.0331*x*x*x*x { - return d * v - } - if math.Log(u) < 0.5*x*x+d*(1-v+math.Log(v)) { - return d * v - } - } -} - -// GreedyBandit always selects the arm with the best hit rate so far. It is a -// useful control: it shows what the adaptive layer achieves without any -// exploration, and it makes switching behaviour deterministic in tests. -type GreedyBandit struct { - mu sync.Mutex - rates map[ascache.PolicyType]float64 - counts map[ascache.PolicyType]int64 -} - -// NewGreedyBandit returns a bandit that always picks the best-measured arm. -func NewGreedyBandit() *GreedyBandit { - return &GreedyBandit{ - rates: map[ascache.PolicyType]float64{}, - counts: map[ascache.PolicyType]int64{}, - } -} - -// RecordStats stores the arm's hit rate for the epoch just measured. -func (b *GreedyBandit) RecordStats(stats ascache.ShadowStats) { - b.mu.Lock() - defer b.mu.Unlock() - - total := stats.Hits + stats.Misses - if total == 0 { - return - } - b.rates[stats.Policy] = float64(stats.Hits) / float64(total) - b.counts[stats.Policy] = total -} - -// SelectPolicy returns the arm with the highest measured hit rate. -func (b *GreedyBandit) SelectPolicy() ascache.PolicyType { - b.mu.Lock() - defer b.mu.Unlock() - - best := ascache.Undefined - bestRate := -1.0 - for policy, rate := range b.rates { - if rate > bestRate { - best, bestRate = policy, rate - } - } - - return best -} diff --git a/bench/evidence_test.go b/bench/evidence_test.go index 0f4f774..465fbc2 100644 --- a/bench/evidence_test.go +++ b/bench/evidence_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" "github.com/sshaplygin/as-cache/bench" ) @@ -20,6 +21,15 @@ import ( // working set makes every policy look identical. const cacheSize = 500 +// tiedArmTolerance is how far below the worst fixed policy adaptive selection +// may land before it counts as having picked badly. +// +// It exists because arms tie. Half a point is roughly five times the +// run-to-run variation these replays show, and a small fraction of any real +// separation between policies, so it absorbs the coin-flip without hiding a +// regression. +const tiedArmTolerance = 0.005 + // workloads returns the suite every comparison runs over. func workloads() []bench.Workload { return []bench.Workload{ @@ -116,7 +126,7 @@ func TestAdaptiveVersusFixed(t *testing.T) { require.NoError(t, err) cache, err := ascache.NewAdaptiveCache(arms, - bench.NewThompsonBandit(0.7, 7), + bandit.NewThompson(0.7, 7), &ascache.Settings{ // Short enough that many epochs elapse during a replay, so // the bandit gets the chance to react within a phase. @@ -149,8 +159,22 @@ func TestAdaptiveVersusFixed(t *testing.T) { // The claim worth defending is not that adaptive always wins, but // that it never lands near the bottom: a cache that can pick the // worst arm is worse than any fixed choice. - assert.Greater(t, adaptive.HitRate(), worstFixed.HitRate(), - "adaptive selection must beat the worst fixed policy on %s", w.Name) + // + // "Near the bottom" has to allow for a tie, because on some + // workloads most arms are equivalent. On uniform, six of the seven + // policies sit within a hundredth of a point of each other at + // ~10%, since there is no structure for any of them to exploit. + // Adaptive lands in that same tie, and whether it comes out a + // hundredth above or below the minimum of the group is a property + // of the run, not of the library - asserting strict improvement + // there fails about one run in ten and means nothing when it + // passes. + // + // The tolerance is far above that jitter and far below any real + // separation: the gap between best and worst is 92 points on loop, + // 11 on zipf, 10 on scan. + assert.Greater(t, adaptive.HitRate(), worstFixed.HitRate()-tiedArmTolerance, + "adaptive selection must not land below the worst fixed policy on %s", w.Name) }) } diff --git a/bench/fleet.go b/bench/fleet.go new file mode 100644 index 0000000..eccfc33 --- /dev/null +++ b/bench/fleet.go @@ -0,0 +1,354 @@ +package bench + +import ( + "fmt" + "strings" + "sync" + "time" + + ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" +) + +// Shard splits a workload across n replicas by key, the way a consistent-hash +// router in front of a fleet would. +// +// Sharding by key rather than round-robin is what makes the simulation mean +// anything: a replica behind a hash router sees a stable slice of the keyspace +// and can build a working set in it. Round-robin would give every replica the +// full keyspace at 1/n the density, which is a different workload with +// different best policies, and would flatter pooling by making every replica's +// traffic identical. +func Shard(w Workload, n int) []Workload { + if n <= 1 { + return []Workload{w} + } + + shards := make([]Workload, n) + for i := range shards { + shards[i] = Workload{ + Name: fmt.Sprintf("%s/shard-%d", w.Name, i), + Description: w.Description, + } + } + + for _, k := range w.Keys { + // n is a positive int, so the remainder is below it and the conversion + // back is exact. + shard := int(fnv(k) % uint64(n)) //nolint:gosec // bounded by n on the line above + shards[shard].Keys = append(shards[shard].Keys, k) + } + + return shards +} + +// Split divides a workload into n consecutive slices, so every replica sees +// the whole keyspace but only a fraction of the requests. +// +// This is the homogeneous case: every replica's traffic has the same shape, so +// the best policy is the same everywhere and pooling has nothing to reconcile. +// It is the case pooling should help most, which makes it the fair test of +// whether it helps at all. +func Split(w Workload, n int) []Workload { + if n <= 1 { + return []Workload{w} + } + + shards := make([]Workload, n) + for i := range shards { + shards[i] = Workload{ + Name: fmt.Sprintf("%s/slice-%d", w.Name, i), + Description: w.Description, + } + } + + for i, k := range w.Keys { + shards[i%n].Keys = append(shards[i%n].Keys, k) + } + + return shards +} + +// fnv hashes a key for sharding. Any stable hash does; this one avoids +// pulling in a dependency and avoids Go's map seed, which changes per process +// and would make a run unreproducible. +func fnv(s string) uint64 { + const ( + offset = 14695981039346656037 + prime = 1099511628211 + ) + + h := uint64(offset) + for i := range len(s) { + h ^= uint64(s[i]) + h *= prime + } + + return h +} + +// ReplayPaced replays a workload at a target request rate rather than as fast +// as the machine allows. +// +// Every other measurement here runs flat out, which is right for comparing +// policies: it maximises requests per unit of wall clock and keeps runs short. +// It is wrong for the one question a distributed bandit exists to answer. +// "Too little traffic to tell the arms apart" means few requests per epoch, +// and an unpaced replay delivers thousands per epoch however small the +// workload is - the run just finishes sooner. Pacing is the only way to +// reproduce the regime the fleet is supposed to help with, and it costs +// wall-clock time to do it. +func ReplayPaced(name string, c Cache, w Workload, perSecond float64) Result { + if perSecond <= 0 { + return Replay(name, c, w) + } + + result := Result{Policy: name} + interval := time.Duration(float64(time.Second) / perSecond) + + start := time.Now() + for i, key := range w.Keys { + // Scheduled against the start rather than by sleeping a fixed interval + // each time, so the accumulated cost of the cache operations does not + // drift the rate downwards over a long run. + if due := start.Add(time.Duration(i) * interval); time.Now().Before(due) { + time.Sleep(time.Until(due)) + } + + if _, ok := c.Get(key); ok { + result.Hits++ + + continue + } + result.Misses++ + c.Add(key, i) + } + result.Duration = time.Since(start) + + return result +} + +// FleetSetup describes one way of running a fleet, so the ways can be compared +// on identical traffic. +type FleetSetup struct { + // Name identifies the setup in reports. + Name string + // Bandit builds the bandit for replica i. A setup that pools returns + // bandits sharing one store; one that does not returns independent ones. + Bandit func(replica int) (ascache.Bandit, func() error, error) +} + +// LocalFleet gives every replica its own Thompson bandit and no shared state. +// It is the control: what a fleet does today, each replica deciding alone on +// the fraction of the traffic it happens to see. +func LocalFleet(discount float64, seed uint64) FleetSetup { + return FleetSetup{ + Name: "local", + Bandit: func(replica int) (ascache.Bandit, func() error, error) { + // replica indexes a slice, so it is non-negative and small. + return bandit.NewThompson(discount, seed+uint64(replica)), noClose, nil //nolint:gosec // see above + }, + } +} + +// PooledFleet gives every replica a distributed bandit over one shared +// in-memory store. +func PooledFleet(name string, mode bandit.Mode, epoch time.Duration, seed uint64) FleetSetup { + store := bandit.NewMemStore() + + return FleetSetup{ + Name: name, + Bandit: func(replica int) (ascache.Bandit, func() error, error) { + b, err := bandit.NewDistributed(bandit.Config{ + Store: store, + Namespace: "bench", + NodeID: fmt.Sprintf("r%d", replica), + CoordinationEpoch: epoch, + Mode: mode, + Window: 8, + // replica indexes a slice, so it is non-negative and small. + Seed: seed + uint64(replica), //nolint:gosec // see above + }) + if err != nil { + return nil, noClose, err + } + + return b, b.Close, nil + }, + } +} + +func noClose() error { return nil } + +// FleetResult is what a whole fleet served, plus how its replicas behaved. +type FleetResult struct { + Result + // Replicas is how many caches served the workload. + Replicas int + // Policies counts how many distinct policies the fleet ended on. Under + // leader election this should be one; a fleet that ends split has either + // lost its store or is running shared-posterior selection. + Policies int + // Ending lists each replica's final active policy. + Ending []string +} + +// ReplayFleet runs one workload across a fleet of AdaptiveCaches and reports +// what the fleet as a whole served. +// +// Replicas run concurrently, because the coordination this measures only +// happens in wall-clock time: a serial replay would let one replica finish +// before another had published anything, and the fleet would never actually +// overlap. +func ReplayFleet( + setup FleetSetup, + shards []Workload, + size int, + settings ascache.Settings, +) (FleetResult, error) { + return replayFleet(setup, shards, size, settings, 0) +} + +// ReplayFleetPaced is ReplayFleet with each replica held to perSecond +// requests, so the fleet actually runs in the thin-traffic regime it exists +// for rather than merely being given a small workload. +func ReplayFleetPaced( + setup FleetSetup, + shards []Workload, + size int, + settings ascache.Settings, + perSecond float64, +) (FleetResult, error) { + return replayFleet(setup, shards, size, settings, perSecond) +} + +func replayFleet( + setup FleetSetup, + shards []Workload, + size int, + settings ascache.Settings, + perSecond float64, +) (FleetResult, error) { + type outcome struct { + result Result + ending string + } + + outcomes := make([]outcome, len(shards)) + closers := make([]func() error, 0, len(shards)*2) + + caches := make([]*ascache.AdaptiveCache[string, int], len(shards)) + for i := range shards { + arms, err := AdaptiveArms(size) + if err != nil { + return FleetResult{}, err + } + + policyBandit, closeBandit, err := setup.Bandit(i) + if err != nil { + return FleetResult{}, err + } + closers = append(closers, closeBandit) + + perReplica := settings + cache, err := ascache.NewAdaptiveCache(arms, policyBandit, &perReplica) + if err != nil { + return FleetResult{}, err + } + closers = append(closers, cache.Close) + caches[i] = cache + } + + defer func() { + for _, close := range closers { + _ = close() + } + }() + + start := time.Now() + + var wg sync.WaitGroup + for i := range shards { + wg.Add(1) + go func() { + defer wg.Done() + + outcomes[i].result = ReplayPaced(setup.Name, caches[i], shards[i], perSecond) + outcomes[i].ending = caches[i].ActivePolicy().String() + }() + } + wg.Wait() + + fleet := FleetResult{ + Result: Result{Policy: setup.Name, Duration: time.Since(start)}, + Replicas: len(shards), + Ending: make([]string, 0, len(shards)), + } + + distinct := make(map[string]struct{}, len(shards)) + for _, o := range outcomes { + fleet.Hits += o.result.Hits + fleet.Misses += o.result.Misses + fleet.Ending = append(fleet.Ending, o.ending) + distinct[o.ending] = struct{}{} + } + fleet.Policies = len(distinct) + + return fleet, nil +} + +// ReplayFixedFleet runs the same shards against a single fixed policy per +// replica, which is the baseline every fleet setup has to be compared with: +// the hit rate a team would get by picking one policy and deploying it +// everywhere. +func ReplayFixedFleet(builder PolicyBuilder, shards []Workload, size int) (FleetResult, error) { + fleet := FleetResult{ + Result: Result{Policy: builder.Name}, + Replicas: len(shards), + Ending: make([]string, 0, len(shards)), + } + + start := time.Now() + for _, shard := range shards { + policy, err := builder.Build(size) + if err != nil { + return FleetResult{}, fmt.Errorf("build %s: %w", builder.Name, err) + } + + result := Replay(builder.Name, policyCache{policy}, shard) + fleet.Hits += result.Hits + fleet.Misses += result.Misses + fleet.Ending = append(fleet.Ending, builder.Name) + } + fleet.Duration = time.Since(start) + fleet.Policies = 1 + + return fleet, nil +} + +// policyCache adapts a bare Policy to the Cache the harness replays against. +type policyCache struct { + policy ascache.Policy[string, int] +} + +func (c policyCache) Get(key string) (int, bool) { return c.policy.Get(key) } +func (c policyCache) Add(key string, value int) bool { return c.policy.Add(key, value) } + +// FleetTable renders fleet results as a markdown table, best hit rate first. +func FleetTable(results []FleetResult) string { + sorted := make([]FleetResult, len(results)) + copy(sorted, results) + for i := 1; i < len(sorted); i++ { + for j := i; j > 0 && sorted[j].HitRate() > sorted[j-1].HitRate(); j-- { + sorted[j], sorted[j-1] = sorted[j-1], sorted[j] + } + } + + var b strings.Builder + b.WriteString("| Setup | Hit rate | Policies in use at the end |\n| --- | --- | --- |\n") + for _, r := range sorted { + fmt.Fprintf(&b, "| %s | %.2f%% | %d |\n", r.Policy, r.HitRate()*100, r.Policies) + } + + return b.String() +} diff --git a/bench/fleet_test.go b/bench/fleet_test.go new file mode 100644 index 0000000..4e71f43 --- /dev/null +++ b/bench/fleet_test.go @@ -0,0 +1,353 @@ +package bench_test + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" + "github.com/sshaplygin/as-cache/bench" +) + +// fleetSettings is tuned the way the real-trace evidence says to tune it: an +// epoch long enough that the fleet is not spending its time migrating between +// policies, and warm migration so a switch does not throw the working set +// away. +func fleetSettings() ascache.Settings { + return ascache.Settings{ + EpochDuration: 20 * time.Millisecond, + EvictPartialCapacityFilling: true, + MigrationStrategy: ascache.MigrationWarm, + ShadowSampleRate: 1, + } +} + +// TestFleet_DoesPoolingBeatDecidingAlone is the question the distributed +// bandit exists to answer, and it is set up so the answer can come back "no". +// +// A fleet is compared four ways on identical traffic: every replica running +// each fixed policy, every replica deciding alone, and the fleet pooling its +// evidence under each of the two coordination modes. The comparison that +// matters is pooled against local, since both are adaptive and only one of +// them can see the fleet. +func TestFleet_DoesPoolingBeatDecidingAlone(t *testing.T) { + if testing.Short() { + t.Skip("evidence run: replays a fleet of caches, see `make evidence`") + } + + const ( + replicas = 8 + size = 500 + ) + + workloads := []struct { + name string + workload bench.Workload + split func(bench.Workload, int) []bench.Workload + }{ + { + name: "zipf/homogeneous", + workload: bench.Zipf(400_000, 20_000, 1.1, 11), + split: bench.Split, + }, + { + name: "phase-shift/homogeneous", + workload: bench.PhaseShift(4, 100_000, 20_000, 3_000, 12), + split: bench.Split, + }, + { + name: "zipf/sharded", + workload: bench.Zipf(400_000, 20_000, 1.1, 13), + split: bench.Shard, + }, + } + + for _, w := range workloads { + t.Run(w.name, func(t *testing.T) { + shards := w.split(w.workload, replicas) + + perReplica := 0 + for _, shard := range shards { + perReplica += shard.Len() + } + perReplica /= len(shards) + + results := make([]bench.FleetResult, 0, 12) + + for _, builder := range bench.FixedPolicies() { + fixed, err := bench.ReplayFixedFleet(builder, shards, size) + require.NoError(t, err) + results = append(results, fixed) + } + + setups := []bench.FleetSetup{ + bench.LocalFleet(0.7, 21), + bench.PooledFleet("pooled/leader", bandit.ModeLeader, 50*time.Millisecond, 31), + bench.PooledFleet("pooled/shared", bandit.ModeSharedPosterior, 50*time.Millisecond, 41), + } + + adaptive := make(map[string]bench.FleetResult, len(setups)) + for _, setup := range setups { + result, err := bench.ReplayFleet(setup, shards, size, fleetSettings()) + require.NoError(t, err) + results = append(results, result) + adaptive[setup.Name] = result + } + + t.Logf("\n%s: %d replicas, %d requests each, cache size %d\n%s", + w.name, replicas, perReplica, size, bench.FleetTable(results)) + + best, worst := bestAndWorstFixed(results) + t.Logf("best fixed policy %s at %.2f%%, worst %s at %.2f%%", + best.Policy, best.HitRate()*100, worst.Policy, worst.HitRate()*100) + + for name, result := range adaptive { + t.Logf("%s: %.2f%% (%+.2f vs best fixed, %+.2f vs local), %d policies in use at the end", + name, + result.HitRate()*100, + (result.HitRate()-best.HitRate())*100, + (result.HitRate()-adaptive["local"].HitRate())*100, + result.Policies) + } + + // The claim this repository is willing to make everywhere else is + // the one asserted here: adaptive selection bounds the cost of + // guessing wrong. Beating the best fixed policy is not claimed, + // and is not asserted. + for name, result := range adaptive { + assert.Greater(t, result.HitRate(), worst.HitRate(), + "%s did worse than the worst policy it could have been given", name) + } + + // The fleet ending on one policy is reported, not asserted. + // Replicas finish their shards at different moments, so each one's + // last-applied decision is sampled at a different instant and two + // of them straddling a fleet-wide switch is a property of when the + // replay stopped, not of whether coordination worked. The + // single-policy invariant is asserted where it can be checked + // deterministically, against a clock the test controls: see + // TestDistributed_FleetRunsOnePolicyAtATime in the bandit module. + }) + } +} + +// TestFleet_ThinTrafficIsWherePoolingShouldPay isolates the case the +// distributed bandit was built for: replicas each seeing too little traffic to +// rank arms on their own. +// +// It reports rather than asserts an improvement. If pooling does not help even +// here, that is the finding, and it belongs in the README rather than in a +// failing test. +func TestFleet_ThinTrafficIsWherePoolingShouldPay(t *testing.T) { + if testing.Short() { + t.Skip("evidence run: replays a fleet of caches, see `make evidence`") + } + + const size = 300 + + for _, replicas := range []int{2, 8, 32} { + t.Run(fmt.Sprintf("%d-replicas", replicas), func(t *testing.T) { + // The total traffic is fixed, so more replicas means each sees + // less of it - which is exactly the axis pooling is supposed to + // win on. + shards := bench.Split(bench.Zipf(200_000, 20_000, 1.1, 17), replicas) + + local, err := bench.ReplayFleet(bench.LocalFleet(0.7, 5), shards, size, fleetSettings()) + require.NoError(t, err) + + pooled, err := bench.ReplayFleet( + bench.PooledFleet("pooled/leader", bandit.ModeLeader, 50*time.Millisecond, 6), + shards, size, fleetSettings()) + require.NoError(t, err) + + var best bench.FleetResult + for _, builder := range bench.FixedPolicies() { + fixed, err := bench.ReplayFixedFleet(builder, shards, size) + require.NoError(t, err) + if fixed.HitRate() > best.HitRate() { + best = fixed + } + } + + t.Logf("%d replicas, %d requests each: local %.2f%%, pooled %.2f%% (%+.2f), best fixed %s %.2f%%", + replicas, shards[0].Len(), + local.HitRate()*100, pooled.HitRate()*100, + (pooled.HitRate()-local.HitRate())*100, + best.Policy, best.HitRate()*100) + }) + } +} + +// TestFleet_PacedThinTraffic is the case the distributed bandit was actually +// built for, and the only test here that reproduces it. +// +// Every other fleet measurement replays flat out, which delivers thousands of +// requests per epoch however small the workload is - so "thin traffic" never +// happens, the run just finishes sooner. Here each replica is held to a rate +// that puts a handful of requests in each cache epoch, which is the regime +// where a replica genuinely cannot rank its own arms and pooling has something +// to add. +// +// It costs wall-clock time to run, which is the point: the regime cannot be +// simulated any faster than it happens. +func TestFleet_PacedThinTraffic(t *testing.T) { + if testing.Short() { + t.Skip("evidence run: paced replay, takes tens of seconds") + } + + const ( + replicas = 8 + size = 300 + perSecond = 400 // with a 20ms epoch, about 8 requests per epoch per replica + requests = 4_000 + ) + + shards := bench.Split(bench.Zipf(requests*replicas, 20_000, 1.1, 77), replicas) + + local, err := bench.ReplayFleetPaced( + bench.LocalFleet(0.7, 81), shards, size, fleetSettings(), perSecond) + require.NoError(t, err) + + pooled, err := bench.ReplayFleetPaced( + bench.PooledFleet("pooled/leader", bandit.ModeLeader, 200*time.Millisecond, 91), + shards, size, fleetSettings(), perSecond) + require.NoError(t, err) + + var best bench.FleetResult + for _, builder := range bench.FixedPolicies() { + fixed, err := bench.ReplayFixedFleet(builder, shards, size) + require.NoError(t, err) + if fixed.HitRate() > best.HitRate() { + best = fixed + } + } + + t.Logf("paced fleet: %d replicas at %d req/s, %d requests each, ~%d requests per cache epoch", + replicas, perSecond, shards[0].Len(), + int(float64(perSecond)*fleetSettings().EpochDuration.Seconds())) + t.Logf(" local %.2f%% across %d policies", local.HitRate()*100, local.Policies) + t.Logf(" pooled/leader %.2f%% across %d policies (%+.2f vs local)", + pooled.HitRate()*100, pooled.Policies, (pooled.HitRate()-local.HitRate())*100) + t.Logf(" best fixed %.2f%% (%s)", best.HitRate()*100, best.Policy) +} + +// TestFleet_HeterogeneousShardsAreWherePoolingShouldHurt is the other side of +// the argument, and the reason ModeSharedPosterior exists at all. +// +// When replicas serve traffic of different shapes, one fleet-wide policy is a +// compromise, and a replica choosing for itself can do better than the fleet +// choosing for it. This measures how much that costs. +func TestFleet_HeterogeneousShardsAreWherePoolingShouldHurt(t *testing.T) { + if testing.Short() { + t.Skip("evidence run: replays a fleet of caches, see `make evidence`") + } + + const ( + replicas = 6 + size = 400 + ) + + // Half the replicas get a looping scan, where recency policies serve + // nothing; the other half get Zipf, where they do well. No single policy + // is right for both. + shards := make([]bench.Workload, 0, replicas) + loop := bench.Split(bench.Loop(120_000, 2_000), replicas/2) + zipf := bench.Split(bench.Zipf(120_000, 20_000, 1.1, 23), replicas/2) + shards = append(shards, loop...) + shards = append(shards, zipf...) + + local, err := bench.ReplayFleet(bench.LocalFleet(0.7, 51), shards, size, fleetSettings()) + require.NoError(t, err) + + pooled, err := bench.ReplayFleet( + bench.PooledFleet("pooled/leader", bandit.ModeLeader, 50*time.Millisecond, 61), + shards, size, fleetSettings()) + require.NoError(t, err) + + shared, err := bench.ReplayFleet( + bench.PooledFleet("pooled/shared", bandit.ModeSharedPosterior, 50*time.Millisecond, 71), + shards, size, fleetSettings()) + require.NoError(t, err) + + t.Logf("mixed fleet (%d loop replicas, %d zipf replicas):", replicas/2, replicas/2) + t.Logf(" local %.2f%% across %d policies", local.HitRate()*100, local.Policies) + t.Logf(" pooled/leader %.2f%% across %d policies (%+.2f vs local)", + pooled.HitRate()*100, pooled.Policies, (pooled.HitRate()-local.HitRate())*100) + t.Logf(" pooled/shared %.2f%% across %d policies (%+.2f vs local)", + shared.HitRate()*100, shared.Policies, (shared.HitRate()-local.HitRate())*100) + + assert.Equal(t, 1, pooled.Policies, "leader election commits the whole fleet to one policy") +} + +// TestFleet_CoordinationEpochIsTheSettingThatMatters checks whether the gap +// between pooling and deciding alone is a consequence of how often the fleet +// gets to change its mind. +// +// The single-node evidence found epoch duration to be the setting that decides +// everything; the fleet has a second, slower clock, and a replay is only so +// many coordination rounds long. If the gap closes as the coordination epoch +// shortens, pooling is losing to a lack of decision points. If it does not, +// pooling is losing for a structural reason and no tuning will fix it. +func TestFleet_CoordinationEpochIsTheSettingThatMatters(t *testing.T) { + if testing.Short() { + t.Skip("evidence run: replays a fleet of caches, see `make evidence`") + } + + const ( + replicas = 8 + size = 500 + ) + + shards := bench.Split(bench.Zipf(400_000, 20_000, 1.1, 11), replicas) + + local, err := bench.ReplayFleet(bench.LocalFleet(0.7, 21), shards, size, fleetSettings()) + require.NoError(t, err) + t.Logf("local (no coordination): %.2f%%", local.HitRate()*100) + + for _, epoch := range []time.Duration{ + 10 * time.Millisecond, + 25 * time.Millisecond, + 50 * time.Millisecond, + 200 * time.Millisecond, + } { + pooled, err := bench.ReplayFleet( + bench.PooledFleet("pooled", bandit.ModeLeader, epoch, 31), + shards, size, fleetSettings()) + require.NoError(t, err) + + t.Logf("coordination epoch %-6s: %.2f%% (%+.2f vs local), %d policies at the end", + epoch, pooled.HitRate()*100, + (pooled.HitRate()-local.HitRate())*100, pooled.Policies) + } +} + +func bestAndWorstFixed(results []bench.FleetResult) (best, worst bench.FleetResult) { + fixedNames := make(map[string]struct{}, len(bench.FixedPolicies())) + for _, builder := range bench.FixedPolicies() { + fixedNames[builder.Name] = struct{}{} + } + + first := true + for _, result := range results { + if _, ok := fixedNames[result.Policy]; !ok { + continue + } + if first { + best, worst, first = result, result, false + + continue + } + if result.HitRate() > best.HitRate() { + best = result + } + if result.HitRate() < worst.HitRate() { + worst = result + } + } + + return best, worst +} diff --git a/bench/go.mod b/bench/go.mod index bd0a78d..1594693 100644 --- a/bench/go.mod +++ b/bench/go.mod @@ -4,6 +4,7 @@ go 1.25.2 require ( github.com/sshaplygin/as-cache v0.0.0 + github.com/sshaplygin/as-cache/bandit v0.0.0 github.com/sshaplygin/as-cache/policies v0.0.0 github.com/sshaplygin/as-cache/policies/arc v0.0.0 github.com/sshaplygin/as-cache/policies/tinylfu v0.0.0 @@ -29,3 +30,5 @@ replace github.com/sshaplygin/as-cache/policies => ../policies replace github.com/sshaplygin/as-cache/policies/arc => ../policies/arc replace github.com/sshaplygin/as-cache/policies/tinylfu => ../policies/tinylfu + +replace github.com/sshaplygin/as-cache/bandit => ../bandit diff --git a/bench/timeline_test.go b/bench/timeline_test.go index 0a835f3..fc50822 100644 --- a/bench/timeline_test.go +++ b/bench/timeline_test.go @@ -1,7 +1,9 @@ package bench_test import ( + "encoding/json" "fmt" + "os" "sort" "strings" "sync" @@ -12,6 +14,7 @@ import ( "github.com/stretchr/testify/require" ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" "github.com/sshaplygin/as-cache/bench" ) @@ -23,24 +26,66 @@ type timelineCache struct { mu sync.Mutex samples []ascache.PolicyType + frames []timelineFrame interval int seen int } +// timelineFrame is one sample's worth of what the cache knew: which arm was +// serving, and what every arm had measured at that moment. +// +// The plot alone shows that the active policy changes; it cannot show why. The +// evidence behind each switch is the interesting part, and it is only +// observable while the run is happening. +type timelineFrame struct { + // Request is how many requests had been served when the frame was taken. + Request int `json:"request"` + // Active is the arm serving at that moment. + Active string `json:"active"` + // Epochs is how many reporting epochs had fed the advice so far. + Epochs int64 `json:"epochs"` + // Arms is each policy's measured hit rate, as the bandit could see it. + Arms []timelineArm `json:"arms"` +} + +type timelineArm struct { + Policy string `json:"policy"` + Hits int64 `json:"hits"` + Misses int64 `json:"misses"` + HitRate float64 `json:"hit_rate"` + Active bool `json:"active"` +} + func (c *timelineCache) sample() { c.mu.Lock() c.seen++ due := c.seen%c.interval == 0 + seen := c.seen c.mu.Unlock() if !due { return } + // Read outside the lock: Advice takes the cache's own read lock, and + // holding this one across it would order two locks for no reason. active := c.inner.ActivePolicy() + advice := c.inner.Advice() + + frame := timelineFrame{Request: seen, Active: active.String(), Epochs: advice.Epochs} + for _, report := range advice.Reports { + frame.Arms = append(frame.Arms, timelineArm{ + Policy: report.Policy.String(), + Hits: report.Hits, + Misses: report.Misses, + HitRate: report.HitRate(), + Active: report.Active, + }) + } c.mu.Lock() c.samples = append(c.samples, active) + c.frames = append(c.frames, frame) c.mu.Unlock() } @@ -81,7 +126,7 @@ func TestActivePolicyTimeline(t *testing.T) { require.NoError(t, err) inner, err := ascache.NewAdaptiveCache(arms, - bench.NewThompsonBandit(0.6, 9), + bandit.NewThompson(0.6, 9), &ascache.Settings{ EpochDuration: 2 * time.Millisecond, EvictPartialCapacityFilling: true, @@ -99,10 +144,13 @@ func TestActivePolicyTimeline(t *testing.T) { cache.mu.Lock() samples := append([]ascache.PolicyType(nil), cache.samples...) + frames := append([]timelineFrame(nil), cache.frames...) cache.mu.Unlock() require.NotEmpty(t, samples, "expected timeline samples") + writeTimelineJSON(t, w, size, phases, result.HitRate(), frames) + t.Logf("\nphase-shift timeline (%d requests, cache %d, %d phases)\n%s\nhit rate %.2f%%", w.Len(), size, phases, plotTimeline(samples, phases), result.HitRate()*100) @@ -180,3 +228,43 @@ func plotTimeline(samples []ascache.PolicyType, phases int) string { return b.String() } + +// writeTimelineJSON dumps the run to the path in AS_CACHE_TIMELINE_JSON, for +// building an interactive view of it. It does nothing when the variable is +// unset, so the evidence run is unchanged by default. +func writeTimelineJSON( + t *testing.T, + w bench.Workload, + size, phases int, + hitRate float64, + frames []timelineFrame, +) { + t.Helper() + + path := os.Getenv("AS_CACHE_TIMELINE_JSON") + if path == "" { + return + } + + payload := struct { + Workload string `json:"workload"` + Requests int `json:"requests"` + Size int `json:"size"` + Phases int `json:"phases"` + HitRate float64 `json:"hit_rate"` + Frames []timelineFrame `json:"frames"` + }{ + Workload: w.Name, + Requests: w.Len(), + Size: size, + Phases: phases, + HitRate: hitRate, + Frames: frames, + } + + encoded, err := json.MarshalIndent(payload, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, encoded, 0o600)) + + t.Logf("wrote timeline trace to %s (%d frames)", path, len(frames)) +} diff --git a/bench/trace_test.go b/bench/trace_test.go index 8b6a45b..7417188 100644 --- a/bench/trace_test.go +++ b/bench/trace_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" "github.com/sshaplygin/as-cache/bench" ) @@ -130,7 +131,7 @@ func TestTraceEvidence(t *testing.T) { require.NoError(t, err) cache, err := ascache.NewAdaptiveCache(arms, - bench.NewThompsonBandit(0.7, 13), + bandit.NewThompson(0.7, 13), &ascache.Settings{ EpochDuration: 2 * time.Millisecond, EvictPartialCapacityFilling: true, diff --git a/bench/tuning_test.go b/bench/tuning_test.go index 9113e96..7ac57cc 100644 --- a/bench/tuning_test.go +++ b/bench/tuning_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" "github.com/sshaplygin/as-cache/bench" ) @@ -68,7 +69,7 @@ func TestAdaptiveTuning(t *testing.T) { settings.SwitchCooldownEpochs = 3 } - cache, err := ascache.NewAdaptiveCache(arms, bench.NewThompsonBandit(0.7, 13), settings) + cache, err := ascache.NewAdaptiveCache(arms, bandit.NewThompson(0.7, 13), settings) require.NoError(t, err) r := bench.Replay("adaptive", cache, w) diff --git a/cache.go b/cache.go index 966e557..6b58f34 100644 --- a/cache.go +++ b/cache.go @@ -16,6 +16,11 @@ type AdaptiveCache[K comparable, V any] struct { activePolicy PolicyType policies map[PolicyType]Policy[K, V] + // policyOrder lists every policy type once, sorted, so the epoch report is + // built in a reproducible order rather than a map's random one. It is + // fixed at construction: the set of arms never changes. + policyOrder []PolicyType + // sampler decides which keys shadow policies track. It is shared by every // policy so they all measure the same substream, and is fixed for the // lifetime of the cache. @@ -52,6 +57,11 @@ type AdaptiveCache[K comparable, V any] struct { // --- Control Plane --- bandit Bandit + // epochBandit is bandit again when it implements the optional EpochBandit + // extension, and nil otherwise. The assertion is made once at construction + // rather than on every epoch, and its nil-ness is what selects between the + // two reporting shapes - a bandit never receives both. + epochBandit EpochBandit // epochStats holds the per-policy stats measured in the epoch the last // report covered, keyed by policy. The switch-stability gates in diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..02e3357 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +# Local servers for the bandit/redis test suite. +# +# The suite runs against miniredis by default, which is a fake. The adapter +# leans on three things a fake can be too permissive about - TIME called inside +# a Lua script, SET with NX and PX, and HINCRBY on a key the script names +# itself rather than declaring in KEYS - so the same tests run against a real +# server here. +# +# Both engines are here on purpose. The adapter is documented as needing Redis +# 7.0 or Valkey 7.2 and above, because deriving the bucket from the server's +# clock inside a script requires effects replication. Claiming support for two +# engines and testing one is how that claim quietly stops being true. +# +# make redis-up start both +# make redis-test start both, run the suite against each, stop them +# make redis-down stop both and remove their data +# +# Ports are deliberately off the defaults so this cannot collide with a Redis +# you are already running for something else. + +services: + valkey: + image: valkey/valkey:8-alpine + container_name: as-cache-valkey + ports: + - "63799:6379" + # No persistence: every run starts empty, and nothing here is worth keeping. + command: ["valkey-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 1s + timeout: 3s + retries: 20 + + redis: + image: redis:7-alpine + container_name: as-cache-redis + ports: + - "63798:6379" + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 1s + timeout: 3s + retries: 20 diff --git a/epoch.go b/epoch.go index efe0af9..54c755d 100644 --- a/epoch.go +++ b/epoch.go @@ -40,7 +40,13 @@ func (c *AdaptiveCache[K, V]) runEpoch() { return } - if c.activePolicy != newPolicy && c.allowSwitchLocked(newPolicy) { + // A Bandit is caller-supplied code, and nothing constrains what it returns. + // A selection naming a policy this cache does not hold - Undefined most + // often, from a bandit that has not yet formed an opinion - would reach + // switchLocked, look the missing policy up in the map, and dereference a + // nil interface, panicking the epoch goroutine and taking the process with + // it. An unrecognised selection means no change. + if c.activePolicy != newPolicy && c.hasPolicy(newPolicy) && c.allowSwitchLocked(newPolicy) { c.switchLocked(c.activePolicy, newPolicy) c.lastSwitchEpoch = c.epochID } @@ -48,6 +54,14 @@ func (c *AdaptiveCache[K, V]) runEpoch() { c.epochID++ } +// hasPolicy reports whether the cache holds the named policy as one of its +// arms. It must be called while at least the read lock is held. +func (c *AdaptiveCache[K, V]) hasPolicy(policyType PolicyType) bool { + _, ok := c.policies[policyType] + + return ok +} + // tryChangePolicy records every policy's stats with the bandit (nothing on a // gated epoch — see selectPolicyLocked) and returns the policy selected for // the next epoch. It acquires the write lock and performs no migration. It @@ -92,7 +106,20 @@ func (c *AdaptiveCache[K, V]) selectPolicyLocked() PolicyType { } c.reportingEpochs++ - for _, policy := range c.policies { + // An EpochBandit is handed the whole epoch in one call, so its report is + // collected here rather than delivered arm by arm. The slice is allocated + // per epoch and never reused, so the bandit may retain it. + var report []ShadowStats + if c.epochBandit != nil { + report = make([]ShadowStats, 0, len(c.policyOrder)) + } + + // policyOrder rather than ranging the map: a map's order is random, and an + // epoch's evidence should be reproducible for anything that hashes, + // serialises or logs it. + for _, policyType := range c.policyOrder { + policy := c.policies[policyType] + stats := policy.GetStats() policy.ResetStats() @@ -119,10 +146,26 @@ func (c *AdaptiveCache[K, V]) selectPolicyLocked() PolicyType { tenure.Misses += reported.Misses c.tenureStats[policy.GetType()] = tenure - c.bandit.RecordStats(ShadowStats{ + armStats := ShadowStats{ Policy: policy.GetType(), Hits: reported.Hits, Misses: reported.Misses, + } + + if c.epochBandit != nil { + report = append(report, armStats) + continue + } + c.bandit.RecordStats(armStats) + } + + if c.epochBandit != nil { + c.epochBandit.RecordEpoch(EpochReport{ + EpochID: c.epochID, + Active: currentPolicy, + Stats: report, + Capacity: c.nominalCap[currentPolicy], + SampleRate: c.sampler.rate, }) } diff --git a/epoch_bandit_test.go b/epoch_bandit_test.go new file mode 100644 index 0000000..1556408 --- /dev/null +++ b/epoch_bandit_test.go @@ -0,0 +1,402 @@ +package ascache + +import ( + "fmt" + "slices" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// epochRecordingBandit implements the optional EpochBandit extension and +// records both reporting shapes, so a test can assert that only one of them is +// ever used. +type epochRecordingBandit struct { + mu sync.Mutex + next PolicyType + reports []EpochReport + perArm []ShadowStats + selected int +} + +func (b *epochRecordingBandit) RecordStats(stats ShadowStats) { + b.mu.Lock() + defer b.mu.Unlock() + b.perArm = append(b.perArm, stats) +} + +func (b *epochRecordingBandit) RecordEpoch(report EpochReport) { + b.mu.Lock() + defer b.mu.Unlock() + b.reports = append(b.reports, report) +} + +func (b *epochRecordingBandit) SelectPolicy() PolicyType { + b.mu.Lock() + defer b.mu.Unlock() + b.selected++ + return b.next +} + +func (b *epochRecordingBandit) snapshot() ([]EpochReport, []ShadowStats) { + b.mu.Lock() + defer b.mu.Unlock() + return slices.Clone(b.reports), slices.Clone(b.perArm) +} + +func (b *epochRecordingBandit) setNext(policy PolicyType) { + b.mu.Lock() + defer b.mu.Unlock() + b.next = policy +} + +// makeEpochCache builds a cache driven by an EpochBandit with the epoch ticker +// far enough out that every reporting epoch in the test is one the test +// triggered itself. +func makeEpochCache(t *testing.T, settings *Settings) ( + *AdaptiveCache[string, int], + *epochRecordingBandit, +) { + t.Helper() + + bandit := &epochRecordingBandit{next: LRU} + ac, err := NewAdaptiveCache( + []Policy[string, int]{ + newMockPolicy[string, int](LRU, 10), + newMockPolicy[string, int](TinyLFU, 10), + newMockPolicy[string, int](LFU, 10), + }, + bandit, + settings, + ) + require.NoError(t, err) + t.Cleanup(func() { _ = ac.Close() }) + + return ac, bandit +} + +func defaultEpochSettings() *Settings { + return &Settings{ + EpochDuration: 24 * time.Hour, + EvictPartialCapacityFilling: true, + } +} + +func TestEpochBandit_ReplacesPerArmReporting(t *testing.T) { + ac, bandit := makeEpochCache(t, defaultEpochSettings()) + + ac.Add("a", 1) + ac.Get("a") + ac.Get("missing") + + ac.tryChangePolicy() + + reports, perArm := bandit.snapshot() + require.Len(t, reports, 1, "expected exactly one RecordEpoch per reporting epoch") + assert.Empty(t, perArm, "a bandit implementing EpochBandit must not also receive RecordStats") +} + +func TestEpochBandit_ReportCoversEveryArmInPolicyOrder(t *testing.T) { + ac, bandit := makeEpochCache(t, defaultEpochSettings()) + + ac.Add("a", 1) + ac.Get("a") + + ac.tryChangePolicy() + + reports, _ := bandit.snapshot() + require.Len(t, reports, 1) + + got := make([]PolicyType, 0, len(reports[0].Stats)) + for _, stats := range reports[0].Stats { + got = append(got, stats.Policy) + } + + // Sorted by PolicyType, not by the map's iteration order: LFU(2) before + // TinyLFU(7) even though TinyLFU was passed to the constructor first. + assert.Equal(t, []PolicyType{LRU, LFU, TinyLFU}, got) +} + +func TestEpochBandit_ReportOrderIsStableAcrossEpochs(t *testing.T) { + ac, bandit := makeEpochCache(t, defaultEpochSettings()) + + const epochs = 20 + for i := range epochs { + ac.Add("a", i) + ac.Get("a") + ac.tryChangePolicy() + } + + reports, _ := bandit.snapshot() + require.Len(t, reports, epochs) + + first := make([]PolicyType, 0, len(reports[0].Stats)) + for _, stats := range reports[0].Stats { + first = append(first, stats.Policy) + } + + // Ranging a map would pass this by chance roughly (1/6)^19 of the time. + for i, report := range reports { + order := make([]PolicyType, 0, len(report.Stats)) + for _, stats := range report.Stats { + order = append(order, stats.Policy) + } + assert.Equal(t, first, order, "epoch %d reported a different arm order", i) + } +} + +func TestEpochBandit_ReportCarriesActivePolicyAndEpochID(t *testing.T) { + ac, bandit := makeEpochCache(t, defaultEpochSettings()) + ac.Add("a", 1) + + // tryChangePolicy reports without advancing epochID or switching, so drive + // runEpoch instead: the active policy and the ID both have to move. + bandit.setNext(TinyLFU) + ac.runEpoch() + ac.runEpoch() + + reports, _ := bandit.snapshot() + require.Len(t, reports, 2) + + assert.Equal(t, LRU, reports[0].Active, "first epoch was served by the initial policy") + assert.Equal(t, int64(0), reports[0].EpochID) + + assert.Equal(t, TinyLFU, reports[1].Active, "second epoch was served by the policy the first switched to") + assert.Equal(t, int64(1), reports[1].EpochID) +} + +func TestEpochBandit_ReportCarriesCacheShape(t *testing.T) { + settings := defaultEpochSettings() + settings.ShadowSampleRate = 0.25 + settings.MinShadowCapacity = 1 + + ac, bandit := makeEpochCache(t, settings) + ac.Add("a", 1) + ac.tryChangePolicy() + + reports, _ := bandit.snapshot() + require.Len(t, reports, 1) + + assert.Equal(t, 10, reports[0].Capacity, "the capacity the cache actually serves at") + assert.InDelta(t, 0.25, reports[0].SampleRate, 1e-9) +} + +func TestEpochBandit_ReportedCapacityFollowsResize(t *testing.T) { + ac, bandit := makeEpochCache(t, defaultEpochSettings()) + + ac.Resize(40) + ac.Add("a", 1) + ac.tryChangePolicy() + + reports, _ := bandit.snapshot() + require.Len(t, reports, 1) + assert.Equal(t, 40, reports[0].Capacity) +} + +func TestEpochBandit_ReportCountsMatchPerArmReporting(t *testing.T) { + // The same traffic against the same arms must produce the same numbers + // whichever reporting shape the bandit asks for. + traffic := func(ac *AdaptiveCache[string, int]) { + ac.Add("a", 1) + ac.Add("b", 2) + ac.Get("a") + ac.Get("a") + ac.Get("b") + ac.Get("nope") + } + + epochCache, epochBandit := makeEpochCache(t, defaultEpochSettings()) + traffic(epochCache) + epochCache.tryChangePolicy() + + plainBandit := &recordingBandit{next: LRU} + plainCache, err := NewAdaptiveCache( + []Policy[string, int]{ + newMockPolicy[string, int](LRU, 10), + newMockPolicy[string, int](TinyLFU, 10), + newMockPolicy[string, int](LFU, 10), + }, + plainBandit, + defaultEpochSettings(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = plainCache.Close() }) + + traffic(plainCache) + plainCache.tryChangePolicy() + + reports, _ := epochBandit.snapshot() + require.Len(t, reports, 1) + + viaEpoch := make(map[PolicyType]ShadowStats, len(reports[0].Stats)) + for _, stats := range reports[0].Stats { + viaEpoch[stats.Policy] = stats + } + + viaPerArm := make(map[PolicyType]ShadowStats) + for _, stats := range plainBandit.getRecords() { + viaPerArm[stats.Policy] = stats + } + + assert.Equal(t, viaPerArm, viaEpoch) +} + +func TestEpochBandit_ReportIsNotReusedAcrossEpochs(t *testing.T) { + // RecordEpoch documents that an implementation may retain the report, so a + // later epoch must not write through the slice an earlier one handed over. + ac, bandit := makeEpochCache(t, defaultEpochSettings()) + + ac.Add("a", 1) + ac.Get("a") + ac.tryChangePolicy() + + for range 5 { + ac.Get("a") + ac.Get("miss") + } + ac.tryChangePolicy() + + reports, _ := bandit.snapshot() + require.Len(t, reports, 2) + + assert.NotSame(t, &reports[0].Stats[0], &reports[1].Stats[0], + "the second epoch reused the first epoch's backing array") + + var firstTotal int64 + for _, stats := range reports[0].Stats { + firstTotal += stats.Hits + stats.Misses + } + assert.Equal(t, int64(3), firstTotal, + "the first report changed after it was delivered: expected the one Get it covered, per arm") +} + +func TestEpochBandit_GatedEpochReportsNothing(t *testing.T) { + settings := defaultEpochSettings() + settings.EvictPartialCapacityFilling = false + + ac, bandit := makeEpochCache(t, settings) + + // One entry in a cache of ten: the capacity gate skips the whole report. + ac.Add("a", 1) + ac.Get("a") + ac.tryChangePolicy() + + reports, perArm := bandit.snapshot() + assert.Empty(t, reports, "a gated epoch measured nothing and must report nothing") + assert.Empty(t, perArm) +} + +func TestEpochBandit_ObserveOnlyStillReports(t *testing.T) { + settings := defaultEpochSettings() + settings.ObserveOnly = true + + ac, bandit := makeEpochCache(t, settings) + bandit.setNext(TinyLFU) + + ac.Add("a", 1) + ac.Get("a") + ac.runEpoch() + + reports, _ := bandit.snapshot() + require.Len(t, reports, 1, "observe-only measures and reports; it only declines to act") + assert.Equal(t, LRU, reports[0].Active) + assert.Equal(t, LRU, ac.ActivePolicy(), "observe-only must not apply the selection") +} + +func TestRunEpoch_UnrecognisedSelectionMeansNoChange(t *testing.T) { + // A bandit that has not formed an opinion yet returns Undefined, and a + // distributed one does so for as long as it takes to reach its store. + // Looking that up in the policy map yields a nil interface, so switching + // to it used to panic the epoch goroutine and take the process down. + for _, selection := range []PolicyType{Undefined, ARC} { + t.Run(selection.String(), func(t *testing.T) { + ac, err := NewAdaptiveCache( + []Policy[string, int]{ + newMockPolicy[string, int](LRU, 10), + newMockPolicy[string, int](LFU, 10), + }, + &mockBandit{next: selection}, + defaultEpochSettings(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = ac.Close() }) + + ac.Add("a", 1) + require.NotPanics(t, ac.runEpoch) + + assert.Equal(t, LRU, ac.ActivePolicy(), "an unrecognised selection must leave the active policy alone") + + // The cache must still be serving, not left in a torn state. + value, ok := ac.Get("a") + assert.True(t, ok) + assert.Equal(t, 1, value) + }) + } +} + +func TestMigration_DefaultStrategyNeverServesAShadowZero(t *testing.T) { + // Settings.MigrationStrategy is documented as defaulting to MigrationCold, + // and every strategy has to purge the incoming policy's zero-value shadow + // entries before it starts serving. A strategy value the switch does not + // recognise - the zero value among them - must not skip that and hand + // callers a shadow zero as if it were cached data. + for _, strategy := range []MigrationStrategy{ + 0, MigrationCold, MigrationWarm, MigrationGradual, + } { + t.Run(fmt.Sprint(uint(strategy)), func(t *testing.T) { + ac, err := NewAdaptiveCache( + []Policy[string, int]{ + newMockPolicy[string, int](LRU, 10), + newMockPolicy[string, int](LFU, 10), + }, + &mockBandit{next: LFU}, + &Settings{ + EpochDuration: time.Hour, + EvictPartialCapacityFilling: true, + MigrationStrategy: strategy, + }, + ) + require.NoError(t, err) + t.Cleanup(func() { _ = ac.Close() }) + + ac.Add("real", 42) + // LFU is shadowing, so it now holds "real" mapped to a zero value. + + ac.runEpoch() + require.Equal(t, LFU, ac.ActivePolicy()) + + value, ok := ac.Get("real") + if ok { + assert.Equal(t, 42, value, "served a shadow zero as real data") + } + }) + } +} + +func TestBandit_WithoutExtensionStillReceivesPerArmStats(t *testing.T) { + // The extension is optional: a plain Bandit must be unaffected by its + // existence. + ac, _, _, _ := makeCache(t, MigrationCold) + require.Nil(t, ac.epochBandit) + + bandit := &recordingBandit{next: LRU} + plain, err := NewAdaptiveCache( + []Policy[string, int]{ + newMockPolicy[string, int](LRU, 10), + newMockPolicy[string, int](LFU, 10), + }, + bandit, + defaultEpochSettings(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = plain.Close() }) + + plain.Add("a", 1) + plain.Get("a") + plain.tryChangePolicy() + + assert.Len(t, bandit.getRecords(), 2, "expected one RecordStats per arm") +} diff --git a/interfaces.go b/interfaces.go index 06b298f..483cd50 100644 --- a/interfaces.go +++ b/interfaces.go @@ -48,9 +48,20 @@ type Policy[K comparable, V any] interface { // Bandit chooses which policy should be active, given what each has measured. // // This package ships no implementation, because the choice of strategy is the -// interesting part and depends on how quickly the traffic changes. A -// Thompson-sampling implementation with evidence discounting is in the bench -// module and is short enough to copy. +// interesting part and depends on how quickly the traffic changes. Ready-made +// ones live in the companion github.com/sshaplygin/as-cache/bandit module: +// a local Thompson sampler, and a distributed bandit that pools evidence +// across a fleet through Valkey or Redis. +// +// # Implementations must not block +// +// Both methods are called from the epoch goroutine while it holds the cache's +// write lock, so for as long as either runs, every Get and Add in the process +// is stalled behind it. A bandit that talks to the network, reads a file, or +// waits on a channel must do it on its own goroutine and have these methods +// only exchange buffered state. This is not a performance guideline: Go's +// RWMutex queues new readers behind a waiting writer, so a multi-second +// timeout here is a multi-second outage for the whole cache. type Bandit interface { // RecordStats delivers one policy's performance report. On every // reporting epoch each policy reports — the active policy included — so @@ -59,9 +70,42 @@ type Bandit interface { // Settings.EvictPartialCapacityFilling is false, epochs where the active // policy is not yet full skip reporting entirely; counters then // accumulate and the next report spans the skipped epochs. + // + // It is not called on a bandit that also implements EpochBandit; that + // interface's RecordEpoch replaces it. RecordStats(stats ShadowStats) // SelectPolicy asks the bandit to choose which policy should become the // active one for the next epoch. + // + // Returning a policy the cache was not built with - Undefined included, + // which is the natural answer from a bandit that has not yet formed an + // opinion - is not an error and means no change. SelectPolicy() PolicyType } + +// EpochBandit is an optional extension of Bandit for implementations that need +// to see a reporting epoch as a whole rather than as a sequence of per-policy +// calls. +// +// RecordStats hands over one arm at a time with no epoch identifier, no marker +// for where one epoch ends and the next begins, and no indication of which arm +// was serving traffic. That is enough for a bandit that only accumulates +// posteriors, and not enough for one that has to publish an epoch's evidence +// somewhere else - which needs to know what to key it by, when the epoch is +// complete, and that the active arm's numbers were measured at full capacity +// while every shadow's were measured on a miniature. +// +// A Bandit that implements this receives exactly one RecordEpoch call per +// reporting epoch and no RecordStats calls at all. +type EpochBandit interface { + Bandit + + // RecordEpoch delivers every arm's measurements for one reporting epoch. + // The report and its Stats slice are freshly allocated for each call and + // are never reused by the cache, so an implementation may retain them. + // + // The same non-blocking rule applies as to the rest of Bandit: this runs + // under the cache's write lock. + RecordEpoch(report EpochReport) +} diff --git a/lockfree_bench_test.go b/lockfree_bench_test.go new file mode 100644 index 0000000..61c056f --- /dev/null +++ b/lockfree_bench_test.go @@ -0,0 +1,250 @@ +package ascache + +import ( + "strconv" + "sync/atomic" + "testing" + "time" +) + +// freePolicy is a read-contention-free policy: its map is populated before the +// benchmark and never written during it, and its counters are atomic. Reads +// therefore take no lock at all. +// +// It exists to answer one question: of the cost of a parallel Get through an +// AdaptiveCache, how much is the cache's own RWMutex and how much is the +// underlying policy's lock? Measuring against a policy that contributes zero +// read contention isolates the cache's share, which is the only part a +// lock-free read path could remove. +type freePolicy[K comparable, V any] struct { + data map[K]V + cap int + policy PolicyType + hits atomic.Int64 + misses atomic.Int64 +} + +func newFreePolicy[K comparable, V any](policy PolicyType, capacity int) *freePolicy[K, V] { + return &freePolicy[K, V]{data: make(map[K]V, capacity), cap: capacity, policy: policy} +} + +// prefill populates the map before any concurrent reader exists. +func (p *freePolicy[K, V]) prefill(key K, value V) { p.data[key] = value } + +func (p *freePolicy[K, V]) Get(key K) (V, bool) { + v, ok := p.data[key] + if ok { + p.hits.Add(1) + } else { + p.misses.Add(1) + } + + return v, ok +} + +// Add is a no-op: the benchmark never writes, and a real write would race with +// the lock-free reads this type exists to model. +func (p *freePolicy[K, V]) Add(_ K, _ V) bool { return false } + +func (p *freePolicy[K, V]) Peek(key K) (V, bool) { v, ok := p.data[key]; return v, ok } +func (p *freePolicy[K, V]) Contains(key K) bool { _, ok := p.data[key]; return ok } +func (p *freePolicy[K, V]) Remove(_ K) bool { return false } +func (p *freePolicy[K, V]) Purge() {} +func (p *freePolicy[K, V]) Len() int { return len(p.data) } +func (p *freePolicy[K, V]) Cap() int { return p.cap } +func (p *freePolicy[K, V]) Resize(int) int { return 0 } +func (p *freePolicy[K, V]) GetType() PolicyType { return p.policy } + +func (p *freePolicy[K, V]) Keys() []K { + keys := make([]K, 0, len(p.data)) + for k := range p.data { + keys = append(keys, k) + } + + return keys +} + +func (p *freePolicy[K, V]) Values() []V { + vals := make([]V, 0, len(p.data)) + for _, v := range p.data { + vals = append(vals, v) + } + + return vals +} + +func (p *freePolicy[K, V]) GetStats() PolicyStats { + return PolicyStats{Hits: p.hits.Load(), Misses: p.misses.Load()} +} + +func (p *freePolicy[K, V]) ResetStats() { p.hits.Store(0); p.misses.Store(0) } + +const freeKeys = 1000 + +func freeKeySet() []string { + keys := make([]string, freeKeys) + for i := range keys { + keys[i] = "k" + strconv.Itoa(i) + } + + return keys +} + +// BenchmarkLockFloor_PolicyDirect is the floor: reading the policy with no +// adaptive layer in the way at all. Everything above this is what the cache +// costs. +func BenchmarkLockFloor_PolicyDirect(b *testing.B) { + keys := freeKeySet() + p := newFreePolicy[string, int](LRU, freeKeys*2) + for i, k := range keys { + p.prefill(k, i) + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + p.Get(keys[i%freeKeys]) + i++ + } + }) +} + +// BenchmarkLockFloor_ThroughCache is the same read through AdaptiveCache. The +// gap against the direct benchmark is the cache's RWMutex plus its bookkeeping +// - the entire budget a lock-free read path could recover. +func BenchmarkLockFloor_ThroughCache(b *testing.B) { + for _, sampled := range []bool{false, true} { + name := "sample=off" + rate := 0.0 + if sampled { + name, rate = "sample=0.05", 0.05 + } + + b.Run(name, func(b *testing.B) { + keys := freeKeySet() + + active := newFreePolicy[string, int](LRU, freeKeys*2) + shadow := newFreePolicy[string, int](LFU, freeKeys*2) + for i, k := range keys { + active.prefill(k, i) + shadow.prefill(k, i) + } + + cache, err := NewAdaptiveCache[string, int]( + []Policy[string, int]{active, shadow}, + &mockBandit{next: LRU}, + &Settings{ + EpochDuration: time.Hour, + EvictPartialCapacityFilling: true, + ShadowSampleRate: rate, + MinShadowCapacity: 8, + }) + if err != nil { + b.Fatalf("NewAdaptiveCache: %v", err) + } + b.Cleanup(func() { _ = cache.Close() }) + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + cache.Get(keys[i%freeKeys]) + i++ + } + }) + }) + } +} + +// BenchmarkLockFloor_RWMutexOnly isolates the read lock itself: the same +// parallel loop doing nothing but taking and releasing the cache's RWMutex. +// If this is close to the gap measured above, the lock is the cost; if it is +// far below, the cost is elsewhere and going lock-free would not recover it. +func BenchmarkLockFloor_RWMutexOnly(b *testing.B) { + keys := freeKeySet() + p := newFreePolicy[string, int](LRU, freeKeys*2) + for i, k := range keys { + p.prefill(k, i) + } + + cache, err := NewAdaptiveCache[string, int]( + []Policy[string, int]{p}, + &mockBandit{next: LRU}, + &Settings{EpochDuration: time.Hour, EvictPartialCapacityFilling: true}, + ) + if err != nil { + b.Fatalf("NewAdaptiveCache: %v", err) + } + b.Cleanup(func() { _ = cache.Close() }) + + // Taking and immediately releasing is the entire point: this measures the + // lock, not work done under it. + takeAndRelease := func() { + cache.mu.RLock() + defer cache.mu.RUnlock() + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + takeAndRelease() + } + }) +} + +// BenchmarkLockFloor_RealisticPolicy repeats the comparison with a policy that +// takes its own exclusive lock on Get, which is what a real LRU does: it must +// move the entry to the front, so reads mutate. This is the realistic estimate +// of what a lock-free read path would recover, because such a policy +// serialises regardless of what the cache above it does. +func BenchmarkLockFloor_RealisticPolicy(b *testing.B) { + keys := freeKeySet() + + b.Run("direct", func(b *testing.B) { + p := newBenchPolicy[string, int](LRU, freeKeys*2) + for i, k := range keys { + p.Add(k, i) + } + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + p.Get(keys[i%freeKeys]) + i++ + } + }) + }) + + b.Run("through cache, sampled", func(b *testing.B) { + active := newBenchPolicy[string, int](LRU, freeKeys*2) + shadow := newBenchPolicy[string, int](LFU, freeKeys*2) + + cache, err := NewAdaptiveCache[string, int]( + []Policy[string, int]{active, shadow}, + &mockBandit{next: LRU}, + &Settings{ + EpochDuration: time.Hour, + EvictPartialCapacityFilling: true, + ShadowSampleRate: 0.05, + MinShadowCapacity: 8, + }) + if err != nil { + b.Fatalf("NewAdaptiveCache: %v", err) + } + b.Cleanup(func() { _ = cache.Close() }) + + for i, k := range keys { + cache.Add(k, i) + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + cache.Get(keys[i%freeKeys]) + i++ + } + }) + }) +} diff --git a/migration.go b/migration.go index e5b86e8..2bb95e9 100644 --- a/migration.go +++ b/migration.go @@ -14,9 +14,13 @@ func (c *AdaptiveCache[K, V]) migrateData(from, to PolicyType) { c.clearMigrationState() switch c.settings.MigrationStrategy { - case MigrationCold: - // Purge zero-value shadow entries so callers never observe a cached - // zero as if it were a real value. + // Cold is the default, so it is the default arm rather than a named case. + // Every strategy has to purge the target's zero-value shadow entries, and + // a strategy value this switch did not recognise would otherwise fall + // straight through and skip that - leaving the new active policy holding + // shadow zeros and serving them to callers as real data, which is the one + // invariant this library is built on. + default: c.policies[to].Purge() return diff --git a/models.go b/models.go index 78a9225..3503a6f 100644 --- a/models.go +++ b/models.go @@ -38,7 +38,7 @@ const ( // MigrationCold starts the new active policy from an empty state. This is // the simplest strategy but causes a temporary cache-miss spike after every // policy switch. - MigrationCold MigrationStrategy = iota + MigrationCold MigrationStrategy = iota + 1 // MigrationWarm copies all key/value pairs from the old active policy into // the new active policy at switch time. Shadow zero-value entries in the @@ -82,3 +82,48 @@ type ShadowStats struct { Hits int64 Misses int64 } + +// EpochReport is one reporting epoch's complete set of measurements, delivered +// to a bandit that implements EpochBandit. +// +// It exists because the per-arm ShadowStats stream loses three things a bandit +// coordinating with anything outside the process needs: which epoch the +// numbers belong to, where the epoch ends, and which arm was active. +type EpochReport struct { + // EpochID is the cache's epoch counter at the time of the report. It + // counts ticks, including those the EvictPartialCapacityFilling gate + // skipped, so consecutive reports are not necessarily consecutive IDs. + // It is process-local: two caches in two processes share no origin, so it + // orders one cache's reports and nothing more. + EpochID int64 + + // Active is the policy that was serving traffic during the epoch. Its + // counts were measured at full capacity over the sampled substream; every + // other arm's were measured on a miniature of that capacity. The rates are + // comparable by construction, but not identically measured, and pooling + // one arm's active-role numbers with another's shadow-role numbers gives + // the active one a systematic advantage. + Active PolicyType + + // Stats holds one entry per arm, ordered by PolicyType so the report is + // reproducible, and carries the same counts RecordStats would have + // delivered individually. + Stats []ShadowStats + + // Capacity is the nominal capacity of the active policy: the size the + // cache actually serves at. + // + // It is reported because a hit rate only means something alongside the + // capacity it was measured at. A bandit pooling evidence from several + // caches has to refuse to pool measurements taken at different sizes - + // otherwise it averages a 1000-entry cache's hit rate with a 100-entry + // cache's and acts on a number that describes neither. + Capacity int + + // SampleRate is the fraction of the keyspace the measurements cover, 1 + // when Settings.ShadowSampleRate is off. Like Capacity, it is part of what + // makes two caches' numbers comparable: shadows run as miniatures scaled + // to this rate, so two caches sampling differently are simulating + // different things. + SampleRate float64 +} diff --git a/scripts/release-check.sh b/scripts/release-check.sh index d32a104..bf5a9da 100755 --- a/scripts/release-check.sh +++ b/scripts/release-check.sh @@ -26,11 +26,11 @@ MODULE=github.com/sshaplygin/as-cache # Modules intended for publication. bench and examples/* are deliberately # excluded: they are internal, nothing imports them, and their placeholder # requires are harmless. -PUBLISHABLE=(. lfu policies policies/arc policies/tinylfu metrics) +PUBLISHABLE=(. lfu policies policies/arc policies/tinylfu metrics bandit bandit/redis) # Tagging order. A module cannot require a real version of a sibling until that # sibling is tagged, so releases go bottom-up through the dependency graph. -TAG_ORDER=(. lfu policies policies/arc policies/tinylfu metrics) +TAG_ORDER=(. lfu policies policies/arc policies/tinylfu metrics bandit bandit/redis) fail=0 diff --git a/settings.go b/settings.go index fe14213..48b80e1 100644 --- a/settings.go +++ b/settings.go @@ -3,6 +3,7 @@ package ascache import ( "context" "fmt" + "slices" "time" ) @@ -108,6 +109,7 @@ func NewAdaptiveCache[K comparable, V any]( } availablePolicies := make(map[PolicyType]Policy[K, V], len(policies)) + policyOrder := make([]PolicyType, 0, len(policies)) for _, policy := range policies { if policy == nil { return nil, ErrNilPolicy @@ -116,12 +118,15 @@ func NewAdaptiveCache[K comparable, V any]( return nil, fmt.Errorf("%w: %s", ErrDuplicatePolicy, policy.GetType()) } availablePolicies[policy.GetType()] = policy + policyOrder = append(policyOrder, policy.GetType()) } + slices.Sort(policyOrder) ctx, cancel := context.WithCancel(context.Background()) ac := &AdaptiveCache[K, V]{ policies: availablePolicies, + policyOrder: policyOrder, activePolicy: policies[0].GetType(), bandit: bandit, epochTicker: time.NewTicker(settings.EpochDuration), @@ -130,6 +135,13 @@ func NewAdaptiveCache[K comparable, V any]( settings: settings, } + // A bandit that wants whole epochs gets them instead of the per-arm + // stream, never as well as: RecordEpoch carries the same counts, so + // delivering both would double every arm's evidence. + if epochBandit, ok := bandit.(EpochBandit); ok { + ac.epochBandit = epochBandit + } + sampleRate := settings.ShadowSampleRate if sampleRate <= 0 { sampleRate = 1