cache.go

v0.1.0
Doc Versions Source
1
// Package cache provides a generic, TTL-based, in-memory key-value cache
2
// with background expiry sweeping and configurable expiration strategies.
3
package cache
4
5
import (
6
	"context"
7
	"errors"
8
	"sync"
9
	"time"
10
11
	"sourcecraft.dev/bigbes/auxilia/collect"
12
	"sourcecraft.dev/bigbes/auxilia/fn"
13
)
14
15
const (
16
	// NoExpiration indicates that an item should never expire.
17
	NoExpiration time.Duration = -2
18
	// DefaultExpiration indicates that an item should use the cache's default expiration.
19
	DefaultExpiration time.Duration = -1
20
)
21
22
var (
23
	// ErrNotExists is returned when an operation requires a key that is not present.
24
	ErrNotExists = errors.New("cache: record does not exist")
25
	// ErrAlreadyExists is returned when an insert finds the key already present.
26
	ErrAlreadyExists = errors.New("cache: record already exists")
27
)
28
29
// DeletionCause indicates why an item was removed from the cache.
30
type DeletionCause int
31
32
const (
33
	_ DeletionCause = iota
34
	// Evicted means the item was removed because it expired.
35
	Evicted
36
	// Deleted means the item was removed by an explicit Delete call.
37
	Deleted
38
)
39
40
// ExpirationPolicy controls how the cache determines whether an item has
41
// expired. Use WriteTTL, SlidingTTL, or NewExpirationPolicy to create a
42
// policy.
43
type ExpirationPolicy struct {
44
	// isExpired reports whether an item has expired given its write-time
45
	// deadline, last-access time, and resolved TTL.
46
	isExpired   func(expiration time.Time, lastAccess time.Time, ttl time.Duration) bool
47
	touchOnRead bool // when true, reads update LastAccess
48
}
49
50
// NewExpirationPolicy creates a custom ExpirationPolicy.
51
func NewExpirationPolicy(fn func(expiration time.Time, lastAccess time.Time, ttl time.Duration) bool, touchOnRead bool) ExpirationPolicy {
52
	return ExpirationPolicy{
53
		isExpired:   fn,
54
		touchOnRead: touchOnRead,
55
	}
56
}
57
58
// WriteTTL returns an ExpirationPolicy that expires items based on their
59
// write-time deadline (the default behaviour).
60
func WriteTTL() ExpirationPolicy {
61
	return ExpirationPolicy{
62
		isExpired: func(expiration time.Time, _ time.Time, _ time.Duration) bool {
63
			if expiration.IsZero() {
64
				return false
65
			}
66
			return time.Now().After(expiration)
67
		},
68
	}
69
}
70
71
// SlidingTTL returns an ExpirationPolicy that resets the expiration window
72
// on every read (Get, GetWithExpiration, GetOrInsert).
73
func SlidingTTL() ExpirationPolicy {
74
	return ExpirationPolicy{
75
		isExpired: func(expiration time.Time, lastAccess time.Time, ttl time.Duration) bool {
76
			if expiration.IsZero() {
77
				return false
78
			}
79
			return time.Now().After(lastAccess.Add(ttl))
80
		},
81
		touchOnRead: true,
82
	}
83
}
84
85
// Item wraps a cached value with an absolute expiration timestamp.
86
type Item[V any] struct {
87
	Object     V
88
	Expiration time.Time     // absolute write-time deadline; zero means no expiration
89
	LastAccess time.Time     // last access time; used by SlidingTTL
90
	TTL        time.Duration // resolved TTL used to compute Expiration
91
}
92
93
type keyAndValue[K comparable, V any] struct {
94
	key   K
95
	value V
96
}
97
98
// cache is the internal unexported implementation.
99
type cache[K comparable, V any] struct {
100
	defaultExpiration time.Duration
101
	items             map[K]Item[V]
102
	mu                sync.RWMutex
103
	onDeletion        func(K, V, DeletionCause)
104
	policy            ExpirationPolicy
105
	touchOnRead       bool // when true, reads update LastAccess (e.g. SlidingTTL)
106
}
107
108
// Cache is the exported handle embedding *cache plus janitor.
109
type Cache[K comparable, V any] struct {
110
	*cache[K, V]
111
	janitor *janitor[K, V]
112
}
113
114
func (c *cache[K, V]) isExpired(item Item[V]) bool {
115
	return c.policy.isExpired(item.Expiration, item.LastAccess, item.TTL)
116
}
117
118
func (c *cache[K, V]) resolveTTL(d time.Duration) time.Duration {
119
	if d == DefaultExpiration {
120
		return c.defaultExpiration
121
	}
122
	return d
123
}
124
125
func (c *cache[K, V]) getExpiration(d time.Duration) time.Time {
126
	d = c.resolveTTL(d)
127
	if d > 0 {
128
		return time.Now().Add(d)
129
	}
130
	return time.Time{}
131
}
132
133
func (c *cache[K, V]) set(k K, x V, d time.Duration) {
134
	resolved := c.resolveTTL(d)
135
	now := time.Now()
136
	c.items[k] = Item[V]{
137
		Object:     x,
138
		Expiration: c.getExpiration(d),
139
		LastAccess: now,
140
		TTL:        resolved,
141
	}
142
}
143
144
func (c *cache[K, V]) get(k K) (V, bool) {
145
	item, found := c.items[k]
146
	if !found {
147
		return fn.Zero[V](), false
148
	}
149
	if c.isExpired(item) {
150
		return fn.Zero[V](), false
151
	}
152
	return item.Object, true
153
}
154
155
func (c *cache[K, V]) touchAccess(k K) {
156
	if item, found := c.items[k]; found {
157
		item.LastAccess = time.Now()
158
		c.items[k] = item
159
	}
160
}
161
162
func (c *cache[K, V]) delete(key K) (V, bool, bool) {
163
	item, found := c.items[key]
164
	if !found {
165
		return fn.Zero[V](), false, false
166
	}
167
	delete(c.items, key)
168
	if c.onDeletion != nil {
169
		return item.Object, true, true
170
	}
171
	return fn.Zero[V](), true, false
172
}
173
174
// Set stores the value x for the key k with the given expiration duration.
175
func (c *cache[K, V]) Set(k K, x V, d time.Duration) {
176
	c.mu.Lock()
177
	c.set(k, x, d)
178
	c.mu.Unlock()
179
}
180
181
// SetDefault stores the value x for the key k using the cache's default
182
// expiration.
183
func (c *cache[K, V]) SetDefault(k K, x V) {
184
	c.Set(k, x, DefaultExpiration)
185
}
186
187
// Add inserts x for k only if k is absent or expired. It returns
188
// ErrAlreadyExists if a live entry already exists.
189
func (c *cache[K, V]) Add(k K, x V, d time.Duration) error {
190
	c.mu.Lock()
191
	_, found := c.get(k)
192
	if found {
193
		c.mu.Unlock()
194
		return ErrAlreadyExists
195
	}
196
	c.set(k, x, d)
197
	c.mu.Unlock()
198
	return nil
199
}
200
201
// Replace updates the value for k only if k is present and unexpired. It
202
// returns ErrNotExists if the key is missing or expired.
203
func (c *cache[K, V]) Replace(k K, x V, d time.Duration) error {
204
	c.mu.Lock()
205
	_, found := c.get(k)
206
	if !found {
207
		c.mu.Unlock()
208
		return ErrNotExists
209
	}
210
	c.set(k, x, d)
211
	c.mu.Unlock()
212
	return nil
213
}
214
215
// Touch refreshes the expiration on an existing non-expired item. It returns
216
// true if the key was found and refreshed.
217
func (c *cache[K, V]) Touch(k K, d time.Duration) bool {
218
	c.mu.Lock()
219
	item, found := c.items[k]
220
	if !found {
221
		c.mu.Unlock()
222
		return false
223
	}
224
	if c.isExpired(item) {
225
		c.mu.Unlock()
226
		return false
227
	}
228
	resolved := c.resolveTTL(d)
229
	item.Expiration = c.getExpiration(d)
230
	item.LastAccess = time.Now()
231
	item.TTL = resolved
232
	c.items[k] = item
233
	c.mu.Unlock()
234
	return true
235
}
236
237
// GetWithTouch atomically retrieves the value for k and refreshes its
238
// expiration. Returns (zero, false) if the key is missing or expired.
239
func (c *cache[K, V]) GetWithTouch(k K, d time.Duration) (V, bool) {
240
	c.mu.Lock()
241
	item, found := c.items[k]
242
	if !found {
243
		c.mu.Unlock()
244
		return fn.Zero[V](), false
245
	}
246
	if c.isExpired(item) {
247
		c.mu.Unlock()
248
		return fn.Zero[V](), false
249
	}
250
	resolved := c.resolveTTL(d)
251
	item.Expiration = c.getExpiration(d)
252
	item.LastAccess = time.Now()
253
	item.TTL = resolved
254
	c.items[k] = item
255
	c.mu.Unlock()
256
	return item.Object, true
257
}
258
259
// Delete removes the key k from the cache. If an onDeletion callback is
260
// registered, it is called after the lock is released.
261
func (c *cache[K, V]) Delete(k K) {
262
	c.mu.Lock()
263
	v, present, hasCallback := c.delete(k)
264
	c.mu.Unlock()
265
	if present && hasCallback {
266
		c.onDeletion(k, v, Deleted)
267
	}
268
}
269
270
// DeleteExpired removes all expired items from the cache. If an onDeletion
271
// callback is registered, it is called for each evicted item after the lock
272
// is released.
273
func (c *cache[K, V]) DeleteExpired() {
274
	var evicted []keyAndValue[K, V]
275
	c.mu.Lock()
276
	for k, item := range c.items {
277
		if c.isExpired(item) {
278
			v, _, hasCallback := c.delete(k)
279
			if hasCallback {
280
				evicted = append(evicted, keyAndValue[K, V]{key: k, value: v})
281
			}
282
		}
283
	}
284
	c.mu.Unlock()
285
	for _, kv := range evicted {
286
		c.onDeletion(kv.key, kv.value, Evicted)
287
	}
288
}
289
290
// Flush removes all items from the cache.
291
func (c *cache[K, V]) Flush() {
292
	c.mu.Lock()
293
	c.items = make(map[K]Item[V])
294
	c.mu.Unlock()
295
}
296
297
// Get returns the value for k if it exists and is not expired. The second
298
// return value indicates whether the key was found. Expired items are not
299
// deleted by Get.
300
func (c *cache[K, V]) Get(k K) (V, bool) {
301
	if c.touchOnRead {
302
		c.mu.Lock()
303
		v, found := c.get(k)
304
		if found {
305
			c.touchAccess(k)
306
		}
307
		c.mu.Unlock()
308
		return v, found
309
	}
310
	c.mu.RLock()
311
	v, found := c.get(k)
312
	c.mu.RUnlock()
313
	return v, found
314
}
315
316
// GetWithExpiration returns the value and expiration time for k. The third
317
// return value indicates whether the key was found and unexpired. A zero
318
// expiration time means the item does not expire.
319
func (c *cache[K, V]) GetWithExpiration(k K) (V, time.Time, bool) {
320
	if c.touchOnRead {
321
		c.mu.Lock()
322
		item, found := c.items[k]
323
		if !found {
324
			c.mu.Unlock()
325
			return fn.Zero[V](), time.Time{}, false
326
		}
327
		if c.isExpired(item) {
328
			c.mu.Unlock()
329
			return fn.Zero[V](), time.Time{}, false
330
		}
331
		c.touchAccess(k)
332
		c.mu.Unlock()
333
		return item.Object, item.Expiration, true
334
	}
335
	c.mu.RLock()
336
	item, found := c.items[k]
337
	if !found {
338
		c.mu.RUnlock()
339
		return fn.Zero[V](), time.Time{}, false
340
	}
341
	if c.isExpired(item) {
342
		c.mu.RUnlock()
343
		return fn.Zero[V](), time.Time{}, false
344
	}
345
	c.mu.RUnlock()
346
	return item.Object, item.Expiration, true
347
}
348
349
// ItemList returns a snapshot of all non-expired items in the cache.
350
func (c *cache[K, V]) ItemList() map[K]Item[V] {
351
	c.mu.RLock()
352
	rv := make(map[K]Item[V], len(c.items))
353
	for k, item := range c.items {
354
		if !c.isExpired(item) {
355
			rv[k] = item
356
		}
357
	}
358
	c.mu.RUnlock()
359
	return rv
360
}
361
362
// ItemIterator returns a lazy iterator over all non-expired items in the
363
// cache. The iterator yields collect.Tuple2 pairs of (key, Item).
364
func (c *cache[K, V]) ItemIterator() collect.ListIterator[collect.Tuple2[K, Item[V]]] {
365
	c.mu.RLock()
366
	keys := make([]K, 0, len(c.items))
367
	for k := range c.items {
368
		keys = append(keys, k)
369
	}
370
	c.mu.RUnlock()
371
372
	pos := 0
373
	return collect.NewListIterator(func() (collect.Tuple2[K, Item[V]], bool) {
374
		for pos < len(keys) {
375
			k := keys[pos]
376
			pos++
377
			c.mu.RLock()
378
			item, found := c.items[k]
379
			c.mu.RUnlock()
380
			if found && !c.isExpired(item) {
381
				return collect.NewTuple2(k, item), true
382
			}
383
		}
384
		return fn.Zero[collect.Tuple2[K, Item[V]]](), false
385
	}, len(keys))
386
}
387
388
// ItemCount returns the number of items in the cache, including expired items
389
// that have not yet been swept.
390
func (c *cache[K, V]) ItemCount() int {
391
	c.mu.RLock()
392
	n := len(c.items)
393
	c.mu.RUnlock()
394
	return n
395
}
396
397
// GetOrInsert atomically retrieves the value for k or inserts insertV if k is
398
// missing or expired. It returns the value and true if the key was already
399
// present and valid, or (insertV, false) if the value was inserted. Eviction
400
// callbacks fire after the lock is released.
401
func (c *cache[K, V]) GetOrInsert(k K, insertV V, insertD time.Duration) (V, bool) {
402
	var evicted *keyAndValue[K, V]
403
	c.mu.Lock()
404
	item, found := c.items[k]
405
	if found && !c.isExpired(item) {
406
		if c.touchOnRead {
407
			c.touchAccess(k)
408
		}
409
		c.mu.Unlock()
410
		return item.Object, true
411
	}
412
	if found {
413
		// expired — evict
414
		v, _, hasCallback := c.delete(k)
415
		if hasCallback {
416
			evicted = &keyAndValue[K, V]{key: k, value: v}
417
		}
418
	}
419
	c.set(k, insertV, insertD)
420
	c.mu.Unlock()
421
	if evicted != nil {
422
		c.onDeletion(evicted.key, evicted.value, Evicted)
423
	}
424
	return insertV, false
425
}
426
427
// OnDeletion sets the deletion callback. Deprecated: use OnDeletionAppend.
428
func (c *cache[K, V]) OnDeletion(f func(K, V, DeletionCause)) {
429
	c.mu.Lock()
430
	c.onDeletion = f
431
	c.mu.Unlock()
432
}
433
434
// OnDeletionAppend prepends f in front of the existing callback chain. If f
435
// is nil the callback is disabled entirely.
436
func (c *cache[K, V]) OnDeletionAppend(f func(K, V, DeletionCause)) {
437
	c.mu.Lock()
438
	if f == nil {
439
		c.onDeletion = nil
440
		c.mu.Unlock()
441
		return
442
	}
443
	prev := c.onDeletion
444
	if prev == nil {
445
		c.onDeletion = f
446
	} else {
447
		c.onDeletion = func(k K, v V, cause DeletionCause) {
448
			f(k, v, cause)
449
			prev(k, v, cause)
450
		}
451
	}
452
	c.mu.Unlock()
453
}
454
455
// Start starts the background janitor goroutine for periodic expiry sweeping.
456
func (c *Cache[K, V]) Start(ctx context.Context) {
457
	c.janitor.Start(ctx)
458
}
459
460
// Stop stops the background janitor goroutine.
461
func (c *Cache[K, V]) Stop() {
462
	c.janitor.Stop()
463
}
464
465
func newCache[K comparable, V any](defaultExpiration time.Duration, items map[K]Item[V], policy ExpirationPolicy) *cache[K, V] {
466
	if defaultExpiration == 0 {
467
		defaultExpiration = NoExpiration
468
	}
469
	if items == nil {
470
		items = make(map[K]Item[V])
471
	}
472
	if policy.isExpired == nil {
473
		policy = WriteTTL()
474
	}
475
	return &cache[K, V]{
476
		defaultExpiration: defaultExpiration,
477
		items:             items,
478
		policy:            policy,
479
		touchOnRead:       policy.touchOnRead,
480
	}
481
}
482
483
func newCacheWithJanitor[K comparable, V any](ctx context.Context, defaultExpiration, cleanupInterval time.Duration, items map[K]Item[V], started bool, policy ExpirationPolicy) *Cache[K, V] {
484
	c := newCache(defaultExpiration, items, policy)
485
	j := newJanitor(c, cleanupInterval)
486
	C := &Cache[K, V]{cache: c, janitor: j}
487
	if started {
488
		j.Start(ctx)
489
	}
490
	return C
491
}
492
493
// New creates a new Cache with the given default expiration and cleanup
494
// interval. The background janitor is started immediately with
495
// context.Background.
496
func New[K comparable, V any](defaultExpiration, cleanupInterval time.Duration) *Cache[K, V] {
497
	return newCacheWithJanitor[K, V](context.Background(), defaultExpiration, cleanupInterval, nil, true, ExpirationPolicy{})
498
}
499
500
// NewWithPolicy creates a new Cache with the given default expiration, cleanup
501
// interval, and expiration policy. The background janitor is started
502
// immediately with context.Background.
503
func NewWithPolicy[K comparable, V any](defaultExpiration, cleanupInterval time.Duration, policy ExpirationPolicy) *Cache[K, V] {
504
	return newCacheWithJanitor[K, V](context.Background(), defaultExpiration, cleanupInterval, nil, true, policy)
505
}
506
507
// NewInitialized creates a new Cache with the given default expiration and
508
// cleanup interval but does NOT start the background janitor. The caller must
509
// call Start to begin periodic expiry sweeping.
510
func NewInitialized[K comparable, V any](defaultExpiration, cleanupInterval time.Duration) *Cache[K, V] {
511
	return newCacheWithJanitor[K, V](context.Background(), defaultExpiration, cleanupInterval, nil, false, ExpirationPolicy{})
512
}
513
514
// NewInitializedWithPolicy creates a new Cache with the given default
515
// expiration, cleanup interval, and expiration policy but does NOT start the
516
// background janitor. The caller must call Start to begin periodic expiry
517
// sweeping.
518
func NewInitializedWithPolicy[K comparable, V any](defaultExpiration, cleanupInterval time.Duration, policy ExpirationPolicy) *Cache[K, V] {
519
	return newCacheWithJanitor[K, V](context.Background(), defaultExpiration, cleanupInterval, nil, false, policy)
520
}
521
522
// NewFromItems creates a new Cache pre-seeded with items. The background
523
// janitor is started immediately with context.Background.
524
func NewFromItems[K comparable, V any](defaultExpiration, cleanupInterval time.Duration, items map[K]Item[V]) *Cache[K, V] {
525
	return newCacheWithJanitor(context.Background(), defaultExpiration, cleanupInterval, items, true, ExpirationPolicy{})
526
}
527
528
// NewFromItemsWithPolicy creates a new Cache pre-seeded with items and using
529
// the given expiration policy. The background janitor is started immediately
530
// with context.Background.
531
func NewFromItemsWithPolicy[K comparable, V any](defaultExpiration, cleanupInterval time.Duration, items map[K]Item[V], policy ExpirationPolicy) *Cache[K, V] {
532
	return newCacheWithJanitor(context.Background(), defaultExpiration, cleanupInterval, items, true, policy)
533
}
534

Source Files