Skip to content

Commit

Permalink
fixup! feat: introduce a ttl cache for resources
Browse files Browse the repository at this point in the history
  • Loading branch information
atzoum committed May 28, 2024
1 parent a08e174 commit c4f3b44
Show file tree
Hide file tree
Showing 2 changed files with 24 additions and 28 deletions.
16 changes: 6 additions & 10 deletions resourcettl/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (

// NewCache creates a new resource cache.
//
// - new - function is used to create a new resource when it is not available in the cache.
// - ttl - is the time after which the resource is considered expired and cleaned up.
//
// A resource's ttl is extended every time it is checked out.
Expand All @@ -28,9 +27,8 @@ import (
// - Close() error
// - Stop()
// - Stop() error
func NewCache[K comparable, R any](new func(key K) (R, error), ttl time.Duration) *Cache[K, R] {
func NewCache[K comparable, R any](ttl time.Duration) *Cache[K, R] {
c := &Cache[K, R]{
new: new,
keyMu: kitsync.NewPartitionLocker(),
resources: make(map[string]R),
checkouts: make(map[string]int),
Expand All @@ -55,8 +53,6 @@ func NewCache[K comparable, R any](new func(key K) (R, error), ttl time.Duration
// - Stop()
// - Stop() error
type Cache[K comparable, R any] struct {
new func(key K) (R, error) // creates a new resource

// synchronizes access to the cache for a given key. This is to
// allow multiple go-routines to access the cache concurrently for different keys, but still
// avoid multiple go-routines creating multiple resources for the same key.
Expand All @@ -71,11 +67,11 @@ type Cache[K comparable, R any] struct {
ttlcache *cachettl.Cache[K, string]
}

// Checkout returns a resource for the given key. If the resource is not available, it creates a new one.
// Checkout returns a resource for the given key. If the resource is not available, it creates a new one, using the new function.
// The caller must call the returned checkin function when the resource is no longer needed, to release the resource.
// Multiple checkouts for the same key are allowed and they can all share the same resource. The resource is cleaned up
// only when all checkouts are checked-in and the resource's ttl has expired (or its key has been invalidated through [Invalidate]).
func (c *Cache[K, R]) Checkout(key K) (resource R, checkin func(), err error) {
func (c *Cache[K, R]) Checkout(key K, new func() (R, error)) (resource R, checkin func(), err error) {
defer c.lockKey(key)()

if resourceID := c.ttlcache.Get(key); resourceID != "" {
Expand All @@ -85,7 +81,7 @@ func (c *Cache[K, R]) Checkout(key K) (resource R, checkin func(), err error) {
c.checkouts[resourceID]++
return r, c.checkinFunc(r, resourceID), nil
}
return c.newInstance(key)
return c.newInstance(key, new)
}

// Invalidate invalidates the resource for the given key.
Expand All @@ -103,8 +99,8 @@ func (c *Cache[K, R]) Invalidate(key K) {
}

// newInstance creates a new resource for the given key.
func (c *Cache[K, R]) newInstance(key K) (R, func(), error) {
r, err := c.new(key)
func (c *Cache[K, R]) newInstance(key K, new func() (R, error)) (R, func(), error) {
r, err := new()
if err != nil {
return r, nil, err
}
Expand Down
36 changes: 18 additions & 18 deletions resourcettl/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ func TestCache(t *testing.T) {
t.Run("checkout, checkin, then expire", func(t *testing.T) {
t.Run("using cleanup", func(t *testing.T) {
producer := &MockProducer{}
c := resourcettl.NewCache(producer.NewCleanuper, ttl)
c := resourcettl.NewCache[string, *cleanuper](ttl)

r1, checkin1, err1 := c.Checkout(key)
r1, checkin1, err1 := c.Checkout(key, producer.NewCleanuper)
require.NoError(t, err1, "it should be able to create a new resource")
require.NotNil(t, r1, "it should return a resource")
require.EqualValues(t, 1, producer.instances.Load(), "it should create a new resource")

r2, checkin2, err2 := c.Checkout(key)
r2, checkin2, err2 := c.Checkout(key, producer.NewCleanuper)
require.NoError(t, err2, "it should be able to checkout the same resource")
require.NotNil(t, r2, "it should return a resource")
require.EqualValues(t, 1, producer.instances.Load(), "it shouldn't create a new resource")
Expand All @@ -34,7 +34,7 @@ func TestCache(t *testing.T) {
checkin1()
checkin2()

r3, checkin3, err3 := c.Checkout(key)
r3, checkin3, err3 := c.Checkout(key, producer.NewCleanuper)
require.NoError(t, err3, "it should be able to create a new resource")
require.NotNil(t, r3, "it should return a resource")
require.EqualValues(t, 2, producer.instances.Load(), "it should create a new resource since the previous one expired")
Expand All @@ -46,14 +46,14 @@ func TestCache(t *testing.T) {

t.Run("using closer", func(t *testing.T) {
producer := &MockProducer{}
c := resourcettl.NewCache(producer.NewCloser, ttl)
c := resourcettl.NewCache[string, *closer](ttl)

r1, checkin1, err1 := c.Checkout(key)
r1, checkin1, err1 := c.Checkout(key, producer.NewCloser)
require.NoError(t, err1, "it should be able to create a new resource")
require.NotNil(t, r1, "it should return a resource")
require.EqualValues(t, 1, producer.instances.Load(), "it should create a new resource")

r2, checkin2, err2 := c.Checkout(key)
r2, checkin2, err2 := c.Checkout(key, producer.NewCloser)
require.NoError(t, err2, "it should be able to checkout the same resource")
require.NotNil(t, r2, "it should return a resource")
require.EqualValues(t, 1, producer.instances.Load(), "it shouldn't create a new resource")
Expand All @@ -63,7 +63,7 @@ func TestCache(t *testing.T) {
checkin1()
checkin2()

r3, checkin3, err3 := c.Checkout(key)
r3, checkin3, err3 := c.Checkout(key, producer.NewCloser)
require.NoError(t, err3, "it should be able to create a new resource")
require.NotNil(t, r3, "it should return a resource")
require.EqualValues(t, 2, producer.instances.Load(), "it should create a new resource since the previous one expired")
Expand All @@ -76,22 +76,22 @@ func TestCache(t *testing.T) {

t.Run("expire while being used", func(t *testing.T) {
producer := &MockProducer{}
c := resourcettl.NewCache(producer.NewCleanuper, ttl)
c := resourcettl.NewCache[string, *cleanuper](ttl)

r1, checkin1, err1 := c.Checkout(key)
r1, checkin1, err1 := c.Checkout(key, producer.NewCleanuper)
require.NoError(t, err1, "it should be able to create a new resource")
require.NotNil(t, r1, "it should return a resource")
require.EqualValues(t, 1, producer.instances.Load(), "it should create a new resource")

r2, checkin2, err2 := c.Checkout(key)
r2, checkin2, err2 := c.Checkout(key, producer.NewCleanuper)
require.NoError(t, err2, "it should be able to checkout the same resource")
require.NotNil(t, r2, "it should return a resource")
require.EqualValues(t, 1, producer.instances.Load(), "it shouldn't create a new resource")
require.Equal(t, r1.id, r2.id, "it should return the same resource")

time.Sleep(ttl + time.Millisecond) // wait for expiration

r3, checkin3, err3 := c.Checkout(key)
r3, checkin3, err3 := c.Checkout(key, producer.NewCleanuper)
require.NoError(t, err3, "it should be able to return a resource")
require.NotNil(t, r3, "it should return a resource")
require.EqualValues(t, 2, producer.instances.Load(), "it should create a new resource since the previous one expired")
Expand All @@ -108,22 +108,22 @@ func TestCache(t *testing.T) {

t.Run("invalidate", func(t *testing.T) {
producer := &MockProducer{}
c := resourcettl.NewCache(producer.NewCleanuper, ttl)
c := resourcettl.NewCache[string, *cleanuper](ttl)

r1, checkin1, err1 := c.Checkout(key)
r1, checkin1, err1 := c.Checkout(key, producer.NewCleanuper)
require.NoError(t, err1, "it should be able to create a new resource")
require.NotNil(t, r1, "it should return a resource")
require.EqualValues(t, 1, producer.instances.Load(), "it should create a new resource")

r2, checkin2, err2 := c.Checkout(key)
r2, checkin2, err2 := c.Checkout(key, producer.NewCleanuper)
require.NoError(t, err2, "it should be able to checkout the same resource")
require.NotNil(t, r2, "it should return a resource")
require.EqualValues(t, 1, producer.instances.Load(), "it shouldn't create a new resource")
require.Equal(t, r1.id, r2.id, "it should return the same resource")

c.Invalidate(key)

r3, checkin3, err3 := c.Checkout(key)
r3, checkin3, err3 := c.Checkout(key, producer.NewCleanuper)
require.NoError(t, err3, "it should be able to create a new resource")
require.NotNil(t, r3, "it should return a resource")
require.EqualValues(t, 2, producer.instances.Load(), "it should create a new resource since the previous one was invalidated")
Expand All @@ -144,12 +144,12 @@ type MockProducer struct {
instances atomic.Int32
}

func (m *MockProducer) NewCleanuper(_ string) (*cleanuper, error) {
func (m *MockProducer) NewCleanuper() (*cleanuper, error) {
m.instances.Add(1)
return &cleanuper{id: uuid.NewString()}, nil
}

func (m *MockProducer) NewCloser(_ string) (*closer, error) {
func (m *MockProducer) NewCloser() (*closer, error) {
m.instances.Add(1)
return &closer{id: uuid.NewString()}, nil
}
Expand Down

0 comments on commit c4f3b44

Please sign in to comment.