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= diff --git a/yacache/errors.go b/yacache/errors.go new file mode 100644 index 0000000..631ccfb --- /dev/null +++ b/yacache/errors.go @@ -0,0 +1,16 @@ +package yacache + +import "errors" + +var ( + 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 new file mode 100644 index 0000000..a4ed617 --- /dev/null +++ b/yacache/memory.go @@ -0,0 +1,533 @@ +// ========================= 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 yacache + +import ( + "context" + "fmt" + "net/http" + "strconv" + "sync" + "time" + "weak" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" +) + +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 // nested map mainKey → childKey → *memoryCacheItem + mutex sync.RWMutex // guards *all* access to data + ticker *time.Ticker // drives the cleanup loop + done chan struct{} // 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 +// tickToClean – sweep interval; choose a value >> typical TTL to amortise cost +// +// Example: +// +// memory := cache.NewMemory(cache.NewMemoryContainer(), 30*time.Second) +func NewMemory(data MemoryContainer, tickToClean time.Duration) *Memory { + cache := Memory{ + data: data, + mutex: sync.RWMutex{}, + ticker: time.NewTicker(tickToClean), + done: make(chan struct{}), + } + + go cleanup(weak.Make(&cache), tickToClean, cache.done) + + 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 cleanup( + pointer weak.Pointer[Memory], + tickToClean time.Duration, + done <-chan struct{}, +) { + ticker := time.NewTicker(tickToClean) + + for { + select { + case <-ticker.C: + memory := pointer.Value() + + if memory == nil { + return + } + + memory.mutex.Lock() + + for mainKey, mainValue := range memory.data { + for childKey, childValue := range mainValue { + if childValue.isExpired() { + delete(memory.data[mainKey], childKey) + + if memory.data.decrementLen(mainKey) == 0 { + // remove empty top‑level map to free memory and keep Len accurate + delete(memory.data, mainKey) + + break + } + } + } + } + + memory.mutex.Unlock() + case <-done: + return + } + } +} + +// 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, + childKey string, + value string, + ttl time.Duration, +) yaerrors.Error { + m.mutex.Lock() + + defer m.mutex.Unlock() + + childMap, err := m.data.getChildMap(mainKey, ErrFailedToSetNewValue) + if err != nil { + childMap = make(map[string]*memoryCacheItem) + + m.data[mainKey] = childMap + } + + childMap[childKey] = newMemoryCacheItemEX(value, time.Now().Add(ttl)) + + m.data.incrementLen(mainKey) + + return nil +} + +// HGet implementation for Memory. +// +// Example: +// +// value, _ := memory.HGet(ctx, "main", "field") +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, ErrFailedToGetValue) + if err != nil { + return "", err.Wrap("[MEMORY] failed to get map item") + } + + value, err := childMap.get(childKey, ErrNotFoundValue) + if err != nil { + return "", err.Wrap("[MEMORY] failed to get map item") + } + + return value, nil +} + +// HGetAll implementation for Memory. +// +// Example: +// +// main, _ := memory.HGetAll(ctx, "main") +func (m *Memory) HGetAll( + _ context.Context, + mainKey string, +) (map[string]string, yaerrors.Error) { + m.mutex.RLock() + + defer m.mutex.RUnlock() + + childMap, err := m.data.getChildMap(mainKey, ErrFailedToGetValues) + 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 +} + +// HGetDelSingle implementation for Memory. +// +// Example: +// +// value, _ := mem.HGetDelSingle(ctx, "jobs", "id‑1") +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, ErrFailedToGetDeleteSingle) + if err != nil { + return "", err.Wrap("[MEMORY] failed to get and delete item") + } + + value, ok := childMap[childKey] + if !ok { + return "", yaerrors.FromError( + http.StatusInternalServerError, + ErrNotFoundValue, + fmt.Sprintf("[MEMORY] failed `HGETDEL` by %s:%s", mainKey, childKey), + ) + } + + delete(childMap, childKey) + + m.data.decrementLen(mainKey) + + return value.Value, nil +} + +// HLen implements [Cache.HLen] for the in‑memory back‑end. +func (m *Memory) HLen( + _ context.Context, + mainKey string, +) (int64, yaerrors.Error) { + m.mutex.RLock() + + defer m.mutex.RUnlock() + + 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, + childKey string, +) (bool, yaerrors.Error) { + m.mutex.RLock() + + defer m.mutex.RUnlock() + + childMap, err := m.data.getChildMap(mainKey, ErrFailedToGetExist) + if err != nil { + return false, err.Wrap("[MEMORY] failed to check exist") + } + + 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, + childKey string, +) yaerrors.Error { + m.mutex.Lock() + + defer m.mutex.Unlock() + + childMap, err := m.data.getChildMap(mainKey, ErrFailedToDeleteSingle) + if err != nil { + return err.Wrap("[MEMORY] failed to delete item") + } + + delete(childMap, childKey) + + m.data.decrementLen(mainKey) + + 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() + + defer m.mutex.Unlock() + + for k := range m.data { + delete(m.data, k) + } + + m.done <- struct{}{} + + 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 // 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, + Endless: true, + } +} + +// 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, +) *memoryCacheItem { + return &memoryCacheItem{ + Value: value, + ExpiresAt: expiresAt, + Endless: false, + } +} + +// 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, + wrapErr error, +) (string, yaerrors.Error) { + value, ok := c[key] + if !ok { + return "", yaerrors.FromError( + http.StatusInternalServerError, + wrapErr, + fmt.Sprintf("[MEMORY] failed to get value in child map by `%s`", key), + ) + } + + 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, ErrFailedToGetLen) + if yaerr != nil { + return 0 + } + + value, ok := childMap[yaMapLen] + if !ok { + m[mainKey][yaMapLen] = newMemoryCacheItem("0") + + return 0 + } + + count, err := strconv.Atoi(value.Value) + if err != nil { + return 0 + } + + 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) + + 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) + + 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, + wrapErr error, +) (childMemoryContainer, yaerrors.Error) { + childMap, ok := m[mainKey] + if !ok { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + wrapErr, + fmt.Sprintf("[MEMORY] failed to get child map by `%s`", mainKey), + ) + } + + return childMap, nil +} diff --git a/yacache/memory_test.go b/yacache/memory_test.go new file mode 100644 index 0000000..1a1524f --- /dev/null +++ b/yacache/memory_test.go @@ -0,0 +1,187 @@ +package yacache_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yacache" + "github.com/stretchr/testify/assert" +) + +const ( + yamainKey = "yamain" + yachildKey = "yachild" + yavalue = "yavalue" + yattl = time.Hour +) + +func TestMemory_New_Works(t *testing.T) { + memory := yacache.NewMemory(yacache.NewMemoryContainer(), time.Hour) + + assert.Equal(t, memory.Ping(context.Background()), nil) +} + +func TestMemory_TTLCleanup_Works(t *testing.T) { + ctx := context.Background() + + tick := time.Second / 10 + + memory := yacache.NewMemory(yacache.NewMemoryContainer(), tick) + + _ = memory.HSetEX(ctx, yamainKey, yachildKey, yavalue, time.Microsecond) + + 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 := yacache.NewMemory(yacache.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 := yacache.NewMemory(yacache.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 := yacache.NewMemory(yacache.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/yacache/redis.go b/yacache/redis.go new file mode 100644 index 0000000..b42fd04 --- /dev/null +++ b/yacache/redis.go @@ -0,0 +1,324 @@ +package yacache + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" + "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, + password string, + db int, + log yalogger.Logger, +) *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{ + Addr: redisAddr, + Password: password, + DB: db, + Network: "tcp4", + }) + + if err := client.Ping(context.Background()).Err(); err != nil { + log.Fatalf("Failed to connect redis: %v", err) + } + + log.Infof("Redis connected to addr %s", redisAddr) + + 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, + 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, + errors.Join(err, ErrFailedToSetNewValue), + "[REDIS] failed `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, + childKey string, +) (string, yaerrors.Error) { + result, err := r.client.HGet(ctx, mainKey, childKey).Result() + if err != nil { + return "", yaerrors.FromError( + http.StatusInternalServerError, + errors.Join(err, ErrFailedToGetValue), + fmt.Sprintf("[REDIS] failed `HGET` by `%s:%s`", mainKey, childKey), + ) + } + + 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, +) (map[string]string, yaerrors.Error) { + result, err := r.client.HGetAll(ctx, mainKey).Result() + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + errors.Join(err, ErrFailedToGetValues), + fmt.Sprintf("[REDIS] failed `HGETALL` by `%s`", mainKey), + ) + } + + 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, + childKey string, +) (string, yaerrors.Error) { + result, err := r.client.HGetDel(ctx, mainKey, childKey).Result() + if err != nil { + return "", yaerrors.FromError( + http.StatusInternalServerError, + errors.Join(err, ErrFailedToGetDeleteSingle), + fmt.Sprintf("[REDIS] failed `HGETDEL` by `%s:%s`", mainKey, childKey), + ) + } + + if len(result) == 0 { + return "", yaerrors.FromError( + http.StatusInternalServerError, + errors.Join(err, ErrNotFoundValue), + fmt.Sprintf("[REDIS] not found value by `%s:%s`", mainKey, childKey), + ) + } + + 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, +) (int64, yaerrors.Error) { + result, err := r.client.HLen(ctx, mainKey).Result() + if err != nil { + return 0, yaerrors.FromError( + http.StatusInternalServerError, + errors.Join(err, ErrFailedToGetLen), + fmt.Sprintf("[REDIS] failed `HLEN` by `%s`", mainKey), + ) + } + + 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, + childKey string, +) (bool, yaerrors.Error) { + result, err := r.client.HExists(ctx, mainKey, childKey).Result() + if err != nil { + return result, yaerrors.FromError( + http.StatusInternalServerError, + errors.Join(err, ErrFailedToGetExist), + fmt.Sprintf("[REDIS] failed `HEXIST` by `%s:%s`", mainKey, childKey), + ) + } + + 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, + childKey string, +) yaerrors.Error { + _, err := r.client.HDel(ctx, mainKey, childKey).Result() + if err != nil { + return yaerrors.FromError( + http.StatusInternalServerError, + errors.Join(err, ErrFailedToDeleteSingle), + fmt.Sprintf("[REDIS] failed `HDEL` by `%s:%s`", mainKey, childKey), + ) + } + + 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( + http.StatusInternalServerError, + errors.Join(err, ErrFailedPing), + "[REDIS] failed `PING`", + ) + } + + 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( + http.StatusInternalServerError, + errors.Join(err, ErrFailedToCloseBackend), + "[REDIS] failed `CLOSE`", + ) + } + + return nil +} diff --git a/yacache/redis_test.go b/yacache/redis_test.go new file mode 100644 index 0000000..046975d --- /dev/null +++ b/yacache/redis_test.go @@ -0,0 +1,106 @@ +package yacache_test + +import ( + "context" + "fmt" + "testing" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yacache" + "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 := yacache.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/yacache/yacache.go b/yacache/yacache.go new file mode 100644 index 0000000..0349877 --- /dev/null +++ b/yacache/yacache.go @@ -0,0 +1,230 @@ +// 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 yacache + +import ( + "context" + "time" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "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, + childKey string, + value string, + 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 verifies that the cache service is reachable and healthy. + // + // Example: + // + // _ = c.Ping(ctx) + Ping(ctx context.Context) yaerrors.Error + + // 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: + 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/yacache/yacache_test.go b/yacache/yacache_test.go new file mode 100644 index 0000000..8a1689a --- /dev/null +++ b/yacache/yacache_test.go @@ -0,0 +1,34 @@ +package yacache_test + +import ( + "context" + "testing" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yacache" + "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 := yacache.NewCache(client) + + result := cache.Ping(ctx) + + assert.Nil(t, result) + }) + + t.Run("[Memory] initialize works", func(t *testing.T) { + cache := yacache.NewCache(yacache.NewMemoryContainer()) + + result := cache.Ping(ctx) + + assert.Nil(t, result) + }) +}