redcache

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Jun 19, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

README

redcache

Build CodeQL Govulncheck OpenSSF Scorecard Go Reference Go Version Latest Release Go Report Card codecov License

A typed cache-aside for Redis, built on the rueidis client. It combines rueidis client-side caching with distributed SET NX locking so that, across every process, only one caller populates a missing key while the rest wait on the invalidation push for the populated value. The result is a stampede-resistant cache behind a single generic Cache[K, V] interface.

Features

  • One generic interfaceCache[K, V] for typed keys and values; fakeable in tests.
  • Stampede protection — in-process leader/follower coordination plus a distributed SET NX lock-and-wait, so a single caller runs your loader per key and the rest wait on the invalidation rather than piling onto the origin.
  • Client-side caching — rueidis client-side cache with Redis invalidation messages cuts round trips and unblocks waiters the moment the value lands.
  • Multi-key batchingGetMulti groups operations by Redis cluster slot and executes the per-slot groups concurrently.
  • Refresh-ahead + XFetch — optional background refresh of stale-but-valid entries, with XFetch-style probabilistic early expiration to smear reload moments across the keyspace.
  • Write-through primingSet / ForceSet / Touch (and their multi variants) populate or extend entries on every subscribed client without a read-through miss.
  • Typed keys and values — pluggable Codec[V] and KeyCodec[K]; per-key partial failures surface as *BatchKeyError[K].
  • Pluggable metrics — a Metrics interface for hits/misses, lock contention, lock-wait duration, and refresh events.

Requirements

  • Go 1.25+
  • Redis 7+ with RESP3 and client-side caching (tracking) enabled

RESP3 client-side caching is load-bearing, not optional: redcache wakes waiters through Redis invalidation pushes. Without RESP3 (or with tracking disabled) there are no pushes — waiters fall back to jittered polling until the lock TTL, raising tail latency and Redis read traffic under contention.

Installation

go get github.com/dcbickfo/redcache

Runnable examples

Standalone examples live under examples/. They compile with the module and can be run directly against a local Redis:

go run ./examples/string-cache
go run ./examples/typed-cache
go run ./examples/metrics

Set REDIS_ADDR to point them at a non-default Redis address.

Migrating from v0.2.x to v0.3.x

v0.3.0 is the next minor release after v0.2.x. redcache is still pre-1.0, and this minor intentionally breaks the old string-only API. These notes focus on the interface changes needed for migration. Existing integrations on *CacheAside or *PrimeableCacheAside do not need to adopt typed domain keys immediately. The direct replacement is usually one Conn plus a Cache[string, string] view:

// v0.2.x
client, err := redcache.NewRedCacheAside(
    rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
    redcache.CacheAsideOption{
        LockTTL:              5 * time.Second,
        RefreshAfterFraction: 0.8,
        RefreshWorkers:       4,
        RefreshQueueSize:     64,
    },
)
if err != nil {
    return err
}
defer client.Client().Close()

val, err := client.Get(ctx, time.Minute, "user:123", loadString)
// v0.3.x
conn, err := redcache.Open(
    rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
    redcache.WithLockTTL(5*time.Second),
    redcache.WithRefreshAfterFraction(0.8),
    redcache.WithRefreshWorkers(4),
    redcache.WithRefreshQueueSize(64),
)
if err != nil {
    return err
}
defer conn.Close()

cache := redcache.NewString[string](conn, redcache.StringCodec{})
val, err := cache.Get(ctx, time.Minute, "user:123", loadString)

NewPrimeableCacheAside goes away too. You use the same Cache[string, string] view for read-through and write-through methods:

// v0.2.x
client, err := redcache.NewPrimeableCacheAside(opt, redcache.CacheAsideOption{
    LockTTL: 5 * time.Second,
})
if err != nil {
    return err
}
defer client.Client().Close()

err = client.Set(ctx, time.Minute, "config:greeting", func(ctx context.Context, key string) (string, error) {
    return "hello", nil
})
// v0.3.x
conn, err := redcache.Open(opt, redcache.WithLockTTL(5*time.Second))
if err != nil {
    return err
}
defer conn.Close()

cache := redcache.NewString[string](conn, redcache.StringCodec{})
err = cache.Set(ctx, time.Minute, "config:greeting", func(ctx context.Context, key string) (string, error) {
    return "hello", nil
})
Migration checklist
  • Replace NewRedCacheAside / NewPrimeableCacheAside with Open, then derive one or more views with NewString, New, or NewBytes.
  • Keep old string-key integrations on redcache.NewString[string](conn, redcache.StringCodec{}). Move to New[K, V] and a KeyCodec[K] only when you want domain-typed keys.
  • Move lifecycle and raw Redis access to the Conn: cache.Close() / cache.Client() become conn.Close() / conn.Client(). A derived Cache[K, V] is operations-only and safe to inject into application code.
  • Replace CacheAsideOption{...} fields with functional options: LockTTL -> WithLockTTL, Logger -> WithLogger, Metrics -> WithMetrics, LockPrefix -> WithLockPrefix, RefreshLockPrefix -> WithRefreshLockPrefix, RefreshAfterFraction -> WithRefreshAfterFraction, RefreshBeta -> WithRefreshBeta, RefreshWorkers -> WithRefreshWorkers, RefreshQueueSize -> WithRefreshQueueSize, and ClientBuilder -> WithClientBuilder. WithRefreshTimeout is new; omit it to use the data ttl as the refresh callback budget, or set it explicitly for slower refresh functions.
  • If you implemented Metrics directly, add LoaderDuration, LoaderErrors, and RedisError. Implementations that embed NoopMetrics only need to override the methods they care about.
  • DelMulti and TouchMulti now take []K, matching GetMulti and SetMulti: change cache.DelMulti(ctx, "a", "b") to cache.DelMulti(ctx, []string{"a", "b"}).
  • Partial multi-key write errors are now *BatchKeyError[K]. For string-key caches, migrate var be *redcache.BatchError checks to var be *redcache.BatchKeyError[string].
  • TTL-bearing methods now reject ttl <= 0 with ErrInvalidTTL before touching Redis. Use Del / DelMulti to remove entries.
  • The Go floor is now 1.25.

Once the string-key migration compiles, you can opt into typed values by choosing a value codec, for example NewString[User](conn, redcache.JSONCodec[User]{}). That changes loaders from returning strings to returning User directly, but it is not required for a straight v0.2.x migration.

Quickstart

Open builds a Conn that owns a rueidis client; NewString[V] derives a Cache[string, V] view over it. Pair it with JSONCodec[V] to store JSON-encoded values. Get returns the cached value, calling your loader only on a miss — and only on one caller per key.

package main

import (
    "context"
    "log"
    "time"

    "github.com/redis/rueidis"

    "github.com/dcbickfo/redcache"
)

type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

func main() {
    conn, err := redcache.Open(
        rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
        redcache.WithLockTTL(5*time.Second),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    cache := redcache.NewString[User](conn, redcache.JSONCodec[User]{})

    ctx := context.Background()

    // Get-with-loader: fn runs only on a cache miss, and only on one caller
    // per key across all processes. The loader receives the key being missed.
    u, err := cache.Get(ctx, time.Minute, "u-123",
        func(ctx context.Context, key string) (User, error) {
            // load from the database / upstream service
            return User{ID: "u-123", Name: "alice"}, nil
        },
    )
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("user: %+v", u)
}

Absence semantics are the caller's to own — there is no ErrNotFound sentinel. Use a *T, sql.Null[T], or a domain sentinel inside V to cache "not found" and avoid cache penetration.

GetMulti follows the same pattern in batch: it returns the cached values and calls the loader once with just the missing keys.

users, err := cache.GetMulti(ctx, time.Minute, []string{"u-1", "u-2", "u-3"},
    func(ctx context.Context, missing []string) (map[string]User, error) {
        out := make(map[string]User, len(missing))
        // load the missing keys in one round trip
        for _, id := range missing {
            out[id] = User{ID: id}
        }
        return out, nil
    },
)

Peek is a read-only, client-side-cached lookup — no loader, no lock. It returns (value, true, nil) on a cached hit and (zero, false, nil) on a miss (or when the key currently holds a lock value), for warm-cache checks without populating:

u, ok, err := cache.Peek(ctx, time.Minute, "u-123")
// ok == false means not currently cached; Peek never runs your loader.

Typed keys

Derive a view with New[K, V] and a KeyCodec[K] to key the cache by a domain type. KeyCodecFunc[K] adapts a plain function into a KeyCodec[K].

type UserID int64

userIDCodec := redcache.KeyCodecFunc[UserID](func(id UserID) (string, error) {
    return fmt.Sprintf("user:%d", id), nil
})

conn, err := redcache.Open(
    rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
)
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

cache := redcache.New[UserID, User](conn, userIDCodec, redcache.JSONCodec[User]{})

u, err := cache.Get(ctx, time.Minute, UserID(123),
    func(ctx context.Context, id UserID) (User, error) {
        return loadUser(ctx, id)
    },
)

The key codec must be deterministic, concurrent-safe, produce a non-empty key, and encode distinct logical keys to distinct Redis keys. Multi-key operations reject typed-key collisions before touching Redis.

Raw bytes

NewBytes derives a zero-copy Cache[string, []byte] for opaque payloads — NewString[[]byte] preset with UnsafeBytesCodec. The decoded slice aliases the cache's borrowed read buffer; do not mutate or retain it past the callback. Copy it out if you need an owned value.

conn, err := redcache.Open(
    rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
)
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

cache := redcache.NewBytes(conn)

b, err := cache.Get(ctx, time.Minute, "blob:42",
    func(ctx context.Context, key string) ([]byte, error) {
        return loadBlob(ctx, key)
    },
)
// b aliases borrowed memory — copy it before retaining or mutating.

Codecs

A Codec[V] maps values to and from the stored envelope payload; a KeyCodec[K] maps typed keys to the Redis key string. Both must be concurrent-safe.

Codec For Allocation behavior
JSONCodec[V] any V (default) Encode/Decode via encoding/json; returns fresh, caller-owned copies — safe to retain.
StringCodec V = string Identity for immutable strings; cache fast paths may skip byte copies — safe to retain.
UnsafeBytesCodec V = []byte Zero-copy. The decoded slice aliases borrowed library memory; do not mutate or retain it past the call.
StringKeyCodec K = string Identity key codec; enables the K=string fast path.
KeyCodecFunc[K] any K Adapts func(K) (string, error) into a KeyCodec[K].

The allocation tradeoff is explicit: JSONCodec returns owned decoded values, and StringCodec returns immutable strings that are safe to retain while the cache may avoid extra byte copies on string-valued views. UnsafeBytesCodec skips the copy for throughput, but the []byte it returns borrows the cache's internal buffer — it is only valid for the duration of the call and must not be mutated. Likewise, a slice handed to Encode is given to the library and must not be mutated afterward. Choose the copying codecs unless you have measured a reason not to.

Decode failures on read are returned wrapped with redcache.ErrDecode (errors.Is-checkable). The library does not auto-evict on a decode failure; the caller decides whether to log, Del, or retry.

Options

Construction takes functional options. They are applied in order; later wins.

Option Default Description
WithLockTTL(d) 10s Bounds both how long a Redis lock survives and how long callers wait for one. Values below 100ms are rejected.
WithLogger(l) slog.Default() Logger for errors and debug output. Must be concurrent-safe.
WithMetrics(m) NoopMetrics{} Observability sink. Runs on the hot path; must be concurrent-safe.
WithLockPrefix(p) __redcache:lock: Prefix tagged onto in-Redis lock values so reads recognise a lock as a miss.
WithRefreshLockPrefix(p) __redcache:refresh: Prefix for refresh-ahead dedup keys.
WithRefreshAfterFraction(f) 0 (disabled) Enables refresh-ahead. Reads with low remaining TTL may trigger a background refresh. Must be in [0, 1).
WithRefreshBeta(b) 0 (XFetch off) Enables XFetch probabilistic sampling within the refresh window, weighted by recorded compute time. 1.0 matches canonical XFetch.
WithRefreshTimeout(d) data ttl Bounds how long a refresh-ahead callback may run. Decoupled from LockTTL.
WithRefreshWorkers(n) 4 Refresh worker pool size.
WithRefreshQueueSize(n) 64 Pending-refresh queue capacity; over-full drops silently and the stale value keeps serving.
WithClientBuilder(b) rueidis.NewClient Overrides how the internal client is built. A test seam (see Testing).

Refresh-ahead and XFetch

Set WithRefreshAfterFraction to enable background refreshes. When a Get/GetMulti returns a value whose remaining TTL has crossed the configured threshold, the stale value is returned immediately and a background worker repopulates the entry. Distributed and local dedup ensure only one refresh runs per key.

conn, err := redcache.Open(
    rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
    redcache.WithLockTTL(5*time.Second),
    redcache.WithRefreshAfterFraction(0.8), // refresh once 80% of TTL has elapsed
    redcache.WithRefreshTimeout(20*time.Second),
    redcache.WithRefreshWorkers(4),
    redcache.WithRefreshQueueSize(64),
)
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

cache := redcache.NewString[User](conn, redcache.JSONCodec[User]{})
XFetch probabilistic refresh

Set WithRefreshBeta(b) with b > 0 to add the XFetch sampling layer on top of the floor. Below the floor, each read fires a refresh with probability proportional to how close the value is to expiry, weighted by how long the previous value took to compute. Slow-to-compute values get more headroom and refresh earlier; cheap values defer until closer to expiry. This smooths refresh load instead of bunching it at the floor crossing. 1.0 matches the canonical XFetch beta (Vattani et al.); for multi-key writes the recorded compute time is divided evenly across the returned values. That is an approximation: if one key in a batch dominates loader time and per-key XFetch precision matters, split that workload into separate Get calls or smaller GetMulti groups.

Decoupled refresh budget

By default WithRefreshTimeout is the data ttl passed to Get/GetMulti, not LockTTL. This matters: the refresh callback's compute budget is independent of how long the lock is held. A refresh function that legitimately takes 20s is no longer silently cancelled by a 10s LockTTL. The refresh lock itself still uses LockTTL, and the back-write that records a slow-but-successful result is decoupled from the timeout so a successful-but-slow refresh keeps its write.

Value envelope and rollback

Values are stored with a small envelope (__redcache:v1:<delta_ns>:<payload>) capturing the compute time XFetch needs. Reads transparently unwrap it, and legacy un-enveloped values are served with delta=0, falling back to plain floor-based refresh.

Rollback warning: if a deployment writes values under the enveloped format and then rolls back to a pre-envelope release, those older clients will return the raw envelope string as the user value. Flush affected keys (or run a full cache invalidation) before rolling back.

Comparison to rueidisaside

rueidis already ships rueidisaside and rueidislock, and the core lock-and-wait stampede technique is shared between them and redcache: one caller wins a distributed lock, populates the key, and everyone else waits on the client-side-cache invalidation. If that single-key, single-value-type contract is all you need, rueidisaside is an excellent, well-maintained choice and you should reach for it first.

redcache adds, on top of that shared foundation:

  • GetMulti with cluster-slot batching — multi-key reads and writes grouped by Redis cluster slot and executed concurrently per slot.
  • Refresh-ahead + XFetch — probabilistic early refresh of stale-but-valid entries, decoupling reload latency from request latency.
  • Typed keys, not just values — a KeyCodec[K] maps a domain key type to the Redis key, and multi-key write failures come back as a typed, per-key *BatchKeyError[K].
  • Write-through primingSet / ForceSet / Touch (and multi variants) populate or extend entries without a read-through miss. This is the hardest piece for rueidisaside to absorb rather than just a missing feature: rueidisaside claims only missing keys, with a single per-client placeholder and no value backup, whereas Set overwrites an already-live value under a per-call lock token and restores the prior value if the write fails. In-place locking, per-call lock identity, and a backup/restore path are structural to redcache's model, not a flag on the read-miss-only one.
  • Conn + New — open one connection and derive sibling typed caches that share its client, engine, and invalidation stream, so you can cache multiple value types over a single connection.

This is an honest superset for those specific needs, not a claim that rueidisaside is deficient — it deliberately keeps a smaller surface.

Sharing one client across value types

Open a Conn once, then derive typed Cache[K, V] views over it with New (or NewString / NewBytes). Every view shares the Conn's rueidis client and invalidation stream, with its own key/value types and codecs. Use it to cache several value types over a single Redis connection instead of opening one connection per type. Lifecycle lives on the Conn: the views are operations-only (no Close / Client), so closing happens in exactly one place. Open the Conn, derive all the views you need, and close the Conn when done.

// One connection backs several typed views.
conn, err := redcache.Open(
    rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
)
if err != nil {
    log.Fatal(err)
}
defer conn.Close() // closes the shared client and all views

sessions := redcache.NewString[Session](conn, redcache.JSONCodec[Session]{})

orders := redcache.New[OrderID, Order](
    conn,
    orderIDCodec,
    redcache.JSONCodec[Order]{},
)

New (and NewString / NewBytes) does no I/O — it returns a Cache[K, V] with no error, and panics on a nil codec. The constructors are exactly Open (which builds the Conn and owns the client) plus the New / NewString / NewBytes derive-funcs; close the Conn to tear down the underlying client and every view derived from it.

Long-lived service caching multiple value types

For a service that caches several value types, open one Conn, derive a view per type, and inject the views. Because views are operations-only, the injected code can read and write the cache but cannot close the shared connection — lifecycle stays with whoever holds the Conn:

conn, err := redcache.Open(rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}})
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

users := redcache.NewString[User](conn, redcache.JSONCodec[User]{})
sessions := redcache.NewString[Session](conn, redcache.JSONCodec[Session]{})

// Inject `users` and `sessions` (operations-only Cache values) into services.
userSvc := NewUserService(users)
sessionSvc := NewSessionService(sessions)

Metrics

Implement Metrics, or embed NoopMetrics and override only the methods you care about, to wire counters into Prometheus, OpenTelemetry, or any other backend. Methods run on the hot path and must be concurrent-safe.

High-volume events (CacheHits, CacheMisses, LockContended, RefreshTriggered, RefreshSkipped, RefreshDropped) are aggregated per operation and emitted once with a count rather than once per key. LockWaitDuration fires once per resolved wait, and LoaderDuration once per foreground origin-loader call (Get/GetMulti miss, Set/SetMulti — background refresh excluded). LoaderErrors(n) fires when a foreground loader returns an error, with n the number of keys it was responsible for. RedisError(op) fires when a Redis command fails, tagged with op ("read", "lock", "set", "del", "touch"). Diagnostic events (LockLost, RefreshError, RefreshPanicked, InvalidationError) carry the affected key where applicable.

type myMetrics struct {
    redcache.NoopMetrics
    hits, misses atomic.Int64
}

func (m *myMetrics) CacheHits(n int64)   { m.hits.Add(n) }
func (m *myMetrics) CacheMisses(n int64) { m.misses.Add(n) }

conn, err := redcache.Open(
    rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
    redcache.WithMetrics(&myMetrics{}),
)
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

cache := redcache.NewString[User](conn, redcache.JSONCodec[User]{})
OpenTelemetry

For OpenTelemetry, use the redcacheotel subpackage — a drop-in Metrics adapter you import as github.com/dcbickfo/redcache/redcacheotel:

import "github.com/dcbickfo/redcache/redcacheotel"

m, err := redcacheotel.NewMetrics(meterProvider) // metric.MeterProvider (e.g. your own *sdkmetric.MeterProvider)
if err != nil {
    log.Fatal(err)
}
conn, err := redcache.Open(opt, redcache.WithMetrics(m))
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

cache := redcache.NewString[User](conn, redcache.JSONCodec[User]{})

It records counters (hits, misses, lock contention, refresh and error events) and histograms (lock.wait.duration, loader.duration, in seconds). High-cardinality keys are deliberately not attached as labels; RedisError's bounded op is.

Importing redcache's core does not pull OpenTelemetry into your binary (verified: zero otel symbols linked) — OTel is only compiled in if you import redcacheotel. It does appear in the module graph, since it lives in the main module.

Testing code that depends on redcache

Depend on the redcache.Cache[K, V] interface in your own code, not on a concrete type. Then in unit tests substitute the in-memory fake from the redcachetest subpackage — no Redis required:

import "github.com/dcbickfo/redcache/redcachetest"

func TestUserService(t *testing.T) {
    cache := redcachetest.New[string, User]() // satisfies redcache.Cache[string, User]
    svc := NewUserService(cache)
    // exercise svc; the fake calls your loader, stores results, and honours TTL.
}

redcachetest.New[K, V]() returns a *Fake[K, V] that satisfies redcache.Cache[K, V], backed by a map with TTL semantics. It validates the observable single-process contract — your loader runs once per miss, a present unexpired entry is a hit, TTLs expire — which is enough to test loaders, wiring, and call shape. For deterministic expiry tests, construct it with redcachetest.NewWithClock[K, V](clk) and advance a redcachetest.Clock by hand instead of sleeping.

What the fake does not model: distributed single-flight, the SET NX lock layer, client-side-cache invalidation pushes, the stored envelope, or refresh-ahead. Those only emerge against real Redis. For fuller fidelity, drive the real redcache.Cache against rueidis/mock via WithClientBuilder (note: miniredis cannot emulate RESP3 client-side invalidation, so it is unsuitable here).

Stampede mitigation without refresh-ahead

If you can't or don't want to enable refresh-ahead, the lock layer already prevents per-key thundering herd: only one caller per key runs the origin function while peers wait on the cached invalidation. The remaining stampede risk is cross-key simultaneous expiry — many keys SET in the same window (deploys, batch imports, cold-start backfill) all expire together.

This library deliberately does not jitter the ttl you pass to Get/GetMulti/Set/ForceSet: the contract is that you get the TTL you asked for. Jitter expiries at the call site instead, so callers retain control over the policy:

ttl := baseTTL + time.Duration(rand.Int64N(int64(baseTTL/10))) // ±10%
val, err := cache.Get(ctx, ttl, key, fetch)

For workloads that already use WithRefreshAfterFraction + WithRefreshBeta, XFetch handles this naturally — the probabilistic refresh window smears reload moments across the keyspace without changing observable TTLs.

Local Development

# Install pinned Go/golangci-lint versions via asdf
make setup

# Start Redis
docker compose up -d

# Run tests (requires Redis on localhost:6379)
make test-race

# Check coverage against the current baseline
make cover-check

# Lint
make lint

# Benchmarks
make bench

Documentation

Overview

Package redcache is a typed cache-aside for Redis, built on the rueidis client. It combines rueidis client-side caching with distributed SET NX locking so that, across every process, only one caller populates a missing key while the rest wait on the invalidation push for the populated value — a stampede-resistant cache behind a single generic Cache[K, V] interface.

How it works

A read registers an in-process lock entry, then issues a client-side-cached GET. That GET serves a dual purpose: it returns any cached value and subscribes the connection to Redis invalidation for the key. On a miss, one caller wins a distributed lock (SET NX with a UUIDv7 value), runs the loader, and atomically replaces the lock with the real value via a Lua CAS. Every other caller — in this process or another — waits for the resulting invalidation, with jittered polling as a fallback until the lock TTL, and then reads the populated value. In-process leader/follower coordination collapses a thundering herd on one key to a single Redis SET NX.

Choosing a constructor

Open one Conn (it owns the rueidis client and invalidation stream), then derive typed views over it that all share that single client:

  • NewString — Cache[string, V]: string keys, typed values. The common case.
  • NewBytes — Cache[string, []byte]: zero-copy opaque payloads.
  • New — Cache[K, V]: typed keys via a KeyCodec (and typed values).

The views are operations-only; lifecycle (Close) and the raw-client escape hatch (Client) live on the Conn. Deriving a view does no I/O, returns no error, and panics on a nil codec.

Minimal example

conn, err := redcache.Open(
	rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
)
if err != nil {
	return err
}
defer conn.Close()

cache := redcache.NewString[string](conn, redcache.StringCodec{})

v, err := cache.Get(ctx, time.Minute, "k", func(ctx context.Context, key string) (string, error) {
	return loadFromUpstream(ctx, key) // runs only on a miss, once per key
})

Beyond read-through

Every Cache also supports write-through priming — Set, ForceSet, and Touch (with Multi variants) — for populating or extending entries without a read-through miss. Opt into background refresh of stale-but-valid entries with WithRefreshAfterFraction (and WithRefreshBeta for XFetch-style probabilistic early expiration). Wire counters through WithMetrics.

Values, codecs, and absence

Values are stored through a Codec; keys through a KeyCodec. JSONCodec is the typical choice; StringCodec and UnsafeBytesCodec are identity codecs. The string path is safe to retain because strings are immutable; the bytes path is zero-copy and returns borrowed memory. Decode failures on read are wrapped with ErrDecode. Absence semantics are the caller's to own — there is no ErrNotFound sentinel; cache a *T, sql.Null[T], or a domain sentinel inside V to represent "not found" and avoid cache penetration.

Index

Examples

Constants

View Source
const (
	// DefaultLockPrefix tags in-Redis lock values so reads recognise a lock as
	// a miss.
	DefaultLockPrefix = "__redcache:lock:"
	// DefaultRefreshPrefix prefixes refresh-ahead dedup lock keys.
	DefaultRefreshPrefix = "__redcache:refresh:"
)

Variables

View Source
var ErrDecode = errors.New("decode failed")

ErrDecode is returned (wrapped) from typed reads when a stored value cannot be decoded. The library does not auto-evict — the caller decides whether to log, Del, or retry.

View Source
var ErrInvalidTTL = errors.New("redcache: ttl must be positive; use Del to remove")

ErrInvalidTTL is returned by TTL-bearing methods when ttl is not positive. Redis rejects PX 0; use Del to remove an entry.

View Source
var ErrLockLost = errors.New("lock was lost or expired before value could be set")

ErrLockLost is returned when the distributed lock was stolen or expired before the value could be written.

Functions

This section is empty.

Types

type BatchKeyError added in v0.3.0

type BatchKeyError[K comparable] struct {
	Failed    map[K]error
	Succeeded []K
}

BatchKeyError is the typed, key-preserving error returned via errors.As from the multi-key write methods (SetMulti, ForceSetMulti) on partial failure. K is the cache's key type. All accessors are nil-safe.

func (*BatchKeyError[K]) Error added in v0.3.0

func (e *BatchKeyError[K]) Error() string

Error formats the batch outcome with failed keys sorted by their %v rendering for stable output.

func (*BatchKeyError[K]) ErrorFor added in v0.3.0

func (e *BatchKeyError[K]) ErrorFor(k K) error

ErrorFor returns the error for k, or nil.

func (*BatchKeyError[K]) HasError added in v0.3.0

func (e *BatchKeyError[K]) HasError(k K) bool

HasError reports whether k failed.

func (*BatchKeyError[K]) HasFailures added in v0.3.0

func (e *BatchKeyError[K]) HasFailures() bool

HasFailures reports whether any key failed.

type Cache added in v0.3.0

type Cache[K comparable, V any] interface {
	// Get returns the cached value for k, calling fn on a miss. Only one caller
	// across all processes runs fn for a given key; the rest wait on the
	// resulting invalidation. Decode errors on read are wrapped with ErrDecode.
	Get(ctx context.Context, ttl time.Duration, k K, fn func(context.Context, K) (V, error)) (V, error)
	// GetMulti returns cached values for keys, calling fn for misses. SETs are
	// grouped by Redis cluster slot. A decode error aborts the batch (wrapped
	// with ErrDecode).
	GetMulti(ctx context.Context, ttl time.Duration, keys []K, fn func(context.Context, []K) (map[K]V, error)) (map[K]V, error)
	// Peek is a read-only, client-side-cached lookup with no loader and no lock.
	// It returns (value, true, nil) on a cached hit, (zero, false, nil) on a miss
	// or when the key currently holds a lock value, and (zero, false, err) on a
	// real Redis or decode error. ttl is the client-side-cache subscription TTL,
	// like Get.
	Peek(ctx context.Context, ttl time.Duration, k K) (V, bool, error)
	// Set populates k via fn under a write lock, writing the value to every
	// subscribed client. On callback error the prior value is restored.
	Set(ctx context.Context, ttl time.Duration, k K, fn func(context.Context, K) (V, error)) error
	// SetMulti populates keys via fn under write locks. Partial failures surface
	// as *BatchKeyError[K] via errors.As.
	SetMulti(ctx context.Context, ttl time.Duration, keys []K, fn func(context.Context, []K) (map[K]V, error)) error
	// ForceSet writes v unconditionally, bypassing locks. In-progress Get
	// callers on the same key retry transparently and observe the force-set
	// value; in-progress Set callers receive ErrLockLost and their pending set
	// is abandoned (not retried).
	ForceSet(ctx context.Context, ttl time.Duration, k K, v V) error
	// ForceSetMulti writes values unconditionally. Encode failures are collected
	// per-key; successfully-encoded entries are still written. Partial failures
	// surface as *BatchKeyError[K].
	ForceSetMulti(ctx context.Context, ttl time.Duration, values map[K]V) error
	// Del removes a key, triggering invalidation on all subscribed clients.
	Del(ctx context.Context, k K) error
	// DelMulti removes keys, triggering invalidation.
	DelMulti(ctx context.Context, keys []K) error
	// Touch sets the TTL of a cached value. No-ops on a missing key or lock
	// value. Clients caching the key are invalidated and re-fetch it (with the
	// new TTL) on their next read.
	Touch(ctx context.Context, ttl time.Duration, k K) error
	// TouchMulti extends the TTL of cached values, with the same invalidation
	// behavior as Touch.
	TouchMulti(ctx context.Context, ttl time.Duration, keys []K) error
}

Cache is the typed cache-aside surface: a generic interface over a key type K and value type V, and a pure operational handle. Read methods run the stampede-protected lock loop; write methods populate every subscribed client's cache. It carries no lifecycle or raw-client access — those live on the owning Conn (see Open/New) — so a Cache is safe to inject into code that should not be able to close the shared connection, and trivial to fake in tests.

func New added in v0.3.0

func New[K comparable, V any](c *Conn, keyCodec KeyCodec[K], valCodec Codec[V]) Cache[K, V]

New derives a typed Cache[K, V] view over c with its own key/value codecs. The view shares c's client and invalidation stream and is a pure operational handle — lifecycle (Close) and the raw-client escape hatch live on the Conn, not on the view, so a view is safe to hand to code that should not be able to tear the connection down. Deriving a view does no I/O and cannot fail; keyCodec and valCodec must be non-nil (passing nil panics — a programmer error).

Example

A Conn owns one Redis client and invalidation stream; New/NewString derive typed views over it — one client backing multiple value types.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/rueidis"

	"github.com/dcbickfo/redcache"
)

func main() {
	conn, err := redcache.Open(
		rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
		redcache.WithLockTTL(5*time.Second),
	)
	if err != nil {
		panic(err)
	}
	defer conn.Close() // closing the Conn closes the shared client (and all views)

	// users and loginCounts share one client, connection, and invalidation stream.
	users := redcache.NewString[string](conn, redcache.StringCodec{})
	loginCounts := redcache.New[string, int](conn, redcache.StringKeyCodec{}, redcache.JSONCodec[int]{})
	_ = users

	n, err := loginCounts.Get(context.Background(), time.Minute, "u-123",
		func(ctx context.Context, key string) (int, error) {
			return 7, nil
		},
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(n)
}
Example (TypedKeys)

New keys the cache by a domain type via a KeyCodec. KeyCodecFunc adapts a plain function into a KeyCodec.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/rueidis"

	"github.com/dcbickfo/redcache"
)

func main() {
	type UserID int64
	type User struct {
		ID   UserID
		Name string
	}

	userIDCodec := redcache.KeyCodecFunc[UserID](func(id UserID) (string, error) {
		return fmt.Sprintf("user:%d", id), nil
	})

	conn, err := redcache.Open(
		rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
		redcache.WithLockTTL(5*time.Second),
	)
	if err != nil {
		panic(err)
	}
	defer conn.Close()
	cache := redcache.New[UserID, User](conn, userIDCodec, redcache.JSONCodec[User]{})

	u, err := cache.Get(context.Background(), time.Minute, UserID(123),
		func(ctx context.Context, id UserID) (User, error) {
			// Load from your data source; runs only on a miss.
			return User{ID: id, Name: "alice"}, nil
		},
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(u.Name)
}

func NewBytes added in v0.3.0

func NewBytes(c *Conn) Cache[string, []byte]

NewBytes is NewString with UnsafeBytesCodec — a zero-copy raw []byte view. The decoded slice aliases borrowed memory; do not mutate or retain it.

func NewString added in v0.3.0

func NewString[V any](c *Conn, valCodec Codec[V]) Cache[string, V]

NewString is New with StringKeyCodec preset (enabling the K=string fast path).

Example
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/rueidis"

	"github.com/dcbickfo/redcache"
)

func main() {
	conn, err := redcache.Open(
		rueidis.ClientOption{
			InitAddress: []string{"127.0.0.1:6379"},
		},
		redcache.WithLockTTL(5*time.Second),
	)
	if err != nil {
		panic(err)
	}
	defer conn.Close()
	client := redcache.NewString[string](conn, redcache.StringCodec{})

	val, err := client.Get(context.Background(), time.Minute, "example:get", func(ctx context.Context, key string) (string, error) {
		// Called only on cache miss — fetch from your data source.
		return "hello", nil
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(val)
	// This example dials Redis, so it omits an Output: directive (which would
	// make `go test` run it). It is compiled to keep the snippet honest.
}
Example (GetMulti)
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/rueidis"

	"github.com/dcbickfo/redcache"
)

func main() {
	conn, err := redcache.Open(
		rueidis.ClientOption{
			InitAddress: []string{"127.0.0.1:6379"},
		},
		redcache.WithLockTTL(5*time.Second),
	)
	if err != nil {
		panic(err)
	}
	defer conn.Close()
	client := redcache.NewString[string](conn, redcache.StringCodec{})

	keys := []string{"example:multi:a", "example:multi:b"}
	vals, err := client.GetMulti(context.Background(), time.Minute, keys, func(ctx context.Context, keys []string) (map[string]string, error) {
		// Called only for keys not in cache — fetch from your data source.
		result := make(map[string]string, len(keys))
		for _, k := range keys {
			result[k] = "value-for-" + k
		}
		return result, nil
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(len(vals))
	// Redis-dependent; omits Output: so `go test` does not run it.
}

type Codec added in v0.3.0

type Codec[V any] interface {
	Encode(V) ([]byte, error)
	Decode([]byte) (V, error)
}

Codec encodes and decodes V into the envelope payload. Implementations must be concurrent-safe.

Ownership of return values:

  • Encode's returned slice is handed to the library and must not be mutated by the caller afterward. The library may alias it without copying, so the codec must not retain or later modify it either.
  • Decode's input slice is borrowed from library-internal memory and is only valid for the duration of the call; it must not be retained.

Identity codecs (UnsafeBytesCodec) alias this borrowed memory directly and so trade safety for zero copies — the decoded []byte must not outlive the call or be mutated. JSONCodec returns owned decoded values. StringCodec returns immutable strings that are safe to retain, and cache fast paths may avoid extra byte copies for string-valued views.

type Conn added in v0.3.0

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

Conn owns one rueidis client, its invalidation stream, and a lock namespace. Derive typed cache views over it with New, NewString, or NewBytes — they all share the single client and invalidation subscription. Lifecycle stays on the Conn: open it once, derive all the views you need, and close the Conn when done with all of them.

func Open added in v0.3.0

func Open(clientOption rueidis.ClientOption, opts ...Option) (*Conn, error)

Open builds a Conn with its own rueidis.Client (wired for invalidation). Derive typed views with New/NewString/NewBytes.

func (*Conn) Client added in v0.3.0

func (c *Conn) Client() rueidis.Client

Client returns the underlying rueidis.Client, shared by every view.

func (*Conn) Close added in v0.3.0

func (c *Conn) Close()

Close closes the underlying engine and client. Idempotent. Closes every view derived from this Conn too, since they share the client.

type JSONCodec added in v0.3.0

type JSONCodec[V any] struct{}

JSONCodec encodes V via encoding/json.

func (JSONCodec[V]) Decode added in v0.3.0

func (JSONCodec[V]) Decode(b []byte) (V, error)

Decode unmarshals b into V.

func (JSONCodec[V]) Encode added in v0.3.0

func (JSONCodec[V]) Encode(v V) ([]byte, error)

Encode marshals v to JSON.

type KeyCodec added in v0.3.0

type KeyCodec[K any] interface {
	EncodeKey(K) (string, error)
}

KeyCodec encodes a typed key K into the Redis key string. Must be deterministic, concurrent-safe, produce a non-empty key, and encode distinct logical keys to distinct Redis keys.

type KeyCodecFunc added in v0.3.0

type KeyCodecFunc[K any] func(K) (string, error)

KeyCodecFunc adapts a func to KeyCodec[K].

func (KeyCodecFunc[K]) EncodeKey added in v0.3.0

func (f KeyCodecFunc[K]) EncodeKey(k K) (string, error)

EncodeKey calls f.

type Logger added in v0.1.6

type Logger interface {
	Error(msg string, args ...any)
	Debug(msg string, args ...any)
}

Logger is the slog-shaped subset the cache calls into. *slog.Logger satisfies it.

type Metrics added in v0.1.7

type Metrics interface {
	CacheHits(n int64)
	CacheMisses(n int64)
	LockContended(n int64)
	// LockWaitDuration fires once per resolved wait, regardless of outcome
	// (invalidation, ctx cancel, or lockTTL). Typically histogrammed.
	LockWaitDuration(d time.Duration)
	// LoaderDuration measures foreground origin-loader latency (Get/GetMulti
	// miss, Set/SetMulti). Background refresh is excluded. Typically histogrammed.
	LoaderDuration(d time.Duration)
	// LoaderErrors fires when a foreground loader returns an error; n is the
	// number of keys the loader was responsible for (1 for single, len(missing)
	// for multi).
	LoaderErrors(n int64)
	// RedisError fires when a Redis command fails. op is one of "read", "lock",
	// "set", "del", "touch". Lock-lost, redis-nil, and script-drift parse
	// failures are not Redis errors and do not fire it.
	RedisError(op string)
	RefreshTriggered(n int64)
	// RefreshSkipped covers both local and distributed dedup.
	RefreshSkipped(n int64)
	// RefreshDropped fires when the refresh queue is full.
	RefreshDropped(n int64)
	// LockLost fires when a CAS detected a stolen lock (e.g. via ForceSet).
	LockLost(key string)
	RefreshError(key string)
	// RefreshPanicked fires once per key when a refresh callback panicked.
	RefreshPanicked(key string)
	// InvalidationError fires when an invalidation message couldn't be parsed.
	InvalidationError()
}

Metrics receives observability events. Methods run on the hot path and must be concurrent-safe. High-volume events (hits/misses/contention/refresh counts) are batched per operation and emitted once with n. Diagnostics (LockLost, RefreshError, RefreshPanicked) carry the affected key. Embed NoopMetrics to opt in to a subset.

type NoopMetrics added in v0.1.7

type NoopMetrics struct{}

NoopMetrics is the zero-value Metrics implementation. Embed and override.

Example

Pass a Metrics implementation via WithMetrics to count cache events. The cache calls these on the hot path, batched per operation. This example invokes them directly (no Redis needed) to show the counting shape.

package main

import (
	"fmt"
	"sync/atomic"

	"github.com/dcbickfo/redcache"
)

// countingMetrics shows the embed-and-override pattern: embed NoopMetrics and
// implement only the events you care about.
type countingMetrics struct {
	redcache.NoopMetrics
	hits, misses atomic.Int64
}

func (m *countingMetrics) CacheHits(n int64)   { m.hits.Add(n) }
func (m *countingMetrics) CacheMisses(n int64) { m.misses.Add(n) }

func main() {
	m := &countingMetrics{}
	// In real use: open a Conn and derive redcache.NewString[string](conn, codec) with redcache.WithMetrics(m) passed to Open.
	m.CacheHits(3)
	m.CacheMisses(1)
	fmt.Println(m.hits.Load(), m.misses.Load())
}
Output:
3 1

func (NoopMetrics) CacheHits added in v0.2.0

func (NoopMetrics) CacheHits(int64)

func (NoopMetrics) CacheMisses added in v0.2.0

func (NoopMetrics) CacheMisses(int64)

func (NoopMetrics) InvalidationError added in v0.1.7

func (NoopMetrics) InvalidationError()

func (NoopMetrics) LoaderDuration added in v0.3.0

func (NoopMetrics) LoaderDuration(time.Duration)

func (NoopMetrics) LoaderErrors added in v0.3.0

func (NoopMetrics) LoaderErrors(int64)

func (NoopMetrics) LockContended added in v0.1.7

func (NoopMetrics) LockContended(int64)

func (NoopMetrics) LockLost added in v0.1.7

func (NoopMetrics) LockLost(string)

func (NoopMetrics) LockWaitDuration added in v0.2.0

func (NoopMetrics) LockWaitDuration(time.Duration)

func (NoopMetrics) RedisError added in v0.3.0

func (NoopMetrics) RedisError(string)

func (NoopMetrics) RefreshDropped added in v0.1.7

func (NoopMetrics) RefreshDropped(int64)

func (NoopMetrics) RefreshError added in v0.1.7

func (NoopMetrics) RefreshError(string)

func (NoopMetrics) RefreshPanicked added in v0.1.7

func (NoopMetrics) RefreshPanicked(string)

func (NoopMetrics) RefreshSkipped added in v0.1.7

func (NoopMetrics) RefreshSkipped(int64)

func (NoopMetrics) RefreshTriggered added in v0.1.7

func (NoopMetrics) RefreshTriggered(int64)

type Option added in v0.3.0

type Option func(*config)

Option configures a cache. Options are applied in order; later wins.

func WithClientBuilder added in v0.3.0

func WithClientBuilder(b func(option rueidis.ClientOption) (rueidis.Client, error)) Option

WithClientBuilder overrides rueidis.NewClient when building the internal client. Useful as a test seam.

func WithLockPrefix added in v0.3.0

func WithLockPrefix(p string) Option

WithLockPrefix sets the in-Redis prefix tagged onto lock values so reads can recognise a lock as a miss. Defaults to DefaultLockPrefix.

func WithLockTTL added in v0.3.0

func WithLockTTL(d time.Duration) Option

WithLockTTL bounds both how long a Redis lock survives and how long callers wait for one. Defaults to 10s; values below 100ms are rejected.

func WithLogger added in v0.3.0

func WithLogger(l Logger) Option

WithLogger sets the logger. Defaults to slog.Default().

func WithMetrics added in v0.3.0

func WithMetrics(m Metrics) Option

WithMetrics sets the metrics sink. Defaults to NoopMetrics. Methods run on the hot path; impls must be concurrent-safe.

func WithRefreshAfterFraction added in v0.3.0

func WithRefreshAfterFraction(f float64) Option

WithRefreshAfterFraction enables refresh-ahead. Reads with remaining TTL below (1 - fraction) * ttl may trigger a background refresh while still returning the cached value. Must be in [0, 1); 0 disables.

func WithRefreshBeta added in v0.3.0

func WithRefreshBeta(b float64) Option

WithRefreshBeta enables XFetch-style probabilistic sampling within the refresh window, weighting by recorded compute time so slow values get more headroom. 0 (default) = always refresh below the floor; 1.0 matches canonical XFetch (Vattani et al). Multi-key writes record fn duration divided evenly across returned values.

func WithRefreshLockPrefix added in v0.3.0

func WithRefreshLockPrefix(p string) Option

WithRefreshLockPrefix sets the prefix for refresh-ahead dedup keys. Defaults to DefaultRefreshPrefix. Refresh lock keys embed the data key for uniqueness; refresh-ahead correctness does not require Redis cluster-slot co-location.

func WithRefreshQueueSize added in v0.3.0

func WithRefreshQueueSize(n int) Option

WithRefreshQueueSize bounds pending refresh jobs; over-full drops are counted via RefreshDropped. Defaults to 64.

func WithRefreshTimeout added in v0.3.0

func WithRefreshTimeout(d time.Duration) Option

WithRefreshTimeout bounds how long a refresh-ahead callback may run. Defaults to the data ttl passed to Get/GetMulti (not LockTTL). The refresh lock itself still uses LockTTL; the back-write that records a slow-but-successful result is decoupled from this timeout so it is not lost.

func WithRefreshWorkers added in v0.3.0

func WithRefreshWorkers(n int) Option

WithRefreshWorkers sets the refresh worker pool size. Defaults to 4.

type StringCodec added in v0.3.0

type StringCodec struct{}

StringCodec is the identity codec for string.

func (StringCodec) Decode added in v0.3.0

func (StringCodec) Decode(b []byte) (string, error)

Decode returns b as a string.

func (StringCodec) Encode added in v0.3.0

func (StringCodec) Encode(v string) ([]byte, error)

Encode returns v as bytes.

type StringKeyCodec added in v0.3.0

type StringKeyCodec struct{}

StringKeyCodec is the identity KeyCodec for string keys.

func (StringKeyCodec) EncodeKey added in v0.3.0

func (StringKeyCodec) EncodeKey(k string) (string, error)

EncodeKey returns k.

type UnsafeBytesCodec added in v0.3.0

type UnsafeBytesCodec struct{}

UnsafeBytesCodec is the zero-copy identity codec for []byte. It aliases library-internal memory in both directions: Encode hands its input straight to the library (which may alias it), and Decode returns a []byte backed by the cache's borrowed read buffer. The decoded slice must not be mutated or retained past the call. Use a copying codec if you need an owned value.

func (UnsafeBytesCodec) Decode added in v0.3.0

func (UnsafeBytesCodec) Decode(b []byte) ([]byte, error)

Decode returns b unchanged (no copy); the result aliases borrowed memory.

func (UnsafeBytesCodec) Encode added in v0.3.0

func (UnsafeBytesCodec) Encode(v []byte) ([]byte, error)

Encode returns v unchanged (no copy).

Directories

Path Synopsis
examples
metrics command
Package main demonstrates wiring a custom Metrics implementation.
Package main demonstrates wiring a custom Metrics implementation.
string-cache command
Package main demonstrates a migration-friendly string-key/string-value cache.
Package main demonstrates a migration-friendly string-key/string-value cache.
typed-cache command
Package main demonstrates typed keys and JSON-encoded values.
Package main demonstrates typed keys and JSON-encoded values.
internal
cmdx
Package cmdx provides Redis cluster slot calculation utilities.
Package cmdx provides Redis cluster slot calculation utilities.
lockpool
Package lockpool provides fast lock value generation using an atomic counter and a per-instance UUID prefix.
Package lockpool provides fast lock value generation using an atomic counter and a per-instance UUID prefix.
poolx
Package poolx provides typed sync.Pool wrappers for reusing slice headers across multi-key call paths.
Package poolx provides typed sync.Pool wrappers for reusing slice headers across multi-key call paths.
syncx
Package syncx provides generic typed wrappers around standard library sync primitives.
Package syncx provides generic typed wrappers around standard library sync primitives.
Package redcacheotel provides a drop-in OpenTelemetry adapter for the redcache.Metrics interface.
Package redcacheotel provides a drop-in OpenTelemetry adapter for the redcache.Metrics interface.
Package redcachetest provides an in-memory test double for redcache.Cache.
Package redcachetest provides an in-memory test double for redcache.Cache.

Jump to

Keyboard shortcuts

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