all repos

mugit @ 75a25c9f1746e2a04b8748ac97fdceac8caf87f6

🐮 git server that your cow will love
3 files changed, 125 insertions(+), 61 deletions(-)
cache: refactor; tests
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-02-21 19:09:30 +0200
Change ID: kzxlnzxxplyowxpzrzvzzmxmylstrsvz
Parent: 9d07e8a
M internal/cache/cache.go

@@ -2,8 +2,6 @@ package cache

import ( "errors" - "sync" - "time" ) var ErrNotFound = errors.New("not found")

@@ -12,62 +10,3 @@ type Cacher[T any] interface {

Set(key string, val T) Get(key string) (val T, found bool) } - -type item[T any] struct { - v T - expiry time.Time -} - -func (i item[T]) isExpired() bool { - return time.Now().After(i.expiry) -} - -type InMemory[T any] struct { - mu sync.RWMutex - ttl time.Duration - data map[string]item[T] -} - -func NewInMemory[T any](ttl time.Duration) *InMemory[T] { - c := &InMemory[T]{ - data: make(map[string]item[T]), - ttl: ttl, - } - - go c.clean() - return c -} - -func (m *InMemory[T]) Set(key string, val T) { - m.mu.Lock() - defer m.mu.Unlock() - m.data[key] = item[T]{ - v: val, - expiry: time.Now().Add(m.ttl), - } -} - -func (m *InMemory[T]) Get(key string) (T, bool) { - m.mu.Lock() - defer m.mu.Unlock() - - val, found := m.data[key] - if !found || val.isExpired() { - var t T - return t, false - } - return val.v, true -} - -func (m *InMemory[T]) clean() { - for range time.Tick(5 * time.Second) { - m.mu.Lock() - for k, v := range m.data { - if v.isExpired() { - delete(m.data, k) - } - } - - m.mu.Unlock() - } -}
A internal/cache/in_memory.go

@@ -0,0 +1,64 @@

+package cache + +import ( + "sync" + "time" +) + +type item[T any] struct { + v T + expiry time.Time +} + +func (i item[T]) isExpired() bool { + return time.Now().After(i.expiry) +} + +type InMemory[T any] struct { + mu sync.RWMutex + ttl time.Duration + data map[string]item[T] +} + +func NewInMemory[T any](ttl time.Duration) *InMemory[T] { + c := &InMemory[T]{ + data: make(map[string]item[T]), + ttl: ttl, + } + + go c.clean() + return c +} + +func (m *InMemory[T]) Set(key string, val T) { + m.mu.Lock() + defer m.mu.Unlock() + m.data[key] = item[T]{ + v: val, + expiry: time.Now().Add(m.ttl), + } +} + +func (m *InMemory[T]) Get(key string) (T, bool) { + m.mu.Lock() + defer m.mu.Unlock() + + val, found := m.data[key] + if !found || val.isExpired() { + var t T + return t, false + } + return val.v, true +} + +func (m *InMemory[T]) clean() { + for range time.Tick(5 * time.Second) { + m.mu.Lock() + for k, v := range m.data { + if v.isExpired() { + delete(m.data, k) + } + } + m.mu.Unlock() + } +}
A internal/cache/in_memory_test.go

@@ -0,0 +1,61 @@

+package cache + +import ( + "fmt" + "sync" + "testing" + "testing/synctest" + "time" + + "olexsmir.xyz/x/is" +) + +func TestInMemory_Set(t *testing.T) { + c := NewInMemory[string](time.Minute) + t.Run("sets", func(t *testing.T) { + c.Set("asdf", "qwer") + is.Equal(t, c.data["asdf"].v, "qwer") + }) + t.Run("overwrites prev value", func(t *testing.T) { + c.Set("asdf", "one") + c.Set("asdf", "two") + is.Equal(t, c.data["asdf"].v, "two") + }) +} + +func TestInMemory_Get(t *testing.T) { + c := NewInMemory[string](time.Minute) + + t.Run("hit", func(t *testing.T) { + c.Set("asdf", "qwer") + v, found := c.Get("asdf") + is.Equal(t, true, found) + is.Equal(t, "qwer", v) + }) + t.Run("miss", func(t *testing.T) { + _, found := c.Get("missing") + is.Equal(t, false, found) + }) + t.Run("expired item", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + c.Set("asdf", "qwer") + time.Sleep(2 * time.Minute) + v, found := c.Get("asdf") + is.Equal(t, false, found) + is.Equal(t, "", v) + }) + }) +} + +func TestInMemory_ConcurrentSetGet(t *testing.T) { + c := NewInMemory[int](time.Minute) + synctest.Test(t, func(t *testing.T) { + var wg sync.WaitGroup + for i := range 50 { + key := fmt.Sprintf("key-%d", i) + wg.Go(func() { c.Set(key, i) }) + wg.Go(func() { c.Get(key) }) + } + wg.Wait() + }) +}