smartcache

package module
v0.0.0-...-b4bdd19 Latest Latest
Warning

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

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

README


type: Overview title: "smartcache" status: stable tags:

  • readme stale_after: 2026-12-31 generated: by: Tushar Dwivedi at: 2026-08-25T00:00:00Z

smartcache

Section What it covers
Install Install
Why smartcache Most Go caching options are either a raw store (a Redis client, an in-memory map) that…
Core Concepts Manager + Register — the only way to build a Cache[T]. A Manager holds the injected…
Failure Semantics Populate failures — if the backend Set call fails after a loader runs, GetByKey still…
Outcomes GetByKey, GetManyByKey, and PutByKey each report an Outcome so callers can meter…
Usage Setup — one Manager and one Cache[User] registered on it, shared by every snippet below:
Configuration Every option is set at one of two levels: on the manager (a default inherited by every…
Examples Runnable, end-to-end examples live in examples/, demonstrating every method above —…
Implementation Notes The backend is an interface, so any store — or a fake, for tests — can replace Redis or…
Outcome reference GetByKey, GetManyByKey, and PutByKey return an Outcome (an int enum with a String()…
Error reference All sentinels are exported from the smartcache package; check them with errors.Is.…
License MIT

A small Go cache library whose one generic, type-safe Cache[T] does both read-through and write-through over a pluggable, backend-agnostic store — so you stop re-implementing the miss → load → populate → invalidate dance for every entity, and you can swap Redis for in-memory (or your own backend) without touching a single call site.

I built it after I realised that a bunch of work that I have been doing here and there for years, in multiple projects, may be reused across projects.

I have written about this project on dev.to:

Install

go get github.com/Bytonomics/smartcache

Why smartcache

Most Go caching options are either a raw store (a Redis client, an in-memory map) that hands you get/set and leaves you to build — and repeat — the read-through, write-through, and invalidation logic at every call site, or an in-memory-only cache with no way to put a real backend behind the same API. smartcache is the missing middle: one primitive that owns that orchestration for you, generically and type-safely, over whatever backend you inject.

The core value:

  • One Cache[T] for both directions — read-through (GetByKey + a loader) and write-through (PutByKey + a writer) in the same type-safe primitive; no interface{}, no manual casts.
  • Backend-agnostic — Cache[T] talks only to a small CacheStore interface. redisstore and memstore ship in the box; anything else (an LRU, ristretto, bigcache, your own backend) is a tiny adapter, and swapping backends never changes a call site.

Correct by default — the choices that prevent the classic caching bugs are made for you, not left as footguns:

  • Required TTL backstop — a value can never be cached forever by accident, so a missed or failed invalidation self-heals within a bounded window (opt into no-expiry explicitly with AllowInfinite).
  • Downward-only TTL jitter — every cached value's effective TTL is shaved down by a small random amount by default, so a batch of keys written together doesn't all expire at the exact same instant and stampede the database together.
  • Delete-on-write — writes evict rather than overwrite in place, avoiding the concurrent stale-set race.
  • A cache failure never fails your operation — if writing to the backend fails, your GetByKey/PutByKey/GetManyByKey still returns the real value; the cache stays a performance layer, not a hard dependency.
  • Cache-stampede protection — concurrent reads that miss the same key are coalesced into a single load (see below).
  • Optional negative caching — briefly remember "not found" so repeated probes of bad or non-existent ids don't reach your database. Off by default (NegativeTTL has no default duration — it stays disabled until you explicitly set one).
Cache stampede

A cache stampede — also called a thundering herd (or "dog-piling") — happens when a hot key is missing or has just expired and many concurrent requests all miss it at the same instant, so they all fall through to the database at once and can overwhelm it. smartcache prevents this on reads: concurrent GetByKey calls for the same key are coalesced so that only one runs your loader and performs the write-back, while the rest wait and share that single result. This is on by default (disable with DisableSingleflight). Writes are never coalesced — each PutByKey/PutValue is a distinct intended write, and GetManyByKey's batched load is not coalesced across concurrent GetManyByKey calls either.

Background reading: Cache stampede (Wikipedia).

Core Concepts

  • Manager + Register — the only way to build a Cache[T]. A Manager holds the injected CacheStore, a set of default options, and (optionally) an OpenTelemetry metrics exporter; Register[T] creates one named Cache[T] on it, inheriting the manager's defaults unless you override them for that entity.
  • CacheStore interface — the injected cache backend (never the application's own database). memstore (in-memory) and redisstore (go-redis) are provided; bring your own implementation for any other system.
  • Generic Cache[T] — read-through GetByKey/GetManyByKey with a loader function; write-through PutByKey with a writer function; PutValue to cache a value you already hold, with no external write; EvictByKey and EvictManyByKey for delete-on-write; EvictByKeyPrefix to delete every entry whose key starts with a given prefix (a rare bulk operation, for example erasing one tenant); value-derived Put, PutValue, and Evict methods that derive the cache key from val.CacheUniqueKey().
  • RegisterAliasGroup + alias groups — an opt-in second constructor for a value reachable by several lookup keys (id, email, slug, …), where a write or delete through any one of them keeps the rest consistent — requires T to implement UniqueKeyed; pick per-cache slot placement with AliasMode (Colocated vs Sharded) for Redis Cluster — see RegisterAliasGroup.
  • Required positive TTL backstop — the resolved TTL must be positive. Opt in to no expiry via AllowInfinite: true.
  • Downward-only TTL jitter — both TTL and NegativeTTL are independently shaved down by a random fraction (default 10%, configurable, 0 disables) so keys written together don't all expire in sync.
  • Optional negative caching, off by default — cache "not found" results for a separate duration via NegativeTTL. There is no built-in default duration; negative caching only activates once you set NegativeTTL to a positive value yourself, so its behavior is never a surprise.
  • Singleflight de-duplication — concurrent cold loads for the same key are serialized (on by default; disable via DisableSingleflight: true).
  • Pluggable Codec — customize serialization. JSON is the default.
  • Optional OpenTelemetry metrics — configure WithOTLP on the Manager to export per-cache counters and a load-latency histogram, fire-and-forget on a background interval.

Failure Semantics

  • Populate failures — if the backend Set call fails after a loader runs, GetByKey still returns the loaded value and reports Outcome == LoadedNotCached. If the backend Set call fails after a writer runs, PutByKey still returns the written value and reports Outcome == WrittenNotCached. GetManyByKey applies the same rule per key. Neither the read nor the write fails — only caching the result failed.
  • Writer failures — if writer returns a non-nil error, PutByKey returns that error unchanged and the cache is left untouched. If writer returns (nil, nil), PutByKey returns ErrNilWrite and the cache is left untouched — the cache is never set to a nil value.
  • GetManyByKey load failures — if loadMissing returns a non-nil error, GetManyByKey returns that error wrapped, with a nil result map — even the keys that were already cache hits are discarded, matching GetByKey's "a failed load fails the call" behavior.
  • GetManyByKey batch-read failures — if the backend's batch read fails, every requested key is treated as a miss and loaded via loadMissing, exactly as if none of them were cached; the batch-read failure itself never surfaces to the caller.
  • EvictByKey failures — if a backend Delete call fails, the error is returned to the caller so it can retry or alert.
  • EvictByKeyPrefix failures — a prefix that contains * returns ErrWildcardKeyPrefix before anything is deleted. If the store fails partway through, EvictByKeyPrefix returns the count removed so far together with the joined error, so the call can be repeated. On redisstore, a failed DEL does not stop the sweep (the remaining keys are still deleted), and an error from the key scan is joined into the result. For an alias-group cache on redisstore, an error from the key scan aborts the call before any record is evicted (count 0); a failure while evicting one record is joined and the other records are still evicted. Entries that a failed call left behind expire when their TTL backstop elapses.
  • Value-derived method failures — when using Put(ctx, writer), PutValue(ctx, val), or Evict(ctx, val), if T does not implement UniqueKeyed, these methods return ErrNotUniqueKeyed.
  • TTL backstop — the TTL bounds how long any missed or failed eviction can leave a stale value in the cache.

Outcomes

GetByKey, GetManyByKey, and PutByKey each report an Outcome so callers can meter cache-hit rate and alert on populate failures. GetByKey/GetManyByKey never return a PutByKey-only outcome, and PutByKey never returns a GetByKey-only outcome — the two are read-side and write-side vocabularies, not interchangeable.

GetByKey and GetManyByKey return (or, for GetManyByKey, meter per key) one of:

  • Hit — value was served from the cache (and not expired).
  • Loaded — a miss occurred, the loader ran, and the value was cached. GetManyByKey also reports Loaded for a key confirmed not-found in the source of truth (whether or not negative caching is enabled).
  • LoadedNotCached — a miss occurred, the loader ran, but the backend Set failed; the value is still returned, uncached.
  • NegativeHit — a previously cached "not found" was served.

PutByKey returns one of:

  • Written — the writer ran and the value it returned was cached.
  • WrittenNotCached — the writer ran, but the backend Set failed; the value is still returned, uncached.

PutValue, Put (value-derived), and PutValueByKey return only an error — they have no Outcome, since they perform no external write (or no external write separate from value derivation) to characterize.

Usage

Setup — one Manager and one Cache[User] registered on it, shared by every snippet below:

import (
	"context"
	"fmt"
	"time"

	"github.com/Bytonomics/smartcache"
	"github.com/Bytonomics/smartcache/redisstore"
	"github.com/redis/go-redis/v9"
)

type User struct {
	ID   string
	Name string
}

func main() {
	rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
	store := redisstore.New(rdb)

	mgr, err := smartcache.NewManager(store)
	if err != nil {
		panic(err)
	}

	ttl := time.Hour
	users, err := smartcache.Register[User](mgr, "user", &smartcache.EntityOptions{TTL: &ttl})
	if err != nil {
		panic(err)
	}
	ctx := context.Background()

	// ... the snippets below continue here, using ctx and users.
}
Manager + Register — the only way to build a Cache[T]
mgr, err := smartcache.NewManager(store,
	smartcache.WithDefaultTTL(time.Hour),
	smartcache.WithDefaultNegativeTTL(30*time.Second),
)
if err != nil {
	panic(err)
}

// name is required and unique per manager: it doubles as the metric name and
// the default key prefix. EntityOptions fields are pointers: nil inherits the
// manager default, non-nil overrides it for this cache only.
teamTTL := 15 * time.Minute
teams, err := smartcache.Register[Team](mgr, "team", &smartcache.EntityOptions{TTL: &teamTTL})
if err != nil {
	panic(err)
}

Register is a package-level generic function, not a method on Manager — Go methods cannot have type parameters, so mgr.Register[Team](...) is not possible. Manager.Shutdown(ctx) flushes and stops the OTLP exporter (see Telemetry below); it is a no-op when WithOTLP was never configured.

GetByKey — read-through
user, outcome, err := users.GetByKey(ctx, "u_123", func(ctx context.Context) (*User, error) {
	return loadUserFromDB(ctx, "u_123") // your own database call
})
if err != nil {
	panic(err)
}
fmt.Println(user.Name, outcome)

GetByKey checks the cache for "u_123" first. On a miss, it calls the loader function you passed in, caches exactly the value the loader returned, and returns that value. On a hit, the loader is never called at all — this is what makes repeat reads for a hot key stop touching your database. outcome reports which of these happened: Hit, Loaded, LoadedNotCached, or NegativeHit — see Outcomes for what each one means.

Signaling and detecting "not found". To cache a "not found" — and to enable negative caching — your loader returns smartcache.ErrNotFound. smartcache then reports Outcome == Loaded (cold) or NegativeHit (warm) and returns smartcache.ErrNotFound to you. Any other error from your loader is treated as transient: it is returned to you unchanged and is never cached. A caller therefore tells the two apart with errors.Is(err, smartcache.ErrNotFound) — that is a definite not-found; any other non-nil error is a transient failure. (GetManyByKey's loadMissing signals not-found differently: simply omit the id from the returned map — see below.)

GetManyByKey — batch read-through
result, err := users.GetManyByKey(ctx, []string{"u_1", "u_2", "u_3"}, func(ctx context.Context, missing []string) (map[string]*User, error) {
	return loadUsersFromDB(ctx, missing) // your own batched database call — one query for all missing ids
})
if err != nil {
	panic(err)
}
for id, user := range result {
	fmt.Println(id, user.Name)
}

GetManyByKey reads every key from the cache in one batch round trip (a Redis MGET when the backend supports it; otherwise one GetByKey per key). Cached keys never touch your loader at all. Every key that misses is collected and loaded in exactly one call to loadMissing — never one call per missing key — and each returned value is cached individually. An id loadMissing does not return (present as a missing key in the input but absent from its result map) is treated as confirmed not-found: it is negative-cached when NegativeTTL > 0, and is never present in the returned map either way. Unlike GetByKey, GetManyByKey is not deduplicated via singleflight — two concurrent GetManyByKey calls for overlapping missing keys may each call loadMissing.

PutByKey — write-through
newUser := &User{ID: "u_456", Name: "Ada Lovelace"}
saved, outcome, err := users.PutByKey(ctx, "u_456", func(ctx context.Context) (*User, error) {
	if err := saveUserToDB(ctx, newUser); err != nil { // your own database call
		return nil, err
	}
	return newUser, nil
})
if err != nil {
	panic(err)
}
fmt.Println(saved.Name, outcome)

PutByKey is for when the write itself should go through smartcache. It calls your writer function to persist the value to your own source of truth, then caches exactly the value writer returned — so a GetByKey for "u_456" right after this PutByKey is served from cache with no database round trip. If writer returns a non-nil error, PutByKey returns that error unchanged and the cache stays untouched. If writer returns (nil, nil), PutByKey returns ErrNilWrite, because the cache is never set to a nil value. Unlike GetByKey's loader, writer is never deduplicated: two concurrent PutByKey calls for the same key are two distinct writes, and singleflight would silently drop one of them. outcome is Written or WrittenNotCached — see Outcomes.

PutValueByKey — direct cache write by key
if err := users.PutValueByKey(ctx, "u_456", newUser); err != nil {
	panic(err)
}

PutValueByKey writes a value you already hold straight into the cache — no external write happens, no writer function is called. Use it when your own code already performed the real write (e.g. you just ran the INSERT yourself) and you only need the cache updated to match it. PutValueByKey returns only an error; it has no Outcome, since there is no external write for one to characterize.

EvictByKey — delete-on-write by key
// After updating the user elsewhere (e.g. via PutByKey's writer, or your own code):
if err := users.EvictByKey(ctx, "u_123"); err != nil {
	panic(err)
}

EvictByKey removes the cached entry for "u_123" right away. Call it after the source of truth changes outside of PutByKey/PutValueByKey, so the next GetByKey for that key is a clean read-through instead of serving stale data. EvictManyByKey does the same for several keys at once, joining any errors.

EvictByKeyPrefix — bulk delete by key prefix
// Erase everything cached for one tenant. The cache keys were built as "<appID>::<id>",
// so every entry of tenant "app-123" starts with "app-123::".
removed, err := users.EvictByKeyPrefix(ctx, "app-123::")
switch {
case errors.Is(err, smartcache.ErrEmptyKeyPrefix):
	// An empty prefix would match every entry, so it is rejected instead of flushing the cache.
case errors.Is(err, smartcache.ErrWildcardKeyPrefix):
	// The prefix contains '*', which is never allowed.
case errors.Is(err, smartcache.ErrPrefixEvictNotSupported):
	// The store (or the Redis connection behind it) cannot prefix-delete.
case err != nil:
	// The store failed partway; removed is the count deleted before the failure.
	// Repeat the call, or let the TTL backstop expire what is left.
}
fmt.Println("evicted", removed, "entries")

EvictByKeyPrefix deletes every entry of this cache whose logical key (the key you pass to GetByKey/PutByKey) starts with keyPrefix, and returns how many it removed. Cached "not found" markers under a matching key are removed too. The prefix is literal: a prefix that contains * is rejected with ErrWildcardKeyPrefix, so a caller can never ask for a pattern, and ?, [, ] and \ match themselves. End the prefix with your own separator (as "app-123::" does) so that "app-12" can never match "app-123"'s entries. Four outcomes are errors you can test with errors.Is: an empty prefix returns ErrEmptyKeyPrefix, a prefix that contains * returns ErrWildcardKeyPrefix, a store without the capability returns ErrPrefixEvictNotSupported, and a partial failure returns the count removed so far with the joined error (see Failure Semantics).

On an alias-group cache the prefix is matched against the primary key (the value of CacheUniqueKey()), and the returned count is the number of records removed. Each matching record is removed together with its members hash and all of its alias pointers, exactly as EvictByAlias or a primary-key evict removes one record. A record whose value has already expired but whose members hash is still present is cleaned up too.

Not for the request hot path. On Redis, EvictByKeyPrefix walks the keyspace with SCAN (never KEYS, so it does not block the server) and issues one DEL per key (so Redis Cluster never receives a cross-slot command). Cost grows with the number of keys in Redis, not only with the number of matches. Use it for rare bulk operations such as erasing one tenant, never per request.

memstore supports EvictByKeyPrefix out of the box. redisstore supports it when the connection passed to redisstore.New also implements redisstore.KeyScanner (see Implementation Notes). With a connection that cannot scan keys, EvictByKeyPrefix returns ErrPrefixEvictNotSupported.

Key-taking vs value-derived methods

smartcache offers two parallel method families, distinguished by how they derive the cache key:

Key-taking methods — explicit cache key parameter:

  • GetByKey(ctx, key, loader), GetManyByKey(ctx, keys, loadMissing) — read-through by explicit key
  • PutByKey(ctx, key, writer), PutValueByKey(ctx, key, val) — write-through by explicit key
  • EvictByKey(ctx, key), EvictManyByKey(ctx, keys) — delete by explicit key
  • EvictByKeyPrefix(ctx, keyPrefix) — delete every entry whose key starts with keyPrefix; returns the number removed

These work for any T, including slice types, and do not require T to implement any interface.

Value-derived methods — key derived from val.CacheUniqueKey():

  • Put(ctx, writer) — write-through, derives key from result of writer (requires T implements UniqueKeyed)
  • PutValue(ctx, val) — direct cache write, derives key from val (requires T implements UniqueKeyed)
  • Evict(ctx, val) — delete, derives key from val (requires T implements UniqueKeyed)

These methods require T to implement UniqueKeyed { CacheUniqueKey() string }, and return ErrNotUniqueKeyed if it doesn't. Use value-derived methods when the cache key is a natural part of the entity itself (e.g. a user's ID). Use key-taking methods for everything else, including slice types and composite caches.

Note: There is no value-derived Get — reads always use GetByKey or GetByAlias, because loading a value from an external source requires the key upfront to perform the load.

RegisterAliasGroup — one value, many lookup keys
type User struct {
	ID    string
	Name  string
	Email string
}

func (u User) CacheUniqueKey() string { return u.ID }

users, err := smartcache.RegisterAliasGroup[User](mgr, "user", &smartcache.EntityOptions{TTL: &ttl})
if err != nil {
	panic(err)
}

RegisterAliasGroup is an alias-aware cache constructor: it builds a Cache[T] whose one cached value can be reachable by several lookup keys — e.g. a user by id, by email, and by slug — where writing or deleting through any key keeps the rest consistent. User must implement UniqueKeyed (one method, CacheUniqueKey() string, returning its own primary cache key; the key may be composite, e.g. "provider:subscriptionID"); RegisterAliasGroup panics at startup — not at first use — if User doesn't implement it, or if the injected CacheStore doesn't support alias groups. Both bundled stores, memstore and redisstore, support alias groups. Plain Register does not require UniqueKeyed, so slice types and other composites can be cached with Register.

PutAliased / PutAliasedValue — register an alias
_, _, err = users.PutAliased(ctx, "u_123", smartcache.AliasRef{Field: "email", Value: "ada@example.com"},
	func(ctx context.Context) (*User, error) {
		return loadUserFromDB(ctx, "u_123")
	})

PutAliased is PutByKey's alias-aware counterpart — it runs your writer and registers the given alias for the result, so a later GetByAlias finds it. PutAliasedValue is PutValueByKey's counterpart: it registers an alias for a value you already hold, with no writer call. Call either once per alias, as your code first needs each lookup path. A field holds at most one value per record: registering email again for the same primary replaces the old one, and if that email was already registered to a different primary, it is moved rather than shared.

GetByAlias — read-through by alias
user, outcome, err := users.GetByAlias(ctx, smartcache.AliasRef{Field: "email", Value: "ada@example.com"},
	func(ctx context.Context) (*User, error) {
		return loadUserByEmailFromDB(ctx, "ada@example.com")
	})

GetByAlias is GetByKey's alias-aware counterpart. On a hit it resolves the alias straight to the value, same as GetByKey. On a miss it runs your loader, reads the loaded value's CacheUniqueKey(), and registers this alias for it automatically — so a first-ever login by email warms the cache exactly like a first GetByKey by id would.

EvictByAlias — delete-on-write by alias
if err := users.EvictByAlias(ctx, smartcache.AliasRef{Field: "email", Value: "ada@example.com"}); err != nil {
	panic(err)
}

EvictByAlias is EvictByKey's alias-aware counterpart: deleting through an alias removes the value and every alias for it, exactly as deleting through the primary key does — so no lookup path is left pointing at stale or deleted data. There is no separate "update by alias" method: a primary PutByKey/PutValueByKey already updates every alias's view.

Slot placement: AliasColocated vs AliasSharded

An alias-group cache chooses one of two Redis-Cluster slot-placement strategies via EntityOptions.AliasMode (default AliasColocated). The public API is identical in both modes; only slot placement, hop count, and cross-key atomicity differ.

AliasColocated (default) AliasSharded
slot granularity one slot per entity ({ns}) one slot per record + per alias
GetByAlias 1 atomic Lua 2 hops (resolve → validate-on-read)
write / evict 1 atomic Lua cascade record-slot atomic Lua + best-effort pointer ops
atomicity full single-Lua cascade record atomic; pointers best-effort, self-healing
stale reads none none (validate-on-read)
Cluster hotspot yes — one slot per entity no
best for single-instance Redis, or small / low-traffic entities Cluster with any large / hot entity

Rule of thumb: single-instance Redis → AliasColocated; Cluster with any hot or large entity → AliasSharded. AliasColocated is still correct on a Cluster — it just concentrates that entity's traffic on one node.

mode := smartcache.AliasSharded
users, _ := smartcache.RegisterAliasGroup[User](mgr, "user", &smartcache.EntityOptions{
    TTL:       &ttl,
    AliasMode: &mode,
})

AliasColocated — the whole user entity lives on one slot:

flowchart LR
  subgraph S["slot = hash('user') — WHOLE entity here"]
    V["bc:{user}:5 (value)"]
    M["bc:memb:{user}:5 (HASH email→foo, slug→ada)"]
    P1["bc:grp:{user}:email:foo → 5"]
    P2["bc:grp:{user}:slug:ada → 5"]
  end
  P1 -. resolve .-> V
  P2 -. resolve .-> V

AliasSharded — the record and each alias land on independent slots:

flowchart LR
  subgraph R["slot = hash('user:5')"]
    V["bc:{user:5} (value)"]
    M["bc:memb:{user:5} (HASH email→foo, slug→ada)"]
  end
  subgraph A1["slot = hash('user:email:foo')"]
    P1["bc:grp:{user:email:foo} → 5"]
  end
  subgraph A2["slot = hash('user:slug:ada')"]
    P2["bc:grp:{user:slug:ada} → 5"]
  end
  P1 -. "resolve → validate members[email]==foo" .-> V
  P2 -. "resolve → validate members[slug]==ada" .-> V
TTL jitter
fraction := 0.10 // default; 0 disables jitter for this cache
users, err := smartcache.Register[User](mgr, "user", &smartcache.EntityOptions{
	TTL:             &ttl,
	JitterFraction:  &fraction,
})

Every positive and negative TTL is shaved down — never extended — by a random amount up to JitterFraction of the base TTL before being applied on a cache Set. This means a large batch of keys written around the same time (e.g. warming a cache on deploy) does not all expire at the exact same instant and cause a synchronized cache stampede when they do. The configured TTL always remains a hard upper bound on staleness; jitter only ever shortens it. Set JitterFraction: ptrFloat64(0) to disable jitter for a specific cache, or smartcache.WithDefaultJitterFraction(0) on the Manager to disable it everywhere.

Telemetry — OpenTelemetry metrics
url := "localhost:4317"
mgr, err := smartcache.NewManager(store, smartcache.WithOTLP(smartcache.OTLPConfig{
	URL: &url, // required; every other field is optional with a sensible default
}))
if err != nil {
	panic(err)
}
defer func() { _ = mgr.Shutdown(context.Background()) }()

When WithOTLP is configured, every cache registered on the manager gets its own set of counters — smartcache.<name>.hit, .loaded, .loaded_not_cached, .negative_hit, .load_error, .written, .written_not_cached, .evict — plus a smartcache.<name>.load_latency histogram (seconds), exported over OTLP gRPC on a background interval (default 15s), fire-and-forget. Metrics are never labeled by cache key — only by the cache's registered name — so cardinality stays bounded regardless of traffic. Call Manager.Shutdown on process exit to flush the final batch; it is a no-op when WithOTLP was never configured, so callers that don't need metrics pay no lifecycle cost.

The .evict counter counts evictions requested or performed: EvictByKey adds 1, EvictManyByKey adds the number of keys it was given, and EvictByKeyPrefix adds the number of entries it removed (keys for a plain cache, records for an alias group). EvictByKeyPrefix adds nothing when it removes none, including when it returns an error before removing anything.

Configuration

Every option is set at one of two levels: on the manager (a default inherited by every cache it builds), or on an individual cache at Register time (which overrides the manager default for that one cache). Per-cache options are pointers — a nil field inherits the manager default; a non-nil field overrides it.

Manager options — NewManager(store, ...ManagerOption)
Option Sets Default when the option is omitted
WithDefaultTTL(d) Default positive TTL for caches that do not set their own 0 — unset; each cache must then set TTL or AllowInfinite, or Register returns ErrInvalidTTL
WithDefaultJitterFraction(f) Default downward TTL-jitter fraction 0.10
WithDefaultNegativeTTL(d) Default negative-cache TTL 0 — negative caching off
WithDefaultDisableSingleflight(b) Default singleflight toggle false — singleflight on
WithDefaultCodec(c) Default serialization codec nil → JSON
WithOTLP(cfg) Turn on OpenTelemetry metric export not set — metrics disabled
OTLPConfig — the value passed to WithOTLP

Pointer fields; a nil field uses the default shown.

Field Type Default
URL *string required — WithOTLP returns an error if nil; no default
FlushInterval *time.Duration 15s
Timeout *time.Duration 10s
Insecure *bool false (TLS)
ServiceName *string "smartcache"
Per-cache options — EntityOptions in Register[T](mgr, name, opts)

Pointer fields; nil inherits the manager default, non-nil overrides it.

Field Type Default when nil
Prefix *string the registered name
TTL *time.Duration manager default TTL (0 unless WithDefaultTTL set); TTL <= 0 without AllowInfinite → ErrInvalidTTL
AllowInfinite *bool false (per-cache only — there is no manager-level default)
JitterFraction *float64 manager default (0.10 unless changed); 0 disables jitter; must be in [0, 1) or Register returns ErrInvalidJitterFraction
NegativeTTL *time.Duration manager default (0 = negative caching off)
DisableSingleflight *bool manager default (false = singleflight on)
Codec Codec manager default codec, else JSON

name (the second argument to Register) is required and unique per manager: an empty name returns ErrEmptyName, a duplicate returns ErrDuplicateName. Registering a pointer type (Register[*T]) panics with ErrPointerType.

Examples

Runnable, end-to-end examples live in examples/, demonstrating every method above — GetByKey (miss then hit), negative caching, PutValueByKey, PutByKey, GetManyByKey, and EvictByKey — against both backends:

cd examples
make deps   # first time only — downloads examples' own dependencies

# No infrastructure needed:
make run-memstore

# Needs a local Redis:
docker run --rm -p 6379:6379 redis:7
make run-redisstore

examples is its own Go module (own go.mod/go.sum, replaced to the local checkout) so trying things out — or adding a new example with its own dependencies — never touches the root module's dependency graph.

Implementation Notes

The backend is an interface, so any store — or a fake, for tests — can replace Redis or in-memory storage without touching call sites. The optional OpenTelemetry metrics exporter (see Telemetry) means every consumer of this module — even one that only uses the in-memory memstore and never configures WithOTLP — pulls the OpenTelemetry API and SDK as a dependency; this is a deliberate trade-off for a zero-friction "just pass an endpoint" telemetry story.

Shared values under singleflight

When singleflight is enabled (the default), a GetByKey call that returns Loaded or LoadedNotCached may hand back the exact same *T given to every other concurrent caller deduped onto that same loader call — this is singleflight.Do's own contract: all duplicate callers receive the one result the leader produced. Treat a Loaded/LoadedNotCached result as read-only; copy it before mutating. A Hit result is different: GetByKey unmarshals a fresh value from the cache on every call, so it is never shared with another caller.

The CacheStore interface

To back Cache[T] with your own storage system, implement CacheStore:

type CacheStore interface {
	// Get returns the raw bytes for key, or ErrStoreMiss if the key is absent.
	Get(ctx context.Context, key string) ([]byte, error)
	// Set stores val under key with the given ttl. A ttl <= 0 means no expiry.
	Set(ctx context.Context, key string, val []byte, ttl time.Duration) error
	// Delete removes key. Deleting an absent key is not an error.
	Delete(ctx context.Context, key string) error
	// Exists reports whether key is present (and not expired).
	Exists(ctx context.Context, key string) (bool, error)
}

Cache[T] owns all serialization — CacheStore only ever sees raw bytes, never the cached type T. Pass your implementation to smartcache.NewManager(store, ...) in place of memstore.New() or redisstore.New(rdb).

Optionally, also implement BatchCacheStore (embeds CacheStore and adds one GetMany(ctx, keys) (map[string][]byte, error) method) so Cache[T].GetManyByKey can read several keys in a single round trip instead of falling back to one GetByKey call per key. redisstore implements it via Redis MGET; memstore implements it with an in-process loop.

Optionally, also implement PrefixCacheStore (embeds CacheStore and adds one DeleteByPrefix(ctx, keyPrefix) (int64, error) method) so Cache[T].EvictByKeyPrefix works on plain (non-alias) caches:

type PrefixCacheStore interface {
	CacheStore
	// DeleteByPrefix removes every key that starts with keyPrefix and returns how many it removed.
	DeleteByPrefix(ctx context.Context, keyPrefix string) (int64, error)
}

keyPrefix is a complete, literal storage-key prefix that Cache[T] builds for you; a store must treat every character in it literally, never as a glob. Register detects the interface with a type assertion, the same way it detects BatchCacheStore; on a store that does not implement it, EvictByKeyPrefix returns ErrPrefixEvictNotSupported. memstore implements it with an in-process scan. redisstore implements it with SCAN and one DEL per key, and needs its connection to implement redisstore.KeyScanner (see below).

AliasCacheStore — backing RegisterAliasGroup

To back an alias-group cache (see RegisterAliasGroup) with your own storage system, implement AliasCacheStore in addition to CacheStore. It is a factory: given the cache's namespace and slot-placement mode, it returns an AliasOps handle that owns all key math and grouped operations for that cache:

type AliasMode int

const (
	AliasColocated AliasMode = iota // one Cluster slot per entity; single atomic Lua per op. Default.
	AliasSharded                    // distributed per record/alias; validate-on-read.
)

type AliasRef struct {
	Field string
	Value string
}

type AliasOps interface {
	GetValue(ctx context.Context, primary string) ([]byte, error)
	PutValue(ctx context.Context, primary string, val []byte, ttl time.Duration) error
	EvictByPrimary(ctx context.Context, primary string) error
	GetByAlias(ctx context.Context, ref AliasRef) ([]byte, error)
	PutByAlias(ctx context.Context, primary string, ref AliasRef, val []byte, ttl time.Duration) error
	EvictByAlias(ctx context.Context, ref AliasRef) error
}

type AliasCacheStore interface {
	CacheStore
	AliasGroup(ns string, mode AliasMode) AliasOps
}

RegisterAliasGroup detects AliasCacheStore the same way Cache[T].GetMany detects BatchCacheStore — a type assertion at registration time, so a store that doesn't implement it simply can't be used with RegisterAliasGroup (see the Error reference for the resulting panic). redisstore ships both a AliasColocated and an AliasSharded implementation; memstore ships a single mode-agnostic implementation (a single process has no Cluster slots to distribute across, so mode doesn't affect its behavior). The mode is chosen per cache via EntityOptions.AliasMode (nil defaults to AliasColocated) and is fixed for the cache's lifetime. See docs/1-alias-cache-design.md for the full design, including the trade-offs between the two modes.

Optionally, the AliasOps that AliasGroup returns can also implement PrefixAliasOps (embeds AliasOps and adds one method) so Cache[T].EvictByKeyPrefix works on alias-group caches:

type PrefixAliasOps interface {
	AliasOps
	// EvictByPrimaryPrefix removes every record whose primary key starts with primaryPrefix and
	// cascades to its members hash and alias pointers exactly as EvictByPrimary does for one record.
	// primaryPrefix is literal. It returns the number of records removed.
	EvictByPrimaryPrefix(ctx context.Context, primaryPrefix string) (int64, error)
}

RegisterAliasGroup detects it with a type assertion on the AliasOps handle, so an alias-group cache whose handle lacks it returns ErrPrefixEvictNotSupported from EvictByKeyPrefix. memstore and both redisstore modes (AliasColocated and AliasSharded) implement it.

redisstore.KeyScanner — scanning keys for prefix eviction

redisstore finds the keys to delete with SCAN. RedisConn is unchanged: it still has the same six methods, so every existing RedisConn implementation keeps compiling and working. Prefix eviction needs one more capability, expressed as a separate optional interface that the connection may implement:

type KeyScanner interface {
	// ScanKeys calls fn with batches of every key that matches the Redis glob pattern match,
	// visiting every node of a cluster. fn is never called concurrently. ScanKeys stops and
	// returns the first error fn returns.
	ScanKeys(ctx context.Context, match string, fn func(keys []string) error) error
}

redisstore checks for KeyScanner when EvictByKeyPrefix is called, not when the cache is registered. A connection that does not implement it makes EvictByKeyPrefix return ErrPrefixEvictNotSupported; nothing else changes. The contract an implementation must keep:

  • The keys that ScanKeys passes to fn are in the same key space as the other RedisConn methods accept. A connection that adds its own key prefix (for example an environment prefix) to every key must apply that prefix to the match pattern and strip it from the keys it passes to fn.
  • ScanKeys must not call fn concurrently, and must stop and return the first error fn returns.

One helper does the scanning for a KeyScanner implementation:

  • redisstore.ScanAll(ctx, client redis.UniversalClient, match string, fn func(keys []string) error) error is the cluster-aware SCAN loop itself, for wrappers that implement KeyScanner around their own prefixing: scan with your own prefixed pattern and strip the prefix inside fn. A *redis.ClusterClient is scanned on every master node; any other client is scanned once. ScanAll serializes calls to fn, and the first error fn returns stops the scan on every node.
Custom serialization (Codec)

By default Cache[T] serializes values as JSON. To use a different encoding — for speed, a compact binary format, compression, or encryption-at-rest — supply a Codec, either per cache (EntityOptions.Codec) or as a manager default (WithDefaultCodec):

type Codec interface {
	Marshal(v any) ([]byte, error)
	Unmarshal(data []byte, v any) error
}

A compressing or encrypting codec simply wraps another: on Marshal, encode then compress/encrypt the bytes; on Unmarshal, decrypt/decompress then decode. This is how "compressed" and "encrypted-at-rest" cached values are achieved with no change to smartcache itself.

Adapting another cache library

To back Cache[T] with any existing Go cache (an LRU, ristretto, bigcache, patrickmn/go-cache, …), write a small adapter type that implements CacheStore by calling into that library — the same pattern memstore and redisstore already use. Get must return ErrStoreMiss (not the underlying library's own miss value) so Cache[T] recognizes it as a read-through miss.

Outcome reference

GetByKey, GetManyByKey, and PutByKey return an Outcome (an int enum with a String() method) describing how the call was served. GetByKey/GetManyByKey use the read-side values; PutByKey uses the write-side values; the two never mix.

Outcome Reported by Meaning
Hit GetByKey, GetManyByKey Served from the cache (present and not expired).
Loaded GetByKey, GetManyByKey A miss occurred, the loader ran, and the value was cached. GetManyByKey also reports Loaded for a key confirmed not-found in the source.
LoadedNotCached GetByKey, GetManyByKey A miss occurred and the loader ran, but the backend Set failed; the value is still returned, uncached.
NegativeHit GetByKey, GetManyByKey A previously cached "not found" was served.
Written PutByKey The writer ran and the value it returned was cached.
WrittenNotCached PutByKey The writer ran, but the backend Set failed; the value is still returned, uncached.

PutValueByKey, Put, PutValue, EvictByKey, and EvictManyByKey return only an error — they have no Outcome. EvictByKeyPrefix returns (int64, error): the number of entries removed and an error — it has no Outcome either.

Error reference

All sentinels are exported from the smartcache package; check them with errors.Is. ErrPointerType and ErrAliasingNotSupported are the only ones that panic (programming errors caught at construction) rather than being returned.

Sentinel Raised by When
ErrNotFound GetByKey / GetManyByKey return it; your loader returns it A cacheable "not found". Your loader returns it to mark a not-found (this is what enables negative caching); GetByKey returns it to you. Detect with errors.Is(err, smartcache.ErrNotFound).
ErrNilWrite PutByKey (returned) The writer returned (nil, nil); the cache is never set to a nil value.
ErrNotUniqueKeyed Put / PutValue / Evict (returned) T does not implement UniqueKeyed, but a value-derived method was called.
ErrInvalidTTL Register (returned) The resolved TTL <= 0 and AllowInfinite is false.
ErrInvalidJitterFraction Register (returned) The resolved jitter fraction is outside [0, 1).
ErrEmptyName Register (returned) The cache name is empty.
ErrDuplicateName Register (returned) A cache with that name is already registered on the manager.
ErrPointerType Register / RegisterAliasGroup (panics) T is itself a pointer type (e.g. Register[*User]) — a programming error, caught at construction.
ErrNilStore NewManager (returned) The CacheStore passed to NewManager is nil.
ErrAliasingNotSupported RegisterAliasGroup (panics) The injected CacheStore doesn't support alias groups.
ErrNotAliasGroup GetByAlias / PutAliased / PutAliasedValue / EvictByAlias (returned) Called on a cache built with Register instead of RegisterAliasGroup.
ErrEmptyKeyPrefix EvictByKeyPrefix (returned) The keyPrefix is empty. An empty prefix would match every entry of the cache, so it is rejected instead of flushing it.
ErrWildcardKeyPrefix EvictByKeyPrefix (returned) The keyPrefix contains the wildcard *. A prefix is always literal, so * is rejected before anything is deleted.
ErrPrefixEvictNotSupported EvictByKeyPrefix (returned) The manager's store does not implement PrefixCacheStore (or, for an alias-group cache, its AliasOps does not implement PrefixAliasOps), or the redisstore connection does not implement redisstore.KeyScanner.
ErrStoreMiss CacheStore.Get (backend contract) The key is absent. A backend's Get returns it and Cache[T] treats it as a miss; it is never surfaced to callers. Relevant only when writing a custom backend.

License

MIT

Documentation

Overview

Package smartcache is a small, type-safe caching library.

It provides a generic read-through / write-through cache (Cache[T]) over a pluggable byte key-value backend (the CacheStore interface). Cache[T] owns serialization (JSON by default), enforces a positive TTL backstop so a missed or failed eviction can only cause bounded staleness, applies downward-only TTL jitter by default so keys written together don't expire in sync, optionally negative-caches "not found" results, and de-duplicates concurrent cold loads with singleflight.

Every Cache[T] is built through a Manager: NewManager creates one over a CacheStore and a set of defaults, and the package-level generic function Register creates a named, type-safe Cache[T] on it (Go methods cannot have type parameters, so registration cannot be a method on Manager). GetMany batches a read-through lookup for several keys in one round trip, calling the caller's batch loader at most once for whatever keys are not already cached.

The library performs no logging itself: Get, GetMany, and Put return an Outcome so the caller can meter cache-hit rate and detect populate failures without any of the library's own log lines. A Manager can optionally be configured with WithOTLP to export per-cache metrics (hit/miss/load/evict counters and a load-latency histogram) over OpenTelemetry OTLP, fire-and- forget on a background interval.

The backend is injected as an interface, so Redis (see subpackage redisstore) can be swapped for any other store (see subpackage memstore) without touching call sites.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrStoreMiss is returned by CacheStore.Get when the key is absent. It is an
	// internal miss signal that Cache[T] handles; it is never surfaced to callers.
	ErrStoreMiss = errors.New("smartcache: store miss")

	// ErrNotFound is the sentinel a Loader returns (or wraps) to signal a cacheable
	// "does not exist". When negative caching is enabled Cache[T] remembers it
	// briefly and returns it from Get.
	ErrNotFound = errors.New("smartcache: not found")

	// ErrInvalidTTL is returned by Register when the resolved TTL <= 0 and
	// AllowInfinite is false.
	ErrInvalidTTL = errors.New("smartcache: TTL must be > 0 (set AllowInfinite for no expiry)")

	// ErrPointerType is the value Register panics with when T is itself a pointer
	// type. Cache[T] already returns *T from every method; T being a pointer
	// too makes that a double pointer (e.g. **User), which opens a hole where
	// a non-nil outer pointer can wrap a nil inner one — silently violating
	// the "a successful Get/Put never returns nil" guarantee the rest of this
	// package relies on. This is a programming error at the call site
	// (Register[*User] instead of Register[User]), not a runtime condition, so
	// Register panics with it instead of returning it.
	ErrPointerType = errors.New("smartcache: T must not itself be a pointer type")

	// ErrNilWrite is returned by Put when writer succeeds (nil error) but
	// returns a nil value. The cache is never set to nil, so this is treated
	// as a contract violation, not a cacheable state.
	ErrNilWrite = errors.New("smartcache: writer returned a nil value with no error")

	// ErrNilStore is returned by NewManager when the CacheStore is nil.
	ErrNilStore = errors.New("smartcache: store must not be nil")

	// ErrEmptyName is returned by Register when the cache name is empty. The name
	// is required: it is both the metric name and the default key prefix.
	ErrEmptyName = errors.New("smartcache: cache name must not be empty")

	// ErrDuplicateName is returned by Register when a cache name is already
	// registered on the manager. Each registered cache must have a unique name.
	ErrDuplicateName = errors.New("smartcache: cache name already registered")

	// ErrEmptyPrefix is returned by Register when EntityOptions.Prefix is explicitly
	// set to an empty string. Prefix namespaces every key this cache stores; an empty
	// prefix would collide with any other cache that also opts out of namespacing.
	ErrEmptyPrefix = errors.New("smartcache: prefix must not be empty")

	// ErrInvalidJitterFraction is returned by Register when the resolved jitter
	// fraction is outside [0, 1). Zero disables jitter.
	ErrInvalidJitterFraction = errors.New("smartcache: jitter fraction must be in [0, 1)")

	// ErrNotAliasGroup is returned when an alias-only method (GetByAlias, PutAliased,
	// PutAliasedValue, EvictByAlias) is called on a cache that was not created with
	// RegisterAliasGroup.
	ErrNotAliasGroup = errors.New("smartcache: cache is not an alias group")

	// ErrAliasingNotSupported is used in the RegisterAliasGroup panic when the manager's
	// store does not implement AliasCacheStore.
	ErrAliasingNotSupported = errors.New("smartcache: store does not implement AliasCacheStore")

	// ErrNotUniqueKeyed is returned by the value-derived methods (Put/PutValue/Evict that take a
	// value instead of a key) when the cached type T does not implement UniqueKeyed. The *ByKey
	// variants (GetByKey/PutByKey/PutValueByKey/EvictByKey/EvictManyByKey/GetManyByKey) work for any T.
	ErrNotUniqueKeyed = errors.New("smartcache: value type does not implement UniqueKeyed (CacheUniqueKey() string)")

	// ErrEmptyKeyPrefix is returned by EvictByKeyPrefix when keyPrefix is empty. An empty prefix
	// would match every entry of the cache, so it is rejected instead of silently flushing it.
	ErrEmptyKeyPrefix = errors.New("smartcache: key prefix must not be empty")

	// ErrPrefixEvictNotSupported is returned by EvictByKeyPrefix when the manager's store (or, for an
	// alias-group cache, its AliasOps) does not implement prefix eviction, or when the store's
	// connection cannot scan keys.
	ErrPrefixEvictNotSupported = errors.New("smartcache: store does not support prefix eviction")

	// ErrWildcardKeyPrefix is returned by EvictByKeyPrefix when keyPrefix contains the wildcard '*'. A key prefix is always literal; rejecting '*' means a caller can never ask for a pattern.
	ErrWildcardKeyPrefix = errors.New("smartcache: key prefix must not contain the wildcard '*'")
)

Functions

This section is empty.

Types

type AliasCacheStore

type AliasCacheStore interface {
	CacheStore
	AliasGroup(ns string, mode AliasMode) AliasOps
}

AliasCacheStore is the optional CacheStore extension for backends that support alias groups. It is a FACTORY: given an entity namespace and slot mode it returns an AliasOps bound to them. RegisterAliasGroup calls AliasGroup once per cache. redisstore returns a Colocated or Sharded implementation; memstore returns its single mode-agnostic implementation (one process has no slots, so both modes are behaviourally identical there). Detected via a comma-ok type assertion, mirroring BatchCacheStore.

type AliasMode

type AliasMode int

AliasMode selects the Redis-Cluster slot-placement strategy for an alias-group cache.

const (
	// AliasColocated tags every key of an entity with {ns}: all of the entity's keys share one
	// Cluster slot and every op is a single atomic Lua cascade. One slot per entity (hotspot risk
	// on Cluster with a hot entity). Default.
	AliasColocated AliasMode = iota
	// AliasSharded tags value+members with {ns:pk} (one slot per record) and reverse pointers with
	// {ns:field:value} (one slot per alias), distributing load. GetByAlias is a two-hop resolve with
	// validate-on-read; writes/evicts are a record-slot atomic Lua plus best-effort reverse-pointer
	// ops. No hotspot, no stale reads, weaker cross-key atomicity (self-healing).
	AliasSharded
)

type AliasOps

type AliasOps interface {
	GetValue(ctx context.Context, primary string) ([]byte, error)
	PutValue(ctx context.Context, primary string, val []byte, ttl time.Duration) error
	EvictByPrimary(ctx context.Context, primary string) error
	GetByAlias(ctx context.Context, ref AliasRef) ([]byte, error)
	PutByAlias(ctx context.Context, primary string, ref AliasRef, val []byte, ttl time.Duration) error
	EvictByAlias(ctx context.Context, ref AliasRef) error
}

AliasOps is the per-(namespace, mode) strategy handle for an alias-group cache. It OWNS all key-math and operation sequencing; Cache[T] delegates to it with logical identifiers (never a pre-built key) and keeps codec, the negative-marker convention, metrics, singleflight, and UniqueKeyed. All methods are byte-level: the value bytes may be the internal negative marker, which Cache[T] (not the store) interprets. A miss (absent, or a Sharded validate-on-read mismatch) is signalled by returning ErrStoreMiss.

type AliasRef

type AliasRef struct {
	Field string
	Value string
}

AliasRef names one secondary lookup key for an alias-group cache: a field ("email") and a value ("foo@bar.com").

type BatchCacheStore

type BatchCacheStore interface {
	CacheStore
	// GetMany returns the raw bytes for the keys that are present. Keys that are
	// absent (or expired) are omitted from the returned map — a miss is never an
	// error here.
	GetMany(ctx context.Context, keys []string) (map[string][]byte, error)
}

BatchCacheStore is an optional extension of CacheStore for backends that can read many keys in one round trip. Cache[T].GetMany uses it when the injected store implements it (redisstore does, via MGET); stores that do not are handled transparently by GetMany's per-key fallback.

type Cache

type Cache[T any] struct {
	// contains filtered or unexported fields
}

Cache is a generic, type-safe read-through / delete-on-write cache over a CacheStore. It is constructed only via Register on a Manager, never directly.

func Register

func Register[T any](m *Manager, name string, opts *EntityOptions) (*Cache[T], error)

Register creates a Cache[T] on m under name (required, unique; it doubles as the metric name and the default key prefix). It panics with ErrPointerType if T is a pointer type. It returns ErrEmptyName, ErrEmptyPrefix, ErrDuplicateName, ErrInvalidTTL, or ErrInvalidJitterFraction on invalid input. A failed Register never consumes the name.

func RegisterAliasGroup

func RegisterAliasGroup[T any](m *Manager, name string, opts *EntityOptions) (*Cache[T], error)

RegisterAliasGroup registers an alias-group cache: one cached value reachable by several alias keys. It behaves like Register but additionally (a) requires the manager's store to implement AliasCacheStore, (b) requires T to implement UniqueKeyed, and (c) resolves the slot mode from opts.AliasMode (default AliasColocated) and binds an AliasOps strategy handle via the store's AliasGroup factory. Like Register's pointer-type check, it panics on a misconfiguration that must fail at init: a pointer T, a store without AliasCacheStore, or a T that is not UniqueKeyed.

func (*Cache[T]) Evict

func (c *Cache[T]) Evict(ctx context.Context, val *T) error

Evict is the value-derived delete: it derives the key from val.CacheUniqueKey().

func (*Cache[T]) EvictByAlias

func (c *Cache[T]) EvictByAlias(ctx context.Context, alias AliasRef) error

EvictByAlias deletes the whole group reachable through alias. Only valid on an alias-group cache.

func (*Cache[T]) EvictByKey

func (c *Cache[T]) EvictByKey(ctx context.Context, key string) error

EvictByKey deletes key (delete-on-write). It returns the delete error so the caller can retry or alarm; the TTL backstop bounds staleness if it fails.

func (*Cache[T]) EvictByKeyPrefix

func (c *Cache[T]) EvictByKeyPrefix(ctx context.Context, keyPrefix string) (int64, error)

EvictByKeyPrefix deletes every cache entry whose logical key starts with keyPrefix, including cached not-found (negative) markers. For an alias-group cache, keyPrefix is matched against the primary key, and each matching record is evicted together with its members hash and alias pointers. It returns the number removed: keys for a plain cache, records for an alias group.

keyPrefix is literal. A keyPrefix that contains '*' returns ErrWildcardKeyPrefix, so a caller can never ask for a pattern; the other glob characters ('?', '[', ']' and '\') are matched literally. An empty keyPrefix returns ErrEmptyKeyPrefix, because it would match every entry. A store that cannot prefix-delete returns ErrPrefixEvictNotSupported. If the store fails partway through, the count removed so far is returned together with the joined error. Entries that a failed call left behind expire when their TTL backstop elapses, which bounds how stale they can get.

Prefix eviction scans the keyspace on Redis. It is a rare bulk operation (for example, erasing one tenant's data), never for the request hot path.

func (*Cache[T]) EvictManyByKey

func (c *Cache[T]) EvictManyByKey(ctx context.Context, keys ...string) error

EvictManyByKey deletes several keys, joining any errors.

func (*Cache[T]) GetByAlias

func (c *Cache[T]) GetByAlias(ctx context.Context, alias AliasRef, loader Loader[T]) (*T, Outcome, error)

GetByAlias reads a value by one of its alias keys (read-through: on a miss it runs loader, reads the value's CacheUniqueKey, and rebuilds the group). Only valid on an alias-group cache.

func (*Cache[T]) GetByKey

func (c *Cache[T]) GetByKey(ctx context.Context, key string, loader Loader[T]) (*T, Outcome, error)

GetByKey reads through to loader on a cache miss. See Outcome for the result modes.

Sharing note: when singleflight is enabled (the default), a Loaded or LoadedNotCached result may be the exact same *T handed to every concurrent caller deduped onto the same loader call — that is singleflight.Do's own contract. Treat a Loaded/LoadedNotCached result as read-only; copy it before mutating. A Hit result is always freshly unmarshaled per call and is never shared with another caller.

func (*Cache[T]) GetManyByKey

func (c *Cache[T]) GetManyByKey(
	ctx context.Context,
	keys []string,
	loadMissing func(ctx context.Context, missing []string) (map[string]*T, error),
) (map[string]*T, error)

GetManyByKey reads several keys in one batch: cache hits (and warm negative hits) are served without touching loadMissing; keys not found in the cache are collected and loaded in ONE loadMissing call, then populated back into the cache (with per-key downward jitter, on both the positive TTL and NegativeTTL). Keys loadMissing does not return are negative-cached (when NegativeTTL > 0) and omitted from the result map. Unlike GetByKey, GetManyByKey is never deduplicated via singleflight.

func (*Cache[T]) Put

func (c *Cache[T]) Put(ctx context.Context, writer Writer[T]) (*T, Outcome, error)

Put is the value-derived write-through: it runs writer, derives the key from the returned value's CacheUniqueKey(), then caches it. If the writer committed but the key is underivable (T is not UniqueKeyed, or CacheUniqueKey() is empty), it returns the value with WrittenNotCached and ErrNotUniqueKeyed — the committed write is never reported as a failed write to the caller.

func (*Cache[T]) PutAliased

func (c *Cache[T]) PutAliased(ctx context.Context, primaryKey string, alias AliasRef, writer Writer[T]) (*T, Outcome, error)

PutAliased write-throughs writer's value under primaryKey and registers alias (one-alias-per- field: re-registering a field replaces its old value). Only valid on an alias-group cache.

func (*Cache[T]) PutAliasedValue

func (c *Cache[T]) PutAliasedValue(ctx context.Context, primaryKey string, alias AliasRef, val *T) error

PutAliasedValue caches a value you already hold under primaryKey and registers alias, with no writer. Only valid on an alias-group cache.

func (*Cache[T]) PutByKey

func (c *Cache[T]) PutByKey(ctx context.Context, key string, writer Writer[T]) (*T, Outcome, error)

PutByKey performs a write-through: it calls writer to persist the value to your source of truth, then caches exactly the value writer returned. See Outcome for the result modes.

If writer fails, its error is returned unchanged and the cache is untouched. If writer succeeds but returns a nil value, PutByKey returns ErrNilWrite and the cache is untouched. If the cache-side write fails after writer succeeded, PutByKey still returns the value with Outcome == WrittenNotCached — the real write already happened; only caching it failed, and that must never look like a failed write to the caller.

writer is never deduplicated the way GetByKey's loader is: two concurrent PutByKey calls for the same key are two distinct writes, and singleflight would silently drop one of them.

func (*Cache[T]) PutValue

func (c *Cache[T]) PutValue(ctx context.Context, val *T) error

PutValue is the value-derived direct write: it derives the key from val.CacheUniqueKey().

func (*Cache[T]) PutValueByKey

func (c *Cache[T]) PutValueByKey(ctx context.Context, key string, val *T) error

PutValueByKey writes a value you already hold directly into the cache — no external write happens. Use this when you performed the real write yourself and only need the cache updated to match it.

type CacheStore

type CacheStore interface {
	// Get returns the raw bytes for key, or ErrStoreMiss if the key is absent.
	Get(ctx context.Context, key string) ([]byte, error)
	// Set stores val under key with the given ttl. A ttl <= 0 means no expiry.
	Set(ctx context.Context, key string, val []byte, ttl time.Duration) error
	// Delete removes key. Deleting an absent key is not an error.
	Delete(ctx context.Context, key string) error
	// Exists reports whether key is present (and not expired).
	Exists(ctx context.Context, key string) (bool, error)
}

CacheStore is the backend abstraction Cache[T] depends on: a byte key-value cache store — never the application's own database. Cache[T] owns all (de)serialization, so CacheStore never sees the cached type T. Swapping the backend (Redis, in-memory, anything) means providing a different CacheStore implementation; the Cache[T] API is unchanged.

type Codec

type Codec interface {
	Marshal(v any) ([]byte, error)
	Unmarshal(data []byte, v any) error
}

Codec serializes cached values to and from bytes. The default (when Options.Codec is nil) is encoding/json.

type EntityOptions

type EntityOptions struct {
	Prefix              *string
	TTL                 *time.Duration
	AllowInfinite       *bool
	JitterFraction      *float64
	NegativeTTL         *time.Duration
	DisableSingleflight *bool
	Codec               Codec
	AliasMode           *AliasMode // nil => AliasColocated; honored only by RegisterAliasGroup.
}

EntityOptions overrides manager defaults for one registered cache. Every field is an optional pointer: nil inherits the manager default, non-nil overrides it.

type Loader

type Loader[T any] func(ctx context.Context) (*T, error)

Loader loads a value from the source of truth on a cache miss.

Contract: return (val, nil) on success, (nil, ErrNotFound) for a cacheable not-found, or (nil, err) for a transient error (which is never cached).

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager owns the shared backend store, global defaults, and (optionally) the OTLP meter provider. Caches are created against it with Register.

func NewManager

func NewManager(store CacheStore, opts ...ManagerOption) (*Manager, error)

NewManager builds a Manager over store. It returns ErrNilStore if store is nil. When WithOTLP was supplied (and no meter was injected for tests), it stands up the OTLP exporter + periodic reader + meter provider.

func (*Manager) Shutdown

func (m *Manager) Shutdown(ctx context.Context) error

Shutdown flushes and stops the OTLP meter provider. It is a no-op when WithOTLP was not configured.

type ManagerOption

type ManagerOption func(*Manager) error

ManagerOption configures a Manager in NewManager.

func WithDefaultCodec

func WithDefaultCodec(c Codec) ManagerOption

WithDefaultCodec sets the default codec (caches fall back to JSON when nil).

func WithDefaultDisableSingleflight

func WithDefaultDisableSingleflight(b bool) ManagerOption

WithDefaultDisableSingleflight sets the default singleflight toggle.

func WithDefaultJitterFraction

func WithDefaultJitterFraction(f float64) ManagerOption

WithDefaultJitterFraction sets the default downward-jitter fraction (0 disables).

func WithDefaultNegativeTTL

func WithDefaultNegativeTTL(d time.Duration) ManagerOption

WithDefaultNegativeTTL sets the default negative-cache TTL (0 disables negative caching).

func WithDefaultTTL

func WithDefaultTTL(d time.Duration) ManagerOption

WithDefaultTTL sets the default positive TTL inherited by caches that do not override EntityOptions.TTL.

func WithOTLP

func WithOTLP(cfg OTLPConfig) ManagerOption

WithOTLP enables OpenTelemetry OTLP metric export. It returns an error if the config's URL is nil.

type OTLPConfig

type OTLPConfig struct {
	// URL is the OTLP gRPC endpoint as host:port (e.g. "localhost:4317"). Required.
	URL *string
	// FlushInterval is how often the background PeriodicReader exports. Default 15s.
	FlushInterval *time.Duration
	// Timeout bounds a single export. Default 10s.
	Timeout *time.Duration
	// Insecure uses plaintext gRPC when true. Default false.
	Insecure *bool
	// ServiceName becomes the resource service.name attribute. Default "smartcache".
	ServiceName *string
}

OTLPConfig configures the optional OpenTelemetry OTLP metric exporter on a Manager (via WithOTLP). Every field is an optional pointer; a nil field falls back to its documented default. URL is required — WithOTLP returns an error if it is nil.

type Options

type Options struct {
	// Prefix namespaces keys: the stored key is bc:<Prefix>:<key> for a normal
	// cache and bc:{<Prefix>}:<key> for an alias-group cache (built in keyspace.go).
	Prefix string
	// TTL is the positive backstop expiry applied to cached values. Required unless
	// AllowInfinite is true.
	TTL time.Duration
	// AllowInfinite opts in to TTL <= 0 (entries never expire).
	AllowInfinite bool
	// NegativeTTL, when > 0, enables negative caching of ErrNotFound for that
	// duration. Zero disables negative caching.
	NegativeTTL time.Duration
	// Codec overrides serialization. Nil means JSON.
	Codec Codec
	// DisableSingleflight turns off de-duplication of concurrent cold loads.
	DisableSingleflight bool
}

Options is the resolved configuration for a Cache[T], produced by Register.

type Outcome

type Outcome int

Outcome describes how Cache[T].Get or Cache[T].Put served a request, so the caller can meter hit rate and alarm on populate failures without the library logging anything. Hit, Loaded, LoadedNotCached, and NegativeHit are Get-only (they name a load that happened on a read). Written and WrittenNotCached are Put-only (they name a write that happened, not a load) — Put never returns a Get-only value and Get never returns a Put-only value.

const (
	// Hit means Get served the value from cache.
	Hit Outcome = iota
	// Loaded means Get missed, the loader ran, and the value was cached.
	Loaded
	// LoadedNotCached means Get missed, the loader ran, but writing the value
	// back to the store failed. The value is still returned; the read never fails.
	LoadedNotCached
	// NegativeHit means Get served a cached "not found" marker.
	NegativeHit
	// Written means Put's writer succeeded and the value was cached.
	Written
	// WrittenNotCached means Put's writer succeeded, but writing the value to
	// the store failed. The value is still returned; the write never fails on
	// account of the cache.
	WrittenNotCached
)

func (Outcome) String

func (o Outcome) String() string

String returns a human-readable name for the outcome.

type PrefixAliasOps

type PrefixAliasOps interface {
	AliasOps
	// EvictByPrimaryPrefix removes every record whose primary key starts with primaryPrefix and
	// cascades to its members hash and alias pointers exactly as EvictByPrimary does for one record.
	// primaryPrefix is literal. It returns the number of records removed.
	EvictByPrimaryPrefix(ctx context.Context, primaryPrefix string) (int64, error)
}

PrefixAliasOps is an optional extension of AliasOps for alias-group backends that can evict every record whose primary key starts with a prefix. Detected by a comma-ok assertion on the AliasOps returned by AliasCacheStore.AliasGroup.

type PrefixCacheStore

type PrefixCacheStore interface {
	CacheStore
	// DeleteByPrefix removes every key that starts with keyPrefix and returns how many it removed.
	DeleteByPrefix(ctx context.Context, keyPrefix string) (int64, error)
}

PrefixCacheStore is an optional extension of CacheStore for backends that can delete every key that starts with a given prefix. Cache.EvictByKeyPrefix uses it for NON-alias caches. keyPrefix is a complete, literal storage-key prefix built by Cache from internal/keyspace. It is never a glob pattern: a store must treat every character in it literally. DeleteByPrefix returns the number of entries it removed. It may use a full key scan, so callers use it for rare bulk operations only.

type UniqueKeyed

type UniqueKeyed interface {
	CacheUniqueKey() string
}

UniqueKeyed is implemented by a value type T cached with a unique key. The library uses CacheUniqueKey() to (a) learn a value's primary key on a GetByAlias read-through miss (alias groups), and (b) derive the storage/evict key in the value-derived methods Put/PutValue/Evict on any cache. The unique key is a SYMBOLIC identity for the cached structure — it need not equal the DB primary key and MAY be a composite (e.g. "provider:subscriptionID").

type Writer

type Writer[T any] func(ctx context.Context) (*T, error)

Writer persists a value to the source of truth and returns exactly what was written, so Put can cache that same value.

Contract: return (val, nil) with val != nil on success. Any non-nil error is returned to the caller unchanged and nothing is cached. Returning (nil, nil) violates the contract — Put returns ErrNilWrite, because the cache is never set to a nil value.

Directories

Path Synopsis
internal
keyspace
Package keyspace builds every smartcache Redis key string, so the key layout has a single source of truth shared by the smartcache core and its backends.
Package keyspace builds every smartcache Redis key string, so the key layout has a single source of truth shared by the smartcache core and its backends.
Package memstore provides an in-memory smartcache.CacheStore for unit tests and light use.
Package memstore provides an in-memory smartcache.CacheStore for unit tests and light use.
Package redisstore provides a go-redis-backed smartcache.CacheStore.
Package redisstore provides a go-redis-backed smartcache.CacheStore.

Jump to

Keyboard shortcuts

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