SkillAgentSearch skills...

Cascache

Generation-based CAS cache for Go with read-time validation, validated batch entries, pluggable providers/codecs, and shared invalidation via GenStore.

Install / Use

/learn @unkn0wn-root/Cascache
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

CasCache

[!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:

  1. one request reads old data from the database
  2. another request updates the database and invalidates the cache
  3. 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

Moving from v1 to v2

[!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:

  1. Keep pinned to v1 until your migration is ready.
  2. In v2, the recommended Redis entry point is cascache/redis.New(...), while cascache/redis.NewGenStore(...) is for shared-generation setups where values stay outside Redis.
  3. Rewrite cache-backed read and write paths around the v2 CAS flow: SnapshotVersion -> read source of truth -> SetIfVersion -> Invalidate after successful writes.
  4. Update multi-key call sites to the v2: GetMany, SnapshotVersions, and SetIfVersions.
  5. Revisit any direct Redis wiring. In v2, Redis-specific pieces live under cascache/redis.

Why CasCache

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

Core idea

CasCache is built around two rules:

  1. 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.
  2. 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)

Choosing the right topology

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(...) and cascache/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.

1. Local versions

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.

2. Shared versions in Redis

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

3. Preferred Redis setup

Use cascache/redis.New(...) when:

  • Redis is your value store
  • you want one shared cache across processes
  • you want single-key SetIfVersion and Invalidate to 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
View on GitHub
GitHub Stars60
CategoryDevelopment
Updated3d ago
Forks2

Languages

Go

Security Score

85/100

Audited on Mar 25, 2026

No findings