evan.hong 4 years ago
commit
4c059b4138
10 changed files with 783 additions and 0 deletions
  1. 22 0
      .gitignore
  2. 0 0
      .travis.yml
  3. 21 0
      LICENSE
  4. 17 0
      README.md
  5. 220 0
      epsilon_greedy.go
  6. 40 0
      epsilon_value_calculators.go
  7. 13 0
      example_test.go
  8. 62 0
      host_entry.go
  9. 243 0
      hostpool.go
  10. 145 0
      hostpool_test.go

+ 22 - 0
.gitignore

@@ -0,0 +1,22 @@
+# Compiled Object files, Static and Dynamic libs (Shared Objects)
+*.o
+*.a
+*.so
+
+# Folders
+_obj
+_test
+
+# Architecture specific extensions/prefixes
+*.[568vq]
+[568vq].out
+
+*.cgo1.go
+*.cgo2.c
+_cgo_defun.c
+_cgo_gotypes.go
+_cgo_export.*
+
+_testmain.go
+
+*.exe

+ 0 - 0
.travis.yml


+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Bitly
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 17 - 0
README.md

@@ -0,0 +1,17 @@
+go-hostpool
+===========
+
+A Go package to intelligently and flexibly pool among multiple hosts from your Go application.
+Host selection can operate in round robin or epsilon greedy mode, and unresponsive hosts are
+avoided.
+Usage example:
+
+```go
+hp := hostpool.NewEpsilonGreedy([]string{"a", "b"}, 0, &hostpool.LinearEpsilonValueCalculator{})
+hostResponse := hp.Get()
+hostname := hostResponse.Host()
+err := _ // (make a request with hostname)
+hostResponse.Mark(err)
+```
+
+View more detailed documentation on [godoc.org](http://godoc.org/github.com/bitly/go-hostpool)

+ 220 - 0
epsilon_greedy.go

@@ -0,0 +1,220 @@
+package hostpool
+
+import (
+	"log"
+	"math/rand"
+	"time"
+)
+
+type epsilonHostPoolResponse struct {
+	standardHostPoolResponse
+	started time.Time
+	ended   time.Time
+}
+
+func (r *epsilonHostPoolResponse) Mark(err error) {
+	r.Do(func() {
+		r.ended = time.Now()
+		doMark(err, r)
+	})
+}
+
+type epsilonGreedyHostPool struct {
+	standardHostPool               // TODO - would be nifty if we could embed HostPool and Locker interfaces
+	epsilon                float32 // this is our exploration factor
+	decayDuration          time.Duration
+	EpsilonValueCalculator // embed the epsilonValueCalculator
+	timer
+	quit chan bool
+}
+
+// Construct an Epsilon Greedy HostPool
+//
+// Epsilon Greedy is an algorithm that allows HostPool not only to track failure state,
+// but also to learn about "better" options in terms of speed, and to pick from available hosts
+// based on how well they perform. This gives a weighted request rate to better
+// performing hosts, while still distributing requests to all hosts (proportionate to their performance).
+// The interface is the same as the standard HostPool, but be sure to mark the HostResponse immediately
+// after executing the request to the host, as that will stop the implicitly running request timer.
+//
+// A good overview of Epsilon Greedy is here http://stevehanov.ca/blog/index.php?id=132
+//
+// To compute the weighting scores, we perform a weighted average of recent response times, over the course of
+// `decayDuration`. decayDuration may be set to 0 to use the default value of 5 minutes
+// We then use the supplied EpsilonValueCalculator to calculate a score from that weighted average response time.
+func NewEpsilonGreedy(hosts []string, decayDuration time.Duration, calc EpsilonValueCalculator) HostPool {
+
+	if decayDuration <= 0 {
+		decayDuration = defaultDecayDuration
+	}
+	stdHP := New(hosts).(*standardHostPool)
+	p := &epsilonGreedyHostPool{
+		standardHostPool:       *stdHP,
+		epsilon:                float32(initialEpsilon),
+		decayDuration:          decayDuration,
+		EpsilonValueCalculator: calc,
+		timer: &realTimer{},
+		quit:  make(chan bool),
+	}
+
+	// allocate structures
+	for _, h := range p.hostList {
+		h.epsilonCounts = make([]int64, epsilonBuckets)
+		h.epsilonValues = make([]int64, epsilonBuckets)
+	}
+	go p.epsilonGreedyDecay()
+	return p
+}
+
+func (p *epsilonGreedyHostPool) Close() {
+	// No need to do p.quit <- true as close(p.quit) does the trick.
+	close(p.quit)
+}
+
+func (p *epsilonGreedyHostPool) SetEpsilon(newEpsilon float32) {
+	p.Lock()
+	defer p.Unlock()
+	p.epsilon = newEpsilon
+}
+
+func (p *epsilonGreedyHostPool) SetHosts(hosts []string) {
+	p.Lock()
+	defer p.Unlock()
+	p.standardHostPool.setHosts(hosts)
+	for _, h := range p.hostList {
+		h.epsilonCounts = make([]int64, epsilonBuckets)
+		h.epsilonValues = make([]int64, epsilonBuckets)
+	}
+}
+
+func (p *epsilonGreedyHostPool) epsilonGreedyDecay() {
+	durationPerBucket := p.decayDuration / epsilonBuckets
+	ticker := time.NewTicker(durationPerBucket)
+	for {
+		select {
+		case <-p.quit:
+			ticker.Stop()
+			return
+		case <-ticker.C:
+			p.performEpsilonGreedyDecay()
+		}
+	}
+}
+func (p *epsilonGreedyHostPool) performEpsilonGreedyDecay() {
+	p.Lock()
+	for _, h := range p.hostList {
+		h.epsilonIndex += 1
+		h.epsilonIndex = h.epsilonIndex % epsilonBuckets
+		h.epsilonCounts[h.epsilonIndex] = 0
+		h.epsilonValues[h.epsilonIndex] = 0
+	}
+	p.Unlock()
+}
+
+func (p *epsilonGreedyHostPool) Get() HostPoolResponse {
+	p.Lock()
+	defer p.Unlock()
+	host := p.getEpsilonGreedy()
+	if host == "" {
+		return nil
+	}
+
+	started := time.Now()
+	return &epsilonHostPoolResponse{
+		standardHostPoolResponse: standardHostPoolResponse{host: host, pool: p},
+		started:                  started,
+	}
+}
+
+func (p *epsilonGreedyHostPool) getEpsilonGreedy() string {
+	var hostToUse *hostEntry
+
+	// this is our exploration phase
+	if rand.Float32() < p.epsilon {
+		p.epsilon = p.epsilon * epsilonDecay
+		if p.epsilon < minEpsilon {
+			p.epsilon = minEpsilon
+		}
+		return p.getRoundRobin()
+	}
+
+	// calculate values for each host in the 0..1 range (but not ormalized)
+	var possibleHosts []*hostEntry
+	now := time.Now()
+	var sumValues float64
+	for _, h := range p.hostList {
+		if h.canTryHost(now) {
+			v := h.getWeightedAverageResponseTime()
+			if v > 0 {
+				ev := p.CalcValueFromAvgResponseTime(v)
+				h.epsilonValue = ev
+				sumValues += ev
+				possibleHosts = append(possibleHosts, h)
+			}
+		}
+	}
+
+	if len(possibleHosts) != 0 {
+		// now normalize to the 0..1 range to get a percentage
+		for _, h := range possibleHosts {
+			h.epsilonPercentage = h.epsilonValue / sumValues
+		}
+
+		// do a weighted random choice among hosts
+		ceiling := 0.0
+		pickPercentage := rand.Float64()
+		for _, h := range possibleHosts {
+			ceiling += h.epsilonPercentage
+			if pickPercentage <= ceiling {
+				hostToUse = h
+				break
+			}
+		}
+	}
+
+	if hostToUse == nil {
+		if len(possibleHosts) != 0 {
+			log.Println("Failed to randomly choose a host, Dan loses")
+		}
+
+		return p.getRoundRobin()
+	}
+
+	if hostToUse.dead {
+		hostToUse.willRetryHost(p.maxRetryInterval)
+	}
+	return hostToUse.host
+}
+
+func (p *epsilonGreedyHostPool) markSuccess(hostR HostPoolResponse) {
+	// first do the base markSuccess - a little redundant with host lookup but cleaner than repeating logic
+	p.standardHostPool.markSuccess(hostR)
+	eHostR, ok := hostR.(*epsilonHostPoolResponse)
+	if !ok {
+		log.Printf("Incorrect type in eps markSuccess!") // TODO reflection to print out offending type
+		return
+	}
+	host := eHostR.host
+	duration := p.between(eHostR.started, eHostR.ended)
+
+	p.Lock()
+	defer p.Unlock()
+	h, ok := p.hosts[host]
+	if !ok {
+		log.Fatalf("host %s not in HostPool %v", host, p.Hosts())
+	}
+	h.epsilonCounts[h.epsilonIndex]++
+	h.epsilonValues[h.epsilonIndex] += int64(duration.Seconds() * 1000)
+}
+
+// --- timer: this just exists for testing
+
+type timer interface {
+	between(time.Time, time.Time) time.Duration
+}
+
+type realTimer struct{}
+
+func (rt *realTimer) between(start time.Time, end time.Time) time.Duration {
+	return end.Sub(start)
+}

+ 40 - 0
epsilon_value_calculators.go

@@ -0,0 +1,40 @@
+package hostpool
+
+// --- Value Calculators -----------------
+
+import (
+	"math"
+)
+
+// --- Definitions -----------------------
+
+// Structs implementing this interface are used to convert the average response time for a host
+// into a score that can be used to weight hosts in the epsilon greedy hostpool. Lower response
+// times should yield higher scores (we want to select the faster hosts more often) The default
+// LinearEpsilonValueCalculator just uses the reciprocal of the response time. In practice, any
+// decreasing function from the positive reals to the positive reals should work.
+type EpsilonValueCalculator interface {
+	CalcValueFromAvgResponseTime(float64) float64
+}
+
+type LinearEpsilonValueCalculator struct{}
+type LogEpsilonValueCalculator struct{ LinearEpsilonValueCalculator }
+type PolynomialEpsilonValueCalculator struct {
+	LinearEpsilonValueCalculator
+	Exp float64 // the exponent to which we will raise the value to reweight
+}
+
+// -------- Methods -----------------------
+
+func (c *LinearEpsilonValueCalculator) CalcValueFromAvgResponseTime(v float64) float64 {
+	return 1.0 / v
+}
+
+func (c *LogEpsilonValueCalculator) CalcValueFromAvgResponseTime(v float64) float64 {
+	// we need to add 1 to v so that this will be defined on all positive floats
+	return c.LinearEpsilonValueCalculator.CalcValueFromAvgResponseTime(math.Log(v + 1.0))
+}
+
+func (c *PolynomialEpsilonValueCalculator) CalcValueFromAvgResponseTime(v float64) float64 {
+	return c.LinearEpsilonValueCalculator.CalcValueFromAvgResponseTime(math.Pow(v, c.Exp))
+}

+ 13 - 0
example_test.go

@@ -0,0 +1,13 @@
+package hostpool
+
+import (
+	"github.com/bitly/go-hostpool"
+)
+
+func ExampleNewEpsilonGreedy() {
+	hp := hostpool.NewEpsilonGreedy([]string{"a", "b"}, 0, &hostpool.LinearEpsilonValueCalculator{})
+	hostResponse := hp.Get()
+	hostname := hostResponse.Host()
+	err := nil // (make a request with hostname)
+	hostResponse.Mark(err)
+}

+ 62 - 0
host_entry.go

@@ -0,0 +1,62 @@
+package hostpool
+
+import (
+	"time"
+)
+
+// --- hostEntry - this is due to get upgraded
+
+type hostEntry struct {
+	host              string
+	nextRetry         time.Time
+	retryCount        int16
+	retryDelay        time.Duration
+	dead              bool
+	epsilonCounts     []int64
+	epsilonValues     []int64
+	epsilonIndex      int
+	epsilonValue      float64
+	epsilonPercentage float64
+}
+
+func (h *hostEntry) canTryHost(now time.Time) bool {
+	if !h.dead {
+		return true
+	}
+	if h.nextRetry.Before(now) {
+		return true
+	}
+	return false
+}
+
+func (h *hostEntry) willRetryHost(maxRetryInterval time.Duration) {
+	h.retryCount += 1
+	newDelay := h.retryDelay * 2
+	if newDelay < maxRetryInterval {
+		h.retryDelay = newDelay
+	} else {
+		h.retryDelay = maxRetryInterval
+	}
+	h.nextRetry = time.Now().Add(h.retryDelay)
+}
+
+func (h *hostEntry) getWeightedAverageResponseTime() float64 {
+	var value float64
+	var lastValue float64
+
+	// start at 1 so we start with the oldest entry
+	for i := 1; i <= epsilonBuckets; i += 1 {
+		pos := (h.epsilonIndex + i) % epsilonBuckets
+		bucketCount := h.epsilonCounts[pos]
+		// Changing the line below to what I think it should be to get the weights right
+		weight := float64(i) / float64(epsilonBuckets)
+		if bucketCount > 0 {
+			currentValue := float64(h.epsilonValues[pos]) / float64(bucketCount)
+			value += currentValue * weight
+			lastValue = currentValue
+		} else {
+			value += lastValue * weight
+		}
+	}
+	return value
+}

+ 243 - 0
hostpool.go

@@ -0,0 +1,243 @@
+// A Go package to intelligently and flexibly pool among multiple hosts from your Go application.
+// Host selection can operate in round robin or epsilon greedy mode, and unresponsive hosts are
+// avoided. A good overview of Epsilon Greedy is here http://stevehanov.ca/blog/index.php?id=132
+package hostpool
+
+import (
+	"log"
+	"sync"
+	"time"
+)
+
+// Returns current version
+func Version() string {
+	return "0.1"
+}
+
+// --- Response interfaces and structs ----
+
+// This interface represents the response from HostPool. You can retrieve the
+// hostname by calling Host(), and after making a request to the host you should
+// call Mark with any error encountered, which will inform the HostPool issuing
+// the HostPoolResponse of what happened to the request and allow it to update.
+type HostPoolResponse interface {
+	Host() string
+	Mark(error)
+	hostPool() HostPool
+}
+
+type standardHostPoolResponse struct {
+	host string
+	sync.Once
+	pool HostPool
+}
+
+// --- HostPool structs and interfaces ----
+
+// This is the main HostPool interface. Structs implementing this interface
+// allow you to Get a HostPoolResponse (which includes a hostname to use),
+// get the list of all Hosts, and use ResetAll to reset state.
+type HostPool interface {
+	Get() HostPoolResponse
+	// keep the marks separate so we can override independently
+	markSuccess(HostPoolResponse)
+	markFailed(HostPoolResponse)
+
+	ResetAll()
+	// ReturnUnhealthy when called with true will prevent an unhealthy node from
+	// being returned and will instead return a nil HostPoolResponse. If using
+	// this feature then you should check the result of Get for nil
+	ReturnUnhealthy(v bool)
+	Hosts() []string
+	SetHosts([]string)
+
+	// Close the hostpool and release all resources.
+	Close()
+}
+
+type standardHostPool struct {
+	sync.RWMutex
+	hosts             map[string]*hostEntry
+	hostList          []*hostEntry
+	returnUnhealthy   bool
+	initialRetryDelay time.Duration
+	maxRetryInterval  time.Duration
+	nextHostIndex     int
+}
+
+// ------ constants -------------------
+
+const epsilonBuckets = 120
+const epsilonDecay = 0.90 // decay the exploration rate
+const minEpsilon = 0.01   // explore one percent of the time
+const initialEpsilon = 0.3
+const defaultDecayDuration = time.Duration(5) * time.Minute
+
+// Construct a basic HostPool using the hostnames provided
+func New(hosts []string) HostPool {
+	p := &standardHostPool{
+		returnUnhealthy:   true,
+		hosts:             make(map[string]*hostEntry, len(hosts)),
+		hostList:          make([]*hostEntry, len(hosts)),
+		initialRetryDelay: time.Duration(30) * time.Second,
+		maxRetryInterval:  time.Duration(900) * time.Second,
+	}
+
+	for i, h := range hosts {
+		e := &hostEntry{
+			host:       h,
+			retryDelay: p.initialRetryDelay,
+		}
+		p.hosts[h] = e
+		p.hostList[i] = e
+	}
+
+	return p
+}
+
+func (r *standardHostPoolResponse) Host() string {
+	return r.host
+}
+
+func (r *standardHostPoolResponse) hostPool() HostPool {
+	return r.pool
+}
+
+func (r *standardHostPoolResponse) Mark(err error) {
+	r.Do(func() {
+		doMark(err, r)
+	})
+}
+
+func doMark(err error, r HostPoolResponse) {
+	if err == nil {
+		r.hostPool().markSuccess(r)
+	} else {
+		r.hostPool().markFailed(r)
+	}
+}
+
+// return an entry from the HostPool
+func (p *standardHostPool) Get() HostPoolResponse {
+	p.Lock()
+	defer p.Unlock()
+	host := p.getRoundRobin()
+	if host == "" {
+		return nil
+	}
+
+	return &standardHostPoolResponse{host: host, pool: p}
+}
+
+func (p *standardHostPool) getRoundRobin() string {
+	now := time.Now()
+	hostCount := len(p.hostList)
+	for i := range p.hostList {
+		// iterate via sequenece from where we last iterated
+		currentIndex := (i + p.nextHostIndex) % hostCount
+
+		h := p.hostList[currentIndex]
+		if !h.dead {
+			p.nextHostIndex = currentIndex + 1
+			return h.host
+		}
+		if h.nextRetry.Before(now) {
+			h.willRetryHost(p.maxRetryInterval)
+			p.nextHostIndex = currentIndex + 1
+			return h.host
+		}
+	}
+
+	// all hosts are down and returnUnhealhy is false then return no host
+	if !p.returnUnhealthy {
+		return ""
+	}
+
+	// all hosts are down. re-add them
+	p.doResetAll()
+	p.nextHostIndex = 0
+	return p.hostList[0].host
+}
+
+func (p *standardHostPool) ResetAll() {
+	p.Lock()
+	defer p.Unlock()
+	p.doResetAll()
+}
+
+func (p *standardHostPool) SetHosts(hosts []string) {
+	p.Lock()
+	defer p.Unlock()
+	p.setHosts(hosts)
+}
+
+func (p *standardHostPool) ReturnUnhealthy(v bool) {
+	p.Lock()
+	defer p.Unlock()
+	p.returnUnhealthy = v
+}
+
+func (p *standardHostPool) setHosts(hosts []string) {
+	p.hosts = make(map[string]*hostEntry, len(hosts))
+	p.hostList = make([]*hostEntry, len(hosts))
+
+	for i, h := range hosts {
+		e := &hostEntry{
+			host:       h,
+			retryDelay: p.initialRetryDelay,
+		}
+		p.hosts[h] = e
+		p.hostList[i] = e
+	}
+}
+
+// this actually performs the logic to reset,
+// and should only be called when the lock has
+// already been acquired
+func (p *standardHostPool) doResetAll() {
+	for _, h := range p.hosts {
+		h.dead = false
+	}
+}
+
+func (p *standardHostPool) Close() {
+	for _, h := range p.hosts {
+		h.dead = true
+	}
+}
+
+func (p *standardHostPool) markSuccess(hostR HostPoolResponse) {
+	host := hostR.Host()
+	p.Lock()
+	defer p.Unlock()
+
+	h, ok := p.hosts[host]
+	if !ok {
+		log.Fatalf("host %s not in HostPool %v", host, p.Hosts())
+	}
+	h.dead = false
+}
+
+func (p *standardHostPool) markFailed(hostR HostPoolResponse) {
+	host := hostR.Host()
+	p.Lock()
+	defer p.Unlock()
+	h, ok := p.hosts[host]
+	if !ok {
+		log.Fatalf("host %s not in HostPool %v", host, p.Hosts())
+	}
+	if !h.dead {
+		h.dead = true
+		h.retryCount = 0
+		h.retryDelay = p.initialRetryDelay
+		h.nextRetry = time.Now().Add(h.retryDelay)
+	}
+
+}
+func (p *standardHostPool) Hosts() []string {
+	hosts := make([]string, 0, len(p.hosts))
+	for host := range p.hosts {
+		hosts = append(hosts, host)
+	}
+	return hosts
+}

+ 145 - 0
hostpool_test.go

@@ -0,0 +1,145 @@
+package hostpool
+
+import (
+	"errors"
+	"github.com/bmizerany/assert"
+	"io/ioutil"
+	"log"
+	"math/rand"
+	"os"
+	"testing"
+	"time"
+)
+
+func TestHostPool(t *testing.T) {
+	log.SetOutput(ioutil.Discard)
+	defer log.SetOutput(os.Stdout)
+
+	dummyErr := errors.New("Dummy Error")
+
+	p := New([]string{"a", "b", "c"})
+	assert.Equal(t, p.Get().Host(), "a")
+	assert.Equal(t, p.Get().Host(), "b")
+	assert.Equal(t, p.Get().Host(), "c")
+	respA := p.Get()
+	assert.Equal(t, respA.Host(), "a")
+
+	respA.Mark(dummyErr)
+	respB := p.Get()
+	respB.Mark(dummyErr)
+	respC := p.Get()
+	assert.Equal(t, respC.Host(), "c")
+	respC.Mark(nil)
+	// get again, and verify that it's still c
+	assert.Equal(t, p.Get().Host(), "c")
+	// now try to mark b as success; should fail because already marked
+	respB.Mark(nil)
+	assert.Equal(t, p.Get().Host(), "c") // would be b if it were not dead
+	// now restore a
+	respA = &standardHostPoolResponse{host: "a", pool: p}
+	respA.Mark(nil)
+	assert.Equal(t, p.Get().Host(), "a")
+	assert.Equal(t, p.Get().Host(), "c")
+
+	// ensure that we get *something* back when all hosts fail
+	for _, host := range []string{"a", "b", "c"} {
+		response := &standardHostPoolResponse{host: host, pool: p}
+		response.Mark(dummyErr)
+	}
+	resp := p.Get()
+	assert.NotEqual(t, resp, nil)
+}
+
+type mockTimer struct {
+	t int // the time it will always return
+}
+
+func (t *mockTimer) between(start time.Time, end time.Time) time.Duration {
+	return time.Duration(t.t) * time.Millisecond
+}
+
+func TestEpsilonGreedy(t *testing.T) {
+	log.SetOutput(ioutil.Discard)
+	defer log.SetOutput(os.Stdout)
+
+	rand.Seed(10)
+
+	iterations := 12000
+	p := NewEpsilonGreedy([]string{"a", "b"}, 0, &LinearEpsilonValueCalculator{}).(*epsilonGreedyHostPool)
+
+	timings := make(map[string]int64)
+	timings["a"] = 200
+	timings["b"] = 300
+
+	hitCounts := make(map[string]int)
+	hitCounts["a"] = 0
+	hitCounts["b"] = 0
+
+	log.Printf("starting first run (a, b)")
+
+	for i := 0; i < iterations; i += 1 {
+		if i != 0 && i%100 == 0 {
+			p.performEpsilonGreedyDecay()
+		}
+		hostR := p.Get()
+		host := hostR.Host()
+		hitCounts[host]++
+		timing := timings[host]
+		p.timer = &mockTimer{t: int(timing)}
+		hostR.Mark(nil)
+	}
+
+	for host := range hitCounts {
+		log.Printf("host %s hit %d times (%0.2f percent)", host, hitCounts[host], (float64(hitCounts[host])/float64(iterations))*100.0)
+	}
+
+	assert.Equal(t, hitCounts["a"] > hitCounts["b"], true)
+
+	hitCounts["a"] = 0
+	hitCounts["b"] = 0
+	log.Printf("starting second run (b, a)")
+	timings["a"] = 500
+	timings["b"] = 100
+
+	for i := 0; i < iterations; i += 1 {
+		if i != 0 && i%100 == 0 {
+			p.performEpsilonGreedyDecay()
+		}
+		hostR := p.Get()
+		host := hostR.Host()
+		hitCounts[host]++
+		timing := timings[host]
+		p.timer = &mockTimer{t: int(timing)}
+		hostR.Mark(nil)
+	}
+
+	for host := range hitCounts {
+		log.Printf("host %s hit %d times (%0.2f percent)", host, hitCounts[host], (float64(hitCounts[host])/float64(iterations))*100.0)
+	}
+
+	assert.Equal(t, hitCounts["b"] > hitCounts["a"], true)
+}
+
+func BenchmarkEpsilonGreedy(b *testing.B) {
+	b.StopTimer()
+
+	// Make up some response times
+	zipfDist := rand.NewZipf(rand.New(rand.NewSource(0)), 1.1, 5, 5000)
+	timings := make([]uint64, b.N)
+	for i := 0; i < b.N; i++ {
+		timings[i] = zipfDist.Uint64()
+	}
+
+	// Make the hostpool with a few hosts
+	p := NewEpsilonGreedy([]string{"a", "b"}, 0, &LinearEpsilonValueCalculator{}).(*epsilonGreedyHostPool)
+
+	b.StartTimer()
+	for i := 0; i < b.N; i++ {
+		if i != 0 && i%100 == 0 {
+			p.performEpsilonGreedyDecay()
+		}
+		hostR := p.Get()
+		p.timer = &mockTimer{t: int(timings[i])}
+		hostR.Mark(nil)
+	}
+}