sanecache

package module
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: MIT Imports: 14 Imported by: 0

README

sanecache

CI Go Reference Go Report Card

A small in-memory cache for Go that aims to be predictable before it is fast.

There are excellent Go caches already. This one exists because the failures that cost time in production were never about throughput: a write that reports success and is dropped a moment later, a hit rate that collapses because every value is too big for the budget, a limit in entries when the thing to protect is memory, keys that all expire in the same millisecond, and a cold key that a hundred concurrent requests each fetch for themselves.

import "github.com/andared/sanecache"

Requires Go 1.25. No dependencies.

Quick start

Save this complete program as main.go in a new directory:

package main

import (
    "fmt"
    "time"

    "github.com/andared/sanecache"
)

func main() {
    c := sanecache.New(sanecache.Options[string, string]{
        TTL:        time.Minute,
        MaxEntries: 100,
    })
    defer c.Close()

    if err := c.Set("greeting", "hello"); err != nil {
        panic(err)
    }

    value, ok := c.Get("greeting")
    fmt.Println(value, ok)
}
go mod init example.com/cache-demo
go get github.com/andared/sanecache@v0.9.0
go run .

Output: hello true. The same program is in examples/basic, and a second example caches JSON responses from a local HTTP server, 404s included as negative entries. Both run in CI.

Applying a byte budget

With your own Article type and fetch function:

c := sanecache.New(sanecache.Options[string, *Article]{
    TTL:         10 * time.Minute,
    NegativeTTL: 30 * time.Second,
    Jitter:      10,
    MaxBytes:    64 << 20,
    Cost:        func(a *Article) int64 { return a.ApproxBytes() },
})
defer c.Close()

if v, status := c.Lookup(id); status != sanecache.StatusMiss {
    if status == sanecache.StatusNegative {
        return nil, ErrNotFound // the upstream already told us, don't ask again
    }
    return v, nil
}

article, err := fetch(ctx, id)
switch {
case errors.Is(err, ErrNotFound):
    c.SetNegative(id)
    return nil, err
case err != nil:
    return nil, err
}

if err := c.Set(id, article); err != nil {
    // ErrTooLarge: this key will never be cached. Worth a metric.
}

What "sane" means here

Writes are synchronous. A value is readable the moment Set returns, so tests need no Wait(), and neither does code that writes a value and reads it back.

A value that does not fit is refused. Set returns ErrTooLarge when the value costs more than its shard's budget, instead of accepting it and evicting it unseen, which sends every request for that key to the upstream forever.

Budgets are in bytes. Ten thousand entries is nothing for int values and gigabytes for HTML templates. MaxBytes without a Cost function panics at construction rather than quietly counting entries.

"It does not exist" is an answer. SetNegative records that the upstream said no, instead of a sentinel smuggled into the value type. Under a byte budget a negative entry is charged what it holds — entry, map slot and key — so eviction can reclaim it.

TTLs can be jittered. Jitter: 10 spreads each expiry by up to ±10%, so keys warmed together do not expire together and hit the upstream as one wave.

A cold key is fetched once, however many callers ask for it at the same moment.

Loading a cold key once

c := sanecache.New(sanecache.Options[string, *Article]{
    TTL:         10 * time.Minute,
    NegativeTTL: 30 * time.Second,
    Loader: func(ctx context.Context, id string) (*Article, error) {
        a, err := db.Article(ctx, id)
        if errors.Is(err, sql.ErrNoRows) {
            return nil, sanecache.ErrNotFound // remember the absence too
        }
        return a, err
    },
})

article, err := c.GetOrLoad(ctx, id)
switch {
case errors.Is(err, sanecache.ErrNotFound):
    return nil, err // from the loader, or from a negative entry it left behind
case err != nil:
    return nil, err
}

GetOrLoad runs the loader once per key however many callers arrive while it runs. Where implementations differ:

  • "Does not exist" is one error. The loader returns ErrNotFound, GetOrLoad caches it as a negative entry and returns the same error to later callers. Translate the upstream's own error once, in the loader.
  • Giving up does not cancel the load for everyone else. The loader's context carries the first caller's values but is cancelled only once every waiting caller has gone. A bare singleflight.Group leaves that to you, and a callback capturing the first caller's context lets its timeout cancel work others still need.
  • Failures are not cached. Errors other than ErrNotFound are returned unchanged and the next call tries again; single flight already collapses the retry storm.

The loader runs on a goroutine of the cache's own. A panic in it reaches the callers, with the stack of where it happened, rather than taking the process down. Set a deadline inside the loader, where the right number is known. A loader that ignores cancellation still warms the cache, unless the key was invalidated or written in the meantime.

When the loader needs more than the key

When the upstream call needs more than the key — a query string the key only summarises, a request already built — pass the loader in with the call:

page, err := c.GetOrLoadFunc(ctx, pageKey(path, params), func(ctx context.Context) (*Page, error) {
    return fetchPage(ctx, path, params)
})

It shares loads with GetOrLoad: callers of a key wait for whichever load started first, so the function must return what any caller's would for that key. Views have it too. GetOrLoadFuncWithCost and SetWithCost take the value's cost along with it, for when the size is known only where the value is produced, such as the response it was decoded from.

Invalidation while loading

Delete (even of an absent key), Clear and successful writes keep outstanding loads of the key from putting their result in the cache. So if a loader reads an old record and the application then updates the source and calls Delete, the old record does not come back. Callers already waiting still get the loader's result; new callers start a new load. Update the source before invalidating the cache.

This follows the storage key, across views sharing a namespace and the parent cache, and a load's own publication supersedes other loads of the key. Clear works shard by shard, so it is not an atomic snapshot. Close only stops background goroutines.

Loading many keys at once

GetManyOrLoad sends the keys the cache does not hold to a BatchLoader in one call — a query with IN, a pipelined round trip:

c := sanecache.New(sanecache.Options[int, *Article]{
    TTL:         10 * time.Minute,
    NegativeTTL: 30 * time.Second,
    BatchLoader: func(ctx context.Context, ids []int) (map[int]*Article, error) {
        return db.ArticlesByIDs(ctx, ids) // an id missing from the map does not exist
    },
})

articles, err := c.GetManyOrLoad(ctx, ids) // cached or loaded; absent ids are not in it

Everything works per key: a key another call is already loading is waited for, not loaded again, and a Delete of one key keeps only that key's result out. A key missing from the answer is cached as a negative entry and is simply absent from the result. An error fails the whole batch and nothing from it is cached. The upstream call is cancelled only once no caller waits for any of its keys.

With only a BatchLoader, GetOrLoad uses it with a batch of one; with only a Loader, GetManyOrLoad loads the missing keys concurrently. Stats().Batches counts batch calls. Views load in batches the same way, with ViewOptions.BatchLoader and View.GetManyOrLoad.

Refreshing before expiry

On a key in steady use, a TTL means the value expires, the next caller misses and waits, and every hot key goes cold once per TTL. Expirations and Loads moving in step is the sign. RefreshAfter moves that load off the request path:

c := sanecache.New(sanecache.Options[int, *Rule]{
    TTL:          time.Minute,
    RefreshAfter: 40 * time.Second,
    Loader:       loadRule,
})

A read that finds a value older than RefreshAfter returns it at once and starts one load in the background; a key nobody reads simply expires. The refresh is an ordinary load to single flight and invalidation. The TTL still holds: a failed refresh is not retried, the value lives out its TTL, and the next caller then sees the error, so a dead upstream shows up one TTL later than without refresh, never more.

GetOrLoad, GetManyOrLoad and GetOrLoadFunc refresh with their loaders, Get and Lookup with the ones in Options. GetManyOrLoad refreshes due keys in a batch of their own. Views take their own RefreshAfter. Stats().Refreshes and RefreshErrors count refreshes, apart from Loads.

Several value types under one budget

One cache per type means one budget per type, and splitting memory between types up front is the guess a byte budget was meant to avoid.

c := sanecache.New(sanecache.Options[string, any]{
    TTL:      10 * time.Minute,
    MaxBytes: 64 << 20,
})
defer c.Close()

articles := sanecache.NewView(c, sanecache.ViewOptions[*Article]{
    Name: "article",
    Cost: func(a *Article) int64 { return a.ApproxBytes() },
})
seasons := sanecache.NewView(c, sanecache.ViewOptions[*Season]{
    Name: "season",
    Cost: func(*Season) int64 { return 256 },
    TTL:  time.Hour, // seasons change less often than articles do
})

a, ok := articles.Get(id) // a is a *Article, not an any

A view fixes one value type, prefixes its keys with its name and can bring its own Cost and TTLs. Eviction stays global. The cache's Cost, if it has one, is the fallback for views without their own; under MaxBytes without it, NewView requires one, and a value written to the cache directly is charged as a negative entry is, by its key. Reads cost about 6 ns more than the cache underneath, and allocate once name and key exceed 32 bytes. Stats().TypeMisses counts lookups that found another type under a view's key: a bug detector for two views sharing a name.

Views load too:

articles := sanecache.NewView(c, sanecache.ViewOptions[*Article]{
    Name:        "article",
    Cost:        func(a *Article) int64 { return a.ApproxBytes() },
    NegativeTTL: 30 * time.Second,
    Loader:      fetchArticle, // func(context.Context, string) (*Article, error)
})
a, err := articles.GetOrLoad(ctx, id)

The loader gets the key without the prefix and follows the rules of Cache.GetOrLoad. Loads are shared within one View instance; the cache's own loader is never a fallback. Counters belong to the view's name and are included in the cache's.

Switching caching off

A view opened on a nil cache stores nothing, so that code calling GetOrLoad stays the same when a TTL of zero means "do not cache":

var cache *sanecache.Cache[string, any]
if ttl > 0 {
    cache = shared
}
articles := sanecache.NewView(cache, sanecache.ViewOptions[*Article]{
    Name:   "article",
    TTL:    ttl,
    Loader: fetchArticle,
})

It runs the loader on every call, still shared by concurrent callers, and keeps nothing. Writes return ErrDisabled. Its counters are its own.

The Cost function is the part worth getting right

MaxBytes is only as honest as Cost, and resident size is usually several times the serialized size. Measure it once:

runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)

values := make([]*Article, 0, n)
for range n {
    values = append(values, decode(sample))
}

runtime.GC()
var after runtime.MemStats
runtime.ReadMemStats(&after)
runtime.KeepAlive(values)

ratio := float64(after.HeapAlloc-before.HeapAlloc) / float64(n*len(sample))

In one production service this came out at ~2.6× for decoded JSON structs, higher for map[string]any, and ~6.7× for compiled templates.

Sharding

Shards splits the cache into independently locked parts, rounded up to a power of two. It is off by default: each shard gets an equal slice of the budget, and eviction becomes per shard. On an M3 with GOMAXPROCS=8, 4096 keys, all readers on one cache:

shards Get (LRU) Get (ClearOnFull) Set 90/10 mixed
1 131 ns 108 ns 195 ns 145 ns
4 66 ns 37 ns 116 ns 76 ns
16 40 ns 24 ns 76 ns 47 ns
64 33 ns 19 ns 65 ns 39 ns

At one lookup per request none of this matters, and Shards: 0 is the right answer.

Eviction policies

LRU, the default, evicts least recently used entries, so every read takes a write lock. ClearOnFull drops the whole shard except the entry that overflowed it; reads then take a read lock, worth 20% on one shard and 40% sharded. It suits flat access orders where refilling is cheap, and costs hit rate: see the table below.

S3FIFO keeps a new entry in a small queue, a tenth of the budget, until it is read again, so that keys read once leave before they push out the ones in steady use; an entry in the main queue gets another round for each read, up to three. Reads take a read lock, as with ClearOnFull, and the hit rate is the best of the three (see the table below). The cost is the hashes of recently evicted keys, at most one per entry the shard holds.

The clock

An uncontended hit costs 47 ns, and 27 ns of that is time.Now(). ClockGranularity has a background goroutine read the clock instead:

sanecache.Options[string, *Article]{
    TTL:              10 * time.Minute,
    ClockGranularity: 100 * time.Millisecond,
}

A hit then costs 20 ns, and expiry is accurate to one interval either way: nothing against ten minutes, a lot against one second, which is why it is off by default. New and NewView panic on a TTL, NegativeTTL or RefreshAfter under ten intervals. After Close, lookups go back to the wall clock.

Stats

Counters are built in. Poll Stats(), and ViewStats() for every view by name:

s := c.Stats()
// s.Hits, s.Misses, s.Negatives, s.TypeMisses, s.Evictions, s.Expirations,
// s.Replacements, s.Rejections, s.Loads, s.LoadNotFound, s.LoadErrors, s.Coalesced, s.Batches,
// s.Refreshes, s.RefreshErrors, s.Entries, s.Bytes, s.HitRate()

for name, vs := range c.ViewStats() {
    // vs.Hits, vs.Misses, vs.Negatives, vs.TypeMisses, vs.Loads, vs.LoadNotFound,
    // vs.LoadErrors, vs.Coalesced, vs.Refreshes, vs.RefreshErrors, vs.HitRate()
}

s.Sub(prev) is what was gained since the previous snapshot, the increments a statsd exporter sends. Alert on Rejections: keys that can never be cached. Coalesced against Loads is what single flight saves. LoadErrors leaves out ErrNotFound, which is in LoadNotFound, so ids that are gone do not look like failures.

Iterating

All and View.All range over the cached values, for dumping a cache or warming another from it:

for key, value := range old.All() {
    _ = next.Set(key, value)
}

The loop may use the cache: each shard is copied under its read lock and yielded after, so a large shard is copied whole, and the loop sees each shard as it was when reached, not the whole cache at one moment. Negative and expired entries are skipped, and iterating counts no lookups.

Lifecycle

A background goroutine sweeps expired entries off the budget. Close() stops it and the clock goroutine; dropping the cache stops them too. With DisableCleanup: true entries expire only on lookup.

How it compares

The benchmarks/ module, separate so the root stays dependency-free, measures this cache against the ones it would replace: make bench-compare. Same machine, Go 1.25, 256-byte values, a budget of 4096 of them, medians of five runs. Each sanecache row adds a knob to the row above; the S3FIFO row takes the place of ClearOnFull.

ns/op serial Get Get ×8 Set ×8 90/10 ×8
sanecache, defaults 52 135 235 152
⤷ Shards: 16 54 41 94 48
⤷ + ClockGranularity 31 33 86 40
⤷ + ClearOnFull 30 20 94 37
⤷ S3FIFO instead 25 20 95 38
otter v2 70 12 254 27
theine 90 13 169 54
ristretto v2 84 21 266 64
sturdyc 65 30 117 64
golang-lru v2/expirable 46 140 177 160
ttlcache v3 60 169 254 193

Per lookup this cache is cheaper than otter, theine and ristretto, which maintain a frequency sketch and ring buffers. What those buy is scaling: otter and theine turn eight cores into 6–7x the throughput, this one into 1.3–1.5x with ClearOnFull or S3FIFO, which read under a read lock, and none with LRU, which reads under a write lock. At one lookup per request, 20 ns against 12 ns is nothing; it matters when a request does thousands of lookups. Writes are the other way round, 86–95 ns against 117–266: an admission policy costs more than a lock.

What usually matters more is how much of a fixed budget turns into hits. 4096 entries against 100,000 keys:

%hit zipf s=1.20 zipf s=1.01 zipf + scan
sanecache (LRU) 87.3 65.8 78.0
sanecache (ClearOnFull) 83.1 57.9 73.7
sanecache (S3FIFO) 89.1 71.3 81.0
otter v2 88.9 71.5 80.9
theine 89.0 71.2 80.7
ristretto v2 87.4 68.7 78.9
sturdyc 84.9 61.6 75.3
golang-lru expirable 87.3 65.8 78.0
ttlcache v3 87.3 65.8 78.0

When the hot set fits, policies differ by under 2 points. When it does not, W-TinyLFU is worth 5.7 points over LRU, and that much upstream traffic outweighs every nanosecond above. S3FIFO gets the same within a fraction of a point, at the price of a ClearOnFull read, and without admission: a Set is still always stored. ClearOnFull pays 7.9 points for its cheap reads; sturdyc, dropping a full shard's oldest tenth at once, pays 4.2.

The features are not the difference either

As of otter v2.3.0, theine v0.6.2, sturdyc v1.1.6 and ttlcache v3.4.1:

one load per key batch load refresh ahead budget by cost "not found" remembered value over budget
sanecache GetOrLoad GetManyOrLoad RefreshAfter MaxBytes + Cost SetNegative ErrTooLarge
otter v2 Get + Loader BulkGet RefreshCalculator MaximumWeight + Weigher no: ErrNotFound deletes accepted, then evicted
theine loading cache — — Cost — Set returns false
sturdyc GetOrFetch GetOrFetchBatch WithEarlyRefreshes — WithMissingRecordStorage —
ttlcache v3 SuppressedLoader — — WithMaxCost — evicts everything, itself last

Others do things this one does not: otter and theine save the cache to a file and load it back; sturdyc retries failed refreshes, batches refreshes across requests, sits in front of a distributed store, and holds several value types under a count of entries. What is left here is the combination: a byte budget that refuses what does not fit, missing records charged against it, and value types with costs of their own under one budget.

When to use something else

  • Writes should be refused when the newcomer is less likely to be read than what it would evict → otter or theine, whose admission policy does that; here a Set is always stored.
  • How a cache scales across cores is a real difference on your read path → otter or ristretto; with ristretto, budget for Wait() in the tests.
  • You want refreshes retried, refreshes from many requests batched, or a distributed store behind the cache → sturdyc.
  • GC pressure from millions of live pointers is your actual problem → bigcache or freecache, which store entries in byte buffers with few pointers, still on the Go heap (BigCache, FreeCache).
  • A bounded LRU with TTL and nothing else → hashicorp/golang-lru's v2/expirable.

Status

v0.9. The API above is what exists and is tested; expect it to move before v1. Contributions are welcome — see CONTRIBUTING.md for what this library optimises for. MIT licensed.

Documentation

Overview

Package sanecache is a small in-memory cache that is predictable before it is fast: writes are synchronous, a value that cannot fit is refused with an error, budgets are in bytes, "does not exist" is a first-class answer, TTLs carry jitter so that keys warmed together do not expire together, and a cold key is loaded once however many callers ask 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

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrTooLarge is returned by Set when the value's cost exceeds the budget of
	// its shard, so that it could never be cached.
	ErrTooLarge = errors.New("sanecache: value cost exceeds the shard budget")

	// ErrNegativeDisabled is returned by SetNegative without a NegativeTTL: a
	// negative entry would otherwise never expire.
	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 it, from a load or a cached negative entry alike. A
	// loader may wrap it: it is tested with errors.Is.
	ErrNotFound = errors.New("sanecache: the upstream has no such key")

	// ErrDisabled is returned by the writes of a view opened on a nil cache.
	ErrDisabled = errors.New("sanecache: the view has no cache to write to")

	// ErrNoLoader is returned by GetOrLoad and GetManyOrLoad without a loader,
	// and by GetOrLoadFunc given a nil function.
	ErrNoLoader = errors.New("sanecache: loading requires a loader (Options.Loader, Options.BatchLoader or ViewOptions.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 Cost: a programming mistake is better caught at construction than by a cache that silently misbehaves.

func (*Cache[K, V]) All added in v0.9.0

func (c *Cache[K, V]) All() iter.Seq2[K, V]

All iterates over the cached values, for dumping a cache or copying it into another. Each shard is copied under its read lock and yielded after, so the loop may use the cache, and it sees each shard as it was when reached rather than the whole cache at one moment. Negative and expired entries are skipped. Iterating counts no lookups, reorders nothing and starts no refresh.

Example

All carries what a cache holds into another one, here with a larger budget, or out to wherever a warm start is read back from.

package main

import (
	"fmt"

	"github.com/andared/sanecache"
)

func main() {
	old := sanecache.New(sanecache.Options[string, int]{MaxEntries: 100})
	defer old.Close()
	_ = old.Set("a", 1)
	_ = old.Set("b", 2)

	bigger := sanecache.New(sanecache.Options[string, int]{MaxEntries: 1000})
	defer bigger.Close()
	for k, v := range old.All() {
		_ = bigger.Set(k, v)
	}

	v, _ := bigger.Get("b")
	fmt.Println(bigger.Len(), v)

}
Output:
2 2

func (*Cache[K, V]) Bytes

func (c *Cache[K, V]) Bytes() int64

Bytes reports the summed cost of the entries held.

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

func (c *Cache[K, V]) Delete(key K) bool

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

func (c *Cache[K, V]) Get(key K) (V, bool)

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]) GetManyOrLoad added in v0.6.0

func (c *Cache[K, V]) GetManyOrLoad(ctx context.Context, keys []K) (map[K]V, error)

GetManyOrLoad returns the cached values of keys, loading the rest in one Options.BatchLoader call, for an upstream that answers many keys at once.

Single flight and invalidation work per key, as in GetOrLoad: a key already loading elsewhere is waited for, and a GetOrLoad of a key in the batch waits for the batch. A key that does not exist upstream is absent from the result and is not an error. Duplicate keys are looked up once.

The error is the first failure in key order; the keys that failed are absent and the others present. If ctx ends first, the result holds what was at hand and the error is ctx.Err(), while the loads carry on for other waiters.

Without a BatchLoader the missing keys are loaded concurrently with Options.Loader; with neither, it returns ErrNoLoader. Keys due for a refresh are refreshed in a BatchLoader call of their own, not holding up this one.

Example

Fetching many keys from an upstream that answers many at once: one call for whatever the cache does not already hold.

package main

import (
	"context"
	"fmt"
	"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,
		BatchLoader: func(_ context.Context, ids []string) (map[string]*article, error) {
			upstreamCalls.Add(1)
			// One round trip, say SELECT ... WHERE id IN (...). A requested id
			// missing from the answer is an id the upstream does not have.
			found := make(map[string]*article, len(ids))
			for _, id := range ids {
				if id != "gone" {
					found[id] = &article{ID: id, Body: "hello"}
				}
			}

			return found, nil
		},
	})
	defer c.Close()

	if err := c.Set("a1", &article{ID: "a1", Body: "cached"}); err != nil {
		fmt.Println("set:", err)
		return
	}

	got, err := c.GetManyOrLoad(context.Background(), []string{"a1", "a2", "a3", "gone"})
	if err != nil {
		fmt.Println("load:", err)
		return
	}
	fmt.Println("found:", len(got), got["a1"].Body, got["a2"].Body)

	// The same batch again is answered from the cache, "gone" included.
	_, _ = c.GetManyOrLoad(context.Background(), []string{"a1", "a2", "a3", "gone"})
	fmt.Println("upstream calls:", upstreamCalls.Load())

}
Output:
found: 3 cached hello
upstream calls: 1

func (*Cache[K, V]) GetOrLoad added in v0.2.0

func (c *Cache[K, V]) GetOrLoad(ctx context.Context, key K) (V, error)

GetOrLoad returns the cached value, calling Options.Loader when there is none, or Options.BatchLoader with this one key when there is no Loader. Callers of a key whose load is running wait for it instead of starting their own.

A cached "does not exist" is reported as ErrNotFound. A loaded value is stored before this returns; one too large for the budget is still returned, and counted as a rejection. Invalidation or a successful write during the load keeps its result out of the cache, though its waiters still get it, and new callers start a new load. Other errors are returned as the loader produced them and are not cached.

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 "no such row" is translated once, here, and
				// callers see the same error from the negative entry later.
				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]) GetOrLoadFunc added in v0.7.0

func (c *Cache[K, V]) GetOrLoadFunc(ctx context.Context, key K, load func(context.Context) (V, error)) (V, error)

GetOrLoadFunc is GetOrLoad with the loader passed in, for upstream calls that need more than the key. A running load of the key, whoever started it, is waited for instead, so load must return what any caller's would for that key. Options.Loader is not needed; a nil load returns ErrNoLoader.

Example

A search page is cached under a key that only summarises the query. The request itself travels with the call instead of being parsed back out of the key.

package main

import (
	"context"
	"fmt"
	"sync/atomic"
	"time"

	"github.com/andared/sanecache"
)

func main() {
	type query struct {
		Text string
		Page int
	}
	var upstreamCalls atomic.Int64
	search := func(_ context.Context, q query) ([]string, error) {
		upstreamCalls.Add(1)

		return []string{fmt.Sprintf("%s, page %d", q.Text, q.Page)}, nil
	}

	c := sanecache.New(sanecache.Options[string, []string]{TTL: time.Minute})
	defer c.Close()

	q := query{Text: "sanecache", Page: 2}
	key := fmt.Sprintf("%s|%d", q.Text, q.Page)
	for range 3 {
		results, err := c.GetOrLoadFunc(context.Background(), key, func(ctx context.Context) ([]string, error) {
			return search(ctx, q)
		})
		if err != nil {
			fmt.Println("search:", err)
		}
		fmt.Println(results[0])
	}
	fmt.Println("upstream calls:", upstreamCalls.Load())

}
Output:
sanecache, page 2
sanecache, page 2
sanecache, page 2
upstream calls: 1

func (*Cache[K, V]) GetOrLoadFuncWithCost added in v0.8.0

func (c *Cache[K, V]) GetOrLoadFuncWithCost(
	ctx context.Context, key K, load func(context.Context) (V, int64, error),
) (V, error)

GetOrLoadFuncWithCost is GetOrLoadFunc for a load that reports the cost of what it returns, which is used instead of Options.Cost, for values whose size is known only where they are produced. A negative cost counts as zero.

func (*Cache[K, V]) Len

func (c *Cache[K, V]) Len() int

Len reports how many entries are held, including expired ones not yet swept.

func (*Cache[K, V]) Lookup

func (c *Cache[K, V]) Lookup(key K) (V, Status)

Lookup returns the cached value and how the cache answered. With RefreshAfter and a loader in Options, a value due for a refresh starts one.

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

func (c *Cache[K, V]) Set(key K, value V) error

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

func (c *Cache[K, V]) SetNegative(key K) error

SetNegative records that the upstream reports no such key, for the configured NegativeTTL. A successful write supersedes outstanding loads.

func (*Cache[K, V]) SetNegativeTTL

func (c *Cache[K, V]) SetNegativeTTL(key K, ttl time.Duration) error

SetNegativeTTL is SetNegative with an explicit lifetime.

func (*Cache[K, V]) SetTTL

func (c *Cache[K, V]) SetTTL(key K, value V, ttl time.Duration) error

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]) SetWithCost added in v0.8.0

func (c *Cache[K, V]) SetWithCost(key K, value V, cost int64) error

SetWithCost is Set with the value's cost given instead of taken from Options.Cost, for a value whose size is known only where it was produced. A negative cost counts as zero.

func (*Cache[K, V]) Stats

func (c *Cache[K, V]) Stats() 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

func (*Cache[K, V]) ViewStats added in v0.5.0

func (c *Cache[K, V]) ViewStats() map[string]ViewStats

ViewStats returns the counters of every view opened on the cache, by name, so that an exporter needs no registry of its own.

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 it never expires, which
	// suits a bounded cache or one written with SetTTL.
	TTL time.Duration

	// NegativeTTL enables SetNegative and sets how long a "does not exist" answer
	// lives. Keep it short: a missing object may appear at any moment.
	NegativeTTL time.Duration

	// Jitter spreads expiry by up to this percentage either way, so that keys
	// written together do not expire together. Must be 0..100.
	Jitter int

	// RefreshAfter reloads a value in the background once it is this old, so that
	// a key in steady use is replaced before it expires instead of making the next
	// caller wait. Zero turns it off. It must be shorter than TTL, which still
	// holds: a failed refresh is not retried, and the value lives out its TTL.
	//
	// The read that finds a value due returns it and starts its one refresh:
	// GetOrLoad, GetManyOrLoad and GetOrLoadFunc with their loaders, Get and
	// Lookup with Loader or BatchLoader. GetManyOrLoad refreshes the keys it finds
	// due in one BatchLoader call. A refresh is an ordinary load to single flight
	// and invalidation; its context carries the values of the read that started
	// it and is never cancelled. ErrNotFound replaces the value with a negative
	// entry when NegativeTTL is set.
	RefreshAfter time.Duration

	// MaxBytes is the budget in bytes, split evenly across shards. It requires
	// Cost, except for an interface V, such as the any of a cache used through
	// views that bring their own Cost: values written without a cost are then
	// charged as negative entries are, by the entry and its key. 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 resident size of a value in bytes, which is what MaxBytes
	// counts. A decoded struct commonly costs several times its JSON: measure it
	// rather than guess (see the README).
	Cost func(V) int64

	// Loader fetches a value that is not cached, for GetOrLoad. Callers who ask
	// for a key while its load runs share that one call.
	//
	// An error wrapping ErrNotFound means the upstream has no such key, and is
	// cached as a negative entry when NegativeTTL is set. Other errors are
	// returned unchanged and not cached.
	//
	// The context carries the values of the caller that started the load, but is
	// cancelled only once every waiting caller has given up. A loader that ignores
	// it still caches its result, unless a Delete, a Clear or a successful write of
	// the key came after the load started. A loader must not GetOrLoad its own key.
	Loader func(ctx context.Context, key K) (V, error)

	// BatchLoader fetches several uncached keys in one call: the keys of
	// GetManyOrLoad nobody is loading yet, and the key of GetOrLoad when Loader is
	// not set.
	//
	// A requested key missing from the result does not exist upstream, as if a
	// Loader had returned ErrNotFound; keys not requested are ignored. An error
	// fails every key, and nothing from the call is cached. The context is
	// cancelled once no caller waits for any of its keys.
	BatchLoader func(ctx context.Context, keys []K) (map[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. Each shard gets an equal
	// slice of the budget, rounded up, so the budget becomes approximate. Keep it
	// well above the shard count: MaxEntries of 2 across 16 shards holds 16.
	Shards int

	// Policy selects the eviction strategy. Defaults to LRU.
	Policy Policy

	// CleanupInterval is how often a background goroutine drops expired entries.
	// Zero picks it from the TTLs. Lookups drop expired entries too, but until
	// then they count against the budget.
	CleanupInterval time.Duration

	// DisableCleanup runs without that goroutine: entries then expire only on
	// lookup and on eviction.
	DisableCleanup bool

	// ClockGranularity has a background goroutine read the clock this often,
	// instead of every lookup paying for it, which is about half of what a lookup
	// costs. Expiry is then accurate to one interval either way. Zero reads the
	// clock every time. TTL, NegativeTTL and RefreshAfter, and a view's, must be
	// at least 10 intervals, or expiry would be off by more than a tenth. It works
	// with DisableCleanup; Close returns lookups to the wall clock.
	ClockGranularity time.Duration

	// OnEvict is called for every entry that leaves the cache other than by Delete
	// or Clear, with the zero value for a negative entry. 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. Every field is optional: the zero value is an unbounded cache whose entries never expire.

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

	// S3FIFO keeps a new entry in a small queue until it is read again, which
	// lets out keys read only once before they displace the ones in steady use,
	// and gives an entry in the main queue another round for each read. Reads
	// take only a read lock, as with ClearOnFull, and Set still always stores.
	// Near W-TinyLFU's hit rate when the working set does not fit (see the
	// README).
	S3FIFO
)

func (Policy) String

func (p Policy) String() string

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 another type. Hits counts them
	// too, since the cache had the key.
	TypeMisses int64

	Loads int64 // keys loaded by Loader or BatchLoader, successfully or not
	// LoadNotFound counts loads that ended in ErrNotFound, keys missing from a
	// batch answer included; LoadErrors does not.
	LoadNotFound int64
	LoadErrors   int64 // loads that failed: any other error, or a panic
	Batches      int64 // BatchLoader calls; Loads/Batches is keys per call
	Coalesced    int64 // GetOrLoad calls spared a load by another caller's
	// Refreshes counts finished refreshes, by key. Loads does not include them:
	// it is what misses cost.
	Refreshes int64
	// RefreshErrors counts refreshes that failed or panicked, not ErrNotFound.
	RefreshErrors 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, cumulative except Entries and Bytes.

func (Stats) HitRate

func (s Stats) HitRate() float64

HitRate reports hits, cached negatives included, as a fraction of lookups.

func (Stats) Sub added in v0.8.0

func (s Stats) Sub(prev Stats) Stats

Sub returns the counters gained since prev, which is what an exporter sends each interval. A counter below its value in prev means the cache was created anew, and is returned whole. Entries and Bytes are s's own.

type Status

type Status uint8

Status is the outcome of a lookup.

const (
	StatusMiss     Status = iota // the cache knows nothing about this key
	StatusHit                    // a value was cached
	StatusNegative               // the upstream was asked and said the key does not exist
)

The outcomes a lookup can report.

func (Status) String

func (s Status) String() string

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[string, any] that holds several value types under one byte budget: one cache per type would mean splitting the memory between types up front. Each view fixes a type, namespaces its keys and counts its own hits. It costs a string join per operation, which allocates once name and key exceed 32 bytes.

func NewView added in v0.2.0

func NewView[T any](c *Cache[string, any], o ViewOptions[T]) *View[T]

NewView opens a view named o.Name onto c. It panics on invalid options, as New does.

A nil c switches caching off, so that code can keep calling GetOrLoad: the loader runs on every call, still shared by concurrent callers, and nothing is remembered. Lookups miss, Delete reports false, and writes fail with ErrDisabled.

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]) All added in v0.9.0

func (v *View[T]) All() iter.Seq2[string, T]

All is Cache.All for this view's values, by the caller's keys. It walks the whole cache, skipping the entries of other views and values of another type.

func (*View[T]) Delete added in v0.2.0

func (v *View[T]) Delete(key string) bool

Delete is Cache.Delete for this view's key, invalidating its loads in any view or the cache.

func (*View[T]) Get added in v0.2.0

func (v *View[T]) Get(key string) (T, bool)

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]) GetManyOrLoad added in v0.9.0

func (v *View[T]) GetManyOrLoad(ctx context.Context, keys []string) (map[string]T, error)

GetManyOrLoad is Cache.GetManyOrLoad for this view, with ViewOptions.BatchLoader, or with Loader key by key when there is none. With neither it returns ErrNoLoader.

Example

A view's batch loader is asked for the caller's ids, without the view's prefix.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/andared/sanecache"
)

type season struct {
	Num int
}

func main() {
	c := sanecache.New(sanecache.Options[string, any]{TTL: time.Minute})
	defer c.Close()
	seasons := sanecache.NewView(c, sanecache.ViewOptions[*season]{
		Name: "season",
		BatchLoader: func(_ context.Context, ids []string) (map[string]*season, error) {
			fmt.Println("asked for", len(ids))
			found := make(map[string]*season, len(ids))
			for i, id := range ids {
				found[id] = &season{Num: i + 1}
			}

			return found, nil
		},
	})

	_ = seasons.Set("1", &season{Num: 1})
	got, err := seasons.GetManyOrLoad(context.Background(), []string{"1", "2", "3"})
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("found:", len(got), "batches:", seasons.Stats().Batches)

}
Output:
asked for 2
found: 3 batches: 1

func (*View[T]) GetOrLoad added in v0.3.0

func (v *View[T]) GetOrLoad(ctx context.Context, key string) (T, error)

GetOrLoad is Cache.GetOrLoad with ViewOptions.Loader, storing with this view's Cost and TTLs; an entry of another type is a miss the load replaces. Without a view loader it returns ErrNoLoader, even for a cached key.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/andared/sanecache"
)

type article struct {
	ID   string
	Body string
}

func main() {
	// A cache of any needs no Cost when each view brings its own.
	c := sanecache.New(sanecache.Options[string, any]{MaxBytes: 1024})
	defer c.Close()
	articles := sanecache.NewView(c, sanecache.ViewOptions[*article]{
		Name:        "article",
		Cost:        func(a *article) int64 { return int64(len(a.Body)) * 3 },
		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]) GetOrLoadFunc added in v0.7.0

func (v *View[T]) GetOrLoadFunc(ctx context.Context, key string, load func(context.Context) (T, error)) (T, error)

GetOrLoadFunc is Cache.GetOrLoadFunc for this view, sharing loads with its GetOrLoad. A nil load returns ErrNoLoader.

func (*View[T]) GetOrLoadFuncWithCost added in v0.8.0

func (v *View[T]) GetOrLoadFuncWithCost(
	ctx context.Context, key string, load func(context.Context) (T, int64, error),
) (T, error)

GetOrLoadFuncWithCost is Cache.GetOrLoadFuncWithCost for this view.

func (*View[T]) Lookup added in v0.2.0

func (v *View[T]) Lookup(key string) (T, Status)

Lookup returns the cached value and how the view answered. An entry of another type is a miss, counted as a TypeMiss. With RefreshAfter and a Loader, a value due for a refresh starts one.

func (*View[T]) Name added in v0.2.0

func (v *View[T]) Name() string

Name returns the view's name, the prefix of its keys in the cache.

func (*View[T]) Set added in v0.2.0

func (v *View[T]) Set(key string, value T) error

Set caches value under key for the view's TTL.

func (*View[T]) SetNegative added in v0.2.0

func (v *View[T]) SetNegative(key string) error

SetNegative records that the upstream reports no such key, for the view's NegativeTTL.

func (*View[T]) SetNegativeTTL added in v0.2.0

func (v *View[T]) SetNegativeTTL(key string, ttl time.Duration) error

SetNegativeTTL is SetNegative with an explicit lifetime.

func (*View[T]) SetTTL added in v0.2.0

func (v *View[T]) SetTTL(key string, value T, ttl time.Duration) error

SetTTL caches value under key for ttl; zero means it never expires.

func (*View[T]) SetWithCost added in v0.8.0

func (v *View[T]) SetWithCost(key string, value T, cost int64) error

SetWithCost is Cache.SetWithCost for this view, with the view's TTL.

func (*View[T]) Stats added in v0.2.0

func (v *View[T]) Stats() ViewStats

Stats returns the counters of this view's name, shared by every view of that name on the cache. The cache's Stats counts the same lookups too.

type ViewOptions added in v0.2.0

type ViewOptions[T any] struct {
	// Name namespaces the view's keys: it stores under Name + ":" + key. It must
	// not be empty or contain ":".
	Name string

	// Loader fetches an uncached value by the caller's key, without the prefix,
	// under Options.Loader's rules. Loads coalesce within one View instance, not
	// across views of the same name or with the cache's own loads. A loader must
	// not load its own key through this view.
	Loader func(context.Context, string) (T, error)

	// BatchLoader is Options.BatchLoader for this view, by the caller's keys:
	// GetManyOrLoad's missing keys in one call, and GetOrLoad's key when Loader
	// is not set. Batches coalesce with this View instance's loads, as Loader's do.
	BatchLoader func(context.Context, []string) (map[string]T, error)

	// Cost is Options.Cost for this view's values, sparing the cache's Cost a type
	// switch. Unset, the cache's Cost is used.
	Cost func(T) int64

	// TTL is how long this view's values stay valid; zero takes the cache's. The
	// sweep interval follows the cache's TTLs, so with a much shorter TTL here,
	// set Options.CleanupInterval.
	TTL time.Duration

	// NegativeTTL is how long this view's "does not exist" answers live; zero
	// takes the cache's.
	NegativeTTL time.Duration

	// RefreshAfter is Options.RefreshAfter for this view, with its own loaders.
	// It must be shorter than the view's TTL. Zero means no refresh: the cache's
	// RefreshAfter is not inherited and never applies to a view's values.
	RefreshAfter 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 another type: a bug detector, since
	// only two views of one name or writes straight to the cache cause them.
	TypeMisses int64

	Loads        int64 // keys loaded by Loader or BatchLoader, successfully or not
	LoadNotFound int64 // completed loader calls that returned ErrNotFound
	LoadErrors   int64 // completed loader calls that failed otherwise, or panicked
	Batches      int64 // BatchLoader calls
	Coalesced    int64 // calls spared a load by another caller

	Refreshes     int64 // completed refreshes started by RefreshAfter
	RefreshErrors int64 // refreshes that failed or panicked
}

ViewStats is a snapshot of the counters of one view name.

func (ViewStats) HitRate added in v0.2.0

func (s ViewStats) HitRate() float64

HitRate reports hits as a fraction of all lookups, counting a cached negative as a hit and a type miss as a miss.

func (ViewStats) Sub added in v0.8.0

func (s ViewStats) Sub(prev ViewStats) ViewStats

Sub is Stats.Sub for a view.

Directories

Path Synopsis
examples
basic command
http_loader command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL