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]) 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 Options.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 empties the cache. 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. 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.
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.
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.
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.
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.
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]) 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 // 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
}
ViewStats is a snapshot of one view's counters, cumulative since the view was opened.