Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make Cache.Get allocation-free #162

Open
wants to merge 1 commit into
base: v3
Choose a base branch
from
Open
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
8 changes: 4 additions & 4 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func New[K comparable, V any](opts ...Option[K, V]) *Cache[K, V] {
c.events.insertion.fns = make(map[uint64]func(*Item[K, V]))
c.events.eviction.fns = make(map[uint64]func(EvictionReason, *Item[K, V]))

applyOptions(&c.options, opts...)
c.options = applyOptions(c.options, opts...)

return c
}
Expand Down Expand Up @@ -233,7 +233,7 @@ func (c *Cache[K, V]) getWithOpts(key K, lockAndLoad bool, opts ...Option[K, V])
disableTouchOnHit: c.options.disableTouchOnHit,
}

applyOptions(&getOpts, opts...)
getOpts = applyOptions(getOpts, opts...)

if lockAndLoad {
c.items.mu.Lock()
Expand Down Expand Up @@ -388,7 +388,7 @@ func (c *Cache[K, V]) GetOrSet(key K, value V, opts ...Option[K, V]) (*Item[K, V
setOpts := options[K, V]{
ttl: c.options.ttl,
}
applyOptions(&setOpts, opts...) // used only to update the TTL
setOpts = applyOptions(setOpts, opts...) // used only to update the TTL

item := c.set(key, value, setOpts.ttl)

Expand All @@ -412,7 +412,7 @@ func (c *Cache[K, V]) GetAndDelete(key K, opts ...Option[K, V]) (*Item[K, V], bo
getOpts := options[K, V]{
loader: c.options.loader,
}
applyOptions(&getOpts, opts...) // used only to update the loader
getOpts = applyOptions(getOpts, opts...) // used only to update the loader

if getOpts.loader != nil {
item := getOpts.loader.Load(c, key)
Expand Down
39 changes: 30 additions & 9 deletions cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,11 +529,12 @@ func Test_Cache_Set(t *testing.T) {
func Test_Cache_Get(t *testing.T) {
const notFoundKey, foundKey = "notfound", "test1"
cc := map[string]struct {
Key string
DefaultOptions options[string, string]
CallOptions []Option[string, string]
Metrics Metrics
Result *Item[string, string]
Key string
DefaultOptions options[string, string]
CallOptions []Option[string, string]
Metrics Metrics
Result *Item[string, string]
ExpectedNumberOfAllocations int
}{
"Get without loader when item is not found": {
Key: notFoundKey,
Expand All @@ -551,7 +552,8 @@ func Test_Cache_Get(t *testing.T) {
Metrics: Metrics{
Misses: 1,
},
Result: &Item[string, string]{key: "test"},
Result: &Item[string, string]{key: "test"},
ExpectedNumberOfAllocations: 1, // The loader function returns a heap allocated item. If this item was pre-allocated, the number of allocations would be 0.
},
"Get with default loader that returns nil value when item is not found": {
Key: notFoundKey,
Expand Down Expand Up @@ -579,7 +581,8 @@ func Test_Cache_Get(t *testing.T) {
Metrics: Metrics{
Misses: 1,
},
Result: &Item[string, string]{key: "hello"},
Result: &Item[string, string]{key: "hello"},
ExpectedNumberOfAllocations: 1, // The loader function returns a heap allocated item. If this item was pre-allocated, the number of allocations would be 0.
},
"Get with call loader that returns nil value when item is not found": {
Key: notFoundKey,
Expand Down Expand Up @@ -633,7 +636,10 @@ func Test_Cache_Get(t *testing.T) {
oldExpiresAt := cache.items.values[foundKey].Value.(*Item[string, string]).expiresAt
cache.options = c.DefaultOptions

res := cache.Get(c.Key, c.CallOptions...)
var res *Item[string, string]
assert.Equal(t, c.ExpectedNumberOfAllocations, allocsForSingleRun(func() {
res = cache.Get(c.Key, c.CallOptions...)
}))

if c.Key == foundKey {
c.Result = cache.items.values[foundKey].Value.(*Item[string, string])
Expand All @@ -646,7 +652,7 @@ func Test_Cache_Get(t *testing.T) {
return
}

applyOptions(&c.DefaultOptions, c.CallOptions...)
c.DefaultOptions = applyOptions(c.DefaultOptions, c.CallOptions...)

if c.DefaultOptions.disableTouchOnHit {
assert.Equal(t, oldExpiresAt, res.expiresAt)
Expand Down Expand Up @@ -1302,3 +1308,18 @@ func addToCache(c *Cache[string, string], ttl time.Duration, keys ...string) {
}
}
}

func allocsForSingleRun(f func()) (numAllocs int) {
// `testing.AllocsPerRun` "warms up" the function for a single run before
// measuring allocations, so we need do nothing on the first run.
firstRun := false
numAllocs = int(testing.AllocsPerRun(1, func() {
if !firstRun {
firstRun = true
return
}

f()
}))
return
}
Comment on lines +1312 to +1325
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following the package's naming, I think the allocsPerSingleRun would be a more appropriate function name. What is more, I don't see a point in using a named return here, the function scope is quite small, so something like this should be sufficient. Thanks, after this I will merge the PR. 🚀

Suggested change
func allocsForSingleRun(f func()) (numAllocs int) {
// `testing.AllocsPerRun` "warms up" the function for a single run before
// measuring allocations, so we need do nothing on the first run.
firstRun := false
numAllocs = int(testing.AllocsPerRun(1, func() {
if !firstRun {
firstRun = true
return
}
f()
}))
return
}
func allocsPerSingleRun(f func()) int {
// `testing.AllocsPerRun` "warms up" the function for a single run before
// measuring allocations, so we need to do nothing on the first run.
var firstRun bool
return int(testing.AllocsPerRun(1, func() {
if !firstRun {
firstRun = true
return
}
f()
}))
}

33 changes: 20 additions & 13 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import "time"

// Option sets a specific cache option.
type Option[K comparable, V any] interface {
apply(opts *options[K, V])
apply(opts options[K, V]) options[K, V]
}

// optionFunc wraps a function and implements the Option interface.
type optionFunc[K comparable, V any] func(*options[K, V])
type optionFunc[K comparable, V any] func(options[K, V]) options[K, V]

// apply calls the wrapped function.
func (fn optionFunc[K, V]) apply(opts *options[K, V]) {
fn(opts)
func (fn optionFunc[K, V]) apply(opts options[K, V]) options[K, V] {
return fn(opts)
}

// CostFunc is used to calculate the cost of the key and the item to be
Expand All @@ -29,44 +29,49 @@ type options[K comparable, V any] struct {
itemOpts []itemOption[K, V]
}

// applyOptions applies the provided option values to the option struct.
func applyOptions[K comparable, V any](v *options[K, V], opts ...Option[K, V]) {
// applyOptions applies the provided option values to the option struct and returns the modified option struct.
func applyOptions[K comparable, V any](v options[K, V], opts ...Option[K, V]) options[K, V] {
for i := range opts {
opts[i].apply(v)
v = opts[i].apply(v)
}
return v
}

// WithCapacity sets the maximum capacity of the cache.
// It has no effect when used with Get().
func WithCapacity[K comparable, V any](c uint64) Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.capacity = c
return opts
})
}

// WithTTL sets the TTL of the cache.
// It has no effect when used with Get().
func WithTTL[K comparable, V any](ttl time.Duration) Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.ttl = ttl
return opts
})
}

// WithVersion activates item version tracking.
// If version tracking is disabled, the version is always -1.
// It has no effect when used with Get().
func WithVersion[K comparable, V any](enable bool) Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.itemOpts = append(opts.itemOpts, withVersionTracking[K, V](enable))
return opts
})
}

// WithLoader sets the loader of the cache.
// When passing into Get(), it sets an ephemeral loader that
// is used instead of the cache's default one.
func WithLoader[K comparable, V any](l Loader[K, V]) Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.loader = l
return opts
})
}

Expand All @@ -76,18 +81,20 @@ func WithLoader[K comparable, V any](l Loader[K, V]) Option[K, V] {
// When used with Get(), it overrides the default value of the
// cache.
func WithDisableTouchOnHit[K comparable, V any]() Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.disableTouchOnHit = true
return opts
})
}

// WithMaxCost sets the maximum cost the cache is allowed to use (e.g. the used memory).
// The actual cost calculation for each inserted item happens by making use of the
// callback CostFunc.
func WithMaxCost[K comparable, V any](s uint64, callback CostFunc[K, V]) Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.maxCost = s
opts.itemOpts = append(opts.itemOpts, withCostFunc[K, V](callback))
return opts
})
}

Expand Down
21 changes: 11 additions & 10 deletions options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ func Test_optionFunc_apply(t *testing.T) {

var called bool

optionFunc[string, string](func(_ *options[string, string]) {
optionFunc[string, string](func(opts options[string, string]) options[string, string] {
called = true
}).apply(nil)
return opts
}).apply(options[string, string]{})
assert.True(t, called)
}

Expand All @@ -24,7 +25,7 @@ func Test_applyOptions(t *testing.T) {

var opts options[string, string]

applyOptions(&opts,
opts = applyOptions(opts,
WithCapacity[string, string](12),
WithTTL[string, string](time.Hour),
)
Expand All @@ -38,7 +39,7 @@ func Test_WithCapacity(t *testing.T) {

var opts options[string, string]

WithCapacity[string, string](12).apply(&opts)
opts = WithCapacity[string, string](12).apply(opts)
assert.Equal(t, uint64(12), opts.capacity)
}

Expand All @@ -47,7 +48,7 @@ func Test_WithTTL(t *testing.T) {

var opts options[string, string]

WithTTL[string, string](time.Hour).apply(&opts)
opts = WithTTL[string, string](time.Hour).apply(opts)
assert.Equal(t, time.Hour, opts.ttl)
}

Expand All @@ -57,13 +58,13 @@ func Test_WithVersion(t *testing.T) {
var opts options[string, string]
var item Item[string, string]

WithVersion[string, string](true).apply(&opts)
opts = WithVersion[string, string](true).apply(opts)
assert.Len(t, opts.itemOpts, 1)
opts.itemOpts[0].apply(&item)
assert.Equal(t, int64(0), item.version)

opts.itemOpts = []itemOption[string, string]{}
WithVersion[string, string](false).apply(&opts)
opts = WithVersion[string, string](false).apply(opts)
assert.Len(t, opts.itemOpts, 1)
opts.itemOpts[0].apply(&item)
assert.Equal(t, int64(-1), item.version)
Expand All @@ -77,7 +78,7 @@ func Test_WithLoader(t *testing.T) {
l := LoaderFunc[string, string](func(_ *Cache[string, string], _ string) *Item[string, string] {
return nil
})
WithLoader[string, string](l).apply(&opts)
opts = WithLoader[string, string](l).apply(opts)
assert.NotNil(t, opts.loader)
}

Expand All @@ -86,7 +87,7 @@ func Test_WithDisableTouchOnHit(t *testing.T) {

var opts options[string, string]

WithDisableTouchOnHit[string, string]().apply(&opts)
opts = WithDisableTouchOnHit[string, string]().apply(opts)
assert.True(t, opts.disableTouchOnHit)
}

Expand All @@ -96,7 +97,7 @@ func Test_WithMaxCost(t *testing.T) {
var opts options[string, string]
var item Item[string, string]

WithMaxCost[string, string](1024, func(item *Item[string, string]) uint64 { return 1 }).apply(&opts)
opts = WithMaxCost[string, string](1024, func(item *Item[string, string]) uint64 { return 1 }).apply(opts)

assert.Equal(t, uint64(1024), opts.maxCost)
assert.Len(t, opts.itemOpts, 1)
Expand Down
Loading