entcache

package module
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Jul 30, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

README

entcache - Distributed Cache Driver for Ent ORM

Go Reference License

entcache is a production-ready, modular cache driver for ent, the popular Go ORM framework. It drastically reduces database load by intercepting queries, caching results, and offering advanced distributed caching techniques like Cache Stampede Protection (Dogpile Effect prevention), Native RESP3 Client-Side Caching, and Mutation-Aware Cache Invalidation.

Whether you're building a GraphQL server needing a Context caching (GraphQL DataLoader pattern) or a massive microservices architecture demanding Multi-level caching and Distributed caching with Redis (go-redis), Rueidis, or NATS JetStream KV, entcache has you covered.

Core Features

  • Distributed Cache Stampede Protection (Dogpile Effect prevention): When multiple concurrent requests experience a cache miss for the same key, exactly one request becomes the "winner" and fetches the data from the database. The remaining "waiters" block seamlessly.
  • Deadlock-Free Background Heartbeats: The "winner" maintains a background heartbeat (keepalive) while the DB query executes. If the winner crashes, the lock natively expires, and a waiter automatically takes over.
  • Real-Time Cross-Node Cache Invalidation: Waiters do not poll blindly. They utilize native Push/Watch capabilities (like NATS Watch) to receive an immediate event when the winner finishes, allowing them to resume instantaneously.
  • Native RESP3 Client-Side Caching: rueidiscache leverages Rueidis RESP3 Client-Side Caching out of the box (in-memory client cache with server-driven invalidation) with no extra LRU required.
  • Accurate Payload TTLs: Uses a strict 2-key architecture to isolate the stampede lock from the actual payload data, ensuring that caching entries retain exactly the user-defined TTL.
  • Multi-Level Caching: Hierarchical cache structure (e.g. L1 LRU memory cache + L2 remote Redis or NATS JetStream KV store) for optimal latency and durability in Go ORM caching.
  • Mutation-Aware Cache Invalidation: ent hooks automatically invalidate stale cache entries when entity mutations (create, update, delete) occur.
  • Zero Bloat Modular Cache Driver: Clean modular sub-packages for lrucache, natscache, rediscache, and rueidiscache.

Installation

Install core entcache along with your preferred cache backend sub-package:

# Core entcache package
go get github.com/incroy/entcache

# Optional modular backends
go get github.com/incroy/entcache/contextcache # Request-scoped in-memory cache
go get github.com/incroy/entcache/lrucache      # Hashicorp LRU v2
go get github.com/incroy/entcache/natscache     # NATS JetStream KV
go get github.com/incroy/entcache/rediscache    # go-redis/v9
go get github.com/incroy/entcache/rueidiscache  # Rueidis (Native RESP3 Client-Side Caching)

High Level Design

On a high level, entcache.Driver decorates the Query method of the given driver, and for each call, generates a cache key (i.e. hash) from its arguments (statement and query parameters). After the query is executed, the driver records the raw values of the returned rows (sql.Rows) and stores them in the cache store with the generated cache key. Subsequent identical queries replay the recorded rows directly from cache without hitting the database, provided the entry has not expired or been evicted.

entcache provides three main caching architectures to optimize your application:

1. Context-Level Cache

Scoped to a single context.Context (e.g. *http.Request). The context carries an in-memory cache to eliminate duplicate database queries executed during the same request lifecycle.

context-level-cache

drv := entcache.NewDriver(sqlDrv, entcache.ContextLevel())
client := ent.NewClient(ent.Driver(drv))

// Wrap the request context
ctx = entcache.NewContext(ctx)
Solving the GraphQL N+1 Problem (DataLoader Pattern)

When building a GraphQL server, you often face the classic N+1 Problem. A naive resolver executes 1 query to fetch $N$ users, $N$ queries to fetch their todos, and another $N$ queries to fetch the owners of those todos.

query($ids: [ID!]!) {
    nodes(ids: $ids) {
        ... on User {
            id
            name
            todos {
                id
                owner {
                    id
                    name
                }
            }
        }
    }
}

The Ent ORM optimizes this by batching execution into 3 queries. With entcache's Context caching (GraphQL DataLoader pattern), the number of queries is further reduced from 3 to 2, because fetching users (Query 1) and fetching todo owners (Query 3) execute identical SQL statements. Query 3 is served directly from the context cache in-memory!

srv.AroundResponses(func(ctx context.Context, next graphql.ResponseHandler) *graphql.Response {
    if op := graphql.GetOperationContext(ctx).Operation; op != nil && op.Operation == ast.Query {
        // Initialize the Context Cache for this specific GraphQL Request
        ctx = entcache.NewContext(ctx)
    }
    return next(ctx)
})
2. Driver-Level Cache

Embedded directly in ent.Client via entcache.NewDriver. The driver-level cache is process-scoped and shared across all goroutines in the application process.

driver-level-cache

Hashicorp LRU (lrucache)

Thread-safe in-process LRU cache powered by github.com/hashicorp/golang-lru/v2. Fast, zero-network-overhead.

import "github.com/incroy/entcache/lrucache"

drv := entcache.NewDriver(sqlDrv,
    entcache.TTL(time.Minute),
    entcache.Levels(lrucache.MustNew(1000)),
)
client := ent.NewClient(ent.Driver(drv))
3. Multi-Level Cache

A cache hierarchy structures cache stores by access speed and capacity (e.g. L1 in-memory + L2 remote). Lookups cascade down the hierarchy: L1 → L2 → Database.

Entcache supports a variety of backend adapters. Below are the supported cache architectures and their features:

Rueidis (rueidiscache)

High-performance Redis cache powered by Rueidis (github.com/incroy/entcache/rueidiscache).

Note: You do not need to pair rueidiscache with lrucache. Rueidis natively supports RESP3 Client-Side Caching, meaning it automatically maintains an in-memory cache on the client-side and receives server-assisted invalidation pushes transparently!

Features:

  • Native RESP3 Client-Side caching (no explicit LRU needed).
  • Distributed Stampede Protection using zero-network-call wait locks (via DoCache).
  • Automatic Server-Crash recovery mechanisms.
import (
    "github.com/redis/rueidis"
    "github.com/incroy/entcache/rueidiscache"
)

c, _ := rueidis.NewClient(rueidis.ClientOption{
    InitAddress: []string{"127.0.0.1:6379"},
})

// Native Client-Side Caching is active out of the box!
// Lookups: Rueidis In-Memory Map -> Redis Server -> Database
drv := entcache.NewDriver(
    sqlDrv,
    entcache.TTL(time.Minute),
    entcache.Levels(
        rueidiscache.New(c), // Acts as both L1 & L2 seamlessly
    ),
)
Redis (rediscache / go-redis)

Standard remote cache backed by the popular go-redis client (github.com/incroy/entcache/rediscache).

Features:

  • Traditional L1/L2 multi-level cache architecture.
  • Distributed Stampede Protection with polling fallback.
  • Keyspace Events: Use rediscache.WithKeyspaceEvents() to eliminate polling and gracefully handle lock expirations during server crashes via Pub/Sub.
import (
    "github.com/redis/go-redis/v9"
    "github.com/incroy/entcache/rediscache"
    "github.com/incroy/entcache/lrucache"
)

rdb := redis.NewClient(&redis.Options{Addr: ":6379"})

// Lookups: L1 (LRU) -> L2 (go-redis) -> Database
drv := entcache.NewDriver(
    sqlDrv,
    entcache.TTL(time.Minute),
    entcache.Levels(
        lrucache.MustNew(256), // Level 1: fast in-process memory
        rediscache.New(
            rdb,
            rediscache.WithKeyspaceEvents(), // Recommended for optimal stampede protection
        ),                     // Level 2: Redis
    ),
)
NATS JetStream KV (natscache)

Distributed cache backed by durable NATS JetStream KeyValue buckets (github.com/incroy/entcache/natscache).

Features:

  • Masterless distributed architecture.
  • Distributed Stampede Protection via native NATS Watch events (KeyValueDelete / KeyValuePurge).
  • Fault-tolerant background heartbeats for lock ownership tracking (prevents deadlocks).
import (
    "github.com/nats-io/nats.go"
    "github.com/nats-io/nats.go/jetstream"
    "github.com/incroy/entcache/natscache"
    "github.com/incroy/entcache/lrucache"
)

nc, _ := nats.Connect(nats.DefaultURL)
js, _ := jetstream.New(nc)

// Important: LimitMarkerTTL MUST be enabled for the lock's per-key TTL to work.
kv, _ := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
    Bucket:         "entcache",
    TTL:            10 * time.Minute,
    LimitMarkerTTL: time.Second, // Required for stampede protection heartbeats
})

// Lookups: L1 (LRU) -> L2 (NATS) -> Database
drv := entcache.NewDriver(
    sqlDrv,
    entcache.TTL(time.Minute),
    entcache.Levels(
        lrucache.MustNew(256), // Level 1: fast in-process memory
        natscache.New(kv),     // Level 2: durable NATS JetStream KV
    ),
)

Mutation-Aware Invalidation

entcache supports automatic cache invalidation on entity mutations (create, update, delete). A ChangeSet records modified entity keys and evicts stale cache entries on subsequent queries.

// Create a ChangeSet with GC interval.
cs := entcache.NewChangeSet(5 * time.Minute)
cs.Start()
defer cs.Stop()

// Create the cached driver with ChangeSet.
drv := entcache.NewDriver(sqlDrv,
    entcache.TTL(time.Minute),
    entcache.WithKeyTTL(time.Hour),      // longer TTL for Get-by-ID queries
    entcache.WithChangeSet(cs),
)

// Create the client and register the mutation hook.
client := ent.NewClient(ent.Driver(drv))
client.Use(entcache.DataChangeNotify(drv))

// Now, when a User is updated:
client.User.UpdateOneID(42).SetName("new-name").Save(ctx)

// The next Get for that user will bypass the cache and re-fetch from DB.
u, _ := client.User.Get(entcache.WithEntryKey(ctx, "User", 42), 42)

Full Production Example (entpgx + NATS KV + Mutation Hook)

entpgx provides a native pgxpool.Pool-based dialect.Driver that bypasses database/sql entirely. entcache wraps it seamlessly:

package main

import (
    "context"
    "log"
    "time"

    "github.com/incroy/entcache"
    "github.com/incroy/entcache/lrucache"
    "github.com/incroy/entcache/natscache"
    "github.com/incroy/entpgx"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/nats-io/nats.go"
    "github.com/nats-io/nats.go/jetstream"

    "myapp/ent"
)

func main() {
    ctx := context.Background()

    // 1. Database: native pgx pool via entpgx.
    pool, err := pgxpool.New(ctx, "postgres://localhost:5432/mydb")
    if err != nil {
        log.Fatal(err)
    }

    // 2. Remote Cache: NATS JetStream KV.
    nc, err := nats.Connect(nats.DefaultURL)
    if err != nil {
        log.Fatal(err)
    }
    js, err := jetstream.New(nc)
    if err != nil {
        log.Fatal(err)
    }
    
    // Important: LimitMarkerTTL MUST be enabled for the lock's per-key TTL to work.
    kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
        Bucket:         "entcache",
        TTL:            10 * time.Minute,
        LimitMarkerTTL: time.Second, // Required for stampede protection heartbeats
    })
    if err != nil {
        log.Fatal(err)
    }

    // 3. ChangeSet for mutation-aware invalidation.
    cs := entcache.NewChangeSet(5 * time.Minute)
    cs.Start()
    defer cs.Stop()

    // 4. Wrap drivers: entpgx -> entcache -> ent.Client.
    pgxDrv := entpgx.NewDriver(pool)
    drv := entcache.NewDriver(pgxDrv,
        entcache.TTL(time.Minute),
        entcache.WithKeyTTL(30*time.Minute),
        entcache.WithChangeSet(cs),
        entcache.Levels(
            lrucache.MustNew(512),
            natscache.New(kv),
        ),
    )
    client := ent.NewClient(ent.Driver(drv))

    // 5. Register the mutation hook.
    client.Use(entcache.DataChangeNotify(drv))

    // 6. Run migrations (skip cache).
    if err := client.Schema.Create(entcache.Skip(ctx)); err != nil {
        log.Fatal(err)
    }

    // Queries are cached across L1 memory and L2 NATS KV.
    // Distributed stampede locking & real-time Watch invalidation work out of the box!
}

API Reference

Full documentation is available at pkg.go.dev/github.com/incroy/entcache.

Option Functions
Function Description
TTL(d) Default cache TTL for hash-addressed queries
WithKeyTTL(d) Separate TTL for key-addressed (Get-by-ID) queries
Hash(fn) Custom hash function for cache key generation
Levels(...) Configure one or more cache backends
ContextLevel() Use context-scoped caching
WithChangeSet(cs) Enable mutation-aware invalidation
Context Options (Per-Query Control)
Function Description
Skip(ctx) Skip cache for a query
Evict(ctx) Skip and invalidate cache entry for a query
WithKey(ctx, key) Explicitly set cache key for a query
WithTTL(ctx, ttl) Custom TTL for a query
WithEntryKey(ctx, typ, id) Structured entity key (e.g. "User:42") for Get-by-ID queries & ChangeSet invalidation
SkipNotFound(ctx) Prevent caching when query result contains 0 rows
Cache Backends
Sub-Package Package Name Constructor Features
github.com/incroy/entcache/contextcache contextcache contextcache.New() Per-request cache with channel stampede locking
github.com/incroy/entcache/lrucache lrucache lrucache.New(size) Hashicorp LRU v2 in-process cache
github.com/incroy/entcache/natscache natscache natscache.New(kv) NATS KV Create stampede lock & Watch invalidation
github.com/incroy/entcache/rueidiscache rueidiscache rueidiscache.New(client) Native RESP3 Client-Side Caching & lock channels
github.com/incroy/entcache/rediscache rediscache rediscache.New(client) standard go-redis/v9 backend

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrNotFound = errors.New("entcache: entry was not found")

ErrNotFound is returned by Get when an Entry does not exist in the cache.

View Source
var ErrRetryLocker = errors.New("entcache: retry locker")

ErrRetryLocker is a sentinel error used to trigger a stampede lock retry loop.

Functions

func DataChangeNotify

func DataChangeNotify(drv *Driver) ent.Hook

DataChangeNotify returns an ent Hook that marks changed entity keys in the Driver's ChangeSet whenever a mutation (create, update, delete) is committed. This enables automatic cache invalidation for key-addressed queries.

Usage:

drv := entcache.NewDriver(sqlDrv, entcache.WithChangeSet(cs))
client := ent.NewClient(ent.Driver(drv))
client.Use(entcache.DataChangeNotify(drv))

func Evict

func Evict(ctx context.Context) context.Context

Evict returns a new Context that tells the Driver to skip and invalidate the cache entry on Query.

client.T.Query().All(entcache.Evict(ctx))

func NewContext

func NewContext(ctx context.Context) context.Context

NewContext returns a new Context that carries a request-scoped memory cache.

func Skip

func Skip(ctx context.Context) context.Context

Skip returns a new Context that tells the Driver to skip the cache entry on Query.

client.T.Query().All(entcache.Skip(ctx))

func SkipNotFound

func SkipNotFound(ctx context.Context) context.Context

SkipNotFound returns a new Context that tells the Driver to skip caching when the query result contains zero rows. This prevents caching empty results for entities that may be created shortly after.

client.User.Get(entcache.SkipNotFound(ctx), 42)

func WithEntryKey

func WithEntryKey(ctx context.Context, typ string, id any) context.Context

WithEntryKey returns a new Context with a structured entity key (e.g. "User:42") and marks the query as key-addressed. Key-addressed queries are eligible for the longer KeyTTL and precise invalidation via ChangeSet.

client.User.Get(entcache.WithEntryKey(ctx, "User", 42), 42)

func WithKey

func WithKey(ctx context.Context, key Key) context.Context

WithKey returns a new Context that carries the Key for the cache entry. Note that, this option should not be used if the ent.Client query involves more than 1 SQL query (e.g. eager loading).

client.T.Query().All(entcache.WithKey(ctx, "key"))

func WithTTL

func WithTTL(ctx context.Context, ttl time.Duration) context.Context

WithTTL returns a new Context that carries the TTL for the cache entry.

client.T.Query().All(entcache.WithTTL(ctx, time.Second))

Types

type AddGetDeleter

type AddGetDeleter interface {
	Del(ctx context.Context, k Key) error
	Add(ctx context.Context, k Key, e *Entry, ttl time.Duration) error
	Get(ctx context.Context, k Key) (*Entry, error)
}

AddGetDeleter defines the interface for getting, adding and deleting entries from the cache.

type Cache added in v0.3.0

type Cache interface {
	AddGetDeleter
}

Cache combines AddGetDeleter with optional StampedeLocker and Invalidator.

func FromContext

func FromContext(ctx context.Context) (Cache, bool)

FromContext returns the cache value stored in ctx, if any.

type ChangeSet

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

ChangeSet tracks entity keys that have been modified (created, updated, or deleted). It is used by the Driver to detect stale cache entries and force re-queries. A background GC goroutine prunes entries older than the GC interval.

func NewChangeSet

func NewChangeSet(gcInterval time.Duration) *ChangeSet

NewChangeSet creates a new ChangeSet with the given GC interval. If gcInterval is <= 0, the default of 5 minutes is used.

func (*ChangeSet) Changed

func (cs *ChangeSet) Changed(key Key, since time.Time) bool

Changed reports whether the given key has been marked as changed since the given time. This is used by the Driver to decide whether a cache hit should be evicted and re-fetched.

func (*ChangeSet) Clear

func (cs *ChangeSet) Clear(keys ...Key)

Clear removes the change markers for the given keys, acknowledging that the cache has been refreshed.

func (*ChangeSet) Mark

func (cs *ChangeSet) Mark(keys ...Key)

Mark records one or more keys as changed at the current time.

func (*ChangeSet) Start

func (cs *ChangeSet) Start()

Start begins the background GC goroutine that prunes stale change markers. Call Stop to terminate it.

func (*ChangeSet) Stop

func (cs *ChangeSet) Stop()

Stop terminates the background GC goroutine.

type Driver

type Driver struct {
	dialect.Driver
	*Options
	// contains filtered or unexported fields
}

A Driver is an SQL cached client. Users should use the constructor below for creating new driver.

func NewDriver

func NewDriver(drv dialect.Driver, opts ...Option) *Driver

NewDriver returns a new Driver given an existing driver and optional configuration functions.

func (*Driver) ExecContext

func (d *Driver) ExecContext(ctx context.Context, query string, args ...any) (stdsql.Result, error)

ExecContext calls ExecContext of underlying driver.

func (*Driver) Query

func (d *Driver) Query(ctx context.Context, query string, args, v any) error

Query implements the Querier interface for the driver.

func (*Driver) QueryContext

func (d *Driver) QueryContext(ctx context.Context, query string, args ...any) (*stdsql.Rows, error)

QueryContext calls QueryContext of underlying driver.

func (*Driver) Stats

func (d *Driver) Stats() Stats

Stats returns a copy of the cache statistics.

type Entry

type Entry struct {
	Columns []string
	Values  [][]driver.Value
}

Entry defines an entry to store in a cache.

func (Entry) MarshalBinary

func (e Entry) MarshalBinary() ([]byte, error)

MarshalBinary implements the encoding.BinaryMarshaler interface.

func (*Entry) UnmarshalBinary

func (e *Entry) UnmarshalBinary(buf []byte) error

UnmarshalBinary implements the encoding.BinaryUnmarshaler interface.

type Invalidator added in v0.3.0

type Invalidator interface {
	// WatchInvalidations registers a callback that triggers when a key is modified or deleted remotely.
	WatchInvalidations(ctx context.Context, onInvalidate func(key Key)) error
}

Invalidator defines an interface for real-time invalidation event streaming.

type Key

type Key any

A Key defines a comparable Go value. See http://golang.org/ref/spec#Comparison_operators

func DefaultHash

func DefaultHash(query string, args []any) (Key, error)

DefaultHash provides the default implementation for converting a query + args to a cache key.

func NewEntryKey

func NewEntryKey(typ string, id any) Key

NewEntryKey constructs a structured cache key from an entity type name and ID. This produces keys like "User:42" that enable precise invalidation via ChangeSet.

type Option

type Option func(*Options)

Option allows configuring the cache driver using functional options.

func ContextLevel

func ContextLevel() Option

ContextLevel configures the driver to work with context/request level cache.

func Hash

func Hash(hash func(query string, args []any) (Key, error)) Option

Hash configures an optional Hash function for converting query + args to cache key.

func Levels

func Levels(levels ...Cache) Option

Levels configures the Driver to work with the given cache levels.

func TTL

func TTL(ttl time.Duration) Option

TTL configures the period of time that an Entry is valid in the cache.

func WithChangeSet

func WithChangeSet(cs *ChangeSet) Option

WithChangeSet configures the Driver to use the given ChangeSet for mutation-aware cache invalidation.

func WithKeyTTL

func WithKeyTTL(ttl time.Duration) Option

WithKeyTTL configures a separate TTL for key-addressed queries (e.g. Get-by-ID).

type Options

type Options struct {
	// TTL defines the period of time that an Entry
	// is valid in the cache (used for hash-addressed queries).
	TTL time.Duration

	// KeyTTL defines the period of time that a key-addressed Entry
	// (e.g. Get-by-ID queries) is valid in the cache. Key-addressed
	// queries can have a longer TTL because they are precisely
	// invalidated via the ChangeSet. If zero, TTL is used.
	KeyTTL time.Duration

	// Cache defines the GetAddDeleter (cache implementation)
	// for holding the cache entries.
	Cache AddGetDeleter

	// Hash defines an optional Hash function for converting
	// a query and its arguments to a cache key. If no Hash
	// function was provided, the DefaultHash is used.
	Hash func(query string, args []any) (Key, error)

	// ChangeSet holds the mutation change tracker. When set,
	// the Driver checks whether cached entries have been
	// invalidated by mutations before returning them.
	ChangeSet *ChangeSet

	// Logf function. If provided, the Driver will call it with
	// errors that can not be handled.
	Log func(...any)
}

Options wraps the basic configuration cache options.

type StampedeLocker added in v0.3.0

type StampedeLocker interface {
	// LockOrWait attempts to acquire permission to load a missing key.
	// If won == true: caller runs the DB query, calls Add(), then calls release(ctx).
	// If won == false: wait(ctx) blocks until another caller populates the cache and returns the Entry.
	LockOrWait(ctx context.Context, k Key) (won bool, wait func(context.Context) (*Entry, error), release func(context.Context), err error)
}

StampedeLocker defines an interface for distributed or local lock-waiting on cache misses to prevent cache stampedes.

type Stats

type Stats struct {
	Gets   uint64
	Hits   uint64
	Errors uint64
}

Stats represents the cache statistics of the driver.

Directories

Path Synopsis
Package natscache provides a NATS JetStream KeyValue cache backend for entcache.
Package natscache provides a NATS JetStream KeyValue cache backend for entcache.
Package rediscache provides a Redis cache backend for entcache using go-redis.
Package rediscache provides a Redis cache backend for entcache using go-redis.
Package rueidiscache provides a Redis cache backend for entcache using Rueidis.
Package rueidiscache provides a Redis cache backend for entcache using Rueidis.

Jump to

Keyboard shortcuts

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