Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ While the major version is 0, the public API may change in any release.

## [Unreleased]

## [0.5.0] - 2026-09-16

### Added

- `NewView` accepts a nil cache and opens a view with caching switched off: `GetOrLoad`
runs the loader on every call, still coalescing concurrent callers, and keeps nothing.
Its lookups are misses, `Delete` reports false, and writes return the new `ErrDisabled`.
`NewView(nil, …)` used to panic.
- `Cache.ViewStats`, a snapshot of the counters of every view opened on the cache, keyed
by view name. An exporter no longer needs its own registry of views.

### Changed

- View counters belong to the view name rather than the instance: views opened with the
same name on the same cache share one set of counters, as they already share keys.
`View.Stats` on either instance now reports both. Code that opened several instances of
one name and read their counters separately must read them as one.
- A negative entry is charged 128 bytes plus the length of a string key under a byte
budget, instead of a flat 64. `BenchmarkNegativeEntryMemory` puts its real size at
115-133 bytes before the key, so a flood of lookups for ids that do not exist took two
to three times the memory the budget reported. Budgets now fill with negative entries
sooner, and a shard budget below roughly 130 bytes rejects them with `ErrTooLarge`.
- The text of `ErrNoLoader` names both places a loader can come from. Compare with
`errors.Is`, not with the string.

### Documentation

- `Options.Loader` no longer claims that a loader ignoring cancellation can put back a stale
value; the invalidation added in 0.4.0 prevents that.

## [0.4.0] - 2026-09-09

### Changed
Expand Down Expand Up @@ -99,7 +129,8 @@ First cut.
- Built-in counters via `Stats`, and an `OnEvict` callback carrying a reason.
- `ErrTooLarge` from `Set` for values that can never fit.

[Unreleased]: https://github.com/andared/sanecache/compare/v0.4.0...HEAD
[Unreleased]: https://github.com/andared/sanecache/compare/v0.5.0...HEAD
[0.5.0]: https://github.com/andared/sanecache/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/andared/sanecache/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/andared/sanecache/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/andared/sanecache/compare/v0.1.0...v0.2.0
Expand Down
45 changes: 41 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ With Go 1.24 or newer:

```sh
go mod init example.com/cache-demo
go get github.com/andared/sanecache@v0.4.0
go get github.com/andared/sanecache@v0.5.0
go run .
```

Expand Down Expand Up @@ -121,7 +121,8 @@ budget that silently counts entries.
**"It does not exist" is an answer.** `SetNegative` records that the upstream was asked and
said no. Without a first-class form for this, it ends up as a sentinel value smuggled
inside your value type, which does not survive the type parameter and tends to be charged
zero cost — making it the one thing eviction can never reclaim.
zero cost — making it the one thing eviction can never reclaim. Under a byte budget a
negative entry is charged what it holds: the entry, its map slot and its key.

**TTLs can be jittered.** `Jitter: 10` spreads each expiry by up to ±10%. Keys warmed
together by one request otherwise expire together and hit the upstream as one wave.
Expand Down Expand Up @@ -237,7 +238,7 @@ seasons := sanecache.NewView(c, sanecache.ViewOptions[*Season]{
a, ok := articles.Get(id) // a is a *Article, not an any
```

A view fixes one value type, gives each of its own counters, and prefixes its keys with its
A view fixes one value type, counts its own hits and misses, and prefixes its keys with its
name, so two views cannot collide on the same id. It can bring its own `Cost` — which is
what spares you the type switch that a shared `Cost func(any) int64` otherwise becomes — and
its own TTLs. Eviction stays global: a view that suddenly needs more memory takes it from
Expand Down Expand Up @@ -275,6 +276,32 @@ entries still share that namespace. The underlying cache's loader is independent
never used as a fallback. `View.Stats()` includes `Loads`, `LoadErrors` and `Coalesced`;
the parent cache includes those events in its aggregate counters too.

Counters belong to the view's name, not to the instance: views opened with the same name on
the same cache share one set, as they share one set of keys.

### Switching caching off

A view opened on a nil cache stores nothing. It is for the configuration where a TTL of
zero means "do not cache", so that the code calling `GetOrLoad` stays the same either way:

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

Such a view runs the loader on every call. Callers who arrive while a load is running still
share it — turning the cache off should not turn off the protection of the upstream — but
the result, `ErrNotFound` included, is not kept. Lookups are misses, `Delete` reports
`false`, and writes return `ErrDisabled` rather than a success the next read would
contradict. Its counters are its own and do not appear in any cache's `ViewStats`.

## The `Cost` function is the part worth getting right

`MaxBytes` is only as honest as `Cost`. The number you want is resident size, and it is
Expand Down Expand Up @@ -371,6 +398,16 @@ s := c.Stats()
// s.Entries, s.Bytes, s.HitRate()
```

`ViewStats()` returns the same kind of snapshot for every view opened on the cache, keyed
by view name, so an exporter needs no list of views of its own:

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

`Rejections` is the one to alert on: a steady nonzero rate means keys that can never be
cached. `Coalesced` against `Loads` says how much work the single-flight is actually saving
— if they are equal, the cache is cold in a way worth looking at. `OnEvict` is available
Expand Down Expand Up @@ -460,7 +497,7 @@ points of hit rate. It is a trade for caches whose access order is flat, not a f

## Status

v0.3. The API above is what exists and is tested; expect it to move before v1.
v0.5. The API above is what exists and is tested; expect it to move before v1.

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for what this
library optimises for before proposing a change.
Expand Down
36 changes: 36 additions & 0 deletions bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sanecache
import (
"context"
"fmt"
"runtime"
"strconv"
"testing"
"time"
Expand Down Expand Up @@ -185,3 +186,38 @@ func BenchmarkClockBaseline(b *testing.B) {
}
_ = sink
}

// BenchmarkNegativeEntryMemory measures what a negative entry really occupies,
// less the bytes of its key, in a Cache[string, any]: the cache views run on,
// and the one where "does not exist" answers pile up. entryOverhead is taken
// from here: rerun this when the entry or the shard map changes.
func BenchmarkNegativeEntryMemory(b *testing.B) {
for _, n := range []int{100_000, 150_000, 262_000} {
b.Run(strconv.Itoa(n), func(b *testing.B) {
keys := make([]string, n)
for i := range keys {
keys[i] = fmt.Sprintf("article:%016d", i)
}
var perEntry float64
for b.Loop() {
before := heapAlloc()
c := New(Options[string, any]{NegativeTTL: time.Hour, DisableCleanup: true})
for _, k := range keys {
_ = c.SetNegative(k)
}
perEntry = float64(heapAlloc()-before) / float64(n)
runtime.KeepAlive(c)
}
b.ReportMetric(perEntry, "bytes/entry")
})
}
}

func heapAlloc() uint64 {
runtime.GC()
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)

return m.HeapAlloc
}
35 changes: 28 additions & 7 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ type Options[K comparable, V any] struct {
// 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.
// than the upstream answers. It cannot put back a stale value: a Delete, a
// Clear or a successful write of the key since the load started, including
// another load's publication, keeps its result out of the cache.
Loader func(ctx context.Context, key K) (V, error)

// Shards splits the cache into independently locked parts, rounded up to a
Expand Down Expand Up @@ -211,6 +211,12 @@ type core[K comparable, V any] struct {
loader func(context.Context, K) (V, error)
flights []*flightGroup[K, V]

// views holds one set of counters per view name. Views with the same name
// share a namespace in storage, so they share counters too; the map only
// grows with the number of distinct names, which is fixed by the program.
viewsMu sync.Mutex
views map[string]*viewCounters

// coarse holds the time a background goroutine last read, in unix
// nanoseconds, or nil when the cache reads the clock itself. Zero means the
// goroutine has stopped and the wall clock is authoritative again.
Expand Down Expand Up @@ -395,9 +401,24 @@ func (c *Cache[K, V]) Close() {
}
}

// negativeCost is what a "does not exist" marker is charged under a byte budget:
// it holds no value, but it does hold a key and a map slot.
const negativeCost int64 = 64
// entryOverhead is what an entry occupies apart from its value and the bytes of a
// string key: the entry itself and its share of the shard map. Measured by
// BenchmarkNegativeEntryMemory at 115-133 bytes for a Cache[string, any],
// depending on how full the map is, and about 16 less for a value type of one
// word; a "does not exist" marker is charged this much plus its key.
const entryOverhead int64 = 128

// negativeCost is what a "does not exist" marker is charged under a byte budget.
// It holds no value, but it does hold an entry, a map slot and the key; string
// keys are usually the ones worth counting, since a view builds a fresh one for
// every write and the entry keeps it alive.
func negativeCost[K comparable](key K) int64 {
if s, ok := any(key).(string); ok {
return entryOverhead + int64(len(s))
}

return entryOverhead
}

func (c *core[K, V]) valueCost(v V) int64 {
if c.cost == nil {
Expand Down Expand Up @@ -425,7 +446,7 @@ func (c *core[K, V]) setNegative(key K, ttl time.Duration, tokens ...*loadToken)
// nothing would make it un-evictable under a byte budget.
var cost int64
if c.cost != nil {
cost = negativeCost
cost = negativeCost(key)
}

return c.store(&entry[K, V]{
Expand Down
48 changes: 48 additions & 0 deletions cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sanecache

import (
"fmt"
"strings"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -576,3 +577,50 @@ func TestCoarseClockStopsWithTheCache(t *testing.T) {
return !ok
})
}

// A negative entry is charged what it actually holds. At a flat, small price a
// flood of lookups for ids that do not exist would take far more memory than
// the budget says.
func TestNegativeEntryCost(t *testing.T) {
c := New(Options[string, int]{
NegativeTTL: time.Hour,
MaxBytes: 1 << 20,
Cost: func(int) int64 { return 1 },
})
defer c.Close()

if err := c.SetNegative("k"); err != nil {
t.Fatalf("SetNegative: %v", err)
}
if got, want := c.Bytes(), entryOverhead+1; got != want {
t.Fatalf("Bytes = %d; want %d", got, want)
}

long := strings.Repeat("x", 100)
if err := c.SetNegative(long); err != nil {
t.Fatalf("SetNegative: %v", err)
}
if got, want := c.Bytes(), 2*entryOverhead+1+100; got != want {
t.Fatalf("Bytes = %d; want %d", got, want)
}

// Without a byte budget there is nothing to charge.
free := New(Options[string, int]{NegativeTTL: time.Hour})
defer free.Close()
if err := free.SetNegative(long); err != nil {
t.Fatalf("SetNegative: %v", err)
}
if free.Bytes() != 0 {
t.Fatalf("Bytes without Cost = %d; want 0", free.Bytes())
}

// Non-string keys are held inside the entry.
ints := New(Options[int, int]{NegativeTTL: time.Hour, MaxBytes: 1 << 20, Cost: func(int) int64 { return 1 }})
defer ints.Close()
if err := ints.SetNegative(7); err != nil {
t.Fatalf("SetNegative: %v", err)
}
if ints.Bytes() != entryOverhead {
t.Fatalf("int key Bytes = %d; want %d", ints.Bytes(), entryOverhead)
}
}
7 changes: 6 additions & 1 deletion errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ var (
// loader's own error through to the caller that triggered the load.
ErrNotFound = errors.New("sanecache: the upstream has no such key")

// ErrDisabled is returned by the writes of a view opened on a nil cache. Such a
// view stores nothing, and a write that reported success would be a write the
// next read cannot see.
ErrDisabled = errors.New("sanecache: the view has no cache to write to")

// 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")
ErrNoLoader = errors.New("sanecache: GetOrLoad requires a loader (Options.Loader or ViewOptions.Loader)")
)
8 changes: 4 additions & 4 deletions invalidation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func TestLoadInvalidation(t *testing.T) {
var lookup func(string) (int, Status)
var mutate func() error
if view {
c := New(Options[string, any]{MaxBytes: 100, Cost: func(any) int64 { return 1 }, NegativeTTL: time.Minute})
c := New(Options[string, any]{MaxBytes: 1000, Cost: func(any) int64 { return 1 }, NegativeTTL: time.Minute})
defer c.Close()
v := NewView(c, ViewOptions[int]{Name: "items", Loader: loader})
load, lookup = v.GetOrLoad, v.Lookup
Expand All @@ -58,12 +58,12 @@ func TestLoadInvalidation(t *testing.T) {
case "negative":
return writer.SetNegative("k")
case "rejected":
return writer.Set("k", 101)
return writer.Set("k", 1001)
}
return nil
}
} else {
c := New(Options[string, int]{MaxBytes: 100, Cost: func(n int) int64 { return int64(n) }, NegativeTTL: time.Minute, Loader: loader})
c := New(Options[string, int]{MaxBytes: 1000, Cost: func(n int) int64 { return int64(n) }, NegativeTTL: time.Minute, Loader: loader})
defer c.Close()
load, lookup = c.GetOrLoad, c.Lookup
mutate = func() error {
Expand All @@ -77,7 +77,7 @@ func TestLoadInvalidation(t *testing.T) {
case "negative":
return c.SetNegative("k")
case "rejected":
return c.Set("k", 101)
return c.Set("k", 1001)
}
return nil
}
Expand Down
Loading