entcache

package module
v0.3.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: 17 Imported by: 0

README

entcache

Go Reference License

A production-ready, modular cache driver for ent with built-in distributed stampede protection, real-time invalidation event streaming, and modular sub-packages:

  • Modular Storage Backends — clean sub-packages for lrucache (Hashicorp LRU v2), natscache (NATS JetStream KV), rediscache (go-redis), and rueidiscache (Rueidis with native RESP3 client-side caching). Zero bloated dependencies in core entcache.
  • Automatic Stampede Protection — built-in distributed locking (KV.Create placeholder lock + Watch waiting) for NATS, channel lock waiting for Rueidis, and singleflight deduplication for LRU/Redis.
  • Native Client-Side Cachingrueidiscache leverages Rueidis RESP3 client-side caching out of the box (in-memory client cache with server-driven invalidation) with no extra LRU required.
  • Context-level — per-request cache attached to a context.Context (e.g. HTTP request or GraphQL resolve) that eliminates duplicate queries within a single request.
  • Driver-level — process-level cache embedded in the ent.Client and shared across all goroutines.
  • Multi-level — hierarchical cache structure (e.g. L1 LRU memory cache + L2 remote Redis/NATS store) for optimal latency and durability.
  • Mutation-aware invalidation — ent hooks automatically invalidate stale cache entries when entity mutations (create, update, delete) occur.

Compatible with standard database/sql drivers as well as native drivers like entpgx.

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/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)

Quick Start

With database/sql Driver & Hashicorp LRU
import (
    "github.com/incroy/entcache"
    "github.com/incroy/entcache/lrucache"
)

// Open the database connection.
db, err := sql.Open(dialect.Postgres, "postgres://localhost:5432/mydb?sslmode=disable")
if err != nil {
    log.Fatal("opening database", err)
}

// Wrap the sql.Driver with entcache.Driver.
drv := entcache.NewDriver(
    sql.OpenDB(dialect.Postgres, db),
    entcache.TTL(time.Minute),
    entcache.Levels(lrucache.MustNew(1000)),
)

// Create an ent.Client.
client := ent.NewClient(ent.Driver(drv))

// Skip the cache during schema migration.
if err := client.Schema.Create(entcache.Skip(ctx)); err != nil {
    log.Fatal("running schema migration", err)
}

// First call hits the database.
u, err := client.User.Get(ctx, id)

// Second call is served from cache.
u, err = client.User.Get(ctx, id)
With entpgx (Native pgx Driver)

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

import (
    "github.com/incroy/entpgx"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/incroy/entcache"
    "github.com/incroy/entcache/lrucache"
)

// Create a pgxpool.Pool.
pool, err := pgxpool.New(ctx, "postgres://localhost:5432/mydb?sslmode=disable")
if err != nil {
    log.Fatal(err)
}

// Create the entpgx driver.
pgxDrv := entpgx.NewDriver(pool)

// Wrap with entcache.
drv := entcache.NewDriver(
    pgxDrv,
    entcache.TTL(time.Minute),
    entcache.Levels(lrucache.MustNew(1024)),
)

// Create an ent.Client.
client := ent.NewClient(ent.Driver(drv))

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

// Queries are cached transparently.
u, err := client.User.Get(ctx, id)

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.

+------------+       1. Query(SQL, Args)       +------------------+       2. Get(Key)       +-------------+
| ent.Client | ------------------------------> | entcache.Driver  | ----------------------> | Cache Store |
+------------+                                 +------------------+                         +-------------+
                                                         |                                         |
                                                         | (Cache Miss)                            | (Hit)
                                                         v                                         v
                                               +------------------+                       +-----------------+
                                               | Wrapped Driver   |                       | Replay Cached   |
                                               | (SQL Database)   |                       | Rows            |
                                               +------------------+                       +-----------------+

The package provides a rich set of options to configure entry TTLs, control hash functions, set up multi-level cache hierarchies, invalidate/skip entries on-demand, and perform automatic mutation-aware invalidation.

Modular Cache Backends

1. 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)),
)
2. NATS JetStream KV (natscache)

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

Out-of-the-Box Features:

  • Distributed Stampede Protection: On a cache miss, the winner acquires a KV.Create placeholder lock and executes the DB query. Concurrent callers on other nodes automatically wait via KV.Watch until the value is populated.
  • Real-Time Cross-Node Invalidation: Listens to NATS Watch events (KeyValueDelete / KeyValuePurge) and automatically evicts stale keys across all application nodes.
import (
    "github.com/nats-io/nats.go/jetstream"
    "github.com/incroy/entcache/natscache"
)

nc, _ := nats.Connect(nats.DefaultURL)
js, _ := jetstream.New(nc)
kv, _ := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
    Bucket: "entcache",
    MaxAge: 10 * time.Minute,
})

drv := entcache.NewDriver(sqlDrv,
    entcache.TTL(time.Minute),
    entcache.Levels(natscache.New(kv)),
)
3. Rueidis (rueidiscache)

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

Out-of-the-Box Features:

  • Native RESP3 Client-Side Caching: Natively activated out of the box! Rueidis caches keys in-memory on the client and receives server-driven invalidation tracking messages from Redis. No extra L1 LRU layer is required.
  • Stampede Protection: Built-in channel wait locks prevent multiple concurrent database queries for the same key.
import (
    "github.com/redis/rueidis"
    "github.com/incroy/entcache/rueidiscache"
)

c, err := rueidis.NewClient(rueidis.ClientOption{
    InitAddress: []string{"127.0.0.1:6379"},
})
if err != nil {
    log.Fatal(err)
}

// Native Client-Side Caching is active out of the box!
drv := entcache.NewDriver(sqlDrv,
    entcache.TTL(time.Minute),
    entcache.Levels(rueidiscache.New(c)),
)
4. Redis (rediscache)

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

import (
    "github.com/redis/go-redis/v9"
    "github.com/incroy/entcache/rediscache"
)

rdb := redis.NewClient(&redis.Options{Addr: ":6379"})
drv := entcache.NewDriver(sqlDrv,
    entcache.TTL(time.Minute),
    entcache.Levels(rediscache.New(rdb)),
)

Caching Levels

entcache provides several builtin cache levels:

  1. context.Context Cache — Attached to a request (e.g. HTTP request or GraphQL resolve). Used to eliminate duplicate database queries executed during the same request lifecycle.
  2. Driver-Level Cache — Embedded in ent.Client. Shared across all goroutines in the application process.
  3. Remote-Level Cache — Remote cache (NATS KV, Rueidis, Redis) providing persistence and sharing cache entries across multiple service replicas.
  4. Multi-Level Cache — Hierarchical cache structure combining fast in-memory LRU caching with remote persistent backends.

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.

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

A naive GraphQL resolver executes 1 query for fetching $N$ users, $N$ queries for fetching todos of each user, and another query for each todo item to fetch its owner (the classic N+1 Problem).

Ent optimizes this by batching execution into 3 queries. With entcache, the number of queries is further reduced from 3 to 2, because query 1 (fetch users) and query 3 (fetch todo owners) execute identical SQL statements, allowing query 3 to be served directly from context cache.

context-level-cache

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

Wrap the request context.Context with entcache.NewContext when a GraphQL query arrives:

srv.AroundResponses(func(ctx context.Context, next graphql.ResponseHandler) *graphql.Response {
    if op := graphql.GetOperationContext(ctx).Operation; op != nil && op.Operation == ast.Query {
        ctx = entcache.NewContext(ctx)
    }
    return next(ctx)
})

A full runnable server example is located in examples/ctxlevel.


Multi-Level Cache

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

multi-level-cache

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
    ),
)
client := ent.NewClient(ent.Driver(drv))

A full runnable server example is located in examples/multilevel.


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)

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)
    }
    kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
        Bucket: "entcache",
        MaxAge: 10 * time.Minute,
    })
    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
Cache Backends
Sub-Package Package Name Constructor Features
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.

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, levels ...AddGetDeleter) context.Context

NewContext returns a new Context that carries a 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.

func FromContext

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

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

type Cache added in v0.3.0

type Cache interface {
	AddGetDeleter
}

Cache combines AddGetDeleter with optional StampedeLocker and Invalidator.

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 ...AddGetDeleter) 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

Jump to

Keyboard shortcuts

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