136 lines
3.5 KiB
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...)
|
|
}
|