Documentation
¶
Overview ¶
Package sanecache is a small in-memory cache that aims to be predictable before it is fast.
Writes are synchronous, so a value is readable the moment Set returns. A value that cannot fit is refused with an error instead of being accepted and dropped later. Budgets are expressed in bytes, because a limit on the number of entries says nothing about the memory a process will use when entries are documents rather than integers. "The upstream says this key does not exist" is a first class answer rather than a marker smuggled inside the value type. TTLs can carry jitter, so a batch of keys warmed by one request does not expire in lockstep and stampede the upstream. And a cold key is fetched once rather than once per concurrent caller asking for it.
Example ¶
package main
import (
"fmt"
"time"
"github.com/andared/sanecache"
)
type article struct {
ID string
Body string
}
func main() {
c := sanecache.New(sanecache.Options[string, *article]{
TTL: 10 * time.Minute,
Jitter: 10,
MaxBytes: 1 << 20,
// Resident size, not serialized size: a decoded struct costs several
// times its payload. Measure the ratio rather than guessing it.
Cost: func(a *article) int64 { return int64(len(a.Body)) * 3 },
})
defer c.Close()
if err := c.Set("a1", &article{ID: "a1", Body: "hello"}); err != nil {
fmt.Println("set:", err)
return
}
if a, ok := c.Get("a1"); ok {
fmt.Println("cached:", a.Body)
}
}
Output: cached: hello
Index ¶
- Variables
- type Cache
- func (c *Cache[K, V]) Bytes() int64
- func (c *Cache[K, V]) Clear()
- func (c *Cache[K, V]) Close()
- func (c *Cache[K, V]) Delete(key K) bool
- func (c *Cache[K, V]) Get(key K) (V, bool)
- func (c *Cache[K, V]) GetOrLoad(ctx context.Context, key K) (V, error)
- func (c *Cache[K, V]) Len() int
- func (c *Cache[K, V]) Lookup(key K) (V, Status)
- func (c *Cache[K, V]) Set(key K, value V) error
- func (c *Cache[K, V]) SetNegative(key K) error
- func (c *Cache[K, V]) SetNegativeTTL(key K, ttl time.Duration) error
- func (c *Cache[K, V]) SetTTL(key K, value V, ttl time.Duration) error
- func (c *Cache[K, V]) Stats() Stats
- type EvictReason
- type Options
- type Policy
- type Stats
- type Status
- type View
- func (v *View[T]) Delete(key string) bool
- func (v *View[T]) Get(key string) (T, bool)
- func (v *View[T]) GetOrLoad(ctx context.Context, key string) (T, error)
- func (v *View[T]) Lookup(key string) (T, Status)
- func (v *View[T]) Name() string
- func (v *View[T]) Set(key string, value T) error
- func (v *View[T]) SetNegative(key string) error
- func (v *View[T]) SetNegativeTTL(key string, ttl time.Duration) error
- func (v *View[T]) SetTTL(key string, value T, ttl time.Duration) error
- func (v *View[T]) Stats() ViewStats
- type ViewOptions
- type ViewStats
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrTooLarge is returned by Set when the value's cost exceeds the budget of // the shard it hashes to, so caching it could never succeed. It is reported // rather than swallowed: a steady trickle of ErrTooLarge means every request // for those keys goes to the upstream, which is invisible in the hit rate. ErrTooLarge = errors.New("sanecache: value cost exceeds the shard budget") // ErrNegativeDisabled is returned by SetNegative when Options.NegativeTTL was // not set. Negative entries without a TTL would pin a "does not exist" answer // for the lifetime of the process. ErrNegativeDisabled = errors.New("sanecache: negative caching is disabled (Options.NegativeTTL is unset)") // ErrNotFound is how a loader says the upstream has no such key, and how // GetOrLoad reports that answer back — including when it comes from a cached // negative entry rather than a fresh call. It is the same error either way, // so a caller's handling of "no such object" does not depend on whether the // cache happened to remember it. // // A loader may wrap it: GetOrLoad tests with errors.Is and passes the // loader's own error through to the caller that triggered the load. ErrNotFound = errors.New("sanecache: the upstream has no such key") // ErrNoLoader is returned by GetOrLoad when the cache's Options.Loader or // the view's ViewOptions.Loader was not set. ErrNoLoader = errors.New("sanecache: GetOrLoad requires Options.Loader") )
Functions ¶
This section is empty.
Types ¶
type Cache ¶
type Cache[K comparable, V any] struct { // contains filtered or unexported fields }
Cache is a sharded, TTL-based cache. It is safe for concurrent use. The zero value is not usable; call New.
func New ¶
func New[K comparable, V any](o Options[K, V]) *Cache[K, V]
New builds a cache from o. It panics on options that cannot describe a working cache, such as a byte budget without a Cost function: those are programming mistakes, and failing at construction beats a cache that silently misbehaves.
func (*Cache[K, V]) Clear ¶
func (c *Cache[K, V]) Clear()
Clear removes entries and invalidates outstanding loads, shard by shard. Concurrent new loads and writes can repopulate shards already cleared. Existing waiters still receive their load results. OnEvict is not called.
func (*Cache[K, V]) Close ¶
func (c *Cache[K, V]) Close()
Close stops the background goroutines. The cache stays usable afterwards: entries then expire only on lookup, and a cache configured with ClockGranularity goes back to reading the wall clock. Calling Close more than once is safe, and a cache that is simply dropped stops its goroutines too.
func (*Cache[K, V]) Delete ¶
Delete removes key and reports whether it was present. It also invalidates outstanding loads for key, even when no entry was present. Existing waiters still receive their load result, but it cannot be cached. OnEvict is not called.
func (*Cache[K, V]) Get ¶
Get returns the cached value. A cached "does not exist" answer reports false, same as a miss; use Lookup to tell the two apart.
func (*Cache[K, V]) GetOrLoad ¶ added in v0.2.0
GetOrLoad returns the cached value, calling Options.Loader when there is none. Callers that ask for the same key while a load is running wait for it instead of starting their own, so a cold key costs one upstream call rather than one per concurrent caller.
A cached "does not exist" answer is reported as ErrNotFound without calling the loader. A loaded value is stored before this returns, so the next caller finds it cached; a value too large for the budget is still returned, counted as a rejection rather than quietly retried forever. Invalidation or a successful write during loading suppresses publication, but the waiting callers still receive the loader result. New callers do not join invalidated loads.
Errors other than ErrNotFound are returned as the loader produced them and are not cached, so the next call tries again.
Example ¶
A cold key is fetched once however many callers want it at the same moment.
package main
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/andared/sanecache"
)
type article struct {
ID string
Body string
}
func main() {
var upstreamCalls atomic.Int64
c := sanecache.New(sanecache.Options[string, *article]{
TTL: time.Minute,
NegativeTTL: 10 * time.Second,
Loader: func(_ context.Context, id string) (*article, error) {
upstreamCalls.Add(1)
if id == "gone" {
// The upstream's own "no such row" is translated once, here, so
// that callers see the same error whether the answer came from
// the upstream or from the negative entry it left behind.
return nil, sanecache.ErrNotFound
}
return &article{ID: id, Body: "hello"}, nil
},
})
defer c.Close()
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := c.GetOrLoad(context.Background(), "a1"); err != nil {
fmt.Println("load:", err)
}
}()
}
wg.Wait()
_, err := c.GetOrLoad(context.Background(), "gone")
fmt.Println("missing:", errors.Is(err, sanecache.ErrNotFound))
// Ten callers, one upstream call — plus the one that came back empty.
fmt.Println("upstream calls:", upstreamCalls.Load())
}
Output: missing: true upstream calls: 2
func (*Cache[K, V]) Len ¶
Len reports how many entries are held, including expired ones not yet swept.
func (*Cache[K, V]) Lookup ¶
Lookup returns the cached value and how the cache answered.
Example ¶
Telling "nobody asked yet" apart from "the upstream says it does not exist".
package main
import (
"fmt"
"time"
"github.com/andared/sanecache"
)
func main() {
c := sanecache.New(sanecache.Options[string, string]{
TTL: time.Minute,
NegativeTTL: 30 * time.Second,
})
defer c.Close()
_, status := c.Lookup("gone")
fmt.Println("before asking the upstream:", status)
// The upstream answered "no such key". Remember that, or every render of a
// template naming this id will ask again.
if err := c.SetNegative("gone"); err != nil {
fmt.Println("set negative:", err)
return
}
_, status = c.Lookup("gone")
fmt.Println("after:", status)
// Get collapses the two, because both mean "you have no value".
_, ok := c.Get("gone")
fmt.Println("Get reports a hit:", ok)
}
Output: before asking the upstream: miss after: negative Get reports a hit: false
func (*Cache[K, V]) Set ¶
Set caches value under key for the configured TTL. A successful write prevents outstanding loads for the key from publishing over it. Rejected writes do not.
Example ¶
A value that cannot fit is refused rather than accepted and dropped later.
package main
import (
"errors"
"fmt"
"github.com/andared/sanecache"
)
func main() {
c := sanecache.New(sanecache.Options[string, []byte]{
MaxBytes: 1024,
Cost: func(b []byte) int64 { return int64(len(b)) },
})
defer c.Close()
err := c.Set("huge", make([]byte, 4096))
fmt.Println("rejected:", errors.Is(err, sanecache.ErrTooLarge))
// The rejection is counted, so keys that can never be cached show up as
// their own signal instead of an unexplained miss rate.
_, ok := c.Get("huge")
fmt.Println("cached:", ok, "rejections:", c.Stats().Rejections)
}
Output: rejected: true cached: false rejections: 1
func (*Cache[K, V]) SetNegative ¶
SetNegative records that the upstream reports no such key, for the configured NegativeTTL. Without it, a template that names a deleted object hits the upstream on every single render. A successful write supersedes outstanding loads.
func (*Cache[K, V]) SetNegativeTTL ¶
SetNegativeTTL is SetNegative with an explicit lifetime.
func (*Cache[K, V]) SetTTL ¶
SetTTL caches value under key for ttl, overriding Options.TTL. A ttl of zero means the entry never expires on its own. Successful writes supersede outstanding loads for the key.
func (*Cache[K, V]) Stats ¶
Stats returns a snapshot of the counters. It walks every shard, so poll it on a metrics interval rather than per request.
Example ¶
package main
import (
"fmt"
"time"
"github.com/andared/sanecache"
)
func main() {
c := sanecache.New(sanecache.Options[int, string]{
TTL: time.Minute,
NegativeTTL: time.Minute,
})
defer c.Close()
_ = c.Set(1, "one")
_ = c.SetNegative(2)
c.Get(1) // hit
c.Lookup(2) // negative
c.Get(3) // miss
s := c.Stats()
// A cached negative counts towards the hit rate: it saved the same upstream
// call a positive answer would have.
fmt.Printf("hits=%d negatives=%d misses=%d rate=%.2f\n",
s.Hits, s.Negatives, s.Misses, s.HitRate())
}
Output: hits=1 negatives=1 misses=1 rate=0.67
type EvictReason ¶
type EvictReason uint8
EvictReason explains why an entry left the cache. Explicit Delete and Clear calls do not report a reason.
const ( ReasonEvicted EvictReason = iota // dropped to stay inside the budget ReasonExpired // its TTL ran out ReasonReplaced // a later Set overwrote the key )
The reasons an entry can leave the cache on its own.
func (EvictReason) String ¶
func (r EvictReason) String() string
type Options ¶
type Options[K comparable, V any] struct { // TTL is how long a value stays valid. Zero means entries never expire on // their own, which only makes sense for a cache bounded by MaxBytes or // MaxEntries, or one whose entries all get an explicit TTL via SetTTL. TTL time.Duration // NegativeTTL enables SetNegative and sets how long a cached "does not // exist" answer lives. It is usually much shorter than TTL: an object that // does not exist yet may appear at any moment. NegativeTTL time.Duration // Jitter spreads expiry times by up to this percentage in either direction, // so keys written together do not expire together. Must be 0..100. Jitter int // MaxBytes is the total budget in bytes, split evenly across shards. It // requires Cost. Zero means unbounded. MaxBytes int64 // MaxEntries caps the number of entries, split evenly across shards. Zero // means unbounded. Prefer MaxBytes unless entries are uniform in size. MaxEntries int // Cost reports the memory a value occupies, in bytes. It is what MaxBytes is // measured against, so it should approximate resident size rather than // serialized size: a decoded struct commonly costs several times its JSON. // Measure it once rather than guessing (see the README). Cost func(V) int64 // Loader fetches a value that is not cached. It is what GetOrLoad calls, and // callers that ask for the same key while it is running share the one call // instead of each starting their own. // // Returning an error that wraps ErrNotFound means the upstream has no such // key: the answer is cached as a negative entry when NegativeTTL is set, and // reported to every waiting caller. Any other error is passed through // unchanged and is not cached. // // The context is not any one caller's: it carries the values of the caller // that started the load, but it is cancelled only once every caller waiting // for the result has given up. A loader must not call GetOrLoad on the same // cache and key, which would wait for itself. // // A load that nobody is waiting for any more is cancelled, but a loader that // does not watch its context finishes regardless, and its value is cached // even so. That is what keeps a cache warming when callers time out faster // than the upstream answers; the price is that such a load can land after a // later one and put back a value read before it, with the TTL starting over. // A loader that honours cancellation never gets there. Loader func(ctx context.Context, key K) (V, error) // Shards splits the cache into independently locked parts, rounded up to a // power of two. Zero and one both mean a single lock. More shards reduce // contention but make the budget approximate: each shard gets an equal slice // of MaxBytes, and an uneven key distribution leaves some of it unused. // // The per-shard slice is rounded up, so the shards together are never // stricter than what was asked for. With small caps that rounding dominates: // MaxEntries of 2 across 16 shards is one entry per shard, or sixteen in // total. Keep the cap comfortably larger than the shard count. Shards int // Policy selects the eviction strategy. Defaults to LRU. Policy Policy // CleanupInterval is how often a background goroutine drops expired entries. // Zero picks an interval from the configured TTLs. Expired entries are also // dropped lazily on lookup, but until they are swept they still count // against the budget. CleanupInterval time.Duration // DisableCleanup runs the cache without a background goroutine. Expiry then // happens only on lookup and on eviction. DisableCleanup bool // ClockGranularity trades TTL precision for lookup speed. Reading the wall // clock is about half the cost of a lookup, so a cache under enough load for // that to show up can have a background goroutine hold the time instead, // refreshed this often. Expiry is then accurate to within one interval in // either direction. Zero, the default, reads the clock on every operation. // // The goroutine is separate from the sweeper, so this works with // DisableCleanup. Close stops it, and lookups go back to the wall clock. ClockGranularity time.Duration // OnEvict, if set, is called for every entry that leaves the cache without // being explicitly deleted. Negative entries are reported with the zero // value. It runs outside the shard lock, on the goroutine that caused the // removal, so it must not block. OnEvict func(key K, value V, reason EvictReason) // DisableStats skips the counters behind Stats. DisableStats bool }
Options configures a cache. The zero value is a valid, unbounded, never expiring cache; every field below is optional.
type Policy ¶
type Policy uint8
Policy decides what happens when a shard runs over its budget.
const ( // LRU evicts the least recently used entries until the shard fits again. // Keeping that order costs a write lock on every read. LRU Policy = iota // ClearOnFull drops the whole shard except the entry that overflowed it. // Reads then need only a read lock, which is worth more than precise // eviction when access order is flat and refilling is cheap relative to // the bookkeeping. ClearOnFull )
type Stats ¶
type Stats struct {
Hits int64 // lookups that returned a value
Misses int64 // lookups that found nothing
Negatives int64 // lookups that found a cached "does not exist"
Evictions int64 // entries dropped to stay inside the budget
Expirations int64 // entries dropped because their TTL ran out
Replacements int64 // entries overwritten by a later Set
Rejections int64 // Set calls refused with ErrTooLarge
// TypeMisses counts view lookups that found an entry holding some other
// type. Hits counts those too, because the cache did have the key; the pair
// is what tells a namespace collision apart from a plain miss.
TypeMisses int64
Loads int64 // Loader calls that finished, successfully or not
LoadErrors int64 // of those, the ones that returned an error
// Coalesced counts the GetOrLoad calls that another caller's load spared
// from starting one of their own, whether they waited for it or arrived just
// after it published. Against Loads it says how much the single flight is
// actually saving.
Coalesced int64
Entries int // entries currently held, expired-but-not-yet-swept included
Bytes int64 // sum of the costs of those entries
}
Stats is a snapshot of the cache counters. Counters are cumulative since the cache was created; Entries and Bytes are instantaneous.
type Status ¶
type Status uint8
Status is the outcome of a lookup.
type View ¶ added in v0.2.0
type View[T any] struct { // contains filtered or unexported fields }
View is a typed window onto a cache that holds values of several types under one byte budget. The cache is declared as Cache[string, any]; each view fixes one value type, namespaces its keys, and counts its own hits.
This is the shape that a budget in bytes forces. One cache per type would mean one budget per type, and splitting a fixed amount of memory between types up front is exactly the guess the byte budget was meant to avoid: the split that was right at deploy time is wrong by the next traffic pattern. A view is a function rather than a method on Cache because a method cannot introduce a type parameter of its own.
A view costs one string join per operation on top of the cache underneath. While the name and key together fit in 32 bytes the compiler keeps that on the stack; past it, every read allocates.
func NewView ¶ added in v0.2.0
NewView opens a view named o.Name onto c. It panics on a name that cannot keep views apart, for the same reason New panics on a budget it cannot honour.
Example ¶
Several value types under one byte budget, which is the only kind of budget that does not need dividing up in advance.
package main
import (
"fmt"
"time"
"github.com/andared/sanecache"
)
type article struct {
ID string
Body string
}
type season struct {
Num int
}
func main() {
c := sanecache.New(sanecache.Options[string, any]{
TTL: 10 * time.Minute,
MaxBytes: 64 << 20,
// The fallback for views that do not bring their own Cost. With several
// types sharing a budget, this is where the type switch would go.
Cost: func(any) int64 { return 256 },
})
defer c.Close()
articles := sanecache.NewView(c, sanecache.ViewOptions[*article]{
Name: "article",
Cost: func(a *article) int64 { return int64(len(a.Body)) * 3 },
})
seasons := sanecache.NewView(c, sanecache.ViewOptions[*season]{
Name: "season",
TTL: time.Hour, // seasons change less often than articles do
})
_ = articles.Set("1", &article{ID: "1", Body: "hello"})
_ = seasons.Set("1", &season{Num: 7})
a, _ := articles.Get("1")
s, _ := seasons.Get("1")
fmt.Println(a.Body, s.Num)
// The same key in two views is two entries: the view name is part of it.
fmt.Println("entries:", c.Len(), "bytes:", c.Bytes())
}
Output: hello 7 entries: 2 bytes: 271
func (*View[T]) Delete ¶ added in v0.2.0
Delete removes key from this view and reports whether it was present. It also invalidates outstanding loads for the namespaced key, including those in other views or the parent cache. Existing waiters still receive their loader result.
func (*View[T]) Get ¶ added in v0.2.0
Get returns the cached value. As with Cache.Get, a cached "does not exist" answer reports false; use Lookup to tell it from a miss.
func (*View[T]) GetOrLoad ¶ added in v0.3.0
GetOrLoad returns a typed cached value or calls ViewOptions.Loader. It follows Cache.GetOrLoad's error and cancellation policy, storing results with this view's Cost, TTL and NegativeTTL under the shared cache's budget. A wrong-type entry is a miss and can be replaced by the loaded value.
Without a view loader it returns ErrNoLoader, even for a cached key; the underlying cache's loader is never used. Loads coalesce per View instance.
Example ¶
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/andared/sanecache"
)
type article struct {
ID string
Body string
}
func main() {
c := sanecache.New(sanecache.Options[string, any]{MaxBytes: 1024, Cost: func(any) int64 { return 64 }})
defer c.Close()
articles := sanecache.NewView(c, sanecache.ViewOptions[*article]{
Name: "article",
NegativeTTL: time.Minute,
Loader: func(_ context.Context, id string) (*article, error) {
if id == "gone" {
return nil, sanecache.ErrNotFound
}
return &article{ID: id, Body: "hello"}, nil
},
})
a, err := articles.GetOrLoad(context.Background(), "1")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(a.ID, a.Body)
_, err = articles.GetOrLoad(context.Background(), "gone")
fmt.Println("missing:", errors.Is(err, sanecache.ErrNotFound))
fmt.Println("loads:", articles.Stats().Loads, "total:", c.Stats().Loads)
}
Output: 1 hello missing: true loads: 2 total: 2
func (*View[T]) Lookup ¶ added in v0.2.0
Lookup returns the cached value and how the view answered. An entry holding some other type is reported as a miss and counted as a TypeMiss: the value is unusable here, so the caller has to go to the upstream either way.
func (*View[T]) Name ¶ added in v0.2.0
Name returns the view's name, which is also the prefix its keys carry in the underlying cache.
func (*View[T]) SetNegative ¶ added in v0.2.0
SetNegative records that the upstream reports no such key, for the view's NegativeTTL.
func (*View[T]) SetNegativeTTL ¶ added in v0.2.0
SetNegativeTTL is SetNegative with an explicit lifetime.
type ViewOptions ¶ added in v0.2.0
type ViewOptions[T any] struct { // Name identifies the view and namespaces its keys: the view stores under // Name + ":" + key. Two views therefore cannot collide, and an OnEvict // handler on the cache can tell whose entry it is looking at. It must not be // empty and must not contain ":". Name string // Loader fetches an uncached value using the caller's key without the name // prefix. It follows Options.Loader's error, cancellation and panic policy. // Concurrent loads coalesce within this View instance; reuse the instance // to share in-flight work. Separate views (even with the same name) and the // underlying cache have independent loaders and in-flight work. // A loader must not recursively load the same key through this view. Loader func(context.Context, string) (T, error) // Cost reports the memory a value of this view occupies, in bytes, the way // Options.Cost does for the cache as a whole. A view that sets it is spared // the type switch that a shared Cost func(any) int64 turns into once a // budget holds several types. Unset, the cache's own Cost is used. Cost func(T) int64 // TTL is how long this view's values stay valid. Zero takes the cache's TTL; // SetTTL still overrides both. // // A view with a much shorter TTL than the cache is worth a word of warning: // the sweeper's interval is chosen from the cache's TTLs, so those entries // may sit on the budget after expiring until a lookup or a sweep finds them. // Set Options.CleanupInterval when that matters. TTL time.Duration // NegativeTTL is how long this view's "does not exist" answers live. Zero // takes the cache's NegativeTTL, and if that is unset too, SetNegative on // this view reports ErrNegativeDisabled. NegativeTTL time.Duration }
ViewOptions configures a view. Only Name is required.
type ViewStats ¶ added in v0.2.0
type ViewStats struct {
Hits int64 // lookups that returned a value of this view's type
Misses int64 // lookups that found nothing
Negatives int64 // lookups that found a cached "does not exist"
// TypeMisses counts lookups that found an entry holding another type. It is
// a bug detector rather than a routine metric: with keys namespaced by view
// name, the only ways to get one are two views sharing a name and writes
// made straight to the underlying cache.
TypeMisses int64
Loads int64 // completed loader calls, including errors and panics
LoadErrors int64 // completed loader calls that failed
Coalesced int64 // calls spared a load by another caller
}
ViewStats is a snapshot of one view's counters, cumulative since the view was opened.