Skip to content

Commit 7d33211

Browse files
Merge pull request #158 from tomdeering-wf/td/cache
Add Cache Implementation
2 parents 9435245 + c0d8821 commit 7d33211

File tree

2 files changed

+304
-0
lines changed

2 files changed

+304
-0
lines changed

cache/cache.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package cache
2+
3+
import (
4+
"container/list"
5+
"sync"
6+
)
7+
8+
// Cache is a bounded-size in-memory cache of sized items with a configurable eviction policy
9+
type Cache interface {
10+
// Get retrieves items from the cache by key.
11+
// If an item for a particular key is not found, its position in the result will be nil.
12+
Get(keys ...string) []Item
13+
14+
// Put adds an item to the cache.
15+
Put(key string, item Item)
16+
17+
// Remove clears items with the given keys from the cache
18+
Remove(keys ...string)
19+
20+
// Size returns the size of all items currently in the cache.
21+
Size() uint64
22+
}
23+
24+
// Item is an item in a cache
25+
type Item interface {
26+
// Size returns the item's size, in bytes
27+
Size() uint64
28+
}
29+
30+
// A tuple tracking a cached item and a reference to its node in the eviction list
31+
type cached struct {
32+
item Item
33+
element *list.Element
34+
}
35+
36+
// Sets the provided list element on the cached item if it is not nil
37+
func (c *cached) setElementIfNotNil(element *list.Element) {
38+
if element != nil {
39+
c.element = element
40+
}
41+
}
42+
43+
// Private cache implementation
44+
type cache struct {
45+
sync.Mutex // Lock for synchronizing Get, Put, Remove
46+
cap uint64 // Capacity bound
47+
size uint64 // Cumulative size
48+
items map[string]*cached // Map from keys to cached items
49+
keyList *list.List // List of cached items in order of increasing evictability
50+
recordAdd func(key string) *list.Element // Function called to indicate that an item with the given key was added
51+
recordAccess func(key string) *list.Element // Function called to indicate that an item with the given key was accessed
52+
}
53+
54+
// CacheOption configures a cache.
55+
type CacheOption func(*cache)
56+
57+
// Policy is a cache eviction policy for use with the EvictionPolicy CacheOption.
58+
type Policy uint8
59+
60+
const (
61+
// LeastRecentlyAdded indicates a least-recently-added eviction policy.
62+
LeastRecentlyAdded Policy = iota
63+
// LeastRecentlyUsed indicates a least-recently-used eviction policy.
64+
LeastRecentlyUsed
65+
)
66+
67+
// EvictionPolicy sets the eviction policy to be used to make room for new items.
68+
// If not provided, default is LeastRecentlyUsed.
69+
func EvictionPolicy(policy Policy) CacheOption {
70+
return func(c *cache) {
71+
switch policy {
72+
case LeastRecentlyAdded:
73+
c.recordAccess = c.noop
74+
c.recordAdd = c.record
75+
case LeastRecentlyUsed:
76+
c.recordAccess = c.record
77+
c.recordAdd = c.noop
78+
}
79+
}
80+
}
81+
82+
// New returns a cache with the requested options configured.
83+
// The cache consumes memory bounded by a fixed capacity,
84+
// plus tracking overhead linear in the number of items.
85+
func New(capacity uint64, options ...CacheOption) Cache {
86+
c := &cache{
87+
cap: capacity,
88+
keyList: list.New(),
89+
items: map[string]*cached{},
90+
}
91+
// Default LRU eviction policy
92+
EvictionPolicy(LeastRecentlyUsed)(c)
93+
94+
for _, option := range options {
95+
option(c)
96+
}
97+
98+
return c
99+
}
100+
101+
func (c *cache) Get(keys ...string) []Item {
102+
c.Lock()
103+
defer c.Unlock()
104+
105+
items := make([]Item, len(keys))
106+
for i, key := range keys {
107+
cached := c.items[key]
108+
if cached == nil {
109+
items[i] = nil
110+
} else {
111+
c.recordAccess(key)
112+
items[i] = cached.item
113+
}
114+
}
115+
116+
return items
117+
}
118+
119+
func (c *cache) Put(key string, item Item) {
120+
c.Lock()
121+
defer c.Unlock()
122+
123+
// Remove the item currently with this key (if any)
124+
c.remove(key)
125+
126+
// Make sure there's room to add this item
127+
c.ensureCapacity(item.Size())
128+
129+
// Actually add the new item
130+
cached := &cached{item: item}
131+
cached.setElementIfNotNil(c.recordAdd(key))
132+
cached.setElementIfNotNil(c.recordAccess(key))
133+
c.items[key] = cached
134+
c.size += item.Size()
135+
}
136+
137+
func (c *cache) Remove(keys ...string) {
138+
c.Lock()
139+
defer c.Unlock()
140+
141+
for _, key := range keys {
142+
c.remove(key)
143+
}
144+
}
145+
146+
func (c *cache) Size() uint64 {
147+
return c.size
148+
}
149+
150+
// Given the need to add some number of new bytes to the cache,
151+
// evict items according to the eviction policy until there is room.
152+
// The caller should hold the cache lock.
153+
func (c *cache) ensureCapacity(toAdd uint64) {
154+
mustRemove := int64(c.size+toAdd) - int64(c.cap)
155+
for mustRemove > 0 {
156+
key := c.keyList.Back().Value.(string)
157+
mustRemove -= int64(c.items[key].item.Size())
158+
c.remove(key)
159+
}
160+
}
161+
162+
// Remove the item associated with the given key.
163+
// The caller should hold the cache lock.
164+
func (c *cache) remove(key string) {
165+
if cached, ok := c.items[key]; ok {
166+
delete(c.items, key)
167+
c.size -= cached.item.Size()
168+
c.keyList.Remove(cached.element)
169+
}
170+
}
171+
172+
// A no-op function that does nothing for the provided key
173+
func (c *cache) noop(string) *list.Element { return nil }
174+
175+
// A function to record the given key and mark it as last to be evicted
176+
func (c *cache) record(key string) *list.Element {
177+
if item, ok := c.items[key]; ok {
178+
c.keyList.MoveToFront(item.element)
179+
return item.element
180+
}
181+
return c.keyList.PushFront(key)
182+
}

cache/cache_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package cache
2+
3+
import (
4+
"container/list"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestEvictionPolicy(t *testing.T) {
11+
c := &cache{keyList: list.New()}
12+
EvictionPolicy(LeastRecentlyUsed)(c)
13+
accessed, added := c.recordAccess("foo"), c.recordAdd("foo")
14+
assert.NotNil(t, accessed)
15+
assert.Nil(t, added)
16+
17+
c = &cache{keyList: list.New()}
18+
EvictionPolicy(LeastRecentlyAdded)(c)
19+
accessed, added = c.recordAccess("foo"), c.recordAdd("foo")
20+
assert.Nil(t, accessed)
21+
assert.NotNil(t, added)
22+
}
23+
24+
func TestNew(t *testing.T) {
25+
optionApplied := false
26+
option := func(*cache) {
27+
optionApplied = true
28+
}
29+
30+
c := New(314159, option).(*cache)
31+
32+
assert.Equal(t, uint64(314159), c.cap)
33+
assert.Equal(t, uint64(0), c.size)
34+
assert.NotNil(t, c.items)
35+
assert.NotNil(t, c.keyList)
36+
assert.True(t, optionApplied)
37+
38+
accessed, added := c.recordAccess("foo"), c.recordAdd("foo")
39+
assert.NotNil(t, accessed)
40+
assert.Nil(t, added)
41+
}
42+
43+
type testItem uint64
44+
45+
func (ti testItem) Size() uint64 {
46+
return uint64(ti)
47+
}
48+
49+
func TestPutGetRemoveSize(t *testing.T) {
50+
keys := []string{"foo", "bar", "baz"}
51+
testCases := []struct {
52+
label string
53+
cache Cache
54+
useCache func(c Cache)
55+
expectedSize uint64
56+
expectedItems []Item
57+
}{{
58+
label: "Items added, key doesn't exist",
59+
cache: New(10000),
60+
useCache: func(c Cache) {
61+
c.Put("foo", testItem(1))
62+
},
63+
expectedSize: 1,
64+
expectedItems: []Item{testItem(1), nil, nil},
65+
}, {
66+
label: "Items added, key exists",
67+
cache: New(10000),
68+
useCache: func(c Cache) {
69+
c.Put("foo", testItem(1))
70+
c.Put("foo", testItem(10))
71+
},
72+
expectedSize: 10,
73+
expectedItems: []Item{testItem(10), nil, nil},
74+
}, {
75+
label: "Items added, LRA eviction",
76+
cache: New(2, EvictionPolicy(LeastRecentlyAdded)),
77+
useCache: func(c Cache) {
78+
c.Put("foo", testItem(1))
79+
c.Put("bar", testItem(1))
80+
c.Get("foo")
81+
c.Put("baz", testItem(1))
82+
},
83+
expectedSize: 2,
84+
expectedItems: []Item{nil, testItem(1), testItem(1)},
85+
}, {
86+
label: "Items added, LRU eviction",
87+
cache: New(2, EvictionPolicy(LeastRecentlyUsed)),
88+
useCache: func(c Cache) {
89+
c.Put("foo", testItem(1))
90+
c.Put("bar", testItem(1))
91+
c.Get("foo")
92+
c.Put("baz", testItem(1))
93+
},
94+
expectedSize: 2,
95+
expectedItems: []Item{testItem(1), nil, testItem(1)},
96+
}, {
97+
label: "Items removed, key doesn't exist",
98+
cache: New(10000),
99+
useCache: func(c Cache) {
100+
c.Put("foo", testItem(1))
101+
c.Remove("baz")
102+
},
103+
expectedSize: 1,
104+
expectedItems: []Item{testItem(1), nil, nil},
105+
}, {
106+
label: "Items removed, key exists",
107+
cache: New(10000),
108+
useCache: func(c Cache) {
109+
c.Put("foo", testItem(1))
110+
c.Remove("foo")
111+
},
112+
expectedSize: 0,
113+
expectedItems: []Item{nil, nil, nil},
114+
}}
115+
116+
for _, testCase := range testCases {
117+
t.Log(testCase.label)
118+
testCase.useCache(testCase.cache)
119+
assert.Equal(t, testCase.expectedSize, testCase.cache.Size())
120+
assert.Equal(t, testCase.expectedItems, testCase.cache.Get(keys...))
121+
}
122+
}

0 commit comments

Comments
 (0)