From 28ae29b7f35a25abb2ec0a71bb01637a272d1922 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 17:42:06 +0300 Subject: [PATCH 01/17] feat(cache): add cache services: `redis`, `memory` --- cache/cache.go | 83 +++++++++++ cache/memory.go | 336 +++++++++++++++++++++++++++++++++++++++++++ cache/memory_test.go | 187 ++++++++++++++++++++++++ cache/redis.go | 184 ++++++++++++++++++++++++ cache/redis_test.go | 106 ++++++++++++++ go.mod | 12 ++ go.sum | 15 ++ 7 files changed, 923 insertions(+) create mode 100644 cache/cache.go create mode 100644 cache/memory.go create mode 100644 cache/memory_test.go create mode 100644 cache/redis.go create mode 100644 cache/redis_test.go diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 0000000..3537e3f --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,83 @@ +package cache + +import ( + "context" + "time" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/redis/go-redis/v9" +) + +type Cache[T Container] interface { + // + Raw() T + + HSetEX( + ctx context.Context, + mainKey string, + childKey string, + value string, + ttl time.Duration, + ) yaerrors.Error + + HGet( + ctx context.Context, + mainKey string, + childKey string, + ) (string, yaerrors.Error) + + HGetAll( + ctx context.Context, + mainKey string, + ) (map[string]string, yaerrors.Error) + + HGetDelSingle( + ctx context.Context, + mainKey string, + childKey string, + ) (string, yaerrors.Error) + + HLen( + ctx context.Context, + mainKey string, + ) (int64, yaerrors.Error) + + HExist( + ctx context.Context, + mainKey string, + childKey string, + ) (bool, yaerrors.Error) + + HDelSingle( + ctx context.Context, + mainKey string, + childKey string, + ) yaerrors.Error + + // Ping checks if the cache service is available + Ping(ctx context.Context) yaerrors.Error + + // Close closes the cache connection + Close() yaerrors.Error +} + +type Container interface { + *redis.Client | MemoryContainer +} + +func NewCache[T Container](container T) Cache[T] { + switch _container := any(container).(type) { + case *redis.Client: + value, _ := any(NewRedis(_container)).(Cache[T]) + + return value + case MemoryContainer: + value, _ := any(NewMemory(_container, time.Minute)).(Cache[T]) + + return value + default: + value, _ := any(NewMemory(NewMemoryContainer(), time.Minute)).(Cache[T]) + + return value + } +} diff --git a/cache/memory.go b/cache/memory.go new file mode 100644 index 0000000..0f0c1ce --- /dev/null +++ b/cache/memory.go @@ -0,0 +1,336 @@ +package cache + +import ( + "context" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" +) + +const YaMapLen = `[_____YaMapLen_____YA_/\_CODE_/\_DEV]` + +type Memory struct { + data MemoryContainer + mutex sync.RWMutex + ticker *time.Ticker + done chan bool +} + +func NewMemory(data MemoryContainer, timeToClean time.Duration) *Memory { + cache := Memory{ + data: data, + mutex: sync.RWMutex{}, + ticker: time.NewTicker(timeToClean), + done: make(chan bool), + } + + go cache.cleanup() + + return &cache +} + +func (m *Memory) cleanup() { + select { + case <-m.ticker.C: + m.mutex.Lock() + + for mainKey, mainValue := range m.data { + for childKey, childValue := range mainValue { + if childValue.isExpired() { + delete(m.data[mainKey], childKey) + + if m.data.decrementLen(mainKey) == 0 { + delete(m.data, mainKey) + + break + } + } + } + } + + m.mutex.Unlock() + case <-m.done: + return + } +} + +func (m *Memory) Raw() MemoryContainer { + return m.data +} + +func (m *Memory) HSetEX( + _ context.Context, + mainKey string, + childKey string, + value string, + ttl time.Duration, +) yaerrors.Error { + m.mutex.Lock() + + defer m.mutex.Unlock() + + childMap, err := m.data.getChildMap(mainKey) + if err != nil { + childMap = make(map[string]*memoryCacheItem) + + m.data[mainKey] = childMap + } + + childMap[childKey] = newMemoryCachItemEX(value, time.Now().Add(ttl)) + + m.data.incrementLen(mainKey) + + return nil +} + +func (m *Memory) HGet( + _ context.Context, + mainKey string, + childKey string, +) (string, yaerrors.Error) { + m.mutex.RLock() + + defer m.mutex.RUnlock() + + childMap, err := m.data.getChildMap(mainKey) + if err != nil { + return "", err.Wrap("[MEMORY] failed to get map item") + } + + value, err := childMap.get(childKey) + if err != nil { + return "", err.Wrap("[MEMORY] failed to get map item") + } + + return value, nil +} + +func (m *Memory) HGetAll( + _ context.Context, + mainKey string, +) (map[string]string, yaerrors.Error) { + m.mutex.Lock() + + defer m.mutex.Unlock() + + childMap, err := m.data.getChildMap(mainKey) + if err != nil { + return nil, err.Wrap("[MEMORY] failed to get all map items") + } + + result := make(map[string]string) + + for key, value := range childMap { + if key != YaMapLen { + result[key] = value.Value + } + } + + return result, nil +} + +func (m *Memory) HGetDelSingle( + _ context.Context, + mainKey string, + childKey string, +) (string, yaerrors.Error) { + m.mutex.Lock() + + defer m.mutex.Unlock() + + childMap, err := m.data.getChildMap(mainKey) + if err != nil { + return "", err.Wrap("[MEMORY] failed to get and delete item") + } + + value := childMap[childKey] + + delete(childMap, childKey) + + m.data.decrementLen(mainKey) + + return value.Value, nil +} + +func (m *Memory) HLen( + _ context.Context, + mainKey string, +) (int64, yaerrors.Error) { + m.mutex.Lock() + + defer m.mutex.Unlock() + + return int64(m.data.getLen(mainKey)), nil +} + +func (m *Memory) HExist( + _ context.Context, + mainKey string, + childKey string, +) (bool, yaerrors.Error) { + m.mutex.Lock() + + defer m.mutex.Unlock() + + childMap, err := m.data.getChildMap(mainKey) + if err != nil { + return false, err.Wrap("[MEMORY] failed to check exist") + } + + return childMap.exist(childKey), nil +} + +func (m *Memory) HDelSingle( + _ context.Context, + mainKey string, + childKey string, +) yaerrors.Error { + m.mutex.Lock() + + defer m.mutex.Unlock() + + childMap, err := m.data.getChildMap(mainKey) + if err != nil { + return err.Wrap("[MEMORY] failed to delete item") + } + + delete(childMap, childKey) + + m.data.decrementLen(mainKey) + + return nil +} + +func (m *Memory) Ping() yaerrors.Error { + return nil +} + +func (m *Memory) Close() yaerrors.Error { + m.mutex.Lock() + + defer m.mutex.Unlock() + + for k := range m.data { + delete(m.data, k) + } + + m.done <- true + + return nil +} + +type memoryCacheItem struct { + Value string + ExpiresAt time.Time + Endless bool +} + +func newMemoryCachItem(value string) *memoryCacheItem { + return &memoryCacheItem{ + Value: value, + Endless: true, + } +} + +func newMemoryCachItemEX( + value string, + expiresAt time.Time, +) *memoryCacheItem { + return &memoryCacheItem{ + Value: value, + ExpiresAt: expiresAt, + Endless: false, + } +} + +func (m *memoryCacheItem) isExpired() bool { + return time.Now().After(m.ExpiresAt) && !m.Endless +} + +type ( + MemoryContainer map[string]childMemoryContainer + childMemoryContainer map[string]*memoryCacheItem +) + +func NewMemoryContainer() MemoryContainer { + return make(MemoryContainer) +} + +func (c childMemoryContainer) get(key string) (string, yaerrors.Error) { + value, ok := c[key] + if !ok { + return "", yaerrors.FromString( + http.StatusInternalServerError, + fmt.Sprintf("[MEMORY] failed to get value in child map by `%s`", key), + ) + } + + return value.Value, nil +} + +func (c childMemoryContainer) exist(key string) bool { + _, ok := c[key] + + return ok +} + +func (m MemoryContainer) getLen(mainKey string) int { + childMap, yaerr := m.getChildMap(mainKey) + if yaerr != nil { + return 0 + } + + value, ok := childMap[YaMapLen] + if !ok { + m[mainKey][YaMapLen] = newMemoryCachItem("0") + + return 0 + } + + count, err := strconv.Atoi(value.Value) + if err != nil { + return 0 + } + + return count +} + +func (m MemoryContainer) incrementLen(mainKey string) int { + value := m.getLen(mainKey) + + value++ + + m[mainKey][YaMapLen].Value = strconv.Itoa(value) + + return value +} + +func (m MemoryContainer) decrementLen(mainKey string) int { + value := m.getLen(mainKey) + + if value == 0 { + return 0 + } + + value-- + + m[mainKey][YaMapLen].Value = strconv.Itoa(value) + + return value +} + +func (m MemoryContainer) getChildMap(mainKey string) (childMemoryContainer, yaerrors.Error) { + childMap, ok := m[mainKey] + if !ok { + return nil, yaerrors.FromString( + http.StatusInternalServerError, + fmt.Sprintf("[MEMORY] failed to get main map by `%s`", mainKey), + ) + } + + return childMap, nil +} diff --git a/cache/memory_test.go b/cache/memory_test.go new file mode 100644 index 0000000..85c209b --- /dev/null +++ b/cache/memory_test.go @@ -0,0 +1,187 @@ +package cache_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/YaCodeDev/GoYaCodeDevUtils/cache" + "github.com/stretchr/testify/assert" +) + +const ( + yamainKey = "yamain" + yachildKey = "yachild" + yavalue = "yavalue" + yattl = time.Hour +) + +func TestMemory_New_Works(t *testing.T) { + memory := cache.NewMemory(cache.NewMemoryContainer(), time.Hour) + + assert.Equal(t, memory.Ping(), nil) +} + +func TestMemory_TTLCleanup_Works(t *testing.T) { + ctx := context.Background() + + tick := time.Second / 10 + + memory := cache.NewMemory(cache.NewMemoryContainer(), tick) + + memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, time.Microsecond) //nolint:errcheck + + time.Sleep(tick + (time.Millisecond * 5)) + + exist, _ := memory.HExist(ctx, yamainKey, yachildKey) + + expected := false + + assert.Equal(t, expected, exist) +} + +func TestMemory_InsertWorkflow_Works(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + memory := cache.NewMemory(cache.NewMemoryContainer(), time.Hour) + + err := memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, yattl) + if err != nil { + panic(err) + } + + t.Run("[HSetEX] insert value works", func(t *testing.T) { + value := memory.Raw()[yamainKey][yachildKey].Value + + assert.Equal(t, yavalue, value) + }) + + t.Run("[HSetEX] increment len works", func(t *testing.T) { + hlen, _ := memory.HLen(context.Background(), yamainKey) + + expected := int64(1) + + assert.Equal(t, expected, hlen) + }) +} + +func TestMemory_FetchWorkflow_Works(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + memory := cache.NewMemory(cache.NewMemoryContainer(), time.Hour) + + err := memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, yattl) + if err != nil { + panic(err) + } + + t.Run("[HExist] - works", func(t *testing.T) { + exist, _ := memory.HExist(ctx, yamainKey, yachildKey) + + expected := true + + assert.Equal(t, expected, exist) + }) + + t.Run("[HGet] - get item works", func(t *testing.T) { + value, _ := memory.HGet(ctx, yamainKey, yachildKey) + + assert.Equal(t, yavalue, value) + }) + + t.Run("[HGetAll] - get items works", func(t *testing.T) { + expected := make(map[string]string) + + expected[yachildKey] = yavalue + + for i := range 10 { + err := memory.HSetEX( + ctx, + yamainKey, + fmt.Sprintf("%s:%d", yachildKey, i), + fmt.Sprintf("%s:%d", yavalue, i), + yattl, + ) + if err != nil { + panic(err) + } + + expected[fmt.Sprintf("%s:%d", yachildKey, i)] = fmt.Sprintf("%s:%d", yavalue, i) + } + + result, _ := memory.HGetAll(ctx, yamainKey) + + assert.Equal(t, expected, result) + }) + + t.Run("[HGetDelSingle] - get and delete item works", func(t *testing.T) { + deleteMainKey := yamainKey + ":delete_test" + deleteChildKey := yachildKey + ":delete_test" + deleteValue := yavalue + ":delete_test" + + err := memory.HSetEX(ctx, deleteMainKey, deleteChildKey, deleteValue, yattl) + if err != nil { + panic(err) + } + + oldLen, _ := memory.HLen(ctx, deleteMainKey) + + value, _ := memory.HGetDelSingle(ctx, deleteMainKey, deleteChildKey) + + t.Run("[HGetDelSingle] - get works", func(t *testing.T) { + assert.Equal(t, deleteValue, value) + }) + + t.Run("[HGetDelSingle] - delete works", func(t *testing.T) { + _, err := memory.HGet(ctx, deleteMainKey, deleteChildKey) + + assert.NotNil(t, err) + }) + + t.Run("[HGetDelSingle] - decrement len works", func(t *testing.T) { + hlen, _ := memory.HLen(ctx, deleteMainKey) + + expected := oldLen - 1 + + assert.Equal(t, expected, hlen) + }) + }) +} + +func TestMemory_DeleteWorkflow_Works(t *testing.T) { + ctx := context.Background() + + memory := cache.NewMemory(cache.NewMemoryContainer(), time.Hour) + + err := memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, yattl) + if err != nil { + panic(err) + } + + oldLen, _ := memory.HLen(ctx, yamainKey) + + t.Run("[HDelSingle] - delete item works", func(t *testing.T) { + _ = memory.HDelSingle(ctx, yamainKey, yachildKey) + + t.Run("[HDelSingle] - not exists works", func(t *testing.T) { + exist, _ := memory.HExist(ctx, yamainKey, yachildKey) + + expected := false + + assert.Equal(t, exist, expected) + }) + + t.Run("[HDelSingle] - decrement len works", func(t *testing.T) { + hlen, _ := memory.HLen(ctx, yamainKey) + + expected := oldLen - 1 + + assert.Equal(t, expected, hlen) + }) + }) +} diff --git a/cache/redis.go b/cache/redis.go new file mode 100644 index 0000000..5f1a071 --- /dev/null +++ b/cache/redis.go @@ -0,0 +1,184 @@ +package cache + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/redis/go-redis/v9" +) + +type Redis struct { + client *redis.Client +} + +func NewRedis(client *redis.Client) *Redis { + return &Redis{ + client: client, + } +} + +func (r *Redis) Raw() *redis.Client { + return r.client +} + +func (r *Redis) HSetEX( + ctx context.Context, + mainKey string, + childKey string, + value string, + ttl time.Duration, +) yaerrors.Error { + if err := r.client.HSetEXWithArgs( + ctx, + mainKey, + &redis.HSetEXOptions{ + ExpirationType: redis.HSetEXExpirationEX, + ExpirationVal: int64(ttl.Seconds()), + }, + childKey, + value, + ).Err(); err != nil { + return yaerrors.FromError( + http.StatusInternalServerError, + err, + "[REDIS] failed to set new value in `HSETEX`", + ) + } + + return nil +} + +func (r *Redis) HGet( + ctx context.Context, + mainKey string, + childKey string, +) (string, yaerrors.Error) { + result, err := r.client.HGet(ctx, mainKey, childKey).Result() + if err != nil { + return "", yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[REDIS] failed to get value by `%s:%s`", mainKey, childKey), + ) + } + + return result, nil +} + +func (r *Redis) HGetAll( + ctx context.Context, + mainKey string, +) (map[string]string, yaerrors.Error) { + result, err := r.client.HGetAll(ctx, mainKey).Result() + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[REDIS] failed to get map values by `%s`", mainKey), + ) + } + + return result, nil +} + +func (r *Redis) HGetDelSingle( + ctx context.Context, + mainKey string, + childKey string, +) (string, yaerrors.Error) { + result, err := r.client.HGetDel(ctx, mainKey, childKey).Result() + if err != nil { + return "", yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[REDIS] failed to get and delete value by `%s:%s`", mainKey, childKey), + ) + } + + if len(result) == 0 { + return "", yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[REDIS] got empty value by `%s:%s`", mainKey, childKey), + ) + } + + return result[0], nil +} + +func (r *Redis) HLen( + ctx context.Context, + mainKey string, +) (int64, yaerrors.Error) { + result, err := r.client.HLen(ctx, mainKey).Result() + if err != nil { + return 0, yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[REDIS] failed to get len values by `%s`", mainKey), + ) + } + + return result, nil +} + +func (r *Redis) HExist( + ctx context.Context, + mainKey string, + childKey string, +) (bool, yaerrors.Error) { + result, err := r.client.HExists(ctx, mainKey, childKey).Result() + if err != nil { + return result, yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[REDIS] failed to get exists value by `%s:%s`", mainKey, childKey), + ) + } + + return result, nil +} + +func (r *Redis) HDelSingle( + ctx context.Context, + mainKey string, + childKey string, +) yaerrors.Error { + _, err := r.client.HDel(ctx, mainKey, childKey).Result() + if err != nil { + return yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[REDIS] failed to delete value by `%s:%s`", mainKey, childKey), + ) + } + + return nil +} + +func (r *Redis) Ping(ctx context.Context) yaerrors.Error { + if err := r.client.Ping(ctx).Err(); err != nil { + return yaerrors.FromError( + http.StatusInternalServerError, + err, + "[REDIS] failed to get `PONG`", + ) + } + + return nil +} + +func (r *Redis) Close() yaerrors.Error { + if err := r.client.Close(); err != nil { + return yaerrors.FromError( + http.StatusInternalServerError, + err, + "[REDIS] failed to close connection", + ) + } + + return nil +} diff --git a/cache/redis_test.go b/cache/redis_test.go new file mode 100644 index 0000000..e4cc52b --- /dev/null +++ b/cache/redis_test.go @@ -0,0 +1,106 @@ +package cache_test + +import ( + "context" + "fmt" + "testing" + + "github.com/YaCodeDev/GoYaCodeDevUtils/cache" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestRedis(t *testing.T) (*redis.Client, func()) { + mr, err := miniredis.Run() + + require.NoError(t, err) + + client := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + + cleanup := func() { + client.Close() + mr.Close() + } + + return client, cleanup +} + +func TestRedisCacheService(t *testing.T) { + client, cleanup := setupTestRedis(t) + defer cleanup() + + redis := cache.NewRedis(client) + + ctx := context.Background() + + t.Parallel() + + redis.Raw().HSet(ctx, yamainKey, yachildKey, yavalue) + + t.Run("[HGet] - get value works", func(t *testing.T) { + value, _ := redis.HGet(ctx, yamainKey, yachildKey) + + assert.Equal(t, yavalue, value) + }) + + t.Run("[HLen] - get len works", func(t *testing.T) { + hlen, _ := redis.HLen(context.Background(), yamainKey) + + expected := int64(1) + + assert.Equal(t, expected, hlen) + }) + + t.Run("[HGetAll] - get len works", func(t *testing.T) { + expected := make(map[string]string) + + expected[yachildKey] = yavalue + + for i := range 10 { + redis.Raw().HSet( + ctx, + yamainKey, + fmt.Sprintf("%s:%d", yachildKey, i), + fmt.Sprintf("%s:%d", yavalue, i), + ) + + expected[fmt.Sprintf("%s:%d", yachildKey, i)] = fmt.Sprintf("%s:%d", yavalue, i) + } + + hlen, _ := redis.HGetAll(ctx, yamainKey) + + assert.Equal(t, expected, hlen) + }) + + t.Run("[HDelSingle] - delete item works", func(t *testing.T) { + deleteMainKey := yamainKey + ":delete_test" + deleteChildKey := yachildKey + ":delete_test" + deleteValue := yavalue + ":delete_test" + + redis.Raw().HSet(ctx, deleteMainKey, deleteChildKey, deleteValue) + + oldLen, _ := redis.HLen(ctx, deleteMainKey) + + _ = redis.HDelSingle(ctx, deleteMainKey, deleteChildKey) + + t.Run("[HDelSingle] - not exists works", func(t *testing.T) { + exist, _ := redis.HExist(ctx, deleteMainKey, deleteChildKey) + + expected := false + + assert.Equal(t, exist, expected) + }) + + t.Run("[HDelSingle] - decrement len works", func(t *testing.T) { + hlen, _ := redis.HLen(ctx, deleteMainKey) + + expected := oldLen - 1 + + assert.Equal(t, expected, hlen) + }) + }) +} diff --git a/go.mod b/go.mod index 192ac1d..136c378 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,23 @@ module github.com/YaCodeDev/GoYaCodeDevUtils go 1.24.1 require ( + github.com/alicebob/miniredis/v2 v2.35.0 github.com/joho/godotenv v1.5.1 github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.7.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect ) require ( github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.10.0 golang.org/x/sys v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 36953a3..43aa2e8 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,35 @@ +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= +github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 0f31895e9d98997d491be352af7e4a5c36305c7e Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 17:51:59 +0300 Subject: [PATCH 02/17] chore(cache): fix naming, check `ok` returned map --- cache/memory.go | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/cache/memory.go b/cache/memory.go index 0f0c1ce..dc15695 100644 --- a/cache/memory.go +++ b/cache/memory.go @@ -34,27 +34,29 @@ func NewMemory(data MemoryContainer, timeToClean time.Duration) *Memory { } func (m *Memory) cleanup() { - select { - case <-m.ticker.C: - m.mutex.Lock() + for { + select { + case <-m.ticker.C: + m.mutex.Lock() - for mainKey, mainValue := range m.data { - for childKey, childValue := range mainValue { - if childValue.isExpired() { - delete(m.data[mainKey], childKey) + for mainKey, mainValue := range m.data { + for childKey, childValue := range mainValue { + if childValue.isExpired() { + delete(m.data[mainKey], childKey) - if m.data.decrementLen(mainKey) == 0 { - delete(m.data, mainKey) + if m.data.decrementLen(mainKey) == 0 { + delete(m.data, mainKey) - break + break + } } } } - } - m.mutex.Unlock() - case <-m.done: - return + m.mutex.Unlock() + case <-m.done: + return + } } } @@ -80,7 +82,7 @@ func (m *Memory) HSetEX( m.data[mainKey] = childMap } - childMap[childKey] = newMemoryCachItemEX(value, time.Now().Add(ttl)) + childMap[childKey] = newMemoryCacheItemEX(value, time.Now().Add(ttl)) m.data.incrementLen(mainKey) @@ -147,7 +149,10 @@ func (m *Memory) HGetDelSingle( return "", err.Wrap("[MEMORY] failed to get and delete item") } - value := childMap[childKey] + value, ok := childMap[childKey] + if !ok { + return "", yaerrors.FromString(http.StatusInternalServerError, "[MEMORY] childKey not found in childMap") + } delete(childMap, childKey) @@ -229,14 +234,14 @@ type memoryCacheItem struct { Endless bool } -func newMemoryCachItem(value string) *memoryCacheItem { +func newMemoryCacheItem(value string) *memoryCacheItem { return &memoryCacheItem{ Value: value, Endless: true, } } -func newMemoryCachItemEX( +func newMemoryCacheItemEX( value string, expiresAt time.Time, ) *memoryCacheItem { @@ -286,7 +291,7 @@ func (m MemoryContainer) getLen(mainKey string) int { value, ok := childMap[YaMapLen] if !ok { - m[mainKey][YaMapLen] = newMemoryCachItem("0") + m[mainKey][YaMapLen] = newMemoryCacheItem("0") return 0 } From 36ddaadb9b5c8e3701f32be405761d8a85f3b318 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 18:06:36 +0300 Subject: [PATCH 03/17] feat(cache): test assertion type services --- cache/cache_test.go | 34 ++++++++++++++++++++++++++++++++++ cache/memory.go | 2 +- cache/memory_test.go | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 cache/cache_test.go diff --git a/cache/cache_test.go b/cache/cache_test.go new file mode 100644 index 0000000..2bc7cfd --- /dev/null +++ b/cache/cache_test.go @@ -0,0 +1,34 @@ +package cache_test + +import ( + "context" + "testing" + + "github.com/YaCodeDev/GoYaCodeDevUtils/cache" + "github.com/stretchr/testify/assert" +) + +func TestCache_Initialize_Works(t *testing.T) { + ctx := context.Background() + + t.Parallel() + + t.Run("[Redis] initialize works", func(t *testing.T) { + client, cleanup := setupTestRedis(t) + defer cleanup() + + cache := cache.NewCache(client) + + result := cache.Ping(ctx) + + assert.Nil(t, result) + }) + + t.Run("[Memory] initialize works", func(t *testing.T) { + cache := cache.NewCache(cache.NewMemoryContainer()) + + result := cache.Ping(ctx) + + assert.Nil(t, result) + }) +} diff --git a/cache/memory.go b/cache/memory.go index dc15695..3975ff9 100644 --- a/cache/memory.go +++ b/cache/memory.go @@ -210,7 +210,7 @@ func (m *Memory) HDelSingle( return nil } -func (m *Memory) Ping() yaerrors.Error { +func (m *Memory) Ping(ctx context.Context) yaerrors.Error { return nil } diff --git a/cache/memory_test.go b/cache/memory_test.go index 85c209b..9deda18 100644 --- a/cache/memory_test.go +++ b/cache/memory_test.go @@ -20,7 +20,7 @@ const ( func TestMemory_New_Works(t *testing.T) { memory := cache.NewMemory(cache.NewMemoryContainer(), time.Hour) - assert.Equal(t, memory.Ping(), nil) + assert.Equal(t, memory.Ping(context.Background()), nil) } func TestMemory_TTLCleanup_Works(t *testing.T) { From a97baca8d68444424bf81f88f4116c32e0696530 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 18:15:33 +0300 Subject: [PATCH 04/17] feat(cache): add create redis client --- cache/redis.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/cache/redis.go b/cache/redis.go index 5f1a071..ceee857 100644 --- a/cache/redis.go +++ b/cache/redis.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "net/http" + "strconv" "time" "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" "github.com/redis/go-redis/v9" ) @@ -20,6 +22,33 @@ func NewRedis(client *redis.Client) *Redis { } } +func NewRedisClient( + host string, + port uint16, + password string, + db int, + log yalogger.Logger, +) *redis.Client { + redisAddr := fmt.Sprintf("%s:%s", host, strconv.Itoa(int(port))) + + log.Infof("Redis connecting to addr %s", redisAddr) + + client := redis.NewClient(&redis.Options{ + Addr: redisAddr, + Password: password, + DB: db, + Network: "tcp4", + }) + + if err := client.Ping(context.Background()).Err(); err != nil { + log.Fatalf("Failed to connect redis: %e", err) + } + + log.Infof("Redis connected to addr %s", redisAddr) + + return client +} + func (r *Redis) Raw() *redis.Client { return r.client } From d21003b3b0a4878269c0e85cf7da06cd5a1ac999 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 18:16:13 +0300 Subject: [PATCH 05/17] chore(cache): remove define unused param --- cache/memory.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cache/memory.go b/cache/memory.go index 3975ff9..5c9483e 100644 --- a/cache/memory.go +++ b/cache/memory.go @@ -210,7 +210,7 @@ func (m *Memory) HDelSingle( return nil } -func (m *Memory) Ping(ctx context.Context) yaerrors.Error { +func (m *Memory) Ping(_ context.Context) yaerrors.Error { return nil } From c6771f2aae062187800f14284eb238c7c1c21a99 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 18:22:38 +0300 Subject: [PATCH 06/17] perf(cache): use r(lock/unlock) in get memory cach --- cache/memory.go | 12 ++++++------ cache/redis.go | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cache/memory.go b/cache/memory.go index 5c9483e..9661c1e 100644 --- a/cache/memory.go +++ b/cache/memory.go @@ -115,9 +115,9 @@ func (m *Memory) HGetAll( _ context.Context, mainKey string, ) (map[string]string, yaerrors.Error) { - m.mutex.Lock() + m.mutex.RLock() - defer m.mutex.Unlock() + defer m.mutex.RUnlock() childMap, err := m.data.getChildMap(mainKey) if err != nil { @@ -165,9 +165,9 @@ func (m *Memory) HLen( _ context.Context, mainKey string, ) (int64, yaerrors.Error) { - m.mutex.Lock() + m.mutex.RLock() - defer m.mutex.Unlock() + defer m.mutex.RUnlock() return int64(m.data.getLen(mainKey)), nil } @@ -177,9 +177,9 @@ func (m *Memory) HExist( mainKey string, childKey string, ) (bool, yaerrors.Error) { - m.mutex.Lock() + m.mutex.RLock() - defer m.mutex.Unlock() + defer m.mutex.RUnlock() childMap, err := m.data.getChildMap(mainKey) if err != nil { diff --git a/cache/redis.go b/cache/redis.go index ceee857..27e41b2 100644 --- a/cache/redis.go +++ b/cache/redis.go @@ -41,7 +41,7 @@ func NewRedisClient( }) if err := client.Ping(context.Background()).Err(); err != nil { - log.Fatalf("Failed to connect redis: %e", err) + log.Fatalf("Failed to connect redis: %v", err) } log.Infof("Redis connected to addr %s", redisAddr) From 830b11eb4e3fcb7756b7b22e46594504cb44207a Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 19:09:31 +0300 Subject: [PATCH 07/17] feat(cache): add docs --- cache/cache.go | 151 ++++++++++++++++++++++++++++++++++++- cache/memory.go | 197 ++++++++++++++++++++++++++++++++++++++++++++---- cache/redis.go | 106 ++++++++++++++++++++++++++ 3 files changed, 439 insertions(+), 15 deletions(-) diff --git a/cache/cache.go b/cache/cache.go index 3537e3f..1082241 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -1,3 +1,61 @@ +// Package cache provides a generic, pluggable key–value cache abstraction with two +// concrete back‑ends: an in-memory map protected by a RW‑mutex and a Redis hash‑map +// wrapper. Both back-ends expose the same high-level API so that callers can switch +// implementations without changing their business logic. +// +// The public API is intentionally kept small and focused on hash‑like semantics in +// order to cover 90 % of typical caching use‑cases (session stores, idempotency +// keys, short‑lived tokens, etc.) while still being easy to reason about and test. +// +// # Generic design +// +// The package is written using Go 1.22 generics. The [Cache] interface is +// parameterised by a single type parameter T constrained to either *redis.Client or +// MemoryContainer. This allows the concrete implementation to expose its raw +// driver value via [Cache.Raw] without resorting to unsafe type assertions. +// +// # Thread‑safety +// +// - [Redis] is as thread‑safe as the underlying go‑redis/v9 client. +// - [Memory] uses a sync.RWMutex to protect all reads/writes. Long‑running calls +// such as the background TTL sweeper acquire the mutex only for short, bounded +// periods. +// +// # Error handling +// +// All methods return the custom yaerrors.Error type so that callers get +// stack‑traces and HTTP status codes for free. The helper wrappers translate +// driver‑specific errors into this common representation. +// +// # Time‑to‑live (TTL) +// +// The Redis back‑end relies on the HSETEX command and therefore delegates TTL +// handling to Redis. The memory back‑end stores the absolute expiry timestamp in +// each [memoryCacheItem] and relies on a background [Memory.cleanup] goroutine to +// evict expired entries. +// +// ───────────────────────────────────────────────────────────────────────────── +// # Quick start (in-memory) +// +// ```go +// memory := cache.NewCache(cache.NewMemoryContainer()) +// ctx := context.Background() +// _ = memory.HSetEX(ctx, "u:42", "token", "abc", time.Minute) +// value, _ := memory.HGet(ctx, "u:42", "token") +// fmt.Println(value) // "abc" +// ``` +// +// # Quick start (Redis) +// +// ```go +// client := cache.NewRedisClient("localhost", uint16(6379), "", 1, log) +// redis := cache.NewCache(client) +// ctx := context.Background() +// _ = redis.HSetEX(ctx, "jobs", "id1", "yacodder", 0) +// job, _ := redis.HGetDelSingle(ctx, "jobs", "id1") +// fmt.Println(job) // "yacodder" +// ``` +// ───────────────────────────────────────────────────────────────────────────── package cache import ( @@ -8,10 +66,37 @@ import ( "github.com/redis/go-redis/v9" ) +// Cache is a generic, hash‑oriented cache abstraction. +// +// The type parameter T must satisfy [Container] and is used by [Cache.Raw] to +// return the underlying low‑level client (*redis.Client or MemoryContainer). +// +// The API surface mirrors a subset of Redis hash commands (HSETEX, HGET, etc.) +// because this data‑model maps well to most caching scenarios while still keeping +// the implementation portable across back‑ends. +// +// All write‑operations use copy‑semantics – the value is cloned into an internal +// buffer. Callers are therefore safe to mutate the slice/struct after the method +// returns. +// +// Each method returns a yaerrors.Error instead of the built‑in error so that the +// caller can propagate HTTP status codes and stack‑traces up the call‑stack. type Cache[T Container] interface { + // Raw exposes the concrete client. Use this for advanced operations that are + // outside the scope of the high‑level API – e.g., Lua scripts on Redis or a + // full clone of the in‑memory map for debugging. // + // Example: + // + // client := c.Raw() // *redis.Client when Redis backend is active Raw() T + // HSetEX sets (childKey,value) under mainKey and assigns a TTL. If the key + // already exists its value is overwritten and the TTL is refreshed. + // + // Example: + // + // _ = c.HSetEX(ctx, "sessions", "token", "abc", time.Minute) HSetEX( ctx context.Context, mainKey string, @@ -20,51 +105,113 @@ type Cache[T Container] interface { ttl time.Duration, ) yaerrors.Error + // HGet fetches a single field from the hash. If the pair does not exist + // (either the mainKey or childKey is missing) a yaerrors.Error with HTTP 500 is + // returned. + // + // Example: + // + // value, _ := c.HGet(ctx, "sessions", "token") HGet( ctx context.Context, mainKey string, childKey string, ) (string, yaerrors.Error) + // HGetAll returns a shallow copy of the hash (childKey→value). The internal + // bookkeeping key YaMapLen is filtered out automatically. + // + // Example: + // + // values, _ := c.HGetAll(ctx, "sessions") HGetAll( ctx context.Context, mainKey string, ) (map[string]string, yaerrors.Error) + // HGetDelSingle is an atomic *read‑and‑delete* helper. It returns the value + // that was stored under childKey and then deletes exactly that field. If the + // resulting hash becomes empty the Redis backend will leave an empty hash + // while the memory backend deletes the entire map to free memory. + // + // Example: + // + // value, _ := c.HGetDelSingle(ctx, "jobs", "yacodder") HGetDelSingle( ctx context.Context, mainKey string, childKey string, ) (string, yaerrors.Error) + // HLen returns the number of *user* fields in the hash (YaMapLen is excluded). + // + // Example: + // + // hlen, _ := c.HLen(ctx, "sessions") HLen( ctx context.Context, mainKey string, ) (int64, yaerrors.Error) + // HExist answers whether the specific childKey exists in the hash. + // + // Example: + // + // ok, _ := c.HExist(ctx, "sessions", "token") HExist( ctx context.Context, mainKey string, childKey string, ) (bool, yaerrors.Error) + // HDelSingle deletes exactly one field from the hash. + // + // Example: + // + // _ = c.HDelSingle(ctx, "sessions", "token") HDelSingle( ctx context.Context, mainKey string, childKey string, ) yaerrors.Error - // Ping checks if the cache service is available + // Ping verifies that the cache service is reachable and healthy. + // + // Example: + // + // _ = c.Ping(ctx) Ping(ctx context.Context) yaerrors.Error - // Close closes the cache connection + // Close flushes buffers and releases resources. + // + // Example: + // + // _ = c.Close() Close() yaerrors.Error } +// Container is the union (via type-set) of all back‑end client types the generic +// cache can wrap. Add new back‑ends by extending this constraint and updating +// NewCache accordingly. type Container interface { *redis.Client | MemoryContainer } +// NewCache performs a *runtime* type‑switch on the supplied container to create +// the appropriate concrete implementation. When an unsupported type is +// provided a fallback in‑memory cache with a default 1‑minute sweep interval is +// returned so that callers never get a nil value. +// +// Example: +// +// MEMORY +// +// memory := cache.NewCache(cache.NewMemoryContainer()) +// +// REDIS +// +// client := cache.NewRedisClient("localhost", uint16(6379), "", 1, log) +// redis := cache.NewCache(client) func NewCache[T Container](container T) Cache[T] { switch _container := any(container).(type) { case *redis.Client: diff --git a/cache/memory.go b/cache/memory.go index 9661c1e..acf9b82 100644 --- a/cache/memory.go +++ b/cache/memory.go @@ -1,3 +1,9 @@ +// ========================= In‑memory implementation ========================= // + +// Memory is a threadsafe, TTL‑aware map‑backed cache suitable for single‑process +// applications or unit‑tests. A background goroutine cleans up expired entries +// at a fixed interval specified by timeToClean. + package cache import ( @@ -11,15 +17,32 @@ import ( "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" ) -const YaMapLen = `[_____YaMapLen_____YA_/\_CODE_/\_DEV]` +const yaMapLen = `[_____YaMapLen_____YA_/\_CODE_/\_DEV]` +// Memory is a threadsafe, TTL‑aware map‑backed cache. +// +// Example (create + basic operations): +// +// memory := cache.NewMemory(cache.NewMemoryContainer(), time.Minute) +// _ = memory.HSetEX(ctx, "main", "field", "v", time.Hour) +// hlen, _ := memory.HLen(ctx, "main") +// fmt.Println(hlen) // 1 type Memory struct { - data MemoryContainer - mutex sync.RWMutex - ticker *time.Ticker - done chan bool + data MemoryContainer // nested map mainKey → childKey → *memoryCacheItem + mutex sync.RWMutex // guards *all* access to data + ticker *time.Ticker // drives the cleanup loop + done chan bool // signals the goroutine to exit on Close() } +// NewMemory builds a new [Memory] cache instance and immediately starts the +// background sweeper. +// +// data – caller‑provided map; pass NewMemoryContainer() for an empty cache +// timeToClean – sweep interval; choose a value >> typical TTL to amortise cost +// +// Example: +// +// memory := cache.NewMemory(cache.NewMemoryContainer(), 30*time.Second) func NewMemory(data MemoryContainer, timeToClean time.Duration) *Memory { cache := Memory{ data: data, @@ -33,6 +56,9 @@ func NewMemory(data MemoryContainer, timeToClean time.Duration) *Memory { return &cache } +// cleanup runs in its own goroutine, periodically scanning the entire map for +// expired items. Complexity is O(totalItems) but the operation is spread out in +// time thanks to the ticker. func (m *Memory) cleanup() { for { select { @@ -45,6 +71,7 @@ func (m *Memory) cleanup() { delete(m.data[mainKey], childKey) if m.data.decrementLen(mainKey) == 0 { + // remove empty top‑level map to free memory and keep Len accurate delete(m.data, mainKey) break @@ -60,10 +87,20 @@ func (m *Memory) cleanup() { } } +// Raw returns the underlying MemoryContainer. +// +// Example: +// +// raw := mem.Raw() func (m *Memory) Raw() MemoryContainer { return m.data } +// HSetEX implementation for Memory. +// +// Example: +// +// _ = mem.HSetEX(ctx, "main", "field", "val", time.Minute) func (m *Memory) HSetEX( _ context.Context, mainKey string, @@ -89,6 +126,11 @@ func (m *Memory) HSetEX( return nil } +// HGet implementation for Memory. +// +// Example: +// +// value, _ := memory.HGet(ctx, "main", "field") func (m *Memory) HGet( _ context.Context, mainKey string, @@ -111,6 +153,11 @@ func (m *Memory) HGet( return value, nil } +// HGetAll implementation for Memory. +// +// Example: +// +// main, _ := memory.HGetAll(ctx, "main") func (m *Memory) HGetAll( _ context.Context, mainKey string, @@ -127,7 +174,7 @@ func (m *Memory) HGetAll( result := make(map[string]string) for key, value := range childMap { - if key != YaMapLen { + if key != yaMapLen { result[key] = value.Value } } @@ -135,6 +182,11 @@ func (m *Memory) HGetAll( return result, nil } +// HGetDelSingle implementation for Memory. +// +// Example: +// +// value, _ := mem.HGetDelSingle(ctx, "jobs", "id‑1") func (m *Memory) HGetDelSingle( _ context.Context, mainKey string, @@ -161,6 +213,7 @@ func (m *Memory) HGetDelSingle( return value.Value, nil } +// HLen implements [Cache.HLen] for the in‑memory back‑end. func (m *Memory) HLen( _ context.Context, mainKey string, @@ -172,6 +225,11 @@ func (m *Memory) HLen( return int64(m.data.getLen(mainKey)), nil } +// HExist reports whether the childKey exists. +// +// Example: +// +// ok, _ := memory.HExist(ctx, "k", "f") func (m *Memory) HExist( _ context.Context, mainKey string, @@ -189,6 +247,11 @@ func (m *Memory) HExist( return childMap.exist(childKey), nil } +// HGetDelSingle atomically fetches and deletes. +// +// Example: +// +// v, _ := memory.HGetDelSingle(ctx, "jobs", "id-1") func (m *Memory) HDelSingle( _ context.Context, mainKey string, @@ -210,10 +273,20 @@ func (m *Memory) HDelSingle( return nil } +// Ping always succeeds for the in‑memory backend. +// +// Example: +// +// _ = memory.Ping(ctx) func (m *Memory) Ping(_ context.Context) yaerrors.Error { return nil } +// Close stops the sweeper and clears the map. +// +// Example: +// +// _ = memory.Close() func (m *Memory) Close() yaerrors.Error { m.mutex.Lock() @@ -228,12 +301,37 @@ func (m *Memory) Close() yaerrors.Error { return nil } +// memoryCacheItem is the atomic unit stored inside the in-memory cache. +// It keeps the actual value together with TTL metadata. +// +// - Value – payload the user saved. +// - ExpiresAt – absolute point in time when the item becomes stale +// (ignored if Endless is true). +// - Endless – true means “no TTL at all”, so the item never expires. +// +// Example: +// +// // A value without TTL. +// item := newMemoryCacheItem("forever") +// fmt.Println(item.Value) // "forever" +// fmt.Println(item.isExpired())// false +// +// // A value that lives only one second. +// item = newMemoryCacheItemEX("short-lived", time.Now().Add(time.Second)) +// time.Sleep(1100 * time.Millisecond) +// fmt.Println(item.isExpired())// true type memoryCacheItem struct { - Value string - ExpiresAt time.Time - Endless bool + Value string // user payload + ExpiresAt time.Time // TTL deadline (ignored when Endless) + Endless bool // true → infinite lifetime } +// newMemoryCacheItem returns a non-expiring cache item. +// +// Example: +// +// item := newMemoryCacheItem("immutable") +// _ = item // use item in a MemoryContainer func newMemoryCacheItem(value string) *memoryCacheItem { return &memoryCacheItem{ Value: value, @@ -241,6 +339,14 @@ func newMemoryCacheItem(value string) *memoryCacheItem { } } +// newMemoryCacheItemEX returns a cache item that expires at the +// supplied timestamp. +// +// Example: +// +// exp := time.Now().Add(5 * time.Minute) +// item := newMemoryCacheItemEX("with-ttl", exp) +// fmt.Println(item.Endless) // false func newMemoryCacheItemEX( value string, expiresAt time.Time, @@ -252,19 +358,53 @@ func newMemoryCacheItemEX( } } +// isExpired reports whether the item’s TTL has elapsed. +// Endless items are never reported as expired. +// +// Example: +// +// item := newMemoryCacheItem("forever") +// fmt.Println(item.isExpired()) // false func (m *memoryCacheItem) isExpired() bool { return time.Now().After(m.ExpiresAt) && !m.Endless } +// MemoryContainer is the backing store for the in-memory Cache +// implementation. It is a two-level map: +// +// mainKey ─┬─ childKey → *memoryCacheItem +// └─ YaMapLen (service key) → *memoryCacheItem(lenCounter) +// +// The service key **YaMapLen** keeps a running count of children to avoid +// walking the whole map on every HLen call. +// +// Example: +// +// mc := NewMemoryContainer() +// userMap := make(map[string]*memoryCacheItem) +// userMap["name"] = newMemoryCacheItem("Alice") +// mc["user:42"] = userMap type ( MemoryContainer map[string]childMemoryContainer childMemoryContainer map[string]*memoryCacheItem ) +// NewMemoryContainer allocates an empty MemoryContainer. +// +// Example: +// +// container := NewMemoryContainer() +// fmt.Println(len(container)) // 0 func NewMemoryContainer() MemoryContainer { return make(MemoryContainer) } +// get returns the payload stored under childKey or an error if absent. +// +// Example: +// +// val, err := container["profile"].get("avatar") +// if err != nil { … } func (c childMemoryContainer) get(key string) (string, yaerrors.Error) { value, ok := c[key] if !ok { @@ -277,21 +417,33 @@ func (c childMemoryContainer) get(key string) (string, yaerrors.Error) { return value.Value, nil } +// exist reports whether childKey is present. +// +// Example: +// +// ok := container["profile"].exist("avatar") func (c childMemoryContainer) exist(key string) bool { _, ok := c[key] return ok } +// getLen returns how many “business” items (excluding YaMapLen) live under +// mainKey. Zero is returned for non-existent maps. +// +// Example: +// +// count := container.getLen("session") +// fmt.Println(count) // 0 func (m MemoryContainer) getLen(mainKey string) int { childMap, yaerr := m.getChildMap(mainKey) if yaerr != nil { return 0 } - value, ok := childMap[YaMapLen] + value, ok := childMap[yaMapLen] if !ok { - m[mainKey][YaMapLen] = newMemoryCacheItem("0") + m[mainKey][yaMapLen] = newMemoryCacheItem("0") return 0 } @@ -304,16 +456,28 @@ func (m MemoryContainer) getLen(mainKey string) int { return count } +// incrementLen atomically increases the stored length counter for mainKey +// and returns the new value. +// +// Example: +// +// newLen := container.incrementLen("session") func (m MemoryContainer) incrementLen(mainKey string) int { value := m.getLen(mainKey) value++ - m[mainKey][YaMapLen].Value = strconv.Itoa(value) + m[mainKey][yaMapLen].Value = strconv.Itoa(value) return value } +// decrementLen decreases the length counter for mainKey (never below zero) +// and returns the new value. +// +// Example: +// +// newLen := container.decrementLen("session") func (m MemoryContainer) decrementLen(mainKey string) int { value := m.getLen(mainKey) @@ -323,11 +487,18 @@ func (m MemoryContainer) decrementLen(mainKey string) int { value-- - m[mainKey][YaMapLen].Value = strconv.Itoa(value) + m[mainKey][yaMapLen].Value = strconv.Itoa(value) return value } +// getChildMap fetches the inner map for mainKey or returns an error if the +// key does not exist. +// +// Example: +// +// child, err := container.getChildMap("user:42") +// if err != nil { … } func (m MemoryContainer) getChildMap(mainKey string) (childMemoryContainer, yaerrors.Error) { childMap, ok := m[mainKey] if !ok { diff --git a/cache/redis.go b/cache/redis.go index 27e41b2..9b3941e 100644 --- a/cache/redis.go +++ b/cache/redis.go @@ -12,16 +12,51 @@ import ( "github.com/redis/go-redis/v9" ) +// Redis wraps a *redis.Client and implements the Cache interface. +// +// It intentionally exposes only the subset of commands used by the +// in-memory implementation, so that your business-layer code can switch +// between Redis and Memory without `// +build` tags or extra plumbing. +// +// # Typical usage +// +// ```go +// client := cache.NewRedisClient("localhost", uint16(6379), "", 1, log) +// redis := cache.NewCache(client) +// ctx := context.Background() +// _ = redis.HSetEX(ctx, "jobs", "id1", "yacodder", 0) +// job, _ := redis.HGetDelSingle(ctx, "jobs", "id1") +// fmt.Println(job) // "yacodder" +// ``` type Redis struct { client *redis.Client } +// NewRedis turns an already-configured *redis.Client into a **Redis** cache. +// +// Use it when the application creates the low-level client itself +// (e.g. your DI container, connection pool manager, or tests). +// +// Example: +// +// client := cache.NewRedisClient("localhost", uint16(6379), "", 1, log) +// redis := cache.NewCache(client) +// _ = cache.Ping(context.Background()) func NewRedis(client *redis.Client) *Redis { return &Redis{ client: client, } } +// NewRedisClient dials a real Redis instance and performs an initial PING. +// +// It logs both the connection attempt and the final status via the +// supplied yalogger.Logger. On failure the logger’s Fatalf terminates +// the process, mirroring the standard library’s `log.Fatalf` semantics. +// +// Example: +// +// client := cache.NewRedisClient("127.0.0.1", 6379, "", 0, log) func NewRedisClient( host string, port uint16, @@ -49,10 +84,25 @@ func NewRedisClient( return client } +// Raw exposes the underlying *redis.Client so that advanced commands +// (e.g. Lua scripts, pipelines) can still be reached when absolutely +// necessary. Prefer the high-level helpers when possible. +// +// Example: +// +// if err := r.Raw().FlushDB(ctx).Err(); err != nil { … } func (r *Redis) Raw() *redis.Client { return r.client } +// HSetEX stores field → value under mainKey with an absolute TTL. +// +// Internally it uses Redis 7.0 `HSETEX` command (via go-redis helper). +// +// Example: +// +// ttl := 10 * time.Second +// _ = redis.HSetEX(ctx, "session:token", "userID", "42", ttl) func (r *Redis) HSetEX( ctx context.Context, mainKey string, @@ -80,6 +130,14 @@ func (r *Redis) HSetEX( return nil } +// HGet returns the value previously stored by HSetEX. +// +// Returns an error if the key/field pair is missing. +// +// Example: +// +// value, err := redis.HGet(ctx, "session:token", "userID") +// if err != nil { … } func (r *Redis) HGet( ctx context.Context, mainKey string, @@ -97,6 +155,14 @@ func (r *Redis) HGet( return result, nil } +// HGetAll fetches the entire hash under mainKey. +// +// Example: +// +// values, _ := redis.HGetAll(ctx, "user:42") +// for key, value := range values { +// fmt.Printf("%s = %s\n", key, value) +// } func (r *Redis) HGetAll( ctx context.Context, mainKey string, @@ -113,6 +179,14 @@ func (r *Redis) HGetAll( return result, nil } +// HGetDelSingle atomically retrieves *and* deletes one field. +// +// Useful for queue-like semantics without Lua scripting. +// +// Example: +// +// value, _ := redis.HGetDelSingle(ctx, "jobs:ready", "job123") +// // job123 is now removed from the hash func (r *Redis) HGetDelSingle( ctx context.Context, mainKey string, @@ -138,6 +212,12 @@ func (r *Redis) HGetDelSingle( return result[0], nil } +// HLen reports how many fields a hash contains. +// +// Example: +// +// hlen, _ := redis.HLen(ctx, "cart:42") +// fmt.Println("items in cart:", hlen) func (r *Redis) HLen( ctx context.Context, mainKey string, @@ -154,6 +234,12 @@ func (r *Redis) HLen( return result, nil } +// HExist tells whether a particular field exists. +// +// Example: +// +// ok, _ := redis.HExist(ctx, "user:42", "email") +// if !ok { … } func (r *Redis) HExist( ctx context.Context, mainKey string, @@ -171,6 +257,11 @@ func (r *Redis) HExist( return result, nil } +// HDelSingle removes one field from the hash. +// +// Example: +// +// _ = redis.HDelSingle(ctx, "cart:42", "item:99") func (r *Redis) HDelSingle( ctx context.Context, mainKey string, @@ -188,6 +279,14 @@ func (r *Redis) HDelSingle( return nil } +// Ping sends the Redis PING command. +// +// It is called by unit tests to guarantee that NewCache(client) +// returns a live service. +// +// Example: +// +// if err := r.Ping(ctx); err != nil { … } func (r *Redis) Ping(ctx context.Context) yaerrors.Error { if err := r.client.Ping(ctx).Err(); err != nil { return yaerrors.FromError( @@ -200,6 +299,13 @@ func (r *Redis) Ping(ctx context.Context) yaerrors.Error { return nil } +// Close closes the underlying connection(s). Always call it in `defer` +// when you created the *redis.Client* yourself. +// +// Example: +// +// redis := cache.NewRedis(rdb) +// defer redis.Close() func (r *Redis) Close() yaerrors.Error { if err := r.client.Close(); err != nil { return yaerrors.FromError( From 24e518b41f06977060206a6cbd524eef8b95452c Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 19:12:17 +0300 Subject: [PATCH 08/17] feat(cache): transfer to `yacache` --- {cache => yacache}/cache.go | 2 +- {cache => yacache}/cache_test.go | 8 ++++---- {cache => yacache}/memory.go | 2 +- {cache => yacache}/memory_test.go | 14 +++++++------- {cache => yacache}/redis.go | 2 +- {cache => yacache}/redis_test.go | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) rename {cache => yacache}/cache.go (99%) rename {cache => yacache}/cache_test.go (73%) rename {cache => yacache}/memory.go (99%) rename {cache => yacache}/memory_test.go (90%) rename {cache => yacache}/redis.go (99%) rename {cache => yacache}/redis_test.go (95%) diff --git a/cache/cache.go b/yacache/cache.go similarity index 99% rename from cache/cache.go rename to yacache/cache.go index 1082241..0349877 100644 --- a/cache/cache.go +++ b/yacache/cache.go @@ -56,7 +56,7 @@ // fmt.Println(job) // "yacodder" // ``` // ───────────────────────────────────────────────────────────────────────────── -package cache +package yacache import ( "context" diff --git a/cache/cache_test.go b/yacache/cache_test.go similarity index 73% rename from cache/cache_test.go rename to yacache/cache_test.go index 2bc7cfd..8a1689a 100644 --- a/cache/cache_test.go +++ b/yacache/cache_test.go @@ -1,10 +1,10 @@ -package cache_test +package yacache_test import ( "context" "testing" - "github.com/YaCodeDev/GoYaCodeDevUtils/cache" + "github.com/YaCodeDev/GoYaCodeDevUtils/yacache" "github.com/stretchr/testify/assert" ) @@ -17,7 +17,7 @@ func TestCache_Initialize_Works(t *testing.T) { client, cleanup := setupTestRedis(t) defer cleanup() - cache := cache.NewCache(client) + cache := yacache.NewCache(client) result := cache.Ping(ctx) @@ -25,7 +25,7 @@ func TestCache_Initialize_Works(t *testing.T) { }) t.Run("[Memory] initialize works", func(t *testing.T) { - cache := cache.NewCache(cache.NewMemoryContainer()) + cache := yacache.NewCache(yacache.NewMemoryContainer()) result := cache.Ping(ctx) diff --git a/cache/memory.go b/yacache/memory.go similarity index 99% rename from cache/memory.go rename to yacache/memory.go index acf9b82..39c946d 100644 --- a/cache/memory.go +++ b/yacache/memory.go @@ -4,7 +4,7 @@ // applications or unit‑tests. A background goroutine cleans up expired entries // at a fixed interval specified by timeToClean. -package cache +package yacache import ( "context" diff --git a/cache/memory_test.go b/yacache/memory_test.go similarity index 90% rename from cache/memory_test.go rename to yacache/memory_test.go index 9deda18..c081c80 100644 --- a/cache/memory_test.go +++ b/yacache/memory_test.go @@ -1,4 +1,4 @@ -package cache_test +package yacache_test import ( "context" @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/YaCodeDev/GoYaCodeDevUtils/cache" + "github.com/YaCodeDev/GoYaCodeDevUtils/yacache" "github.com/stretchr/testify/assert" ) @@ -18,7 +18,7 @@ const ( ) func TestMemory_New_Works(t *testing.T) { - memory := cache.NewMemory(cache.NewMemoryContainer(), time.Hour) + memory := yacache.NewMemory(yacache.NewMemoryContainer(), time.Hour) assert.Equal(t, memory.Ping(context.Background()), nil) } @@ -28,7 +28,7 @@ func TestMemory_TTLCleanup_Works(t *testing.T) { tick := time.Second / 10 - memory := cache.NewMemory(cache.NewMemoryContainer(), tick) + memory := yacache.NewMemory(yacache.NewMemoryContainer(), tick) memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, time.Microsecond) //nolint:errcheck @@ -46,7 +46,7 @@ func TestMemory_InsertWorkflow_Works(t *testing.T) { ctx := context.Background() - memory := cache.NewMemory(cache.NewMemoryContainer(), time.Hour) + memory := yacache.NewMemory(yacache.NewMemoryContainer(), time.Hour) err := memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, yattl) if err != nil { @@ -73,7 +73,7 @@ func TestMemory_FetchWorkflow_Works(t *testing.T) { ctx := context.Background() - memory := cache.NewMemory(cache.NewMemoryContainer(), time.Hour) + memory := yacache.NewMemory(yacache.NewMemoryContainer(), time.Hour) err := memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, yattl) if err != nil { @@ -156,7 +156,7 @@ func TestMemory_FetchWorkflow_Works(t *testing.T) { func TestMemory_DeleteWorkflow_Works(t *testing.T) { ctx := context.Background() - memory := cache.NewMemory(cache.NewMemoryContainer(), time.Hour) + memory := yacache.NewMemory(yacache.NewMemoryContainer(), time.Hour) err := memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, yattl) if err != nil { diff --git a/cache/redis.go b/yacache/redis.go similarity index 99% rename from cache/redis.go rename to yacache/redis.go index 9b3941e..347bfc7 100644 --- a/cache/redis.go +++ b/yacache/redis.go @@ -1,4 +1,4 @@ -package cache +package yacache import ( "context" diff --git a/cache/redis_test.go b/yacache/redis_test.go similarity index 95% rename from cache/redis_test.go rename to yacache/redis_test.go index e4cc52b..046975d 100644 --- a/cache/redis_test.go +++ b/yacache/redis_test.go @@ -1,11 +1,11 @@ -package cache_test +package yacache_test import ( "context" "fmt" "testing" - "github.com/YaCodeDev/GoYaCodeDevUtils/cache" + "github.com/YaCodeDev/GoYaCodeDevUtils/yacache" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" @@ -33,7 +33,7 @@ func TestRedisCacheService(t *testing.T) { client, cleanup := setupTestRedis(t) defer cleanup() - redis := cache.NewRedis(client) + redis := yacache.NewRedis(client) ctx := context.Background() From 9e80e129d863849e29515a75b58198d75e666501 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 19:13:15 +0300 Subject: [PATCH 09/17] chore(yacache): `cache.go` transfer to `yacache.go` --- yacache/{cache.go => yacache.go} | 0 yacache/{cache_test.go => yacache_test.go} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename yacache/{cache.go => yacache.go} (100%) rename yacache/{cache_test.go => yacache_test.go} (100%) diff --git a/yacache/cache.go b/yacache/yacache.go similarity index 100% rename from yacache/cache.go rename to yacache/yacache.go diff --git a/yacache/cache_test.go b/yacache/yacache_test.go similarity index 100% rename from yacache/cache_test.go rename to yacache/yacache_test.go From 957903fe9ebca2201ce987886e3868a6ed11c370 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 19:14:24 +0300 Subject: [PATCH 10/17] chore(yacache): create from nil error removed --- yacache/redis.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/yacache/redis.go b/yacache/redis.go index 347bfc7..87fd17b 100644 --- a/yacache/redis.go +++ b/yacache/redis.go @@ -202,9 +202,8 @@ func (r *Redis) HGetDelSingle( } if len(result) == 0 { - return "", yaerrors.FromError( + return "", yaerrors.FromString( http.StatusInternalServerError, - err, fmt.Sprintf("[REDIS] got empty value by `%s:%s`", mainKey, childKey), ) } From 5b2554b42f3ef454f9626862e74ba930db551d6c Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 19:15:41 +0300 Subject: [PATCH 11/17] chore(yacache): remove comment for lint --- yacache/memory_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yacache/memory_test.go b/yacache/memory_test.go index c081c80..1a1524f 100644 --- a/yacache/memory_test.go +++ b/yacache/memory_test.go @@ -30,7 +30,7 @@ func TestMemory_TTLCleanup_Works(t *testing.T) { memory := yacache.NewMemory(yacache.NewMemoryContainer(), tick) - memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, time.Microsecond) //nolint:errcheck + _ = memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, time.Microsecond) time.Sleep(tick + (time.Millisecond * 5)) From 56878ba94e7d60eb35b86a262e3e386688f4f932 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 19:44:57 +0300 Subject: [PATCH 12/17] chore(yacache): remove useless condition check --- yacache/memory.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/yacache/memory.go b/yacache/memory.go index 39c946d..fc52d08 100644 --- a/yacache/memory.go +++ b/yacache/memory.go @@ -481,10 +481,6 @@ func (m MemoryContainer) incrementLen(mainKey string) int { func (m MemoryContainer) decrementLen(mainKey string) int { value := m.getLen(mainKey) - if value == 0 { - return 0 - } - value-- m[mainKey][yaMapLen].Value = strconv.Itoa(value) From 82e56c87f3e37aae2d1863e0e14682477cd135de Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 22:35:13 +0300 Subject: [PATCH 13/17] chore(yacache): make own errors instead string --- yacache/errors.go | 12 ++++++++++++ yacache/memory.go | 14 ++++++++++---- yacache/redis.go | 3 ++- 3 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 yacache/errors.go diff --git a/yacache/errors.go b/yacache/errors.go new file mode 100644 index 0000000..588fad6 --- /dev/null +++ b/yacache/errors.go @@ -0,0 +1,12 @@ +package yacache + +import "errors" + +var ( + ErrFailedToGetChildMap = errors.New("[MEMORY] failed to get child map") + ErrFailedToGetValueInChildMap = errors.New("[MEMORY] failed to get value in child map") + ErrKeyNotFoundInChildMap = errors.New("[MEMORY] childKey not found in childMap") + + ErrRedisKeyNotFound = errors.New("[REDIS] key not found") + ErrRedisNotFoundValueInChildMap = errors.New("[REDIS] not found value in child map") +) diff --git a/yacache/memory.go b/yacache/memory.go index fc52d08..afb5ff8 100644 --- a/yacache/memory.go +++ b/yacache/memory.go @@ -203,7 +203,11 @@ func (m *Memory) HGetDelSingle( value, ok := childMap[childKey] if !ok { - return "", yaerrors.FromString(http.StatusInternalServerError, "[MEMORY] childKey not found in childMap") + return "", yaerrors.FromError( + http.StatusInternalServerError, + ErrKeyNotFoundInChildMap, + "[MEMORY] failed to get and delete item", + ) } delete(childMap, childKey) @@ -408,8 +412,9 @@ func NewMemoryContainer() MemoryContainer { func (c childMemoryContainer) get(key string) (string, yaerrors.Error) { value, ok := c[key] if !ok { - return "", yaerrors.FromString( + return "", yaerrors.FromError( http.StatusInternalServerError, + ErrFailedToGetValueInChildMap, fmt.Sprintf("[MEMORY] failed to get value in child map by `%s`", key), ) } @@ -498,9 +503,10 @@ func (m MemoryContainer) decrementLen(mainKey string) int { func (m MemoryContainer) getChildMap(mainKey string) (childMemoryContainer, yaerrors.Error) { childMap, ok := m[mainKey] if !ok { - return nil, yaerrors.FromString( + return nil, yaerrors.FromError( http.StatusInternalServerError, - fmt.Sprintf("[MEMORY] failed to get main map by `%s`", mainKey), + ErrFailedToGetChildMap, + fmt.Sprintf("[MEMORY] failed to get child map by `%s`", mainKey), ) } diff --git a/yacache/redis.go b/yacache/redis.go index 87fd17b..ea6bbc0 100644 --- a/yacache/redis.go +++ b/yacache/redis.go @@ -202,8 +202,9 @@ func (r *Redis) HGetDelSingle( } if len(result) == 0 { - return "", yaerrors.FromString( + return "", yaerrors.FromError( http.StatusInternalServerError, + ErrRedisNotFoundValueInChildMap, fmt.Sprintf("[REDIS] got empty value by `%s:%s`", mainKey, childKey), ) } From cc9efea04d4bc9c274f0e0f58de7f6fbe85e0ae4 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 22:44:56 +0300 Subject: [PATCH 14/17] feat(yacache): use `weak` pointer in `cleanup` --- yacache/memory.go | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/yacache/memory.go b/yacache/memory.go index afb5ff8..8ccac86 100644 --- a/yacache/memory.go +++ b/yacache/memory.go @@ -13,6 +13,7 @@ import ( "strconv" "sync" "time" + "weak" "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" ) @@ -31,7 +32,7 @@ type Memory struct { data MemoryContainer // nested map mainKey → childKey → *memoryCacheItem mutex sync.RWMutex // guards *all* access to data ticker *time.Ticker // drives the cleanup loop - done chan bool // signals the goroutine to exit on Close() + done chan struct{} // signals the goroutine to exit on Close() } // NewMemory builds a new [Memory] cache instance and immediately starts the @@ -43,15 +44,15 @@ type Memory struct { // Example: // // memory := cache.NewMemory(cache.NewMemoryContainer(), 30*time.Second) -func NewMemory(data MemoryContainer, timeToClean time.Duration) *Memory { +func NewMemory(data MemoryContainer, tickToClean time.Duration) *Memory { cache := Memory{ data: data, mutex: sync.RWMutex{}, - ticker: time.NewTicker(timeToClean), - done: make(chan bool), + ticker: time.NewTicker(tickToClean), + done: make(chan struct{}), } - go cache.cleanup() + go cleanup(weak.Make(&cache), tickToClean, cache.done) return &cache } @@ -59,20 +60,32 @@ func NewMemory(data MemoryContainer, timeToClean time.Duration) *Memory { // cleanup runs in its own goroutine, periodically scanning the entire map for // expired items. Complexity is O(totalItems) but the operation is spread out in // time thanks to the ticker. -func (m *Memory) cleanup() { +func cleanup( + pointer weak.Pointer[Memory], + tickToClean time.Duration, + done <-chan struct{}, +) { + ticker := time.NewTicker(tickToClean) + for { select { - case <-m.ticker.C: - m.mutex.Lock() + case <-ticker.C: + memory := pointer.Value() + + if memory == nil { + return + } + + memory.mutex.Lock() - for mainKey, mainValue := range m.data { + for mainKey, mainValue := range memory.data { for childKey, childValue := range mainValue { if childValue.isExpired() { - delete(m.data[mainKey], childKey) + delete(memory.data[mainKey], childKey) - if m.data.decrementLen(mainKey) == 0 { + if memory.data.decrementLen(mainKey) == 0 { // remove empty top‑level map to free memory and keep Len accurate - delete(m.data, mainKey) + delete(memory.data, mainKey) break } @@ -80,8 +93,8 @@ func (m *Memory) cleanup() { } } - m.mutex.Unlock() - case <-m.done: + memory.mutex.Unlock() + case <-done: return } } @@ -300,7 +313,7 @@ func (m *Memory) Close() yaerrors.Error { delete(m.data, k) } - m.done <- true + m.done <- struct{}{} return nil } From fe12ee3a43079695f22eb3ba703e7aaa2f9ebda0 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 22:46:35 +0300 Subject: [PATCH 15/17] chore(yacache): change `YaMapLen` naming in docs --- yacache/memory.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yacache/memory.go b/yacache/memory.go index 8ccac86..7a07e00 100644 --- a/yacache/memory.go +++ b/yacache/memory.go @@ -39,7 +39,7 @@ type Memory struct { // background sweeper. // // data – caller‑provided map; pass NewMemoryContainer() for an empty cache -// timeToClean – sweep interval; choose a value >> typical TTL to amortise cost +// tickToClean – sweep interval; choose a value >> typical TTL to amortise cost // // Example: // @@ -390,9 +390,9 @@ func (m *memoryCacheItem) isExpired() bool { // implementation. It is a two-level map: // // mainKey ─┬─ childKey → *memoryCacheItem -// └─ YaMapLen (service key) → *memoryCacheItem(lenCounter) +// └─ yaMapLen (service key) → *memoryCacheItem(lenCounter) // -// The service key **YaMapLen** keeps a running count of children to avoid +// The service key **yaMapLen** keeps a running count of children to avoid // walking the whole map on every HLen call. // // Example: @@ -446,7 +446,7 @@ func (c childMemoryContainer) exist(key string) bool { return ok } -// getLen returns how many “business” items (excluding YaMapLen) live under +// getLen returns how many “business” items (excluding yaMapLen) live under // mainKey. Zero is returned for non-existent maps. // // Example: From 284c2893e999b88625cf1c041596e249d77659ec Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 22:47:48 +0300 Subject: [PATCH 16/17] feat(yacache): safety check nil log --- yacache/redis.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/yacache/redis.go b/yacache/redis.go index ea6bbc0..4aedeb2 100644 --- a/yacache/redis.go +++ b/yacache/redis.go @@ -66,6 +66,10 @@ func NewRedisClient( ) *redis.Client { redisAddr := fmt.Sprintf("%s:%s", host, strconv.Itoa(int(port))) + if log == nil { + log = yalogger.NewBaseLogger(nil).NewLogger() + } + log.Infof("Redis connecting to addr %s", redisAddr) client := redis.NewClient(&redis.Options{ From c16326fd997c26865fa6672dcb215e999851819f Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Sun, 22 Jun 2025 23:14:54 +0300 Subject: [PATCH 17/17] feat(yacache): make better own errors for wrapping --- yacache/errors.go | 16 ++++++++++------ yacache/memory.go | 34 ++++++++++++++++++++-------------- yacache/redis.go | 41 +++++++++++++++++++++-------------------- 3 files changed, 51 insertions(+), 40 deletions(-) diff --git a/yacache/errors.go b/yacache/errors.go index 588fad6..631ccfb 100644 --- a/yacache/errors.go +++ b/yacache/errors.go @@ -3,10 +3,14 @@ package yacache import "errors" var ( - ErrFailedToGetChildMap = errors.New("[MEMORY] failed to get child map") - ErrFailedToGetValueInChildMap = errors.New("[MEMORY] failed to get value in child map") - ErrKeyNotFoundInChildMap = errors.New("[MEMORY] childKey not found in childMap") - - ErrRedisKeyNotFound = errors.New("[REDIS] key not found") - ErrRedisNotFoundValueInChildMap = errors.New("[REDIS] not found value in child map") + ErrFailedToSetNewValue = errors.New("[CACHE] failed to set new value in `HSETEX`") + ErrFailedToGetValue = errors.New("[CACHE] failed to get value") + ErrFailedToGetValues = errors.New("[CACHE] failed to get values") + ErrFailedToGetDeleteSingle = errors.New("[CACHE] faildet to get and delete value") + ErrNotFoundValue = errors.New("[CACHE] not found value") + ErrFailedToGetLen = errors.New("[CACHE] failed to get len") + ErrFailedToGetExist = errors.New("[CACHE] failed to get exists value") + ErrFailedToDeleteSingle = errors.New("[CACHE] failed to delete value") + ErrFailedPing = errors.New("[CACHE] failed to get `PONG` from ping") + ErrFailedToCloseBackend = errors.New("[CACHE] failed to close backend") ) diff --git a/yacache/memory.go b/yacache/memory.go index 7a07e00..a4ed617 100644 --- a/yacache/memory.go +++ b/yacache/memory.go @@ -125,7 +125,7 @@ func (m *Memory) HSetEX( defer m.mutex.Unlock() - childMap, err := m.data.getChildMap(mainKey) + childMap, err := m.data.getChildMap(mainKey, ErrFailedToSetNewValue) if err != nil { childMap = make(map[string]*memoryCacheItem) @@ -153,12 +153,12 @@ func (m *Memory) HGet( defer m.mutex.RUnlock() - childMap, err := m.data.getChildMap(mainKey) + childMap, err := m.data.getChildMap(mainKey, ErrFailedToGetValue) if err != nil { return "", err.Wrap("[MEMORY] failed to get map item") } - value, err := childMap.get(childKey) + value, err := childMap.get(childKey, ErrNotFoundValue) if err != nil { return "", err.Wrap("[MEMORY] failed to get map item") } @@ -179,7 +179,7 @@ func (m *Memory) HGetAll( defer m.mutex.RUnlock() - childMap, err := m.data.getChildMap(mainKey) + childMap, err := m.data.getChildMap(mainKey, ErrFailedToGetValues) if err != nil { return nil, err.Wrap("[MEMORY] failed to get all map items") } @@ -209,7 +209,7 @@ func (m *Memory) HGetDelSingle( defer m.mutex.Unlock() - childMap, err := m.data.getChildMap(mainKey) + childMap, err := m.data.getChildMap(mainKey, ErrFailedToGetDeleteSingle) if err != nil { return "", err.Wrap("[MEMORY] failed to get and delete item") } @@ -218,8 +218,8 @@ func (m *Memory) HGetDelSingle( if !ok { return "", yaerrors.FromError( http.StatusInternalServerError, - ErrKeyNotFoundInChildMap, - "[MEMORY] failed to get and delete item", + ErrNotFoundValue, + fmt.Sprintf("[MEMORY] failed `HGETDEL` by %s:%s", mainKey, childKey), ) } @@ -256,7 +256,7 @@ func (m *Memory) HExist( defer m.mutex.RUnlock() - childMap, err := m.data.getChildMap(mainKey) + childMap, err := m.data.getChildMap(mainKey, ErrFailedToGetExist) if err != nil { return false, err.Wrap("[MEMORY] failed to check exist") } @@ -278,7 +278,7 @@ func (m *Memory) HDelSingle( defer m.mutex.Unlock() - childMap, err := m.data.getChildMap(mainKey) + childMap, err := m.data.getChildMap(mainKey, ErrFailedToDeleteSingle) if err != nil { return err.Wrap("[MEMORY] failed to delete item") } @@ -422,12 +422,15 @@ func NewMemoryContainer() MemoryContainer { // // val, err := container["profile"].get("avatar") // if err != nil { … } -func (c childMemoryContainer) get(key string) (string, yaerrors.Error) { +func (c childMemoryContainer) get( + key string, + wrapErr error, +) (string, yaerrors.Error) { value, ok := c[key] if !ok { return "", yaerrors.FromError( http.StatusInternalServerError, - ErrFailedToGetValueInChildMap, + wrapErr, fmt.Sprintf("[MEMORY] failed to get value in child map by `%s`", key), ) } @@ -454,7 +457,7 @@ func (c childMemoryContainer) exist(key string) bool { // count := container.getLen("session") // fmt.Println(count) // 0 func (m MemoryContainer) getLen(mainKey string) int { - childMap, yaerr := m.getChildMap(mainKey) + childMap, yaerr := m.getChildMap(mainKey, ErrFailedToGetLen) if yaerr != nil { return 0 } @@ -513,12 +516,15 @@ func (m MemoryContainer) decrementLen(mainKey string) int { // // child, err := container.getChildMap("user:42") // if err != nil { … } -func (m MemoryContainer) getChildMap(mainKey string) (childMemoryContainer, yaerrors.Error) { +func (m MemoryContainer) getChildMap( + mainKey string, + wrapErr error, +) (childMemoryContainer, yaerrors.Error) { childMap, ok := m[mainKey] if !ok { return nil, yaerrors.FromError( http.StatusInternalServerError, - ErrFailedToGetChildMap, + wrapErr, fmt.Sprintf("[MEMORY] failed to get child map by `%s`", mainKey), ) } diff --git a/yacache/redis.go b/yacache/redis.go index 4aedeb2..b42fd04 100644 --- a/yacache/redis.go +++ b/yacache/redis.go @@ -2,6 +2,7 @@ package yacache import ( "context" + "errors" "fmt" "net/http" "strconv" @@ -126,8 +127,8 @@ func (r *Redis) HSetEX( ).Err(); err != nil { return yaerrors.FromError( http.StatusInternalServerError, - err, - "[REDIS] failed to set new value in `HSETEX`", + errors.Join(err, ErrFailedToSetNewValue), + "[REDIS] failed `HSETEX`", ) } @@ -151,8 +152,8 @@ func (r *Redis) HGet( if err != nil { return "", yaerrors.FromError( http.StatusInternalServerError, - err, - fmt.Sprintf("[REDIS] failed to get value by `%s:%s`", mainKey, childKey), + errors.Join(err, ErrFailedToGetValue), + fmt.Sprintf("[REDIS] failed `HGET` by `%s:%s`", mainKey, childKey), ) } @@ -175,8 +176,8 @@ func (r *Redis) HGetAll( if err != nil { return nil, yaerrors.FromError( http.StatusInternalServerError, - err, - fmt.Sprintf("[REDIS] failed to get map values by `%s`", mainKey), + errors.Join(err, ErrFailedToGetValues), + fmt.Sprintf("[REDIS] failed `HGETALL` by `%s`", mainKey), ) } @@ -200,16 +201,16 @@ func (r *Redis) HGetDelSingle( if err != nil { return "", yaerrors.FromError( http.StatusInternalServerError, - err, - fmt.Sprintf("[REDIS] failed to get and delete value by `%s:%s`", mainKey, childKey), + errors.Join(err, ErrFailedToGetDeleteSingle), + fmt.Sprintf("[REDIS] failed `HGETDEL` by `%s:%s`", mainKey, childKey), ) } if len(result) == 0 { return "", yaerrors.FromError( http.StatusInternalServerError, - ErrRedisNotFoundValueInChildMap, - fmt.Sprintf("[REDIS] got empty value by `%s:%s`", mainKey, childKey), + errors.Join(err, ErrNotFoundValue), + fmt.Sprintf("[REDIS] not found value by `%s:%s`", mainKey, childKey), ) } @@ -230,8 +231,8 @@ func (r *Redis) HLen( if err != nil { return 0, yaerrors.FromError( http.StatusInternalServerError, - err, - fmt.Sprintf("[REDIS] failed to get len values by `%s`", mainKey), + errors.Join(err, ErrFailedToGetLen), + fmt.Sprintf("[REDIS] failed `HLEN` by `%s`", mainKey), ) } @@ -253,8 +254,8 @@ func (r *Redis) HExist( if err != nil { return result, yaerrors.FromError( http.StatusInternalServerError, - err, - fmt.Sprintf("[REDIS] failed to get exists value by `%s:%s`", mainKey, childKey), + errors.Join(err, ErrFailedToGetExist), + fmt.Sprintf("[REDIS] failed `HEXIST` by `%s:%s`", mainKey, childKey), ) } @@ -275,8 +276,8 @@ func (r *Redis) HDelSingle( if err != nil { return yaerrors.FromError( http.StatusInternalServerError, - err, - fmt.Sprintf("[REDIS] failed to delete value by `%s:%s`", mainKey, childKey), + errors.Join(err, ErrFailedToDeleteSingle), + fmt.Sprintf("[REDIS] failed `HDEL` by `%s:%s`", mainKey, childKey), ) } @@ -295,8 +296,8 @@ func (r *Redis) Ping(ctx context.Context) yaerrors.Error { if err := r.client.Ping(ctx).Err(); err != nil { return yaerrors.FromError( http.StatusInternalServerError, - err, - "[REDIS] failed to get `PONG`", + errors.Join(err, ErrFailedPing), + "[REDIS] failed `PING`", ) } @@ -314,8 +315,8 @@ func (r *Redis) Close() yaerrors.Error { if err := r.client.Close(); err != nil { return yaerrors.FromError( http.StatusInternalServerError, - err, - "[REDIS] failed to close connection", + errors.Join(err, ErrFailedToCloseBackend), + "[REDIS] failed `CLOSE`", ) }