cascache

package module
v0.0.3 Latest Latest
Warning

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

Go to latest
Published: Aug 26, 2025 License: Apache-2.0 Imports: 9 Imported by: 0

README

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


Why CasCache

Use cascache when you need a cache that never serves stale reads after a write without manual “delete-then-set” gymnastics or race conditions.

  • CAS safety: Writers snapshot a per-key generation before the DB read. Cache writes commit only if the generation is unchanged.
  • Freshness: A cached value is returned only if its generation matches the current generation. Otherwise it self-heals (deleted + miss).
  • Bulk safety: Cache “sets” of keys. On read, validate every member’s generation; if any member is stale, the bulk is dropped and you fall back to safe singles.
  • Pluggable: Choose your value store (Ristretto / BigCache / Redis) and your codec (JSON / Msgpack / CBOR / Proto). Generations can be local (default) or shared (Redis) for multi-replica correctness.

Not a fit if “a little staleness is fine,” or if keys are write-hot enough that caching yields little benefit.

Before → After: the problem it solves

Before (plain cache, easy to go stale):
// 1) GET user -> miss -> read DB -> cache "user:42"
// 2) UPDATE user -> write DB -> (delete might be racing/forgotten)
// 3) GET right after -> stale value may still be served
After (cascache, no stale read):
// 1) GET user -> miss -> snapshot generation BEFORE DB read -> cache only if gen unchanged
// 2) UPDATE user -> Invalidate(key) (bump generation) -> any old entry becomes invalid
// 3) GET right after -> old entry fails validation and self-heals; fresh data is returned

Getting started

1) Build the cache
import (
	"context"
	"time"

	"github.com/unkn0wn-root/cascache"
	"github.com/unkn0wn-root/cascache/codec"
	rp "github.com/unkn0wn-root/cascache/provider/ristretto"
)

type User struct{ ID, Name string }

func newUserCache() (cascache.CAS[User], error) {
	rist, err := rp.New(rp.Config{
		NumCounters: 1_000_000,
		MaxCost:     64 << 20, // 64 MiB
		BufferItems: 64,
		Metrics:     false,
	})
	if err != nil { return nil, err }

	return cascache.New[User](cascache.Options[User]{
		Namespace:  "user",
		Provider:   rist,
		Codec:      codec.JSON[User]{},
		DefaultTTL: 5 * time.Minute,
		BulkTTL:    5 * time.Minute,
		// GenStore: nil -> Local (single-process) generations
	})
}
2) Safe single read (never stale)
type UserRepo struct {
	Cache cascache.CAS[User]
	// db handle...
}

func (r *UserRepo) GetByID(ctx context.Context, id string) (User, error) {
	if u, ok, _ := r.Cache.Get(ctx, id); ok {
		return u, nil
	}

	// CAS snapshot BEFORE reading DB
	obs := r.Cache.SnapshotGen(id)

	u, err := r.dbSelectUser(ctx, id) // your DB load
	if err != nil { return User{}, err }

	// Conditionally cache only if generation didn't move
	_ = r.Cache.SetWithGen(ctx, id, u, obs, 0)
	return u, nil
}
3) Mutations invalidate (one line)

Rule: after a successful DB write, call Invalidate(key) to bump the generation.

func (r *UserRepo) UpdateName(ctx context.Context, id, name string) error {
	if err := r.dbUpdateName(ctx, id, name); err != nil { return err }
	_ = r.Cache.Invalidate(ctx, id) // bump gen + clear single
	return nil
}
4) Optional bulk (safe set caching)

If any member is stale, the bulk is dropped and you fall back to singles. In multi-replica apps, use a shared GenStore (below) or disable bulk.

func (r *UserRepo) GetMany(ctx context.Context, ids []string) (map[string]User, error) {
	values, missing, _ := r.Cache.GetBulk(ctx, ids)
	if len(missing) == 0 {
		return values, nil
	}

	// Snapshot *before* DB read
	obs := r.Cache.SnapshotGens(missing)

	// Load missing from DB in one shot
	loaded, err := r.dbSelectUsers(ctx, missing)
	if err != nil { return nil, err }

	// Index for SetBulkWithGens
	items := make(map[string]User, len(loaded))
	for _, u := range loaded { items[u.ID] = u }

	// Conditionally write bulk (or it will seed singles if any gen moved)
	_ = r.Cache.SetBulkWithGens(ctx, items, obs, 0)

	// Merge and return
	for k, v := range items { values[k] = v }
	return values, nil
}

Distributed generations (multi-replica)

Local generations are correct within a single process. In multi-replica deployments, share generations to eliminate cross-node windows and keep bulks safe across nodes.

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"

func newUserCacheDistributed() (cascache.CAS[User], error) {
	rist, _ := rp.New(rp.Config{NumCounters:1_000_000, MaxCost:64<<20, BufferItems:64})
	rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
	gs  := cascache.NewRedisGenStoreWithTTL(rdb, "user", 90*24*time.Hour)

	return cascache.New[User](cascache.Options[User]{
		Namespace: "user",
		Provider:  rist,                      // or Redis/BigCache for values
		Codec:     codec.JSON[User]{},
		GenStore:  gs,                        // shared generations
		BulkTTL:   5 * time.Minute,
	})
}

If you can’t use a shared GenStore yet, set DisableBulk: true (or a tiny BulkTTL) when running multiple replicas. Singles remain safe and never stale.


Alternative type name: You can use cascache.Cache[V] instead of cascache.CAS[V]. See Cache Type alias.


Design

Read path (single)
      Get(k)
        │
  provider.Get("single:<ns>:"+k) ───► [wire.DecodeSingle]
        │
   gen == currentGen("single:<ns>:"+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:<ns>:hash(sorted(keys))")
Decode -> [(key,gen,payload)]*n
for each item: gen == currentGen("single:<ns>:"+key) ?
if all valid -> decode all, return
else         -> drop bulk, fall back to singles
Components
  • Provider - byte store with TTLs: Ristretto/BigCache/Redis.
  • Codec[V] - serialize/deserialize your V to/from []byte.
  • GenStore - per-key generation counter (Local by default; Redis available).
Keys
  • Single entry: single:<ns>:<key>
  • Bulk entry: bulk:<ns>:<first16(sha256(sorted(keys))>>
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.
  • BigCache: 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 JSON[V any] struct{}

You can drop in Msgpack/CBOR/Proto or decorators (compression/encryption). CAS is codec-agnostic.


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.
  • Allocations: zero-copy wire decode; one string 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
}
  • TTLs: DefaultTTL, BulkTTL. For BigCache, the global life window applies.
  • Cleanup (local gens): periodic prune by last bump time (default retention 30d).

Documentation

Overview

Package cascache implements a provider-agnostic cache with compare-and-swap (CAS) safety via per-key generations. Single-key reads never return stale values; bulk results are validated on read (per member) and rejected if any member is stale.

Components:

  • Provider: byte store with TTL (e.g. Ristretto, BigCache, Redis).
  • Codec[V]: (de)serializes V <-> []byte.
  • GenStore: generation counter per logical key. Local (in-process) by default, optional Redis implementation for multi-replica / restart persistence.

Keys:

single:<ns>:<key>  - single entries
bulk:<ns>:<hash>   - set-shaped entries (hash over sorted keys)

CAS pattern:

obs := cache.SnapshotGen(k) // before DB read
v   := readFromDB(k)
_   = cache.SetWithGen(ctx, k, v, obs, 0) // write iff current gen == obs

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type CAS

type CAS[V any] interface {
	Enabled() bool
	Close(context.Context) error

	// Single
	Get(ctx context.Context, key string) (v V, ok bool, err error)
	SetWithGen(ctx context.Context, key string, value V, observedGen uint64, ttl time.Duration) error
	Invalidate(ctx context.Context, key string) error

	// Bulk (order-agnostic return; use your own ordering by keys slice)
	GetBulk(ctx context.Context, keys []string) (values map[string]V, missing []string, err error)
	SetBulkWithGens(ctx context.Context, items map[string]V, observedGens map[string]uint64, ttl time.Duration) error

	// Generation snapshots (for CAS)
	SnapshotGen(key string) uint64
	SnapshotGens(keys []string) map[string]uint64
}

CAS is the high-level, provider-agnostic cache API with CAS safety via per-key generations. V is the caller's value type. Serialization is handled by a pluggable Codec[V].

func New

func New[V any](opts Options[V]) (CAS[V], error)

type Cache

type Cache[V any] = CAS[V] // just and alias -> cascache.Cache[User] or cascache.CAS[User]

type Fields

type Fields map[string]any

Fields is a minimal structured field map for logs.

type Logger

type Logger interface {
	Debug(msg string, f Fields)
	Info(msg string, f Fields)
	Warn(msg string, f Fields)
	Error(msg string, f Fields)
}

Logger is a tiny leveled logger. Provide an adapter around logging stack. If Logger is nil in Options, logging is disabled.

type NopLogger

type NopLogger struct{}

func (NopLogger) Debug

func (NopLogger) Debug(string, Fields)

func (NopLogger) Error

func (NopLogger) Error(string, Fields)

func (NopLogger) Info

func (NopLogger) Info(string, Fields)

func (NopLogger) Warn

func (NopLogger) Warn(string, Fields)

type Options

type Options[V any] struct {
	// Required
	Namespace string // logical namespace to avoid collisions. e.g. "user", "profile", "order"
	Provider  pr.Provider
	Codec     c.Codec[V]

	// Optional
	Logger          Logger        // if nil, NopLogger is used
	DefaultTTL      time.Duration // singles; 0 => 10m
	BulkTTL         time.Duration // bulks; 0 => 10m
	CleanupInterval time.Duration // 0 => 1h
	GenRetention    time.Duration // 0 => 30d
	Disabled        bool          // default false (enabled)
	ComputeSetCost  SetCostFunc   // default 1
	GenStore        gen.GenStore  // nil => LocalGenStore (in-process)
	DisableBulk     bool          // default false => bulk enabled
}

Options tune the behavior of the generic CAS cache. Only Namespace and Provider are required; others have sensible defaults.

type SetCostFunc

type SetCostFunc func(key string, raw []byte, isBulk bool, bulkCount int) int64

Directories

Path Synopsis
internal
wire
Package wire contains the compact, versioned on-the-wire format used by cascache to store values in the underlying Provider.
Package wire contains the compact, versioned on-the-wire format used by cascache to store values in the underlying Provider.
Package provider defines the storage abstraction used by cascache.
Package provider defines the storage abstraction used by cascache.

Jump to

Keyboard shortcuts

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