Warning
This README documents v2, which is a complete rewrite of CasCache.
It is a breaking change to the public API and should be treated as a migration, not a drop-in upgrade from v1.
If you are running v1 today, stay on v1 until you have planned, tested, and validated the move to v2.
cascache is a cache library for applications/backends where invalidation is part of correctness, not just best-effort cleanup.
Most caches are fine at storing values, but they do not solve one of the hardest cache bugs: a request can read old data, another request can update the source of truth and invalidate the cache, and then the first request can arrive late and put the old value back.
The usual race usually looks like this:
- one request reads old data from the database
- another request updates the database and invalidates the cache
- the first request finishes later and writes its old data back into the cache
That race is easy to miss, and a plain DEL followed by a later SET does not prevent it. The cache has no memory that the key was invalidated after the stale reader started.
CasCache is built around one idea: every logical key has a version. Readers return a cached value only if the stored version still matches the current one. Writers snapshot the version before reading the source of truth, then store only if that version is still current. After a successful write to the source of truth, the caller must successfully run Invalidate; once that succeeds, older snapshots lose automatically.
The effect of this is:
- after a successful invalidate, single-key reads do not serve cached values from an older version
- stale writers do not put old data back into the cache
- version-store trouble degrades to misses or skipped writes instead of "maybe stale"
- TTL stays an eviction policy, not the thing that makes freshness safe
Important
v2 requires a migration from v1.
Do not treat this as a drop-in upgrade.
Update caller code to the v2 API and validate.
v2 is not a compatibility release. The cache internals, naming, Redis integration shape and the API were completely redesigned.
If you are coming from v1, assume you will need to update code, tests, and rollout plans.
What to do:
- Keep pinned to
v1until your migration is ready. - In
v2, the recommended Redis entry point iscascache/redis.New(...), whilecascache/redis.NewGenStore(...)is for shared-generation setups where values stay outside Redis. - Rewrite cache-backed read and write paths around the
v2CAS flow:SnapshotVersion-> read source of truth ->SetIfVersion->Invalidateafter successful writes. - Update multi-key call sites to the
v2:GetMany,SnapshotVersions, andSetIfVersions. - Revisit any direct Redis wiring. In
v2, Redis-specific pieces live undercascache/redis.
CasCache exists for systems where "usually fresh" is not good enough. That includes data such as permissions, profiles, pricing, feature flags, inventory, or any other record where a successful write followed by successful invalidate should take effect immediately from the cache's point of view.
Common cache patterns still leave a stale-data window:
| Pattern | What you do | What still goes wrong |
|---|---|---|
| TTL only | store user:42 for 5 minutes |
readers can see stale data until the TTL expires |
| delete then set | DEL user:42, later SET user:42 |
a slower request can repopulate the cache with an older snapshot |
| write-through | update the database, then update the cache | concurrent readers still need perfect coordination to avoid serving old data |
| version inside the value | store {version, payload} together |
you still need a trusted current version somewhere else |
CasCache changes the contract. Instead of hoping invalidation reaches every writer in time, it validates freshness on read and requires writes to prove they observed the current version before they store anything.
Use it when:
- stale data after invalidation is unacceptable
- several API nodes can race on the same records
- you prefer a cache miss over serving a value that might be stale
- you want local in-memory caches on each node, but shared freshness decisions across nodes
The mental model is:
DB write succeeds -> Invalidate(key)
Cache miss path -> SnapshotVersion -> load source of truth -> SetIfVersion
Cache hit path -> Get validates stored version against the current version
Batch hit path -> GetMany validates each requested member or falls back to singles
Multi-node setup -> share versions in Redis, or keep both values and versions in Redis
CasCache is built around two rules:
- On a cache miss, snapshot the current version before reading the source of truth, then store the value only if that version is still current.
- After a successful write to the source of truth, invalidate the key.
Read-miss fill:
version, err := cache.SnapshotVersion(ctx, key)
if err != nil {
return err
}
value, err := loadFromDB(ctx, key)
if err != nil {
return err
}
_, _ = cache.SetIfVersion(ctx, key, value, version, 0)If another request invalidates the key between SnapshotVersion and SetIfVersion, the write is skipped instead of restoring a stale value.
Write path:
if err := writeToDB(ctx, key, updatedValue); err != nil {
return err
}
return cache.Invalidate(ctx, key)Most confusion looking at this library can come from the Redis related constructors. The short rule is:
- if you are unsure, and especially in multi-pod/container environments, use
cascache/redis.New(...) - use
cascache/redis.NewGenStore(...)only when values should stay outside Redis - treat
cascache/redis.NewProvider(...)andcascache/redis.NewKeyMutator(...)as advanced composition APIs
CasCache supports three real deployment shapes, and choosing the right one matters more than any individual option:
| Use this | Choose it when | What it means |
|---|---|---|
cascache.New(...) |
your cache is local to one process, or each node keeps its own cache and cross-node invalidation is handled elsewhere | values live in your chosen provider, versions live in the built-in local store |
cascache.New(...) + cascache/redis.NewGenStore(...) |
values should stay in a non-Redis provider such as Ristretto or BigCache, but versions must be shared across processes | Redis stores versions only; values still live in your provider |
cascache/redis.New(...) |
both values and versions should live in Redis | the package wires the Redis provider, the Redis version store, and the Redis-native single-key mutation path together |
The important distinction is this:
cascache/redis.NewGenStore(...)gives you shared versions.cascache/redis.New(...)gives you shared versions and Redis native, single-key, atomic "compare-and-write" / invalidate.
If both values and versions are in Redis, prefer cascache/redis.New(...).
If values are not in Redis, cascache/redis.NewGenStore(...) is the right tool. It keeps versions shared across nodes, but it cannot make a write atomic across Redis and a separate value store. Safety still comes from version checks on read and conditional writes, not from one cross-system transaction.
cascache/redis.NewProvider(...) is not the normal starting point. Use it only if you are intentionally composing cascache.New(...) by hand around a custom topology, for example:
- values in Redis, but generations in some non-Redis
GenStore - values in Redis, but single-key mutation is handled by custom code
- migration or framework code that needs the Redis pieces separately
If you manually combine cascache/redis.NewProvider(...) and cascache/redis.NewGenStore(...) with cascache.New(...), the cache still works correctly, but you do not get the Redis-native single-key atomic path unless you also provide cascache/redis.NewKeyMutator(...) as both KeyWriter and KeyInvalidator. In practice, most callers should use cascache/redis.New(...) instead of wiring Redis pieces by hand.
Use plain cascache.New(...) when:
- one process owns the cache
- each process can safely keep its own cache state
- you want an in-memory value store such as Ristretto or BigCache
This is the simplest setup. It is strict within the process, but it is not a distributed invalidation system by itself.
Use cascache/redis.NewGenStore(...) with cascache.New(...) when:
- several processes need to agree on whether cached values are still fresh
- values should remain in a non-Redis store
- you want distributed invalidation without moving the whole cache into Redis
This is a good fit for per-node caches backed by Ristretto or BigCache. Each node keeps its own values, but all nodes consult the same version store.
Choose this over cascache/redis.New(...) when:
- you want hot reads to stay in local process memory
- you want to reduce Redis memory use by keeping only versions there
- you want per-node caches with shared invalidation, not one shared Redis value store
What you get:
- shared freshness decisions across processes
- safe read validation against Redis-backed versions
- generic cache behavior with any provider
What you do not get:
- one atomic operation across Redis and a separate value store
Use cascache/redis.New(...) when:
- Redis is your value store
- you want one shared cache across processes
- you want single-key
SetIfVersionandInvalidateto happen inside Redis
This constructor exists for more than convenience. It wires the Redis-native single-key CAS implementation and keeps the single value key and single version key in the same Redis Cluster slot.
What you get:
- shared values
- shared versions
- atomic single-key compare-and-write in Redis
- atomic single-key invalidate in Redis
Batch entries are still validated on read. CasCache does not try to make arbitrary multi-key batch writes globally atomic.
This should be treated as the default Redis entry point. If you are asking "I want CasCache with Redis, which constructor should I use?", this is usually the answer.
Use cascache/redis.NewProvider(...), cascache/redis.NewGenStore(...), and cascache/redis.NewKeyMutator(...) directly only when you are intentionally assembling a custom topology with cascache.New(...).
That is an advanced path. Most application code should not start there.
package main
import (
"time"
"github.com/unkn0wn-root/cascache"
"github.com/unkn0wn-root/cascache/codec"
ristrettoprovider "github.com/unkn0wn-root/cascache/provider/ristretto"
)
type User struct {
ID string
Name string
}
func newUserCache() (cascache.CAS[User], error) {
provider, err := ristrettoprovider.New(ristrettoprovider.Config{
NumCounters: 1_000_000,
MaxCost: 64 << 20,
BufferItems: 64,
})
if err != nil {
return nil, err
}
return cascache.New(cascache.Options[User]{
Namespace: "user",
Provider: provider,
Codec: codec.JSON[User]{},
DefaultTTL: 5 * time.Minute,
BatchTTL: 5 * time.Minute,
})
}type UserRepo struct {
Cache cascache.CAS[User]
}
func (r *UserRepo) GetByID(ctx context.Context, id string) (User, error) {
if cached, ok, err := r.Cache.Get(ctx, id); err == nil && ok {
return cached, nil
}
version, err := r.Cache.SnapshotVersion(ctx, id)
if err != nil {
return User{}, err
}
user, err := r.dbSelectUser(ctx, id)
if err != nil {
return User{}, err
}
_, _ = r.Cache.SetIfVersion(ctx, id, user, version, 0)
return user, nil
}
func (r *UserRepo) UpdateName(ctx context.Context, id, name string) error {
if err := r.dbUpdateName(ctx, id, name); err != nil {
return err
}
return r.Cache.Invalidate(ctx, id)
}This keeps values in an in-memory provider but stores versions in Redis so all nodes agree on invalidation.
package main
import (
"time"
"github.com/redis/go-redis/v9"
"github.com/unkn0wn-root/cascache"
"github.com/unkn0wn-root/cascache/codec"
ristrettoprovider "github.com/unkn0wn-root/cascache/provider/ristretto"
cascacheredis "github.com/unkn0wn-root/cascache/redis"
)
func newSharedVersionCache() (cascache.CAS[User], error) {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
})
genStore, err := cascacheredis.NewGenStore(client)
if err != nil {
return nil, err
}
provider, err := ristrettoprovider.New(ristrettoprovider.Config{
NumCounters: 1_000_000,
MaxCost: 64 << 20,
BufferItems: 64,
})
if err != nil {
return nil, err
}
return cascache.New(cascache.Options[User]{
Namespace: "user",
Provider: provider,
Codec: codec.JSON[User]{},
GenStore: genStore,
DefaultTTL: 5 * time.Minute,
BatchTTL: 5 * time.Minute,
})
}In this setup, you still own the Redis client lifecycle because the genstore leaves shared clients open by default.
This stores both values and versions in Redis and enables the Redis-native single-key CAS path.
package main
import (
"time"
"github.com/redis/go-redis/v9"
"github.com/unkn0wn-root/cascache"
"github.com/unkn0wn-root/cascache/codec"
cascacheredis "github.com/unkn0wn-root/cascache/redis"
)
func newRedisUserCache() (cascache.CAS[User], error) {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
})
return cascacheredis.New(cascacheredis.Options[User]{
Namespace: "user",
Client: client,
Codec: codec.JSON[User]{},
DefaultTTL: 5 * time.Minute,
BatchTTL: 5 * time.Minute,
CloseClient: true,
})
}Batch support is for validated set caching.
GetMany reads one stored batch blob, checks every requested member against the current versions, and serves only the members that are still valid. If the batch entry is stale, corrupt, or missing members, CasCache drops it and falls back to single-key reads.
SetIfVersions writes one batch blob when every observed version still matches. A successful batch write may also seed single-key entries, depending on BatchWriteSeed.
Batch is intentionally conservative:
- it is not globally atomic across keys
- it is validated on read
- successful batch hits are read-only by default
Example:
func (r *UserRepo) GetMany(ctx context.Context, ids []string) (map[string]User, error) {
values, missing, err := r.Cache.GetMany(ctx, ids)
if err != nil {
return nil, err
}
if len(missing) == 0 {
return values, nil
}
versions, err := r.Cache.SnapshotVersions(ctx, missing)
if err != nil {
return nil, err
}
loaded, err := r.dbSelectUsers(ctx, missing)
if err != nil {
return nil, err
}
items := make([]cascache.VersionedValue[User], 0, len(loaded))
for _, user := range loaded {
items = append(items, cascache.VersionedValue[User]{
Key: user.ID,
Value: user,
Version: versions[user.ID],
})
values[user.ID] = user
}
_, _ = r.Cache.SetIfVersions(ctx, items, 0)
return values, nil
}ReadGuard and BatchReadGuard let you add one more check after decode and version validation.
Use them when version matching alone is not enough. Examples include:
- a database row version column
- a soft-delete flag
- a short-lived business rule that must be checked against the source of truth
If a guard rejects a value, CasCache deletes that cache entry and treats it as a miss.
- Outside the full Redis topology, single-key writes are conditional but not performed as one atomic operation with the version store. Safety comes from version checks on read and conditional store-on-match behavior, not from one cross-system transaction.
Getdoes not return a single-key value whose stored version is no longer current.SetIfVersionstores only when the observed version still matches.Invalidatereturns an error if the version bump fails.GetManyvalidates requested members against current versions before serving them.- built-in strict stores do not recycle versions back to zero
type CAS[V any] interface {
Enabled() bool
Close(context.Context) error
Get(ctx context.Context, key string) (V, bool, error)
SnapshotVersion(ctx context.Context, key string) (Version, error)
SetIfVersion(ctx context.Context, key string, value V, version Version, ttl time.Duration) (WriteResult, error)
Invalidate(ctx context.Context, key string) error
GetMany(ctx context.Context, keys []string) (map[string]V, []string, error)
SnapshotVersions(ctx context.Context, keys []string) (map[string]Version, error)
SetIfVersions(ctx context.Context, items []VersionedValue[V], ttl time.Duration) (BatchWriteResult, error)
}