Skip to content

Commit 517d49d

Browse files
committed
feat: Add Redis v8 client support, numeric operations, graceful shutdown, and sync functionality to sharded cache with updated documentation.
1 parent 58123fe commit 517d49d

10 files changed

Lines changed: 427 additions & 67 deletions

File tree

README.md

Lines changed: 73 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,29 @@
1-
I forked this project from github.com/patrickmn/go-cache and modified this
2-
I want to be able to increment/decriment and if not found set it in my case
31
# go-cache
2+
43
![Go](https://github.com/mysamimi/go-cache/actions/workflows/test.yml/badge.svg)
54
[![Go Report Card](https://goreportcard.com/badge/github.com/mysamimi/go-cache/v3)](https://goreportcard.com/report/github.com/mysamimi/go-cache/v3)
65
[![Go Reference](https://pkg.go.dev/badge/github.com/mysamimi/go-cache/v3.svg)](https://pkg.go.dev/github.com/mysamimi/go-cache/v3)
76

8-
go-cache is an in-memory key:value store/cache similar to memcached that is
9-
suitable for applications running on a single machine. Its major advantage is
10-
that, being essentially a thread-safe `map[string]interface{}` with expiration
11-
times, it doesn't need to serialize or transmit its contents over the network.
7+
go-cache is an in-memory key:value store/cache similar to memcached that is suitable for applications running on a single machine. Its major advantage is that, being essentially a thread-safe `map[string]interface{}` with expiration times, it doesn't need to serialize or transmit its contents over the network.
128

139
**Key Features:**
10+
1411
* **Sharding**: Reduces lock contention for high-concurrency workloads.
15-
* **Redis Integration**: Optional L2 caching and persistence layer (fallback on miss, async write).
12+
* **Redis Integration**: Optional L2 caching and persistence layer using `go-redis` (supports v8 and v9).
1613
* **Capacity Management**: Internal LRU-like eviction when memory limits are reached.
1714
* **Generics**: Type-safe API (Go 1.18+).
18-
19-
Any object can be stored, for a given duration or forever, and the cache can be
20-
safely used by multiple goroutines.
21-
22-
Although go-cache isn't meant to be used as a persistent datastore, the entire
23-
cache can be saved to and loaded from a file (using `c.Items()` to retrieve the
24-
items map to serialize, and `NewFrom()` to create a cache from a deserialized
25-
one) to recover from downtime quickly. (See the docs for `NewFrom()` for caveats.)
15+
* **Numeric Operations**: Atomic increment/decrement support for numeric types, persisted to Redis.
16+
* **Graceful Shutdown**: Ensures pending Redis operations are completed before exit.
17+
* **Sync**: Force refresh items from Redis.
2618

2719
### Installation
2820

2921
`go get github.com/mysamimi/go-cache/v3`
3022

3123
### Usage
3224

25+
#### Basic Cache
26+
3327
```go
3428
import (
3529
"fmt"
@@ -45,11 +39,6 @@ func main() {
4539
// Set the value of the key "foo" to "bar", with the default expiration time
4640
c.Set("foo", "bar", cache.DefaultExpiration)
4741

48-
// Set the value of the key "baz" to "yes", with no expiration time
49-
// (the item won't be removed until it is re-set, or removed using
50-
// c.Delete("baz")
51-
c.Set("baz", "yes", cache.NoExpiration)
52-
5342
// Get the string associated with the key "foo" from the cache
5443
foo, found := c.Get("foo")
5544
if found == cache.Found {
@@ -58,46 +47,87 @@ func main() {
5847
}
5948
```
6049

61-
### Redis Integration (L2 Cache)
50+
#### Sharded Cache (Recommended for High Concurrency)
6251

63-
You can configure a Redis client to act as a Level 2 cache and persistent store. Writes are asynchronous to Redis, while reads fallback to Redis if the key is missing from memory.
52+
`ShardedCache` automatically partitions keys into multiple buckets to reduce lock contention.
6453

6554
```go
66-
import "github.com/redis/go-redis/v9"
67-
68-
rdb := redis.NewClient(&redis.Options{
69-
Addr: "localhost:6379",
70-
})
55+
// Create a sharded cache with 16 shards
56+
c := cache.NewShardedCache[string](16, 5*time.Minute, 10*time.Minute)
7157

72-
c := cache.New[MyStruct](5*time.Minute, 10*time.Minute)
73-
c.WithRedis(rdb)
58+
c.Set("foo", "bar", 0)
59+
val, found := c.Get("foo")
7460
```
7561

76-
### Capacity & Eviction
62+
#### Redis Integration (L2 Cache & Persistence)
63+
64+
You can configure a Redis client to act as a Level 2 cache and persistent store. Writes (Set/Delete/ModifyNumeric) are asynchronous to Redis, while reads look up Redis if the key is missing from memory.
7765

78-
You can set a maximum number of items for the local in-memory cache.
66+
Supports both `go-redis/v8` and `go-redis/v9` via adapters.
7967

8068
```go
81-
c.WithCapacity(1000)
69+
import (
70+
"github.com/redis/go-redis/v9"
71+
"github.com/mysamimi/go-cache/v3"
72+
redisv9 "github.com/mysamimi/go-cache/v3/redis/v9"
73+
)
74+
75+
func main() {
76+
// 1. Initialize Redis Client
77+
rdb := redis.NewClient(&redis.Options{
78+
Addr: "localhost:6379",
79+
})
80+
81+
// 2. Create Cache
82+
c := cache.NewShardedCache[MyStruct](16, 5*time.Minute, 10*time.Minute)
83+
84+
// 3. Enable Redis Integration with Adapter
85+
c.WithRedis(redisv9.New(rdb))
86+
87+
// 4. Use Cache
88+
// This will asynchronously write to Redis
89+
c.Set("key", MyStruct{Val: 1}, 0)
90+
91+
// 5. Graceful Shutdown
92+
// Ensures all pending Redis writes are flushed before exit
93+
defer c.Close()
94+
}
8295
```
8396

84-
**Bulk Eviction Strategy**: When the cache reaches the configured capacity, it automatically triggers a bulk eviction, removing random items until the cache size drops to **75%** of the capacity. This prevents "thrashing" (constant delete/add cycles) under heavy load.
97+
#### Sync from Redis
98+
99+
If you know an item has changed in Redis (e.g. by another service) and want to refresh the local cache immediately:
85100

101+
```go
102+
val, err := c.Sync("key")
103+
```
86104

87-
### ShardedCache
105+
#### Capacity & Aviction
88106

89-
For high concurrency scenarios, you can use `ShardedCache` to reduce lock contention. `ShardedCache` automatically partitions keys into multiple buckets.
107+
Set a maximum number of items for the local cache. When the limit is reached, items are evicted (approx 25% of cache is cleared) to prevent thrashing.
90108

91109
```go
92-
// Create a sharded cache with 16 shards
93-
c := cache.NewShardedCache[string](16, 5*time.Minute, 10*time.Minute)
110+
// Limit local cache to 1000 items (distributed across shards)
111+
c.WithCapacity(1000)
94112

95-
// ShardedCache also supports Redis and Capacity configuration
96-
c.WithRedis(rdb)
113+
// Register a callback for when items are evicted (due to expiry or capacity)
114+
c.OnEvicted(func(k string, v MyStruct) {
115+
fmt.Printf("Item %s evicted\n", k)
116+
})
117+
```
97118

98-
// Capacity is distributed across shards (e.g. 1000 total / 16 shards)
99-
c.WithCapacity(1000)
119+
#### Numeric Operations
100120

101-
c.Set("foo", "bar", 0)
102-
val, found := c.Get("foo")
121+
For counters and numeric values, use `NumericCache` (or `ShardedNumericCache`). Operations are atomic and persisted to Redis.
122+
123+
```go
124+
// Create a Sharded Numeric Cache for int64
125+
nc := cache.NewShardedNumeric[int64](16, 5*time.Minute, 10*time.Minute)
126+
nc.WithRedis(redisv9.New(rdb))
127+
128+
// Increment "counter" by 1
129+
newVal, err := nc.ModifyNumeric("counter", 1, true)
130+
131+
// Decrement "counter" by 5
132+
newVal, err = nc.ModifyNumeric("counter", 5, false)
103133
```

cache.go

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
type RedisClient interface {
1818
Get(ctx context.Context, key string) ([]byte, error)
1919
Set(ctx context.Context, key string, value any, expiration time.Duration) error
20+
Del(ctx context.Context, key string) error
2021
TTL(ctx context.Context, key string) (time.Duration, error)
2122
}
2223

@@ -66,12 +67,21 @@ type cache[V any] struct {
6667
redisCh chan redisItem[V]
6768
localCapacity int
6869
ctx context.Context
70+
wg sync.WaitGroup
6971
}
7072

73+
type redisOp int
74+
75+
const (
76+
redisOpSet redisOp = iota
77+
redisOpDel
78+
)
79+
7180
type redisItem[V any] struct {
72-
k string
73-
v V
74-
d time.Duration
81+
k string
82+
v V
83+
d time.Duration
84+
op redisOp
7585
}
7686

7787
// Add an item to the cache, replacing any existing item. If the duration is 0
@@ -103,7 +113,7 @@ func (c *cache[V]) set(k string, x V, d time.Duration) {
103113
if c.redisClient != nil && c.redisCh != nil {
104114
// Use non-blocking send or buffered
105115
select {
106-
case c.redisCh <- redisItem[V]{k: k, v: x, d: d}:
116+
case c.redisCh <- redisItem[V]{k: k, v: x, d: d, op: redisOpSet}:
107117
default:
108118
// Channel full, drop write to avoid blocking app
109119
}
@@ -117,6 +127,12 @@ func (c *cache[V]) evict() {
117127
// Target size is 75% of capacity
118128
target := c.localCapacity * 3 / 4
119129
for k := range c.items {
130+
// Check if we need to call OnEvicted
131+
if c.onEvicted != nil {
132+
if v, found := c.items[k]; found {
133+
c.onEvicted(k, v.Object)
134+
}
135+
}
120136
delete(c.items, k)
121137
if len(c.items) <= target {
122138
break
@@ -241,6 +257,22 @@ func (c *cache[V]) getFallbackWithExpiration(k string) (V, time.Time, bool) {
241257
return zero, time.Time{}, false
242258
}
243259

260+
// Sync fetches the value for the given key from Redis (if available) and updates the local cache.
261+
// It returns the value and an error if the key was not found in Redis or if Redis is not configured.
262+
func (c *cache[V]) Sync(k string) (V, error) {
263+
val, ttl, found := c.fetchFromRedis(k)
264+
if !found {
265+
var zero V
266+
return zero, fmt.Errorf("item %s not found in Redis", k)
267+
}
268+
269+
c.mu.Lock()
270+
c.setLocal(k, val, ttl)
271+
c.mu.Unlock()
272+
273+
return val, nil
274+
}
275+
244276
func (c *cache[V]) get(k string) (V, bool) {
245277
item, found := c.items[k]
246278
if !found {
@@ -382,6 +414,26 @@ func (c *NumericCache[N]) ModifyNumeric(k string, operand N, isIncrement bool) (
382414
item.Object = newVal
383415
c.items[k] = item
384416
c.mu.Unlock()
417+
418+
// Async Redis Write
419+
if c.redisClient != nil && c.redisCh != nil {
420+
// Calculate remaining TTL
421+
var d time.Duration
422+
if !item.Expiration.IsZero() {
423+
d = time.Until(item.Expiration)
424+
if d < 0 {
425+
d = 1 * time.Second // Expire immediately?
426+
}
427+
} else {
428+
d = -1 // NoExpiration
429+
}
430+
431+
select {
432+
case c.redisCh <- redisItem[N]{k: k, v: newVal, d: d, op: redisOpSet}:
433+
default:
434+
}
435+
}
436+
385437
return newVal, nil
386438
}
387439

@@ -399,21 +451,24 @@ func (c *cache[V]) delete(k string) (V, bool) {
399451
if c.onEvicted != nil {
400452
if v, found := c.items[k]; found {
401453
delete(c.items, k)
402-
// Also call onEvicted here if we are deleting an item that has an onEvicted callback
403-
// However, the current logic in Delete() and DeleteExpired() calls onEvicted *after*
404-
// the lock is released, based on the collected items.
405-
// For consistency with that pattern, we might not call it here directly,
406-
// or the calling functions (Delete, DeleteExpired) must be aware.
407-
// The current `Delete` method does:
408-
// c.mu.Lock()
409-
// v, evicted := c.delete(k)
410-
// c.mu.Unlock()
411-
// if evicted { c.onEvicted(k, v) }
412-
// This implies c.delete should just return the value and a flag.
454+
// Redis Async Delete
455+
if c.redisClient != nil && c.redisCh != nil {
456+
select {
457+
case c.redisCh <- redisItem[V]{k: k, op: redisOpDel}:
458+
default:
459+
}
460+
}
413461
return v.Object, true
414462
}
415463
}
416464
delete(c.items, k)
465+
// Redis Async Delete (even if onEvicted is nil)
466+
if c.redisClient != nil && c.redisCh != nil {
467+
select {
468+
case c.redisCh <- redisItem[V]{k: k, op: redisOpDel}:
469+
default:
470+
}
471+
}
417472
var zero V
418473
return zero, false
419474
}
@@ -648,21 +703,38 @@ func (c *cache[V]) withCapacity(cap int) {
648703
}
649704

650705
func (c *cache[V]) redisWorker() {
706+
defer c.wg.Done()
651707
for item := range c.redisCh {
652708
if c.redisClient != nil {
653-
// Serialize/Marshal is handled by go-redis if we pass structs?
654-
// go-redis handles basic types. For generic V, we might need to marshal.
655-
// But go-redis Set accepts 'any'. It uses internal formatting.
656-
// If V is a struct, it uses fmt.Sprint by default or implements BinaryMarshaler.
657-
// It's safer to marshal to JSON explicitly to ensure we can unmarshal back.
658-
data, err := json.Marshal(item.v)
659-
if err == nil {
660-
c.redisClient.Set(c.ctx, item.k, data, item.d)
709+
switch item.op {
710+
case redisOpSet:
711+
// Serialize/Marshal is handled by go-redis if we pass structs?
712+
// go-redis handles basic types. For generic V, we might need to marshal.
713+
// But go-redis Set accepts 'any'. It uses internal formatting.
714+
// If V is a struct, it uses fmt.Sprint by default or implements BinaryMarshaler.
715+
// It's safer to marshal to JSON explicitly to ensure we can unmarshal back.
716+
data, err := json.Marshal(item.v)
717+
if err == nil {
718+
c.redisClient.Set(c.ctx, item.k, data, item.d)
719+
}
720+
case redisOpDel:
721+
c.redisClient.Del(c.ctx, item.k)
661722
}
662723
}
663724
}
664725
}
665726

727+
// Close stops the background Redis worker and waits for pending operations to complete.
728+
func (c *cache[V]) Close() {
729+
c.mu.Lock()
730+
defer c.mu.Unlock()
731+
if c.redisCh != nil {
732+
close(c.redisCh)
733+
c.redisCh = nil
734+
}
735+
c.wg.Wait()
736+
}
737+
666738
// Configures the cache to use a Redis client for L2 caching and async persistence.
667739
// This also starts the async worker for Redis writes.
668740
func (c *Cache[V]) WithRedis(cli RedisClient) *Cache[V] {
@@ -677,6 +749,7 @@ func (c *cache[V]) withRedis(cli RedisClient) {
677749
if c.redisCh == nil {
678750
c.redisCh = make(chan redisItem[V], 1000) // Buffer for async writes
679751
c.ctx = context.Background() // Or accept context
752+
c.wg.Add(1)
680753
go c.redisWorker()
681754
}
682755
}

cache_redis_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ func (r *RedisAdapter) Set(ctx context.Context, key string, value any, expiratio
2222
return r.client.Set(ctx, key, value, expiration).Err()
2323
}
2424

25+
func (r *RedisAdapter) Del(ctx context.Context, key string) error {
26+
return r.client.Del(ctx, key).Err()
27+
}
28+
2529
func (r *RedisAdapter) TTL(ctx context.Context, key string) (time.Duration, error) {
2630
return r.client.TTL(ctx, key).Result()
2731
}

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ module github.com/mysamimi/go-cache/v3
22

33
go 1.24
44

5-
require github.com/redis/go-redis/v9 v9.17.3
5+
require (
6+
github.com/go-redis/redis/v8 v8.11.5
7+
github.com/redis/go-redis/v9 v9.17.3
8+
)
69

710
require (
811
github.com/cespare/xxhash/v2 v2.3.0 // indirect

0 commit comments

Comments
 (0)