diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb47a45..0272af9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,22 @@ jobs: working-directory: ${{ matrix.module }} run: go test -race -short -count=1 ./... + # The examples carry no tests, so without this they are checked only for + # compiling. Both ship a -smoke mode that drives the cache and exits, so + # a runtime fault in example code fails CI instead of the first reader. + - name: run the example + if: startsWith(matrix.module, 'examples/') + working-directory: ${{ matrix.module }} + run: go run . -smoke + + - name: run the example across every migration strategy + if: matrix.module == 'examples/migration' + working-directory: ${{ matrix.module }} + run: | + for strategy in cold warm gradual; do + go run . -strategy=$strategy -epoch=1 -smoke + done + # Two things block a release and show up in no other check: a module missing # its licence, and a sibling required at a placeholder version. The second is # invisible locally, because a replace directive resolves it - and replace diff --git a/docs/design.md b/docs/design.md index 2c2f751..4aa61d2 100644 --- a/docs/design.md +++ b/docs/design.md @@ -121,10 +121,40 @@ Both methods are called under the cache's write lock, so **an implementation must not block**. Go's `RWMutex` queues new readers behind a waiting writer, so a slow bandit stalls every `Get` in the process for its duration. -A full Thompson Sampling adapter using `stitchfix/mab` is provided in -[examples/basic/main.go](../examples/basic/main.go). Ready-made bandits live in -the `bandit` module: `bandit.NewThompson` for a single process, -[`bandit.NewDistributed`](fleet.md) for a fleet. +Ready-made bandits live in the `bandit` module: `bandit.NewThompson` for a +single process, [`bandit.NewDistributed`](fleet.md) for a fleet. Both examples +use the first of those. + +### Plugging in a third-party bandit + +The interface is two methods, so wrapping an outside implementation is an +adapter of about this size. Illustration only -- it names no real library and +is not compiled: + +```go +type adapter struct { + arms []ascache.PolicyType + ext *externalBandit // your library's type +} + +func (a *adapter) RecordStats(s ascache.ShadowStats) { + // Deliver one arm's epoch result. Called once per arm per epoch. + a.ext.Observe(s.Policy, s.Hits, s.Misses) +} + +func (a *adapter) SelectPolicy() ascache.PolicyType { + // Must return promptly and must not block: see the note above. + // Returning Undefined -- or any policy the cache does not hold -- + // means "no change", which is the right answer before the first epoch. + return a.ext.Choose(a.arms) +} +``` + +Two things catch people out. Ranging a map while drawing random numbers makes +the result depend on map iteration order, so a seeded run stops being +reproducible -- keep arms in a slice. And an arm that saw no requests in an +epoch is not an arm that scored zero; decide deliberately which one your +implementation reports. ## What is not done diff --git a/docs/getting-started.md b/docs/getting-started.md index ae258c6..d0d038a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -39,7 +39,7 @@ move the numbers, is [configuration](configuration.md). Two runnable examples ship with the repository: - [examples/basic/main.go](../examples/basic/main.go) — an HTTP server over an - adaptive cache, with a Thompson Sampling bandit built on `stitchfix/mab`. + adaptive cache, driven by `bandit.NewThompson`. - [examples/migration/main.go](../examples/migration/main.go) — the three migration strategies side by side. @@ -83,6 +83,5 @@ full interface definitions are in [design](design.md#implementing-the-bandit-int - [hashicorp/golang-lru](https://github.com/hashicorp/golang-lru) — the LRU, 2Q and ARC implementations behind three of the arms - [maypok86/otter](https://github.com/maypok86/otter) — the W-TinyLFU arm - [scalalang2/golang-fifo](https://github.com/scalalang2/golang-fifo) — the S3-FIFO and SIEVE arms -- [stitchfix/mab](https://github.com/stitchfix/mab) — Thompson Sampling, used by the basic example - [dgraph-io/ristretto](https://github.com/dgraph-io/ristretto) — an early influence on the idea of measuring admission rather than assuming it - [redis/go-redis](https://github.com/redis/go-redis) and [Valkey](https://valkey.io/) — the client and store behind the distributed bandit diff --git a/examples/basic/go.mod b/examples/basic/go.mod index 1558a8e..c902519 100644 --- a/examples/basic/go.mod +++ b/examples/basic/go.mod @@ -6,14 +6,12 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.6 github.com/sshaplygin/as-cache v0.3.1 github.com/sshaplygin/as-cache/lfu v0.3.1 - github.com/stitchfix/mab v0.1.1 ) -require ( - golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 // indirect - gonum.org/v1/gonum v0.8.2 // indirect -) +require github.com/sshaplygin/as-cache/bandit v0.3.1 replace github.com/sshaplygin/as-cache => ../.. replace github.com/sshaplygin/as-cache/lfu => ../../lfu + +replace github.com/sshaplygin/as-cache/bandit => ../../bandit diff --git a/examples/basic/go.sum b/examples/basic/go.sum index 3b42e28..613248e 100644 --- a/examples/basic/go.sum +++ b/examples/basic/go.sum @@ -1,61 +1,10 @@ -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/hashicorp/golang-lru/v2 v2.0.6 h1:3xi/Cafd1NaoEnS/yDssIiuVeDVywU0QdFGl3aQaQHM= github.com/hashicorp/golang-lru/v2 v2.0.6/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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/stitchfix/mab v0.1.1 h1:UMijkS857AyLd8VDqvwm+OBlGm3Pni70CTzq51Hs/Vw= -github.com/stitchfix/mab v0.1.1/go.mod h1:8XNtsDrZu9hDAixMR4Up33NUNalw3UZ5urBC4LYhYuY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= -gonum.org/v1/gonum v0.8.2 h1:CCXrcPKiGGotvnN6jfUsKk4rRqm7q09/YbKb5xCEvtM= -gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/examples/basic/main.go b/examples/basic/main.go index 8caa805..4fd95dc 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -2,19 +2,19 @@ package main import ( "context" + "flag" "fmt" "log" "net/http" "os" "os/signal" - "sync" "time" ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" slfu "github.com/sshaplygin/as-cache/lfu" hlru "github.com/hashicorp/golang-lru/v2" - "github.com/stitchfix/mab" ) type UserProfile struct { @@ -24,6 +24,9 @@ type UserProfile struct { } func main() { + smoke := flag.Bool("smoke", false, "exercise the cache and exit, without serving") + flag.Parse() + lruCache, err := hlru.New[string, *UserProfile](100) if err != nil { panic(err) @@ -39,15 +42,15 @@ func main() { ascache.NewCache(lfuCache, ascache.LFU, 100), } - armNames := []ascache.PolicyType{ascache.LRU, ascache.LFU} - - myBandit := NewThompsonBanditAdapter( - armNames, - ) + // Thompson Sampling over the two arms above. The first argument discounts + // older epochs (0.9 keeps roughly the last ten in view) so the bandit can + // change its mind when traffic changes; the second seeds its draws, which + // makes a run reproducible. + selector := bandit.NewThompson(0.9, 1) cache, err := ascache.NewAdaptiveCache( policiesList, - myBandit, + selector, &ascache.Settings{ EpochDuration: 5 * time.Minute, }, @@ -57,6 +60,11 @@ func main() { } defer cache.Close() + if *smoke { + runSmoke(cache) + return + } + mux := http.NewServeMux() mux.HandleFunc("/get", func(w http.ResponseWriter, r *http.Request) { @@ -133,103 +141,23 @@ func main() { log.Println("server stopped") } -func NewThompsonBanditAdapter(armNames []ascache.PolicyType) *StitchFixBanditAdapter { - rewardStore := NewCacheRewardSource(armNames) - - return &StitchFixBanditAdapter{ - bandit: &mab.Bandit{ - RewardSource: rewardStore, - Strategy: mab.NewThompson(nil), - Sampler: mab.NewSha1Sampler(), - }, - rewardStore: rewardStore, - armNames: armNames, - unitID: "adaptive-selection-cache", - } -} - -type armStats struct { - // Beta distribution parameters: Alpha = Hits + 1, Beta = Misses + 1. - Hits float64 - Misses float64 -} - -// CacheRewardSource implements the mab.RewardSource interface. -// It stores per-arm statistics supplied by the MAB adapter. -type CacheRewardSource struct { - mu sync.RWMutex - stats map[ascache.PolicyType]*armStats -} - -func NewCacheRewardSource(armNames []ascache.PolicyType) *CacheRewardSource { - crs := &CacheRewardSource{ - stats: make(map[ascache.PolicyType]*armStats, len(armNames)), - } - for _, name := range armNames { - crs.stats[name] = &armStats{} - } - return crs -} - -// GetRewards is the "Pull" method called by stitchfix/mab when it needs to -// make an arm-selection decision. -func (crs *CacheRewardSource) GetRewards(ctx context.Context, banditContext interface{}) ([]mab.Dist, error) { - crs.mu.RLock() - defer crs.mu.RUnlock() - - distributions := make([]mab.Dist, len(crs.stats)) - for i, arm := range crs.stats { - distributions[i] = mab.Beta(arm.Hits+1, arm.Misses+1) - } - - return distributions, nil -} - -// updateStats is the "Push" method called by the MAB adapter to record -// observed hits and misses for a given policy. -func (crs *CacheRewardSource) updateStats(policy ascache.PolicyType, hits, misses int64) { - crs.mu.Lock() - defer crs.mu.Unlock() - - s, ok := crs.stats[policy] - if !ok { - return +// runSmoke exercises the cache the way the HTTP handlers do, so CI can prove +// this example runs rather than only that it compiles. +func runSmoke(cache *ascache.AdaptiveCache[string, *UserProfile]) { + const n = 100 + for i := range n { + key := fmt.Sprintf("user-%d", i) + cache.Add(key, &UserProfile{Name: key, Email: key + "@example.com", CreatedAt: time.Now()}) } - s.Hits += float64(hits) - s.Misses += float64(misses) -} - -// ===================================================================== -// 2. ADAPTER IMPLEMENTING THE `Bandit` INTERFACE -// ===================================================================== - -// StitchFixBanditAdapter wraps the stitchfix bandit and implements the -// ascache.Bandit interface. -type StitchFixBanditAdapter struct { - bandit *mab.Bandit - rewardStore *CacheRewardSource - armNames []ascache.PolicyType - // unitID is a stable identifier for the single shared cache instance, - // used by stitchfix/mab for deterministic arm selection. - unitID string -} - -// RecordStats implements the ascache.Bandit "Push" interface. -// It forwards shadow-cache statistics into the reward store. -func (s *StitchFixBanditAdapter) RecordStats(stats ascache.ShadowStats) { - s.rewardStore.updateStats(stats.Policy, stats.Hits, stats.Misses) -} - -// SelectPolicy implements the ascache.Bandit "Pull" interface. -// It asks the stitchfix bandit to select the best arm by sampling from -// the Beta distributions stored in the reward source. -func (s *StitchFixBanditAdapter) SelectPolicy() ascache.PolicyType { - selectedArm, err := s.bandit.SelectArm(context.Background(), s.unitID, s.armNames) - if err != nil { - // Fall back to the first arm on any error. - return s.armNames[0] + hits := 0 + for i := range n { + if _, ok := cache.Get(fmt.Sprintf("user-%d", i)); ok { + hits++ + } } - return s.armNames[selectedArm.Arm] + stats := cache.Stats() + log.Printf("smoke: %d/%d keys readable, active policy %s, hits %d misses %d", + hits, n, cache.ActivePolicy(), stats.Hits, stats.Misses) } diff --git a/examples/migration/go.mod b/examples/migration/go.mod index f562257..fcc2f08 100644 --- a/examples/migration/go.mod +++ b/examples/migration/go.mod @@ -6,14 +6,12 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.6 github.com/sshaplygin/as-cache v0.3.1 github.com/sshaplygin/as-cache/lfu v0.3.1 - github.com/stitchfix/mab v0.1.1 ) -require ( - golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 // indirect - gonum.org/v1/gonum v0.8.2 // indirect -) +require github.com/sshaplygin/as-cache/bandit v0.3.1 replace github.com/sshaplygin/as-cache => ../.. replace github.com/sshaplygin/as-cache/lfu => ../../lfu + +replace github.com/sshaplygin/as-cache/bandit => ../../bandit diff --git a/examples/migration/go.sum b/examples/migration/go.sum index 3b42e28..613248e 100644 --- a/examples/migration/go.sum +++ b/examples/migration/go.sum @@ -1,61 +1,10 @@ -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/hashicorp/golang-lru/v2 v2.0.6 h1:3xi/Cafd1NaoEnS/yDssIiuVeDVywU0QdFGl3aQaQHM= github.com/hashicorp/golang-lru/v2 v2.0.6/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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/stitchfix/mab v0.1.1 h1:UMijkS857AyLd8VDqvwm+OBlGm3Pni70CTzq51Hs/Vw= -github.com/stitchfix/mab v0.1.1/go.mod h1:8XNtsDrZu9hDAixMR4Up33NUNalw3UZ5urBC4LYhYuY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= -gonum.org/v1/gonum v0.8.2 h1:CCXrcPKiGGotvnN6jfUsKk4rRqm7q09/YbKb5xCEvtM= -gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/examples/migration/main.go b/examples/migration/main.go index ec49a03..33f4d57 100644 --- a/examples/migration/main.go +++ b/examples/migration/main.go @@ -29,98 +29,12 @@ import ( "time" hlru "github.com/hashicorp/golang-lru/v2" - "github.com/stitchfix/mab" ascache "github.com/sshaplygin/as-cache" + "github.com/sshaplygin/as-cache/bandit" slfu "github.com/sshaplygin/as-cache/lfu" ) -// ─── Thompson Sampling reward source ───────────────────────────────────────── - -type armStats struct { - Hits float64 - Misses float64 -} - -// cacheRewardSource implements mab.RewardSource. It accumulates per-policy -// hit/miss counters pushed by the AdaptiveCache bandit hooks. -type cacheRewardSource struct { - mu sync.RWMutex - arms []ascache.PolicyType - stats map[ascache.PolicyType]*armStats -} - -func newCacheRewardSource(arms []ascache.PolicyType) *cacheRewardSource { - crs := &cacheRewardSource{ - arms: arms, - stats: make(map[ascache.PolicyType]*armStats, len(arms)), - } - for _, a := range arms { - crs.stats[a] = &armStats{} - } - return crs -} - -// GetRewards returns Beta distributions for each arm in arm-index order. -func (crs *cacheRewardSource) GetRewards(_ context.Context, _ interface{}) ([]mab.Dist, error) { - crs.mu.RLock() - defer crs.mu.RUnlock() - - dists := make([]mab.Dist, len(crs.arms)) - for i, arm := range crs.arms { - s := crs.stats[arm] - dists[i] = mab.Beta(s.Hits+1, s.Misses+1) - } - return dists, nil -} - -func (crs *cacheRewardSource) update(p ascache.PolicyType, hits, misses int64) { - crs.mu.Lock() - defer crs.mu.Unlock() - - if s, ok := crs.stats[p]; ok { - s.Hits += float64(hits) - s.Misses += float64(misses) - } -} - -// ─── Bandit adapter ─────────────────────────────────────────────────────────── - -// stitchfixAdapter wraps the stitchfix/mab Thompson Sampling bandit and -// implements the ascache.Bandit interface. -type stitchfixAdapter struct { - bandit *mab.Bandit - rewardStore *cacheRewardSource - arms []ascache.PolicyType - unitID string -} - -func newStitchfixAdapter(arms []ascache.PolicyType) *stitchfixAdapter { - rs := newCacheRewardSource(arms) - return &stitchfixAdapter{ - bandit: &mab.Bandit{ - RewardSource: rs, - Strategy: mab.NewThompson(nil), - Sampler: mab.NewSha1Sampler(), - }, - rewardStore: rs, - arms: arms, - unitID: "migration-example", - } -} - -func (a *stitchfixAdapter) RecordStats(stats ascache.ShadowStats) { - a.rewardStore.update(stats.Policy, stats.Hits, stats.Misses) -} - -func (a *stitchfixAdapter) SelectPolicy() ascache.PolicyType { - result, err := a.bandit.SelectArm(context.Background(), a.unitID, a.arms) - if err != nil { - return a.arms[0] - } - return a.arms[result.Arm] -} - // ─── Controllable bandit ────────────────────────────────────────────────────── // controllableBandit wraps an adaptive bandit and allows the demo server to @@ -128,7 +42,7 @@ func (a *stitchfixAdapter) SelectPolicy() ascache.PolicyType { type controllableBandit struct { mu sync.Mutex forced ascache.PolicyType - inner *stitchfixAdapter + inner ascache.Bandit } func (b *controllableBandit) RecordStats(stats ascache.ShadowStats) { @@ -405,6 +319,7 @@ func main() { strategyFlag := flag.String("strategy", "warm", "migration strategy: cold | warm | gradual") epochSec := flag.Int("epoch", 5, "epoch duration in seconds") addr := flag.String("addr", ":8081", "listen address") + smoke := flag.Bool("smoke", false, "exercise the cache and exit, without serving") flag.Parse() logger := log.New(os.Stdout, "[migration] ", log.LstdFlags) @@ -433,9 +348,11 @@ func main() { logger.Fatalf("LFU init: %v", err) } - arms := []ascache.PolicyType{ascache.LRU, ascache.LFU} - inner := newStitchfixAdapter(arms) - bandit := &controllableBandit{inner: inner} + // Thompson Sampling over LRU and LFU. The discount keeps roughly the last + // ten epochs in view so the bandit can follow a change in traffic; the seed + // makes a run reproducible. The demo wraps it so /switch can force the next + // selection. + selector := &controllableBandit{inner: bandit.NewThompson(0.9, 1)} policies := []ascache.Policy[string, string]{ ascache.NewCache(lruCache, ascache.LRU, 100), @@ -444,7 +361,7 @@ func main() { cache, err := ascache.NewAdaptiveCache( policies, - bandit, + selector, &ascache.Settings{ EpochDuration: epochDur, EvictPartialCapacityFilling: true, @@ -460,12 +377,17 @@ func main() { s := &server{ cache: cache, - bandit: bandit, + bandit: selector, epochDur: epochDur, strategy: migrationStrategy, logger: logger, } + if *smoke { + s.runSmoke() + return + } + mux := http.NewServeMux() mux.HandleFunc("/get", s.handleGet) mux.HandleFunc("/set", s.handleSet) @@ -505,3 +427,32 @@ func main() { } logger.Println("stopped") } + +// runSmoke exercises the cache through a forced policy switch, so CI can prove +// this example runs -- and that the configured migration strategy carries keys +// across a switch -- rather than only that it compiles. +func (s *server) runSmoke() { + const n = 100 + for i := range n { + key := fmt.Sprintf("key-%d", i) + s.cache.Add(key, "value-"+key) + } + + before := policyName(s.cache.ActivePolicy()) + target := ascache.LFU + if s.cache.ActivePolicy() == ascache.LFU { + target = ascache.LRU + } + s.bandit.forceNext(target) + time.Sleep(s.epochDur + 500*time.Millisecond) + + readable := 0 + for i := range n { + if _, ok := s.cache.Get(fmt.Sprintf("key-%d", i)); ok { + readable++ + } + } + + s.logger.Printf("smoke: strategy=%s %s -> %s %d/%d keys readable after the switch", + strategyName(s.strategy), before, policyName(s.cache.ActivePolicy()), readable, n) +}