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
141 changes: 141 additions & 0 deletions cache_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package evcache_test

import (
"errors"
"runtime"
"testing"
"time"
Expand All @@ -9,6 +10,146 @@ import (
. "github.com/mgnsk/evcache/v4/internal/testing"
)

func TestCacheStoreLoadHas(t *testing.T) {
c := evcache.New[string, string]()

_, loaded := c.Load("key")
Equal(t, loaded, false)
Equal(t, c.Has("key"), false)

c.Store("key", "value")

value, loaded := c.Load("key")
Equal(t, loaded, true)
Equal(t, value, "value")
Equal(t, c.Has("key"), true)
}

func TestCacheLen(t *testing.T) {
c := evcache.New[int, int]()

Equal(t, c.Len(), 0)

c.Store(1, 1)
c.Store(2, 2)
Equal(t, c.Len(), 2)
}

func TestCacheEvict(t *testing.T) {
c := evcache.New[string, string]()

_, evicted := c.Evict("key")
Equal(t, evicted, false)

c.Store("key", "value")

value, evicted := c.Evict("key")
Equal(t, evicted, true)
Equal(t, value, "value")
Equal(t, c.Has("key"), false)
}

func TestCacheRange(t *testing.T) {
c := evcache.New[int, int]()

c.Store(1, 10)
c.Store(2, 20)
c.Store(3, 30)

seen := map[int]int{}
c.Range(func(key, value int) bool {
seen[key] = value
return true
})
Equal(t, seen, map[int]int{1: 10, 2: 20, 3: 30})
}

func TestCacheRangeStopsEarly(t *testing.T) {
c := evcache.New[int, int]()

c.Store(1, 10)
c.Store(2, 20)

n := 0
c.Range(func(key, value int) bool {
n++
return false
})
Equal(t, n, 1)
}

func TestCacheRangeMayModifyCache(t *testing.T) {
c := evcache.New[int, int]()

c.Store(1, 10)
c.Store(2, 20)
c.Store(3, 30)

c.Range(func(key, value int) bool {
c.Evict(key)
c.Store(key+100, value)
return true
})

Equal(t, c.Len(), 3)
for _, key := range []int{1, 2, 3} {
Equal(t, c.Has(key), false)
}
for _, key := range []int{101, 102, 103} {
Equal(t, c.Has(key), true)
}
}

func TestCacheFetchCachesValue(t *testing.T) {
c := evcache.New[string, int]()

calls := 0
fetch := func() (int, error) {
calls++
return 1, nil
}

v, err := c.Fetch("key", fetch)
Must(t, err)
Equal(t, v, 1)

v, err = c.Fetch("key", fetch)
Must(t, err)
Equal(t, v, 1)
Equal(t, calls, 1)
}

func TestCacheFetchPropagatesError(t *testing.T) {
c := evcache.New[string, int]()

errFetch := errors.New("fetch failed")

_, err := c.Fetch("key", func() (int, error) {
return 0, errFetch
})
Equal(t, errors.Is(err, errFetch), true)
Equal(t, c.Has("key"), false)
}

func TestCacheFetchTTL(t *testing.T) {
c := evcache.New[string, int]()

_, err := c.FetchTTL("key", func() (int, time.Duration, error) {
return 1, time.Millisecond, nil
})
Must(t, err)
Equal(t, c.Has("key"), true)

// New() applies the default 1s expiry debounce (there is no public
// option to disable it), so a short TTL can take up to just under
// 2x the debounce interval to actually expire. Give this plenty of
// margin above that worst case rather than relying on the 1s
// default EventuallyTrue timeout.
EventuallyTrue(t, func() bool {
return c.Len() == 0
}, 3*time.Second)
}

func TestCacheGoGC(t *testing.T) {
capacity := 1_000_000
c := evcache.New[int, struct{}](evcache.WithCapacity(capacity))
Expand Down
152 changes: 98 additions & 54 deletions internal/backend/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import (
. "github.com/mgnsk/evcache/v4/internal/testing"
)

func TestFetchCallbackBlocks(t *testing.T) {
var b backend.Backend[string, string]
b.Init(0, "", 0, 0)
t.Cleanup(b.Close)
// startBlockedFetch starts a Fetch call for "key" that blocks until the
// returned unblock func is called, returning only after the fetch callback
// has started running.
func startBlockedFetch(t *testing.T, b *backend.Backend[string, string]) (unblock func()) {
t.Helper()

wg := sync.WaitGroup{}
done := make(chan struct{})
Expand All @@ -24,11 +25,6 @@ func TestFetchCallbackBlocks(t *testing.T) {
})
fetchStarted := make(chan struct{})

t.Cleanup(func() {
onceDone()
wg.Wait()
})

wg.Add(1)
go func() {
defer wg.Done()
Expand All @@ -45,64 +41,102 @@ func TestFetchCallbackBlocks(t *testing.T) {

<-fetchStarted

t.Run("assert cache empty", func(t *testing.T) {
Equal(t, b.Len(), 0)
return func() {
onceDone()
wg.Wait()
}
}

func TestFetchBlocksCacheEmpty(t *testing.T) {
var b backend.Backend[string, string]
b.Init(0, "", 0, 0)
t.Cleanup(b.Close)

t.Cleanup(startBlockedFetch(t, &b))

Equal(t, b.Len(), 0)
}

func TestFetchBlocksNonBlockingEvict(t *testing.T) {
var b backend.Backend[string, string]
b.Init(0, "", 0, 0)
t.Cleanup(b.Close)

t.Cleanup(startBlockedFetch(t, &b))

_, ok := b.Evict("key")
Equal(t, ok, false)
}

func TestFetchBlocksAutoexpiryOtherKeys(t *testing.T) {
var b backend.Backend[string, string]
b.Init(0, "", 0, 0)
t.Cleanup(b.Close)

t.Cleanup(startBlockedFetch(t, &b))

b.FetchTTL("key1", func() (string, time.Duration, error) {
return "value1", time.Millisecond, nil
})

t.Run("non-blocking Evict", func(t *testing.T) {
_, ok := b.Evict("key")
Equal(t, ok, false)
EventuallyTrue(t, func() bool {
return b.Len() == 0
})
}

t.Run("autoexpiry for other keys works", func(t *testing.T) {
b.FetchTTL("key1", func() (string, time.Duration, error) {
return "value1", time.Millisecond, nil
})
func TestFetchBlocksNonBlockingHas(t *testing.T) {
var b backend.Backend[string, string]
b.Init(0, "", 0, 0)
t.Cleanup(b.Close)

EventuallyTrue(t, func() bool {
return b.Len() == 0
})
t.Cleanup(startBlockedFetch(t, &b))

b.Fetch("key1", func() (string, error) {
return "value1", nil
})

t.Run("non-blocking Has", func(t *testing.T) {
b.Fetch("key1", func() (string, error) {
return "value1", nil
})
Equal(t, b.Has("key1"), true)
}

Equal(t, b.Has("key1"), true)
})
func TestFetchBlocksNonBlockingRange(t *testing.T) {
var b backend.Backend[string, string]
b.Init(0, "", 0, 0)
t.Cleanup(b.Close)

t.Run("non-blocking Range", func(t *testing.T) {
b.Fetch("key1", func() (string, error) {
return "value1", nil
})
t.Cleanup(startBlockedFetch(t, &b))

var keys []string
b.Range(func(key string, _ string) bool {
keys = append(keys, key)
return true
})
Equal(t, len(keys), 1)
Equal(t, keys[0], "key1")
b.Fetch("key1", func() (string, error) {
return "value1", nil
})

t.Run("Store discards the key", func(t *testing.T) {
b.Store("key", "value1")
var keys []string
b.Range(func(key string, _ string) bool {
keys = append(keys, key)
return true
})
Equal(t, len(keys), 1)
Equal(t, keys[0], "key1")
}

value, loaded := b.Load("key")
Equal(t, loaded, true)
Equal(t, value, "value1")
func TestStoreDiscardsInFlightFetch(t *testing.T) {
var b backend.Backend[string, string]
b.Init(0, "", 0, 0)
t.Cleanup(b.Close)

t.Log("assert that overwritten value exists after Fetch returns")
unblock := startBlockedFetch(t, &b)

// TODO: test setup scope
onceDone()
wg.Wait()
b.Store("key", "value1")

value, loaded = b.Load("key")
Equal(t, loaded, true)
Equal(t, value, "value1")
})
value, loaded := b.Load("key")
Equal(t, loaded, true)
Equal(t, value, "value1")

t.Log("assert that overwritten value exists after the in-flight Fetch returns")
unblock()

value, loaded = b.Load("key")
Equal(t, loaded, true)
Equal(t, value, "value1")
}

func TestFetchCallbackPanic(t *testing.T) {
Expand Down Expand Up @@ -428,8 +462,18 @@ func TestMapShrink(t *testing.T) {
t.Logf("alloc before:\t%d bytes", stats.Alloc)
oldSize := stats.Alloc

// Delete more than half elements.
for i := range n * 3 / 4 {
// Delete all but 10% of elements.
//
// Note: the map-shrink heuristic in delete() is an amortized O(1)
// compaction that guarantees the live map stays within roughly 2x
// of the current live count, not within a fixed fraction of the
// original size. Deleting only e.g. 75% here would let the final
// map settle right at the 2x-of-live-count boundary (worst case
// 2 * 25% = 50% of the original size), making the assertion below
// flaky depending on incidental map/bucket overhead. Deleting 90%
// leaves enough deletions for multiple compaction passes to fire,
// so the final map converges much closer to the live count.
for i := range n * 9 / 10 {
b.Evict(i)
}

Expand All @@ -439,7 +483,7 @@ func TestMapShrink(t *testing.T) {
newSize := stats.Alloc

return newSize < oldSize/2
})
}, 5*time.Second)

t.Logf("alloc after:\t%d bytes", stats.Alloc)

Expand Down
16 changes: 16 additions & 0 deletions list/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,22 @@ func TestMoveBackwards(t *testing.T) {
})
}

func TestEmptyList(t *testing.T) {
var l list.List[int]

assertEqual(t, l.Len(), 0)
assertEqual(t, l.Front(), (*list.Element[int])(nil))
assertEqual(t, l.Back(), (*list.Element[int])(nil))
}

func TestNewElementDetached(t *testing.T) {
e := list.NewElement(1)

assertEqual(t, e.Value, 1)
assertEqual(t, e.Next(), (*list.Element[int])(nil))
assertEqual(t, e.Prev(), (*list.Element[int])(nil))
}

func TestDo(t *testing.T) {
var l list.List[string]

Expand Down
Loading
Loading