fix(cache): harden providers and default cache handling

Make cache-write failures non-fatal in GetOrSet/Remember, replace the
unsynchronised default cache with an atomic pointer, fix tag-index leaks
and write-lock-on-read in the memory provider, add a janitor, closed
state and byte copies, hash/namespace memcache keys with CAS tag index,
gate Clear behind AllowFlush, return ErrNotFound without the key, and
allowlist Redis stats. Mark audit status.
This commit is contained in:
Hein
2026-09-30 13:13:06 +02:00
parent 652621a70e
commit 9533c3a0ed
9 changed files with 648 additions and 262 deletions
+114 -105
View File
@@ -2,20 +2,39 @@ package cache
import (
"context"
"errors"
"fmt"
"regexp"
"sync"
"sync/atomic"
"time"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
const (
defaultMemoryMaxSize = 10000
defaultMemoryCleanupInterval = time.Minute
)
// ErrClosed is returned by a provider that has been closed.
var ErrClosed = errors.New("cache: provider closed")
// memoryItem represents a cached item in memory.
type memoryItem struct {
Value []byte
Expiration time.Time
LastAccess time.Time
HitCount int64
Tags []string
lastAccess atomic.Int64 // unix nanos
hitCount atomic.Int64
}
func newMemoryItem(value []byte, expiration time.Time, tags []string) *memoryItem {
buf := make([]byte, len(value))
copy(buf, value)
item := &memoryItem{Value: buf, Expiration: expiration, Tags: tags}
item.lastAccess.Store(time.Now().UnixNano())
return item
}
// isExpired checks if the item has expired.
@@ -34,30 +53,72 @@ type MemoryProvider struct {
options *Options
hits atomic.Int64
misses atomic.Int64
closed bool
done chan struct{}
closeOnce sync.Once
}
// NewMemoryProvider creates a new in-memory cache provider.
// A MaxSize <= 0 selects the default (10000); use MaxSize -1 for an unbounded cache.
// A background goroutine removes expired items until Close is called.
func NewMemoryProvider(opts *Options) *MemoryProvider {
if opts == nil {
opts = &Options{
DefaultTTL: 5 * time.Minute,
MaxSize: 10000,
}
var o Options
if opts != nil {
o = *opts // do not mutate the caller's struct
} else {
o = Options{DefaultTTL: 5 * time.Minute}
}
if o.MaxSize == 0 {
o.MaxSize = defaultMemoryMaxSize
}
if o.CleanupInterval <= 0 {
o.CleanupInterval = defaultMemoryCleanupInterval
}
return &MemoryProvider{
m := &MemoryProvider{
items: make(map[string]*memoryItem),
tagToKeys: make(map[string]map[string]struct{}),
options: opts,
options: &o,
done: make(chan struct{}),
}
go m.janitor(o.CleanupInterval)
return m
}
func (m *MemoryProvider) janitor(interval time.Duration) {
defer logger.CatchPanic("cache.MemoryProvider.janitor")()
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-m.done:
return
case <-t.C:
m.CleanExpired(context.Background())
}
}
}
// removeLocked deletes a key and its tag associations. Caller must hold m.mu for writing.
func (m *MemoryProvider) removeLocked(key string) {
if item, ok := m.items[key]; ok {
for _, tag := range item.Tags {
if ks := m.tagToKeys[tag]; ks != nil {
delete(ks, key)
if len(ks) == 0 {
delete(m.tagToKeys, tag)
}
}
}
}
delete(m.items, key)
}
// Get retrieves a value from the cache by key.
func (m *MemoryProvider) Get(ctx context.Context, key string) ([]byte, bool) {
// First try with read lock for fast path
m.mu.RLock()
item, exists := m.items[key]
if !exists {
if !exists || m.closed {
m.mu.RUnlock()
m.misses.Add(1)
return nil, false
@@ -65,56 +126,29 @@ func (m *MemoryProvider) Get(ctx context.Context, key string) ([]byte, bool) {
if item.isExpired() {
m.mu.RUnlock()
// Upgrade to write lock to delete expired item
// Delete only if the entry is still the same expired one
m.mu.Lock()
delete(m.items, key)
if cur, ok := m.items[key]; ok && cur == item {
m.removeLocked(key)
}
m.mu.Unlock()
m.misses.Add(1)
return nil, false
}
// Update stats and access time with write lock
value := item.Value
item.lastAccess.Store(time.Now().UnixNano())
item.hitCount.Add(1)
out := make([]byte, len(item.Value))
copy(out, item.Value)
m.mu.RUnlock()
// Update access tracking with write lock
m.mu.Lock()
item.LastAccess = time.Now()
item.HitCount++
m.mu.Unlock()
m.hits.Add(1)
return value, true
return out, true
}
// Set stores a value in the cache with the specified TTL.
func (m *MemoryProvider) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
m.mu.Lock()
defer m.mu.Unlock()
if ttl == 0 {
ttl = m.options.DefaultTTL
}
var expiration time.Time
if ttl > 0 {
expiration = time.Now().Add(ttl)
}
// Check max size and evict if necessary
if m.options.MaxSize > 0 && len(m.items) >= m.options.MaxSize {
if _, exists := m.items[key]; !exists {
m.evictOne()
}
}
m.items[key] = &memoryItem{
Value: value,
Expiration: expiration,
LastAccess: time.Now(),
}
return nil
return m.SetWithTags(ctx, key, value, ttl, nil)
}
// SetWithTags stores a value in the cache with the specified TTL and tags.
@@ -122,6 +156,10 @@ func (m *MemoryProvider) SetWithTags(ctx context.Context, key string, value []by
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return ErrClosed
}
if ttl == 0 {
ttl = m.options.DefaultTTL
}
@@ -131,34 +169,14 @@ func (m *MemoryProvider) SetWithTags(ctx context.Context, key string, value []by
expiration = time.Now().Add(ttl)
}
// Check max size and evict if necessary
if m.options.MaxSize > 0 && len(m.items) >= m.options.MaxSize {
if _, exists := m.items[key]; !exists {
m.evictOne()
}
if _, exists := m.items[key]; exists {
m.removeLocked(key) // drops old tag associations
} else if m.options.MaxSize > 0 && len(m.items) >= m.options.MaxSize {
m.evictOne()
}
// Remove old tag associations if key exists
if oldItem, exists := m.items[key]; exists {
for _, tag := range oldItem.Tags {
if keySet, ok := m.tagToKeys[tag]; ok {
delete(keySet, key)
if len(keySet) == 0 {
delete(m.tagToKeys, tag)
}
}
}
}
m.items[key] = newMemoryItem(value, expiration, tags)
// Store the item
m.items[key] = &memoryItem{
Value: value,
Expiration: expiration,
LastAccess: time.Now(),
Tags: tags,
}
// Add new tag associations
for _, tag := range tags {
if m.tagToKeys[tag] == nil {
m.tagToKeys[tag] = make(map[string]struct{})
@@ -174,19 +192,7 @@ func (m *MemoryProvider) Delete(ctx context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
// Remove tag associations
if item, exists := m.items[key]; exists {
for _, tag := range item.Tags {
if keySet, ok := m.tagToKeys[tag]; ok {
delete(keySet, key)
if len(keySet) == 0 {
delete(m.tagToKeys, tag)
}
}
}
}
delete(m.items, key)
m.removeLocked(key)
return nil
}
@@ -195,16 +201,13 @@ func (m *MemoryProvider) DeleteByTag(ctx context.Context, tag string) error {
m.mu.Lock()
defer m.mu.Unlock()
// Get all keys associated with this tag
keySet, exists := m.tagToKeys[tag]
if !exists {
return nil // No keys with this tag
}
// Delete all items with this tag
for key := range keySet {
if item, ok := m.items[key]; ok {
// Remove this tag from the item's tag list
newTags := make([]string, 0, len(item.Tags))
for _, t := range item.Tags {
if t != tag {
@@ -212,8 +215,7 @@ func (m *MemoryProvider) DeleteByTag(ctx context.Context, tag string) error {
}
}
// If item has no more tags, delete it
// Otherwise update its tags
// If item has no more tags, delete it; otherwise update its tags
if len(newTags) == 0 {
delete(m.items, key)
} else {
@@ -222,24 +224,24 @@ func (m *MemoryProvider) DeleteByTag(ctx context.Context, tag string) error {
}
}
// Remove the tag mapping
delete(m.tagToKeys, tag)
return nil
}
// DeleteByPattern removes all keys matching the pattern.
// The pattern is a Go regular expression (unanchored); it is compiled before the lock is taken.
func (m *MemoryProvider) DeleteByPattern(ctx context.Context, pattern string) error {
m.mu.Lock()
defer m.mu.Unlock()
re, err := regexp.Compile(pattern)
if err != nil {
return fmt.Errorf("invalid pattern: %w", err)
}
m.mu.Lock()
defer m.mu.Unlock()
for key := range m.items {
if re.MatchString(key) {
delete(m.items, key)
m.removeLocked(key)
}
}
@@ -252,6 +254,7 @@ func (m *MemoryProvider) Clear(ctx context.Context) error {
defer m.mu.Unlock()
m.items = make(map[string]*memoryItem)
m.tagToKeys = make(map[string]map[string]struct{})
m.hits.Store(0)
m.misses.Store(0)
return nil
@@ -270,12 +273,17 @@ func (m *MemoryProvider) Exists(ctx context.Context, key string) bool {
return !item.isExpired()
}
// Close closes the provider and releases any resources.
// Close closes the provider, stops the janitor and releases stored items.
// Later writes return ErrClosed and reads report a miss.
func (m *MemoryProvider) Close() error {
m.closeOnce.Do(func() { close(m.done) })
m.mu.Lock()
defer m.mu.Unlock()
m.items = nil
m.closed = true
m.items = make(map[string]*memoryItem)
m.tagToKeys = make(map[string]map[string]struct{})
return nil
}
@@ -284,7 +292,7 @@ func (m *MemoryProvider) Stats(ctx context.Context) (*CacheStats, error) {
m.mu.RLock()
defer m.mu.RUnlock()
// Clean expired items first
// Count non-expired items (read-only)
validKeys := 0
for _, item := range m.items {
if !item.isExpired() {
@@ -304,24 +312,25 @@ func (m *MemoryProvider) Stats(ctx context.Context) (*CacheStats, error) {
}
// evictOne removes one item from the cache using LRU strategy.
// Note: this is an O(n) scan. Caller must hold m.mu for writing.
func (m *MemoryProvider) evictOne() {
var oldestKey string
var oldestTime time.Time
var oldest int64
for key, item := range m.items {
if item.isExpired() {
delete(m.items, key)
m.removeLocked(key)
return
}
if oldestKey == "" || item.LastAccess.Before(oldestTime) {
if la := item.lastAccess.Load(); oldestKey == "" || la < oldest {
oldestKey = key
oldestTime = item.LastAccess
oldest = la
}
}
if oldestKey != "" {
delete(m.items, oldestKey)
m.removeLocked(oldestKey)
}
}
@@ -333,7 +342,7 @@ func (m *MemoryProvider) CleanExpired(ctx context.Context) int {
count := 0
for key, item := range m.items {
if item.isExpired() {
delete(m.items, key)
m.removeLocked(key)
count++
}
}