// 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...) }