Skip to content

Commit 9ef5a45

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
16 shards
1 parent 4474864 commit 9ef5a45

File tree

5 files changed

+61
-63
lines changed

5 files changed

+61
-63
lines changed

cache.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type Cache[K comparable, V any] struct {
1919
// New creates a new cache with the given options.
2020
func New[K comparable, V any](ctx context.Context, options ...Option) (*Cache[K, V], error) {
2121
opts := &Options{
22-
MemorySize: 10000,
22+
MemorySize: 16384, // 2^14, divides evenly by 16 shards
2323
}
2424
for _, opt := range options {
2525
opt(opts)
@@ -139,15 +139,15 @@ func (c *Cache[K, V]) Get(ctx context.Context, key K) (V, bool, error) {
139139
return val, true, nil
140140
}
141141

142-
// calculateExpiry returns the expiry time based on TTL and default TTL.
143-
func (c *Cache[K, V]) calculateExpiry(ttl time.Duration) time.Time {
144-
if ttl > 0 {
145-
return time.Now().Add(ttl)
142+
// expiry returns the expiry time based on TTL and default TTL.
143+
func (c *Cache[K, V]) expiry(ttl time.Duration) time.Time {
144+
if ttl <= 0 {
145+
ttl = c.opts.DefaultTTL
146146
}
147-
if c.opts.DefaultTTL > 0 {
148-
return time.Now().Add(c.opts.DefaultTTL)
147+
if ttl <= 0 {
148+
return time.Time{}
149149
}
150-
return time.Time{}
150+
return time.Now().Add(ttl)
151151
}
152152

153153
// Set stores a value in the cache with an optional TTL.
@@ -162,7 +162,7 @@ func (c *Cache[K, V]) Set(ctx context.Context, key K, value V, ttl time.Duration
162162
return nil
163163
}
164164

165-
expiry := c.calculateExpiry(ttl)
165+
expiry := c.expiry(ttl)
166166

167167
// Validate key early if persistence is enabled
168168
if c.persist != nil {
@@ -188,7 +188,7 @@ func (c *Cache[K, V]) Set(ctx context.Context, key K, value V, ttl time.Duration
188188
// Key validation and in-memory caching happen synchronously. Persistence errors are logged but not returned.
189189
// Returns an error only for validation failures (e.g., invalid key format).
190190
func (c *Cache[K, V]) SetAsync(ctx context.Context, key K, value V, ttl time.Duration) error {
191-
expiry := c.calculateExpiry(ttl)
191+
expiry := c.expiry(ttl)
192192

193193
// Validate key early if persistence is enabled (synchronous)
194194
if c.persist != nil {

cache_persist_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -593,15 +593,15 @@ func TestCache_GhostQueue(t *testing.T) {
593593
func TestCache_MainQueueEviction(t *testing.T) {
594594
ctx := context.Background()
595595

596-
// Create cache with capacity divisible by 4 shards
597-
cache, err := New[string, int](ctx, WithMemorySize(40))
596+
// Create cache with capacity divisible by 16 shards (48 = 3 per shard)
597+
cache, err := New[string, int](ctx, WithMemorySize(48))
598598
if err != nil {
599599
t.Fatalf("New: %v", err)
600600
}
601601
defer func() { _ = cache.Close() }() //nolint:errcheck // Test cleanup
602602

603603
// Insert and access items to get them into Main queue
604-
for i := range 60 {
604+
for i := range 72 {
605605
key := fmt.Sprintf("key%d", i)
606606
if err := cache.Set(ctx, key, i, 0); err != nil {
607607
t.Fatalf("Set: %v", err)
@@ -611,17 +611,17 @@ func TestCache_MainQueueEviction(t *testing.T) {
611611
}
612612

613613
// Insert more items to trigger eviction from Main queue
614-
for i := range 40 {
615-
key := fmt.Sprintf("key%d", i+60)
616-
if err := cache.Set(ctx, key, i+60, 0); err != nil {
614+
for i := range 48 {
615+
key := fmt.Sprintf("key%d", i+100)
616+
if err := cache.Set(ctx, key, i+100, 0); err != nil {
617617
t.Fatalf("Set: %v", err)
618618
}
619619
_, _, _ = cache.Get(ctx, key) //nolint:errcheck // Exercising code path
620620
}
621621

622622
// Verify cache is at capacity
623-
if cache.Len() > 40 {
624-
t.Errorf("Cache length %d exceeds capacity 40", cache.Len())
623+
if cache.Len() > 48 {
624+
t.Errorf("Cache length %d exceeds capacity 48", cache.Len())
625625
}
626626
}
627627

cache_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -360,8 +360,8 @@ func TestCache_New_DefaultOptions(t *testing.T) {
360360
}
361361
}()
362362

363-
if cache.opts.MemorySize != 10000 {
364-
t.Errorf("default memory size = %d; want 10000", cache.opts.MemorySize)
363+
if cache.opts.MemorySize != 16384 {
364+
t.Errorf("default memory size = %d; want 16384", cache.opts.MemorySize)
365365
}
366366

367367
if cache.opts.DefaultTTL != 0 {
@@ -544,29 +544,29 @@ func TestCache_DeleteNonExistent(t *testing.T) {
544544

545545
func TestCache_EvictFromMain(t *testing.T) {
546546
ctx := context.Background()
547-
// Cache with capacity divisible by 4 shards
548-
cache, err := New[int, int](ctx, WithMemorySize(40))
547+
// Cache with capacity divisible by 16 shards (48 = 3 per shard)
548+
cache, err := New[int, int](ctx, WithMemorySize(48))
549549
if err != nil {
550550
t.Fatalf("New: %v", err)
551551
}
552552
defer func() { _ = cache.Close() }() //nolint:errcheck // Test cleanup
553553

554554
// Fill small queue and promote items to main by accessing them twice
555-
for i := range 60 {
555+
for i := range 72 {
556556
_ = cache.Set(ctx, i, i, 0) //nolint:errcheck // Test fixture
557557
// Access immediately to promote to main
558558
_, _, _ = cache.Get(ctx, i) //nolint:errcheck // Exercising code path
559559
}
560560

561561
// Add more items to force eviction from main queue
562-
for i := range 40 {
562+
for i := range 48 {
563563
_ = cache.Set(ctx, i+100, i+100, 0) //nolint:errcheck // Test fixture
564564
_, _, _ = cache.Get(ctx, i+100) //nolint:errcheck // Exercising code path
565565
}
566566

567567
// Cache should not exceed capacity
568-
if cache.Len() > 40 {
569-
t.Errorf("cache length = %d; should not exceed 40", cache.Len())
568+
if cache.Len() > 48 {
569+
t.Errorf("cache length = %d; should not exceed 48", cache.Len())
570570
}
571571
}
572572

s3fifo.go

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import (
88
"time"
99
)
1010

11-
const numShards = 4
11+
const numShards = 16
1212

1313
// s3fifo implements the S3-FIFO eviction algorithm from SOSP'23 paper
1414
// "FIFO queues are all you need for cache eviction"
1515
//
16-
// This implementation uses 4-way sharding for improved concurrent performance.
16+
// This implementation uses 16-way sharding for improved concurrent performance.
1717
// Each shard is an independent S3-FIFO instance with its own queues and lock.
1818
//
1919
// Algorithm per shard:
@@ -63,7 +63,7 @@ type entry[K comparable, V any] struct {
6363
// newS3FIFO creates a new sharded S3-FIFO cache with the given total capacity.
6464
func newS3FIFO[K comparable, V any](capacity int) *s3fifo[K, V] {
6565
if capacity <= 0 {
66-
capacity = 10000
66+
capacity = 16384 // 2^14, divides evenly by 16 shards
6767
}
6868

6969
// Divide capacity across shards (round up to avoid zero-capacity shards)
@@ -102,20 +102,10 @@ func newShard[K comparable, V any](capacity int) *shard[K, V] {
102102
}
103103
}
104104

105-
// getShard returns the shard for a given key using maphash.
105+
// getShard returns the shard for a given key using type-optimized hashing.
106106
func (c *s3fifo[K, V]) getShard(key K) *shard[K, V] {
107-
var h maphash.Hash
108-
h.SetSeed(c.seed)
109-
//nolint:errcheck,gosec // maphash.Hash.WriteString never returns error
110-
h.WriteString(any(key).(string))
111-
return c.shards[h.Sum64()%numShards]
112-
}
113-
114-
// getShardInt is an optimized path for common key types.
115-
func (c *s3fifo[K, V]) getShardInt(key K) *shard[K, V] {
116107
switch k := any(key).(type) {
117108
case int:
118-
// Safe: modulo result is always in [0, numShards)
119109
if k < 0 {
120110
k = -k
121111
}
@@ -136,14 +126,19 @@ func (c *s3fifo[K, V]) getShardInt(key K) *shard[K, V] {
136126
h.WriteString(k)
137127
return c.shards[h.Sum64()%numShards]
138128
default:
139-
return c.getShard(key)
129+
// Fallback for other types: convert to string and hash
130+
var h maphash.Hash
131+
h.SetSeed(c.seed)
132+
//nolint:errcheck,gosec // maphash.Hash.WriteString never returns error
133+
h.WriteString(any(key).(string))
134+
return c.shards[h.Sum64()%numShards]
140135
}
141136
}
142137

143138
// getFromMemory retrieves a value from the in-memory cache.
144139
// On hit, increments frequency counter (used during eviction).
145140
func (c *s3fifo[K, V]) getFromMemory(key K) (V, bool) {
146-
return c.getShardInt(key).get(key)
141+
return c.getShard(key).get(key)
147142
}
148143

149144
func (s *shard[K, V]) get(key K) (V, bool) {
@@ -176,7 +171,7 @@ func (s *shard[K, V]) get(key K) (V, bool) {
176171

177172
// setToMemory adds or updates a value in the in-memory cache.
178173
func (c *s3fifo[K, V]) setToMemory(key K, value V, expiry time.Time) {
179-
c.getShardInt(key).set(key, value, expiry)
174+
c.getShard(key).set(key, value, expiry)
180175
}
181176

182177
func (s *shard[K, V]) set(key K, value V, expiry time.Time) {
@@ -224,7 +219,7 @@ func (s *shard[K, V]) set(key K, value V, expiry time.Time) {
224219

225220
// deleteFromMemory removes a value from the in-memory cache.
226221
func (c *s3fifo[K, V]) deleteFromMemory(key K) {
227-
c.getShardInt(key).delete(key)
222+
c.getShard(key).delete(key)
228223
}
229224

230225
func (s *shard[K, V]) delete(key K) {
@@ -404,7 +399,7 @@ func (c *s3fifo[K, V]) queueLens() (small, main int) {
404399

405400
// isInSmall returns whether a key is in the small queue (for testing).
406401
func (c *s3fifo[K, V]) isInSmall(key K) bool {
407-
s := c.getShardInt(key)
402+
s := c.getShard(key)
408403
s.mu.RLock()
409404
defer s.mu.RUnlock()
410405
if ent, ok := s.items[key]; ok {
@@ -415,7 +410,7 @@ func (c *s3fifo[K, V]) isInSmall(key K) bool {
415410

416411
// setExpiry sets the expiry time for a key (for testing).
417412
func (c *s3fifo[K, V]) setExpiry(key K, expiry time.Time) {
418-
s := c.getShardInt(key)
413+
s := c.getShard(key)
419414
s.mu.Lock()
420415
defer s.mu.Unlock()
421416
if ent, ok := s.items[key]; ok {

s3fifo_test.go

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ func TestS3FIFO_BasicOperations(t *testing.T) {
3636
}
3737

3838
func TestS3FIFO_Capacity(t *testing.T) {
39-
capacity := 100
40-
cache := newS3FIFO[int, string](capacity)
39+
cache := newS3FIFO[int, string](100)
40+
capacity := cache.totalCapacity() // Actual capacity after shard rounding
4141

4242
// Fill cache to capacity
4343
for i := range capacity {
@@ -57,27 +57,28 @@ func TestS3FIFO_Capacity(t *testing.T) {
5757
}
5858

5959
func TestS3FIFO_Eviction(t *testing.T) {
60-
cache := newS3FIFO[int, int](100) // Use larger capacity for predictable behavior
60+
cache := newS3FIFO[int, int](100)
61+
capacity := cache.totalCapacity() // Actual capacity after shard rounding
6162

6263
// Fill to capacity
63-
for i := range 100 {
64+
for i := range capacity {
6465
cache.setToMemory(i, i, time.Time{})
6566
}
6667

6768
// Access item 0 to increase its frequency
6869
cache.getFromMemory(0)
6970

7071
// Add one more item - should evict least frequently used
71-
cache.setToMemory(1000, 99, time.Time{})
72+
cache.setToMemory(capacity+1000, 99, time.Time{})
7273

7374
// Item 0 should still exist (it was accessed)
7475
if _, ok := cache.getFromMemory(0); !ok {
7576
t.Error("item 0 was evicted but should have been promoted")
7677
}
7778

7879
// Should be at capacity
79-
if cache.memoryLen() != 100 {
80-
t.Errorf("cache length = %d; want 100", cache.memoryLen())
80+
if cache.memoryLen() != capacity {
81+
t.Errorf("cache length = %d; want %d", cache.memoryLen(), capacity)
8182
}
8283
}
8384

@@ -126,7 +127,7 @@ func TestS3FIFO_TTL(t *testing.T) {
126127
}
127128

128129
func TestS3FIFO_Cleanup(t *testing.T) {
129-
cache := newS3FIFO[string, int](10)
130+
cache := newS3FIFO[string, int](100)
130131

131132
// Add some items with different expiries
132133
now := time.Now()
@@ -157,6 +158,7 @@ func TestS3FIFO_Cleanup(t *testing.T) {
157158

158159
func TestS3FIFO_Concurrent(t *testing.T) {
159160
cache := newS3FIFO[int, int](1000)
161+
capacity := cache.totalCapacity()
160162
var wg sync.WaitGroup
161163

162164
// Concurrent writers
@@ -183,14 +185,15 @@ func TestS3FIFO_Concurrent(t *testing.T) {
183185

184186
wg.Wait()
185187

186-
// Cache should be at capacity
187-
if cache.memoryLen() != 1000 {
188-
t.Errorf("cache length = %d; want 1000", cache.memoryLen())
188+
// Cache should be at or below capacity (we wrote exactly 1000 items)
189+
if cache.memoryLen() > capacity {
190+
t.Errorf("cache length = %d; want <= %d", cache.memoryLen(), capacity)
189191
}
190192
}
191193

192194
func TestS3FIFO_FrequencyPromotion(t *testing.T) {
193-
cache := newS3FIFO[string, int](10)
195+
cache := newS3FIFO[string, int](100)
196+
capacity := cache.totalCapacity()
194197

195198
// Add items - they start in small queue
196199
cache.setToMemory("key0", 0, time.Time{})
@@ -200,8 +203,8 @@ func TestS3FIFO_FrequencyPromotion(t *testing.T) {
200203
cache.getFromMemory("key0")
201204

202205
// Fill to capacity
203-
for i := 2; i < 10; i++ {
204-
cache.setToMemory("key"+string(rune('0'+i)), i, time.Time{})
206+
for i := 2; i < capacity; i++ {
207+
cache.setToMemory(fmt.Sprintf("key%d", i), i, time.Time{})
205208
}
206209

207210
// Add one more to trigger eviction
@@ -241,11 +244,11 @@ func TestS3FIFO_SmallCapacity(t *testing.T) {
241244
}
242245

243246
func TestS3FIFO_ZeroCapacity(t *testing.T) {
244-
// Zero capacity should default to 10000
247+
// Zero capacity should default to 16384
245248
cache := newS3FIFO[string, int](0)
246249

247-
if cache.totalCapacity() != 10000 {
248-
t.Errorf("total capacity = %d; want 10000", cache.totalCapacity())
250+
if cache.totalCapacity() != 16384 {
251+
t.Errorf("total capacity = %d; want 16384", cache.totalCapacity())
249252
}
250253
}
251254

0 commit comments

Comments
 (0)