From 51c08cd02fa50a731ac932a102e3acba7d547cc2 Mon Sep 17 00:00:00 2001 From: Andrey Ryabokon Date: Wed, 16 Sep 2026 17:31:23 +0300 Subject: [PATCH 1/5] Count view stats per name and expose them on the cache --- CHANGELOG.md | 12 ++++++++ README.md | 15 +++++++++- cache.go | 6 ++++ view.go | 65 +++++++++++++++++++++++++++++++++--------- view_test.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e81f11..7cccef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ While the major version is 0, the public API may change in any release. ## [Unreleased] +### Added + +- `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. + ## [0.4.0] - 2026-09-09 ### Changed diff --git a/README.md b/README.md index 50462f7..7a955a8 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,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 @@ -275,6 +275,9 @@ 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. + ## 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 @@ -371,6 +374,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 diff --git a/cache.go b/cache.go index 71c865b..895e2ef 100644 --- a/cache.go +++ b/cache.go @@ -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. diff --git a/view.go b/view.go index ce69439..4440cfe 100644 --- a/view.go +++ b/view.go @@ -76,7 +76,8 @@ type View[T any] struct { loader func(context.Context, string) (T, error) flights []*flightGroup[string, T] - stats viewCounters + // stats is shared by every view of this name on this cache. + stats *viewCounters } // NewView opens a view named o.Name onto c. It panics on a name that cannot keep @@ -102,6 +103,7 @@ func NewView[T any](c *Cache[string, any], o ViewOptions[T]) *View[T] { ttl: o.TTL, negativeTTL: o.NegativeTTL, countStats: c.core.countStats, + stats: c.core.viewCounters(o.Name), } if v.ttl == 0 { v.ttl = c.core.ttl @@ -193,18 +195,43 @@ func (v *View[T]) Delete(key string) bool { return v.cache.Delete(v.prefix + key) } -// Stats returns a snapshot of this view's counters. The cache's own Stats counts -// the same lookups across every view. +// Stats returns a snapshot of the counters for this view's name. Views opened +// with the same name on the same cache share them, as they share the keys. The +// cache's own Stats counts the same lookups across every view. func (v *View[T]) Stats() ViewStats { - return ViewStats{ - Hits: v.stats.hits.Load(), - Misses: v.stats.misses.Load(), - Negatives: v.stats.negatives.Load(), - TypeMisses: v.stats.typeMisses.Load(), - Loads: v.stats.loads.Load(), - LoadErrors: v.stats.loadErrors.Load(), - Coalesced: v.stats.coalesced.Load(), + return v.stats.snapshot() +} + +// ViewStats returns a snapshot of the counters of every view opened on the +// cache, by view name. It is what a metrics exporter polls: the cache knows its +// views, so the application does not need a registry of its own. +func (c *Cache[K, V]) ViewStats() map[string]ViewStats { + c.core.viewsMu.Lock() + defer c.core.viewsMu.Unlock() + + stats := make(map[string]ViewStats, len(c.core.views)) + for name, counters := range c.core.views { + stats[name] = counters.snapshot() } + + return stats +} + +// viewCounters returns the counters for a view name, creating them on first use. +func (c *core[K, V]) viewCounters(name string) *viewCounters { + c.viewsMu.Lock() + defer c.viewsMu.Unlock() + + if c.views == nil { + c.views = make(map[string]*viewCounters) + } + counters, ok := c.views[name] + if !ok { + counters = new(viewCounters) + c.views[name] = counters + } + + return counters } func (v *View[T]) valueCost(value T) int64 { @@ -221,8 +248,8 @@ func (v *View[T]) count(c *atomic.Int64) { } } -// ViewStats is a snapshot of one view's counters, cumulative since the view was -// opened. +// ViewStats is a snapshot of the counters for one view name, cumulative since +// the first view with that name was opened on the cache. type ViewStats struct { Hits int64 // lookups that returned a value of this view's type Misses int64 // lookups that found nothing @@ -259,3 +286,15 @@ type viewCounters struct { loadErrors atomic.Int64 coalesced atomic.Int64 } + +func (c *viewCounters) snapshot() ViewStats { + return ViewStats{ + Hits: c.hits.Load(), + Misses: c.misses.Load(), + Negatives: c.negatives.Load(), + TypeMisses: c.typeMisses.Load(), + Loads: c.loads.Load(), + LoadErrors: c.loadErrors.Load(), + Coalesced: c.coalesced.Load(), + } +} diff --git a/view_test.go b/view_test.go index 236da4a..35ccee2 100644 --- a/view_test.go +++ b/view_test.go @@ -268,6 +268,83 @@ func TestViewStats(t *testing.T) { } } +// A view name is a namespace in storage, so two views of one name are one set of +// entries; counting them apart would make each view's numbers a partial story. +func TestViewsOfOneNameShareCounters(t *testing.T) { + c := newViewCache(Options[string, any]{}) + defer c.Close() + + first := NewView(c, ViewOptions[*article]{Name: "article"}) + second := NewView(c, ViewOptions[*article]{Name: "article"}) + if err := first.Set("1", &article{id: "1"}); err != nil { + t.Fatalf("Set: %v", err) + } + first.Get("1") // hit + second.Get("1") // hit, through the other instance + second.Get("2") // miss + + want := ViewStats{Hits: 2, Misses: 1} + if got := first.Stats(); got != want { + t.Fatalf("first.Stats = %+v; want %+v", got, want) + } + if got := second.Stats(); got != want { + t.Fatalf("second.Stats = %+v; want %+v", got, want) + } +} + +func TestCacheViewStats(t *testing.T) { + c := newViewCache(Options[string, any]{}) + defer c.Close() + + if got := c.ViewStats(); len(got) != 0 { + t.Fatalf("ViewStats before any view = %+v; want empty", got) + } + + articles := NewView(c, ViewOptions[*article]{Name: "article"}) + seasons := NewView(c, ViewOptions[*season]{Name: "season"}) + if err := articles.Set("1", &article{id: "1"}); err != nil { + t.Fatalf("Set: %v", err) + } + articles.Get("1") + seasons.Get("1") + seasons.Get("2") + + got := c.ViewStats() + if len(got) != 2 { + t.Fatalf("ViewStats has %d views; want 2: %+v", len(got), got) + } + if got["article"] != (ViewStats{Hits: 1}) { + t.Fatalf("article = %+v; want 1 hit", got["article"]) + } + if got["season"] != (ViewStats{Misses: 2}) { + t.Fatalf("season = %+v; want 2 misses", got["season"]) + } + + // The snapshot is a copy: later lookups do not change it. + seasons.Get("3") + if got["season"].Misses != 2 { + t.Fatal("ViewStats snapshot changed after the fact") + } +} + +// Views on different caches are different namespaces, so their counters are +// apart even when the names match. +func TestViewCountersBelongToTheirCache(t *testing.T) { + a := newViewCache(Options[string, any]{}) + defer a.Close() + b := newViewCache(Options[string, any]{}) + defer b.Close() + + NewView(a, ViewOptions[int]{Name: "n"}).Get("1") + + if got := b.ViewStats(); len(got) != 0 { + t.Fatalf("other cache ViewStats = %+v; want empty", got) + } + if got := NewView(b, ViewOptions[int]{Name: "n"}).Stats(); got != (ViewStats{}) { + t.Fatalf("view on other cache = %+v; want zero", got) + } +} + func TestViewRespectsDisableStats(t *testing.T) { c := newViewCache(Options[string, any]{DisableStats: true}) defer c.Close() @@ -278,6 +355,9 @@ func TestViewRespectsDisableStats(t *testing.T) { if got := articles.Stats(); got != (ViewStats{}) { t.Fatalf("view stats = %+v; want zero", got) } + if got := c.ViewStats()["article"]; got != (ViewStats{}) { + t.Fatalf("cache view stats = %+v; want zero", got) + } } func TestNewViewPanicsOnUnusableNames(t *testing.T) { From 5c80edb2816306dd0cd05121f94731cf7472e666 Mon Sep 17 00:00:00 2001 From: Andrey Ryabokon Date: Wed, 16 Sep 2026 17:32:52 +0300 Subject: [PATCH 2/5] Let a view on a nil cache load without caching --- CHANGELOG.md | 4 ++ README.md | 23 +++++++ errors.go | 5 ++ view.go | 44 +++++++++++- view_disabled_test.go | 152 ++++++++++++++++++++++++++++++++++++++++++ view_loader.go | 33 +++++++++ view_test.go | 5 +- 7 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 view_disabled_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cccef2..e579fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ While the major version is 0, the public API may change in any release. ### 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. diff --git a/README.md b/README.md index 7a955a8..58b0e97 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,29 @@ 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 diff --git a/errors.go b/errors.go index 938d211..5c3d3ab 100644 --- a/errors.go +++ b/errors.go @@ -24,6 +24,11 @@ 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") diff --git a/view.go b/view.go index 4440cfe..d563217 100644 --- a/view.go +++ b/view.go @@ -82,10 +82,15 @@ type View[T any] struct { // 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. +// +// A nil c opens a view with caching switched off, for the configuration where a +// TTL of zero means "do not cache": code keeps calling GetOrLoad either way. Such +// a view runs the loader on every call, still sharing one call among callers who +// arrive while it runs, and remembers nothing, "does not exist" included. Its +// lookups are misses, Delete reports false, and writes fail with ErrDisabled. +// Its counters are its own. func NewView[T any](c *Cache[string, any], o ViewOptions[T]) *View[T] { switch { - case c == nil: - panic("sanecache: NewView needs a cache") case o.Name == "": panic("sanecache: ViewOptions.Name must not be empty; the name is what keeps views apart") case strings.Contains(o.Name, viewSeparator): @@ -94,6 +99,21 @@ func NewView[T any](c *Cache[string, any], o ViewOptions[T]) *View[T] { panic("sanecache: ViewOptions TTL and NegativeTTL must not be negative") } + if c == nil { + v := &View[T]{ + loader: o.Loader, + name: o.Name, + prefix: o.Name + viewSeparator, + countStats: true, + stats: new(viewCounters), + } + if v.loader != nil { + v.flights = []*flightGroup[string, T]{newFlightGroup[string, T]()} + } + + return v + } + v := &View[T]{ cache: c, loader: o.Loader, @@ -140,6 +160,12 @@ func (v *View[T]) Get(key string) (T, bool) { func (v *View[T]) Lookup(key string) (T, Status) { var zero T + if v.cache == nil { + v.count(&v.stats.misses) + + return zero, StatusMiss + } + raw, st, shard := v.cache.core.lookup(v.prefix + key) switch st { case StatusHit: @@ -174,17 +200,25 @@ func (v *View[T]) Set(key string, value T) error { // SetTTL caches value under key for ttl, overriding both the view's and the // cache's TTL. A ttl of zero means the entry never expires on its own. func (v *View[T]) SetTTL(key string, value T, ttl time.Duration) error { + if v.cache == nil { + return ErrDisabled + } + return v.cache.core.setValue(v.prefix+key, value, v.valueCost(value), ttl) } // SetNegative records that the upstream reports no such key, for the view's // NegativeTTL. func (v *View[T]) SetNegative(key string) error { - return v.cache.core.setNegative(v.prefix+key, v.negativeTTL) + return v.SetNegativeTTL(key, v.negativeTTL) } // SetNegativeTTL is SetNegative with an explicit lifetime. func (v *View[T]) SetNegativeTTL(key string, ttl time.Duration) error { + if v.cache == nil { + return ErrDisabled + } + return v.cache.core.setNegative(v.prefix+key, ttl) } @@ -192,6 +226,10 @@ func (v *View[T]) SetNegativeTTL(key string, ttl time.Duration) error { // invalidates outstanding loads for the namespaced key, including those in other // views or the parent cache. Existing waiters still receive their loader result. func (v *View[T]) Delete(key string) bool { + if v.cache == nil { + return false + } + return v.cache.Delete(v.prefix + key) } diff --git a/view_disabled_test.go b/view_disabled_test.go new file mode 100644 index 0000000..93b2046 --- /dev/null +++ b/view_disabled_test.go @@ -0,0 +1,152 @@ +package sanecache + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" +) + +// A view on a nil cache is how an application switches caching off without +// switching code paths: the loader runs every time and nothing is remembered. +func TestDisabledViewLoadsEveryTime(t *testing.T) { + calls := 0 + v := NewView(nil, ViewOptions[string]{Name: "text", Loader: func(_ context.Context, key string) (string, error) { + calls++ + return fmt.Sprintf("%s#%d", key, calls), nil + }}) + + for want := 1; want <= 2; want++ { + got, err := v.GetOrLoad(context.Background(), "k") + if err != nil || got != fmt.Sprintf("k#%d", want) { + t.Fatalf("GetOrLoad = %q, %v; want k#%d", got, err, want) + } + } + if st := v.Stats(); st != (ViewStats{Misses: 2, Loads: 2}) { + t.Fatalf("Stats = %+v; want 2 misses, 2 loads", st) + } +} + +// Switching the cache off must not switch off the protection of the upstream: +// callers who arrive while a load runs still share it. +func TestDisabledViewCoalesces(t *testing.T) { + release := make(chan struct{}) + load, calls := blockingLoader(release, 42) + v := NewView(nil, ViewOptions[int]{Name: "numbers", Loader: load}) + + const n = 8 + var wg sync.WaitGroup + for range n { + wg.Add(1) + go func() { + defer wg.Done() + if got, err := v.GetOrLoad(context.Background(), "k"); got != 42 || err != nil { + t.Errorf("GetOrLoad = %v, %v", got, err) + } + }() + } + waitFor(t, "waiters", func() bool { return v.Stats().Coalesced == n-1 }) + close(release) + wg.Wait() + + if calls.Load() != 1 { + t.Fatalf("loads = %d; want 1", calls.Load()) + } + + // The flight is over and nothing was kept, so the next caller loads again. + if _, err := v.GetOrLoad(context.Background(), "k"); err != nil { + t.Fatal(err) + } + if calls.Load() != 2 { + t.Fatalf("loads after the flight = %d; want 2", calls.Load()) + } +} + +func TestDisabledViewDoesNotRememberNotFound(t *testing.T) { + calls := 0 + v := NewView(nil, ViewOptions[int]{Name: "missing", NegativeTTL: time.Hour, Loader: func(context.Context, string) (int, error) { + calls++ + return 0, fmt.Errorf("no row: %w", ErrNotFound) + }}) + + for range 2 { + _, err := v.GetOrLoad(context.Background(), "k") + if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "no row") { + t.Fatalf("GetOrLoad err = %v; want the loader's own ErrNotFound", err) + } + } + if calls != 2 { + t.Fatalf("loads = %d; want 2", calls) + } +} + +func TestDisabledViewRefusesWrites(t *testing.T) { + v := NewView(nil, ViewOptions[int]{Name: "numbers", TTL: time.Hour, NegativeTTL: time.Hour}) + + for name, write := range map[string]func() error{ + "Set": func() error { return v.Set("k", 1) }, + "SetTTL": func() error { return v.SetTTL("k", 1, time.Hour) }, + "SetNegative": func() error { return v.SetNegative("k") }, + "SetNegativeTTL": func() error { return v.SetNegativeTTL("k", time.Hour) }, + } { + if err := write(); !errors.Is(err, ErrDisabled) { + t.Errorf("%s err = %v; want ErrDisabled", name, err) + } + } + + if _, st := v.Lookup("k"); st != StatusMiss { + t.Fatalf("Lookup = %v; want miss", st) + } + if _, ok := v.Get("k"); ok { + t.Fatal("Get found a value in a view that stores nothing") + } + if v.Delete("k") { + t.Fatal("Delete reported a present key") + } + if v.Name() != "numbers" { + t.Fatalf("Name = %q", v.Name()) + } +} + +func TestDisabledViewWithoutLoader(t *testing.T) { + v := NewView(nil, ViewOptions[int]{Name: "numbers"}) + if _, err := v.GetOrLoad(context.Background(), "k"); !errors.Is(err, ErrNoLoader) { + t.Fatalf("GetOrLoad err = %v; want ErrNoLoader", err) + } +} + +func TestDisabledViewCancellationAndPanic(t *testing.T) { + release := make(chan struct{}) + defer close(release) + load, _ := blockingLoader(release, 1) + v := NewView(nil, ViewOptions[int]{Name: "slow", Loader: load}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := v.GetOrLoad(ctx, "k"); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled caller err = %v", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if _, err := v.GetOrLoad(ctx, "k"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timed-out caller err = %v", err) + } + + boom := NewView(nil, ViewOptions[int]{Name: "boom", Loader: func(context.Context, string) (int, error) { + panic("disabled boom") + }}) + defer func() { + p, ok := recover().(*loaderPanic) + if !ok || !strings.Contains(p.Error(), "disabled boom") { + t.Fatalf("panic lost: %v", p) + } + if st := boom.Stats(); st.Loads != 1 || st.LoadErrors != 1 { + t.Fatalf("Stats = %+v; want the panic counted as a failed load", st) + } + }() + _, _ = boom.GetOrLoad(context.Background(), "k") +} diff --git a/view_loader.go b/view_loader.go index 1e67003..58de517 100644 --- a/view_loader.go +++ b/view_loader.go @@ -26,10 +26,43 @@ func (v *View[T]) GetOrLoad(ctx context.Context, key string) (T, error) { if err := ctx.Err(); err != nil { return zero, err } + if v.cache == nil { + return v.loadUncached(ctx, key) + } return v.load(ctx, key) } +// loadUncached is load for a view without a cache: callers still share one call +// while it runs, but its result, "does not exist" included, is not kept. +func (v *View[T]) loadUncached(ctx context.Context, key string) (T, error) { + g := v.flights[0] + + g.mu.Lock() + if cl, ok := g.calls[key]; ok { + cl.waiters++ + g.mu.Unlock() + v.count(&v.stats.coalesced) + + return g.wait(ctx, key, cl) + } + loadCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + cl := &call[T]{done: make(chan struct{}), waiters: 1, cancel: cancel} + g.calls[key] = cl + g.mu.Unlock() + + go g.run(key, cl, func() (T, error) { + return v.loader(loadCtx, key) + }, func(failed bool) { + v.count(&v.stats.loads) + if failed { + v.count(&v.stats.loadErrors) + } + }) + + return g.wait(ctx, key, cl) +} + func (v *View[T]) load(ctx context.Context, key string) (T, error) { c := v.cache.core fullKey := v.prefix + key diff --git a/view_test.go b/view_test.go index 35ccee2..23fde48 100644 --- a/view_test.go +++ b/view_test.go @@ -365,7 +365,10 @@ func TestNewViewPanicsOnUnusableNames(t *testing.T) { defer c.Close() for name, build := range map[string]func(){ - "nil cache": func() { NewView(nil, ViewOptions[*article]{Name: "article"}) }, + "nil cache, empty name": func() { NewView(nil, ViewOptions[*article]{}) }, + "nil cache, name with sep": func() { + NewView(nil, ViewOptions[*article]{Name: "a:b"}) + }, "empty name": func() { NewView(c, ViewOptions[*article]{}) }, "name with sep": func() { NewView(c, ViewOptions[*article]{Name: "a:b"}) }, "negative ttl": func() { From fefd376101fb0f45c48aa573f76ae75d2fe5b993 Mon Sep 17 00:00:00 2001 From: Andrey Ryabokon Date: Wed, 16 Sep 2026 17:33:07 +0300 Subject: [PATCH 3/5] Correct the loader and ErrNoLoader documentation --- CHANGELOG.md | 7 +++++++ cache.go | 6 +++--- errors.go | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e579fcc..6ec36d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ While the major version is 0, the public API may change in any release. 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. +- 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 diff --git a/cache.go b/cache.go index 895e2ef..d13eb80 100644 --- a/cache.go +++ b/cache.go @@ -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 diff --git a/errors.go b/errors.go index 5c3d3ab..85d4bd6 100644 --- a/errors.go +++ b/errors.go @@ -31,5 +31,5 @@ var ( // 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)") ) From 3652f85561f73d7c85b5176ac63131df99f27bde Mon Sep 17 00:00:00 2001 From: Andrey Ryabokon Date: Wed, 16 Sep 2026 17:35:42 +0300 Subject: [PATCH 4/5] Charge negative entries their measured size --- CHANGELOG.md | 5 +++++ README.md | 3 ++- bench_test.go | 36 +++++++++++++++++++++++++++++++++ cache.go | 23 +++++++++++++++++---- cache_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++ invalidation_test.go | 8 ++++---- view_loader_test.go | 10 ++++----- 7 files changed, 119 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ec36d6..c0a3ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ While the major version is 0, the public API may change in any release. 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. diff --git a/README.md b/README.md index 58b0e97..820787c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bench_test.go b/bench_test.go index c6ad76c..0699cb2 100644 --- a/bench_test.go +++ b/bench_test.go @@ -3,6 +3,7 @@ package sanecache import ( "context" "fmt" + "runtime" "strconv" "testing" "time" @@ -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 +} diff --git a/cache.go b/cache.go index d13eb80..93a029d 100644 --- a/cache.go +++ b/cache.go @@ -401,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 { @@ -431,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]{ diff --git a/cache_test.go b/cache_test.go index e6ae1b2..69201a3 100644 --- a/cache_test.go +++ b/cache_test.go @@ -2,6 +2,7 @@ package sanecache import ( "fmt" + "strings" "sync" "testing" "time" @@ -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) + } +} diff --git a/invalidation_test.go b/invalidation_test.go index a83f893..01aef85 100644 --- a/invalidation_test.go +++ b/invalidation_test.go @@ -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 @@ -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 { @@ -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 } diff --git a/view_loader_test.go b/view_loader_test.go index 7e573e3..6b65bf4 100644 --- a/view_loader_test.go +++ b/view_loader_test.go @@ -50,7 +50,7 @@ func TestViewLoaderCoalesces(t *testing.T) { func TestViewLoaderPolicies(t *testing.T) { for _, disabled := range []bool{false, true} { t.Run(fmt.Sprint(disabled), func(t *testing.T) { - c := New(Options[string, any]{TTL: time.Hour, NegativeTTL: time.Hour, MaxBytes: 100, Cost: func(any) int64 { return 1 }, DisableStats: disabled, DisableCleanup: true}) + c := New(Options[string, any]{TTL: time.Hour, NegativeTTL: time.Hour, MaxBytes: 1000, Cost: func(any) int64 { return 1 }, DisableStats: disabled, DisableCleanup: true}) defer c.Close() // Drive expiry explicitly so scheduler delays cannot expire recheck fixtures. c.core.coarse = new(atomic.Int64) @@ -64,7 +64,7 @@ func TestViewLoaderPolicies(t *testing.T) { case "error": return "", errUpstream case "large": - return strings.Repeat("x", 101), nil + return strings.Repeat("x", 1001), nil } return "hello", nil }}) @@ -102,7 +102,7 @@ func TestViewLoaderPolicies(t *testing.T) { t.Fatal(err) } } - if got, err := v.GetOrLoad(ctx, "large"); len(got) != 101 || err != nil { + if got, err := v.GetOrLoad(ctx, "large"); len(got) != 1001 || err != nil { t.Fatalf("oversized: %q, %v", got, err) } if _, ok := v.Get("large"); ok { @@ -282,7 +282,7 @@ func TestViewLoaderPanicAndRetry(t *testing.T) { func TestViewLoadsShareBudgetAndInheritOptions(t *testing.T) { for _, policy := range []Policy{LRU, ClearOnFull} { t.Run(fmt.Sprint(policy), func(t *testing.T) { - c := New(Options[string, any]{MaxBytes: 128, Cost: func(any) int64 { return 64 }, TTL: time.Minute, NegativeTTL: time.Minute, DisableCleanup: true, Policy: policy}) + c := New(Options[string, any]{MaxBytes: 512, Cost: func(any) int64 { return 256 }, TTL: time.Minute, NegativeTTL: time.Minute, DisableCleanup: true, Policy: policy}) defer c.Close() c.core.coarse = new(atomic.Int64) c.core.coarse.Store(time.Now().UnixNano()) @@ -300,7 +300,7 @@ func TestViewLoadsShareBudgetAndInheritOptions(t *testing.T) { if _, err := texts.GetOrLoad(ctx, "k"); err != nil { t.Fatal(err) } - if c.Bytes() != 128 { + if c.Bytes() != 512 { t.Fatalf("fallback cost: %d", c.Bytes()) } if _, err := texts.GetOrLoad(ctx, "next"); err != nil { From e23d89395a3c9662c88568db4f595c1736585653 Mon Sep 17 00:00:00 2001 From: Andrey Ryabokon Date: Wed, 16 Sep 2026 17:35:54 +0300 Subject: [PATCH 5/5] Release v0.5.0 --- CHANGELOG.md | 5 ++++- README.md | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a3ede..3dfccbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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` @@ -127,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 diff --git a/README.md b/README.md index 820787c..399fb40 100644 --- a/README.md +++ b/README.md @@ -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 . ``` @@ -497,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.