Repository navigation
v0.2.0
The three things v0.1 said were next, and one the benchmarks turned up on the way.
import "github.com/andared/sanecache"Go 1.24 or newer. Still no dependencies, and still a hard rule rather than a current state of affairs.
Single-flight loading
Options.Loader and GetOrLoad: a cold key is fetched once however many callers arrive while it is running. Written here rather than taken from golang.org/x/sync/singleflight, partly because of the dependency rule and partly because two of its behaviours were worth doing differently.
- "Does not exist" is one error either way. A loader returns
ErrNotFound, that becomes a negative entry, and later callers get the sameErrNotFound. How you handle "no such object" does not depend on whether the cache happened to remember it. - 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.
x/sync/singleflighthands the load the first caller's context, so one request timing out takes down the load the other ninety-nine are waiting for. - Errors other than
ErrNotFoundare passed through and not cached, so the next call tries again. A value is published to the cache before the waiters are released, so a caller can read back what it was just handed. A panicking loader reaches the callers, with the stack of where it happened, instead of taking the process down.
Typed views over one byte budget
NewView, ViewOptions, View, ViewStats. A cache declared as Cache[string, any] can hold several value types under one budget, each view fixing a type, namespacing its keys with its name, and bringing its own Cost and TTLs if it wants them. Eviction stays global, which is the point: splitting a fixed amount of memory between types up front is the guess a byte budget exists to avoid.
A coarse clock
Options.ClockGranularity. Over half of an uncontended hit was time.Now() — 27 ns of 47. A background goroutine can hold the time instead, and a hit costs 20 ns, with expiry accurate to within one interval either way. Off by default: a TTL that quietly means something else is the kind of surprise this library is about.
Comparison benchmarks, and what they found
benchmarks/ is a separate module so the root keeps its promise, comparing against otter v2, ristretto v2, golang-lru/v2/expirable and ttlcache v3. make bench-compare runs it.
Measuring serially made a defect visible. Per operation this cache was already cheaper than otter — 61 ns against 77 — but got five times less out of eight cores, and sharding did nothing about it: reads sat at a flat ~51 ns whether they had 16 shards or 256. The counters were one shared cache line that every core wrote to on every lookup. The lock was never what they were queuing for.
Counters now live on the shards, on their own cache lines, and the padding mattered as much as the split.
Get, 16 shards |
v0.1 | v0.2 |
|---|---|---|
| LRU | 64 ns | 40 ns |
| ClearOnFull | 55 ns | 24 ns |
Stats() is unchanged: it already walked every shard.
The README now carries the comparison tables, including the ones this library loses. otter is worth 5.6 points of hit rate on a working set that does not fit, and that is a better reason to pick it than any throughput number on the page.
Changed
Stats gained TypeMisses, Loads, LoadErrors and Coalesced, which moves Entries and Bytes within the struct. Code that reads fields by name is unaffected; code that builds a Stats positionally is not.
Stability
The major version is 0: the API may change in any release. Breaking changes are listed in CHANGELOG.md.
Next
A loader on views, so a typed view over a shared budget gets the same stampede protection the cache has.
If a different cache fits your problem better, the README says which one and why.