Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 34 additions & 4 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 1 addition & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
8 changes: 3 additions & 5 deletions examples/basic/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
51 changes: 0 additions & 51 deletions examples/basic/go.sum
Original file line number Diff line number Diff line change
@@ -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=
134 changes: 31 additions & 103 deletions examples/basic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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,
},
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
8 changes: 3 additions & 5 deletions examples/migration/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading