cascache
Provider-agnostic CAS like (Compare-And-Set or generation-guarded conditional set) cache with pluggable codecs and a pluggable generation store. Safe single-key reads (no stale values), optional bulk caching with read-side validation, and an opt‑in distributed mode for multi-replica deployments.
Contents
Overview
CAS safety: Writers snapshot a per-key generation before the DB read. Cache writes commit only if the generation is unchanged.
Writers snapshot a per-key before the DB read. Cache writes commit only if the generation is unchanged. Singles: Never return stale values; corrupt/type-mismatched entries self-heal.
Never return stale values; corrupt/type-mismatched entries self-heal. Bulk: Cache a set-shaped result. On read, validate every member’s generation. Reject the bulk if any member is stale.
Cache a set-shaped result. On read, validate every member’s generation. Reject the bulk if any member is stale. Composable: Plug any value provider (Ristretto/BigCache/Redis) and any codec (JSON/Msgpack/CBOR/Proto).
Plug any value provider (Ristretto/BigCache/Redis) and any codec (JSON/Msgpack/CBOR/Proto). Distributed Keep local generations (default) or plug a shared GenStore (e.g., Redis) for cross-replica correctness and warm restarts.
Read path (single)
Get(k) │ provider.Get("single::"+k) ───► [wire.DecodeSingle] │ gen == currentGen("single::"+k) ? │ yes no ▼ ┌───────────────┐ codec.Decode(payload) │ Del(entry) │ │ │ return miss │ return v └───────────────┘
Write path (single, CAS)
obs := SnapshotGen(k) // BEFORE DB read v := DB.Read(k) SetWithGen(k, v, obs) // write iff currentGen(k) == obs
Bulk read validation
GetBulk(keys) -> provider.Get("bulk::hash(sorted(keys))") Decode -> [(key,gen,payload)]*n for each item: gen == currentGen("single::"+key) ? if all valid -> decode all, return else -> drop bulk, fall back to singles
Quick start
import ( "context" "time" "github.com/unkn0wn-root/cascache" rp "github.com/unkn0wn-root/cascache/provider/ristretto" ) type User struct { ID , Name string } func buildCache () cascache. CAS [ User ] { // Value provider (in-process) rist , _ := rp . New (rp. Config { NumCounters : 1_000_000 , MaxCost : 64 << 20 , // 64 MiB BufferItems : 64 , Metrics : false , }) cc , _ := cascache. New [ User ](cascache. Options [ User ]{ Namespace : "user" , Provider : rist , Codec : cascache. JSONCodec [ User ]{}, DefaultTTL : 5 * time . Minute , BulkTTL : 5 * time . Minute , // GenStore: nil -> Local (default). See "Distributed generations". }) return cc } func readUser ( ctx context. Context , c cascache. CAS [ User ], id string ) ( User , bool ) { if u , ok , _ := c . Get ( ctx , id ); ok { return u , true } obs := c . SnapshotGen ( id ) // CAS snapshot BEFORE DB read u := loadFromDB ( id ) _ = c . SetWithGen ( ctx , id , u , obs , 0 ) return u , true }
Alternative type name: You can use cascache.Cache[V] instead of cascache.CAS[V] . See Cache Type alias.
Design
Components
Provider - byte store with TTLs: Ristretto/BigCache/Redis.
- byte store with TTLs: Ristretto/BigCache/Redis. Codec[V] - serialize/deserialize your V to/from []byte .
- serialize/deserialize your to/from . GenStore - per-key generation counter (Local by default; Redis available).
Keys
Single entry: single::
Bulk entry: bulk::>
CAS model
Per-key generation is monotonic .
. Reads validate only; no write amplification.
Mutations call Invalidate(key) → bump generation and delete the single entry.
Wire format
Small binary envelope before the codec payload. Big-endian integers. Magic "CASC" .
Single
+---------+---------+---------+---------------+---------------+-------------------+ | magic | version | kind | gen (u64) | vlen (u32) | payload (vlen) | | "CASC" | 0x01 | 0x01 | 8 bytes | 4 bytes | vlen bytes | +---------+---------+---------+---------------+---------------+-------------------+
Bulk
+---------+---------+---------+------------------------+ | magic | version | kind | n (u32) | +---------+---------+---------+------------------------+ repeated n times: +----------------+-----------------+----------------+-------------------+------------------+ | keyLen (u16) | key (keyLen) | gen (u64) | vlen (u32) | payload (vlen) | +----------------+-----------------+----------------+-------------------+------------------+
Decoders are zero-copy for payloads and keys (one string alloc per bulk item).
Providers
type Provider interface { Get ( ctx context. Context , key string ) ([] byte , bool , error ) Set ( ctx context. Context , key string , value [] byte , cost int64 , ttl time. Duration ) ( bool , error ) Del ( ctx context. Context , key string ) error Close ( ctx context. Context ) error }
Ristretto : in-process; per-entry TTL; cost-based eviction.
: in-process; per-entry TTL; cost-based eviction. BigCache : in-process; global life window; per-entry TTL ignored.
: in-process; global life window; per-entry TTL ignored. Redis: distributed (optional); per-entry TTL.
Use any provider for values. Generations can be local or distributed independently.
Codecs
type Codec [ V any ] interface { Encode ( V ) ([] byte , error ) Decode ([] byte ) ( V , error ) } type JSONCodec [ V any ] struct {}
You can drop in Msgpack/CBOR/Proto or decorators (compression/encryption). CAS is codec-agnostic.
Distributed generations
Local generations are correct for singles but bulk validation can be stale across replicas. Use a shared GenStore to eliminate this window and survive restarts.
Important: LocalGenStore is single-process only. In multi-replica setups, both singles and bulks can be stale on nodes that haven’t observed the bump. Use a shared GenStore (e.g., Redis) for cross-replica correctness or run a single instance.
import "github.com/redis/go-redis/v9" rdb := redis . NewClient ( & redis. Options { Addr : "127.0.0.1:6379" }) gs := cascache . NewRedisGenStore ( rdb , "user" ) // namespace should match Options.Namespace // or, with TTL: // gs := gen.NewRedisGenStoreWithTTL(rdb, "user", 90*24*time.Hour) // with TTL to prevent growth cache , _ := cascache. New [ User ](cascache. Options [ User ]{ Namespace : "user" , Provider : ristrettoProvider , // or Redis, BigCache Codec : cascache. JSONCodec [ User ]{}, GenStore : gs , // shared generations })
Behavior
Singles: never stale (same as local).
Bulks: validated against shared generations across replicas.
Restarts: generations persist; valid entries remain valid.
If you do not use a distributed GenStore in a multi-replica deployment, set Options.DisableBulk = true (or use a very short BulkTTL). Singles remain safe: they never return stale data.
API
type CAS [ V any ] interface { Enabled () bool Close (context. Context ) error // Single Get ( ctx context. Context , key string ) ( V , bool , error ) SetWithGen ( ctx context. Context , key string , value V , observedGen uint64 , ttl time. Duration ) error Invalidate ( ctx context. Context , key string ) error // Bulk GetBulk ( ctx context. Context , keys [] string ) ( map [ string ] V , [] string , error ) SetBulkWithGens ( ctx context. Context , items map [ string ] V , observedGens map [ string ] uint64 , ttl time. Duration ) error // Generations SnapshotGen ( key string ) uint64 SnapshotGens ( keys [] string ) map [ string ] uint64 }
Cache Type Alias
For readability, we provide a type alias:
type Cache [ V any ] = CAS [ V ]
You may use either name. They are identical types. Example:
var a cascache. CAS [ User ] var b cascache. Cache [ User ] a = b // ok b = a // ok
In examples we often use CAS to emphasize the CAS semantics, but Cache is equally valid and may read more naturally in your codebase.
Performance notes
Time: O(1) singles; O(n) bulk for n members.
O(1) singles; O(n) bulk for n members. Allocations: zero-copy wire decode; one string alloc per bulk item.
zero-copy wire decode; one alloc per bulk item. Ristretto cost hint: evict bulks first under pressure.
ComputeSetCost: func ( key string , raw [] byte , isBulk bool , n int ) int64 { if isBulk { return int64 ( n ) } return 1 }