Files
go-omni/cache/cache.go

136 lines
3.5 KiB
Go

// Package cache provides replaceable byte-oriented caches and bounded local
// request coalescing. Storage errors are never silently treated as misses.
package cache
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
var (
ErrMiss = errors.New("cache: key not found")
ErrClosed = errors.New("cache: closed")
ErrTooLarge = errors.New("cache: entry exceeds capacity")
ErrBusy = errors.New("cache: too many concurrent loads")
)
// Store implementations copy values and accept ttl=0 for no expiration.
// Negative TTLs are invalid. Delete is successful for missing keys.
type Store interface {
Get(context.Context, string) ([]byte, error)
Set(context.Context, string, []byte, time.Duration) error
Delete(context.Context, ...string) error
}
func Check(ctx context.Context, key string, ttl time.Duration) error {
if ctx == nil {
return errors.New("cache: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
if key == "" || len(key) > 1024 {
return errors.New("cache: key must contain 1..1024 bytes")
}
if ttl < 0 {
return errors.New("cache: negative TTL")
}
return nil
}
type LoaderConfig struct {
MaxLoads int `json:"maxLoads"`
Timeout time.Duration `json:"timeout"`
}
type flight struct {
done chan struct{}
value []byte
err error
}
// Loader coalesces concurrent misses for the same key within this process.
// Caller cancellation stops waiting without canceling other callers' load.
// Load functions must observe their context, which has a bounded deadline.
type Loader struct {
store Store
config LoaderConfig
mu sync.Mutex
flights map[string]*flight
}
func NewLoader(store Store, cfg LoaderConfig) (*Loader, error) {
if store == nil || cfg.MaxLoads < 0 || cfg.Timeout < 0 {
return nil, errors.New("cache: invalid loader configuration")
}
if cfg.MaxLoads == 0 {
cfg.MaxLoads = 128
}
if cfg.Timeout == 0 {
cfg.Timeout = 5 * time.Second
}
return &Loader{store: store, config: cfg, flights: make(map[string]*flight)}, nil
}
func (l *Loader) GetOrLoad(ctx context.Context, key string, ttl time.Duration, load func(context.Context) ([]byte, error)) ([]byte, error) {
if err := Check(ctx, key, ttl); err != nil {
return nil, err
}
if load == nil {
return nil, errors.New("cache: nil loader")
}
value, err := l.store.Get(ctx, key)
if !errors.Is(err, ErrMiss) {
return value, err
}
l.mu.Lock()
f := l.flights[key]
if f == nil {
if len(l.flights) >= l.config.MaxLoads {
l.mu.Unlock()
return nil, ErrBusy
}
f = &flight{done: make(chan struct{})}
l.flights[key] = f
go l.run(context.WithoutCancel(ctx), key, ttl, load, f)
}
l.mu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-f.done:
return append([]byte(nil), f.value...), f.err
}
}
func (l *Loader) run(parent context.Context, key string, ttl time.Duration, load func(context.Context) ([]byte, error), f *flight) {
ctx, cancel := context.WithTimeout(parent, l.config.Timeout)
defer cancel()
defer func() {
if value := recover(); value != nil {
f.err = fmt.Errorf("cache: loader panic: %v", value)
}
l.mu.Lock()
delete(l.flights, key)
close(f.done)
l.mu.Unlock()
}()
// Another request may have populated the key between the first Get and
// flight acquisition. Recheck inside the coalesced load.
f.value, f.err = l.store.Get(ctx, key)
if !errors.Is(f.err, ErrMiss) {
return
}
f.value, f.err = load(ctx)
if f.err == nil {
f.err = ctx.Err()
}
if f.err == nil {
f.err = l.store.Set(ctx, key, f.value, ttl)
}
f.value = append([]byte(nil), f.value...)
}