entcache

package module
v0.4.1 Latest Latest
Warning

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

Go to latest
Published: Nov 23, 2025 License: Apache-2.0 Imports: 18 Imported by: 0

README

entcache

An experimental cache driver for ent with variety of storage options, such as:

  1. A context.Context-based cache. Usually, attached to an HTTP request.

  2. A driver level cache embedded in the ent.Client. Used to share cache entries on the process level.

  3. A remote cache. For example, a Redis database that provides a persistence layer for storing and sharing cache entries between multiple processes.

  4. A cache hierarchy, or multi-level cache allows structuring the cache in hierarchical way. For example, a 2-level cache that composed from an LRU-cache in the application memory, and a remote-level cache backed by a Redis database.

Quick Introduction

First, go get the package using the following command.

go get ariga.io/entcache

After installing entcache, you can easily add it to your project with the snippet below:

// Open the database connection.
db, err := sql.Open(dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
	log.Fatal("opening database", err)
}
// Decorates the sql.Driver with entcache.Driver.
drv := entcache.NewDriver(db)
// Create an ent.Client.
client := ent.NewClient(ent.Driver(drv))

// Tell the entcache.Driver to skip the caching layer
// when running the schema migration.
if client.Schema.Create(ctx); err != nil {
	log.Fatal("running schema migration", err)
}

// Run queries.
if u, err := client.User.Get(ctx, id); err != nil {
	log.Fatal("querying user", err)
}
// The query below is cached.
if u, err := client.User.Get(ctx, id); err != nil {
	log.Fatal("querying user", err)
}

Cache Control

entcache provides a powerful options-based API for fine-grained cache control, allowing you to compose different caching behaviors:

Basic Usage
// Enable caching
ctx := entcache.Cache(ctx)
users, err := client.User.Query().All(ctx)
Custom Configuration
// Cache with custom TTL (1 hour)
ctx := entcache.Cache(ctx, entcache.WithTTL(time.Hour))

// Cache with custom key
ctx := entcache.Cache(ctx, entcache.WithKey("user-list"))

// Execute query and invalidate cache immediately
ctx := entcache.Cache(ctx, entcache.Evict())
users, err := client.User.Query().All(ctx)
Cache Inspection

Read from cache without database fallback:

// Read from cache only (returns empty result if not cached)
ctx := entcache.Cache(ctx, entcache.CacheOnly())
users, err := client.User.Query().All(ctx)
if len(users) == 0 {
    // Cache miss - no data available
}
Cache Invalidation

Invalidate cache entries without executing the query:

// Invalidate cache using the query-generated key (no DB execution)
query := client.User.Query().Where(user.ID(123))
ctx := entcache.Cache(ctx, entcache.CacheOnly(), entcache.Evict())
_, _ = query.All(ctx) // Invalidates cache, doesn't execute DB query
Composing Options

The options pattern allows clean composition of multiple behaviors:

// Complex: execute, cache with custom key, set TTL, then invalidate
ctx := entcache.Cache(ctx,
    entcache.WithKey("active-users"),
    entcache.WithTTL(30*time.Minute),
    entcache.Evict(),
)
users, err := client.User.Query().Where(user.Active(true)).All(ctx)

// Cache-only read with custom key
ctx := entcache.Cache(ctx,
    entcache.CacheOnly(),
    entcache.WithKey("user-profile-123"),
)
profile, err := client.User.Get(ctx, 123)
Migration from Previous API

If you were using the previous context-based API:

// Old API (removed)
ctx = entcache.Evict(entcache.WithTTL(entcache.WithKey(ctx, "key"), time.Hour))

// New API (recommended)
ctx = entcache.Cache(ctx, entcache.Evict(), entcache.WithTTL(time.Hour), entcache.WithKey("key"))

However, you need to choose the cache storage carefully before adding entcache to your project. The section below covers the different approaches provided by this package.

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 (i.e. statement and 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. This means, that the recorded rows will be returned the next time the query is executed, if it was not evicted by the cache store.

The package provides a variety of options to configure the TTL of the cache entries, control the hash function, provide custom and multi-level cache stores, evict and skip cache entries. See the full documentation in go.dev/entcache.

Caching Levels

entcache provides several builtin cache levels:

  1. A context.Context-based cache. Usually, attached to a request and does not work with other cache levels. It is used to eliminate duplicate queries that are executed by the same request.

  2. A driver-level cache used by the ent.Client. An application usually creates a driver per database, and therefore, we treat it as a process-level cache.

  3. A remote cache. For example, a Redis database that provides a persistence layer for storing and sharing cache entries between multiple processes. A remote cache layer is resistant to application deployment changes or failures, and allows reducing the number of identical queries executed on the database by different process.

  4. A cache hierarchy, or multi-level cache allows structuring the cache in hierarchical way. The hierarchy of cache stores is mostly based on access speeds and cache sizes. For example, a 2-level cache that composed from an LRU-cache in the application memory, and a remote-level cache backed by a Redis database.

Context Level Cache

The ContextLevel option configures the driver to work with a context.Context level cache. The context is usually attached to a request (e.g. *http.Request) and is not available in multi-level mode. When this option is used as a cache store, the attached context.Context carries an LRU cache (can be configured differently), and the driver stores and searches entries in the LRU cache when queries are executed.

This option is ideal for applications that require strong consistency, but still want to avoid executing duplicate database queries on the same request. For example, given the following GraphQL query:

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

A naive solution for resolving the above query will execute, 1 for getting N users, another N query for getting the todos of each user, and a query for each todo item for getting its owner (read more about the N+1 Problem).

However, Ent provides a unique approach for resolving such queries(read more in Ent website) and therefore, only 3 queries will be executed in this case. 1 for getting N users, 1 for getting the todo items of all users, and 1 query for getting the owners of all todo items.

With entcache, the number of queries may be reduced to 2, as the first and last queries are identical (see code example).

context-level-cache

Usage In GraphQL

In order to instantiate an entcache.Driver in a ContextLevel mode and use it in the generated ent.Client use the following configuration.

db, err := sql.Open(dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
	log.Fatal("opening database", err)
}
drv := entcache.NewDriver(db, entcache.ContextLevel())
client := ent.NewClient(ent.Driver(drv))

Then, when a GraphQL query hits the server, we wrap the request context.Context with an entcache.NewContext.

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

That's it! Your server is ready to use entcache with GraphQL, and a full server example exits in examples/ctxlevel.

Middleware Example

An example of using the common middleware pattern in Go for wrapping the request context.Context with an entcache.NewContext in case of GET requests.

srv.Use(func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodGet {
			r = r.WithContext(entcache.NewContext(r.Context()))
		}
		next.ServeHTTP(w, r)
	})
})
Driver Level Cache

A driver-based level cached stores the cache entries on the ent.Client. An application usually creates a driver per database (i.e. sql.DB), and therefore, we treat it as a process-level cache. The default cache storage for this option is an LRU cache with no limit and no TTL for its entries, but can be configured differently.

driver-level-cache

Create a default cache driver, with no limit and no TTL.
db, err := sql.Open(dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
	log.Fatal("opening database", err)
}
drv := entcache.NewDriver(db)
client := ent.NewClient(ent.Driver(drv))
Set the TTL to 1s.
drv := entcache.NewDriver(drv, entcache.TTL(time.Second))
client := ent.NewClient(ent.Driver(drv))
Limit the cache to 128 entries and set the TTL to 1s.
drv := entcache.NewDriver(
    drv,
    entcache.TTL(time.Second),
    entcache.Levels(entcache.NewLRU(128)),
)
client := ent.NewClient(ent.Driver(drv))
Remote Level Cache

A remote-based level cache is used to share cached entries between multiple processes. For example, a Redis database. A remote cache layer is resistant to application deployment changes or failures, and allows reducing the number of identical queries executed on the database by different processes. This option plays nicely the multi-level option below.

Multi Level Cache

A cache hierarchy, or multi-level cache allows structuring the cache in hierarchical way. The hierarchy of cache stores is mostly based on access speeds and cache sizes. For example, a 2-level cache that compounds from an LRU-cache in the application memory, and a remote-level cache backed by a Redis database.

context-level-cache

rdb := redis.NewClient(&redis.Options{
    Addr: ":6379",
})
if err := rdb.Ping(ctx).Err(); err != nil {
    log.Fatal(err)
}
drv := entcache.NewDriver(
    drv,
    entcache.TTL(time.Second),
    entcache.Levels(
        entcache.NewLRU(256),
        entcache.NewRedis(rdb),
    ),
)
client := ent.NewClient(ent.Driver(drv))
Future Work

There are a few features we are working on, and wish to work on, but need help from the community to design them properly. If you are interested in one of the tasks or features below, do not hesitate to open an issue, or start a discussion on GitHub or in Ent Slack channel.

  1. Add a Memcache implementation for a remote-level cache.
  2. Support for smart eviction mechanism based on SQL parsing.

Documentation

Index

Constants

This section is empty.

Variables

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

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

Functions

func Cache added in v0.2.0

func Cache(ctx context.Context, opts ...QueryOption) context.Context

Cache returns a context that enables caching for the query. Accepts optional configuration via functional options.

Examples:

// Basic caching
ctx := entcache.Cache(ctx)

// With custom TTL
ctx := entcache.Cache(ctx, WithTTL(time.Hour))

// Cache-only read (no DB fallback)
ctx := entcache.Cache(ctx, CacheOnly())

// Invalidate without execution
ctx := entcache.Cache(ctx, CacheOnly(), Evict())

// Execute, cache, and set custom key
ctx := entcache.Cache(ctx, WithKey("my-key"), WithTTL(time.Minute))

func CacheOnlySentinelColumns added in v0.3.0

func CacheOnlySentinelColumns() []string

CacheOnlySentinelColumns returns the sentinel column names used for CacheOnly miss scenarios. This is exported for testing purposes.

func NewContext

func NewContext(ctx context.Context, levels ...AddGetDeleter) context.Context

NewContext returns a new Context that carries a cache.

Types

type AddGetDeleter

type AddGetDeleter interface {
	Del(context.Context, Key) error
	Add(context.Context, Key, *Entry, time.Duration) error
	Get(context.Context, 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 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 a new driver.

func NewDriver

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

NewDriver returns a new Driver an existing driver and optional configuration functions. For example,

entcache.NewDriver(
	drv,
	entcache.TTL(time.Minute),
	entcache.Levels(
		NewLRU(256),
		NewRedis(redis.NewClient(&redis.Options{
			Addr: ":6379",
		})),
	)
)

func (*Driver) ExecContext

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

ExecContext calls ExecContext of the underlying driver, or fails if it is not supported.

func (*Driver) Query

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

Query implements the Querier interface for the driver. It falls back to the underlying wrapped driver in case of caching error.

Note that unless Singleflight is enabled, the driver does not synchronize identical queries that are executed concurrently. Hence, if two identical queries are executed at the ~same time, and there is no cache entry for them, the driver will execute both of them and the last successful one will be stored in the cache.

func (*Driver) QueryContext

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

QueryContext calls QueryContext of the underlying driver, or fails if it is not supported. Note, this method is not part of the caching layer since Ent does not use it by default.

func (*Driver) Stats

func (d *Driver) Stats() Stats

Stats return a copy of the cache statistics.

type Entry

type Entry struct {
	Columns []string         `cbor:"0,keyasint" json:"c" bson:"c"`
	Values  [][]driver.Value `cbor:"1,keyasint" json:"v" bson:"v"`
}

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 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 and its argument to a cache key.

type LRU

type LRU struct {
	*lru.Cache
	// contains filtered or unexported fields
}

LRU provides an LRU cache that implements the AddGetter interface.

func NewLRU

func NewLRU(maxEntries int) *LRU

NewLRU creates a new Cache. If maxEntries is zero, the cache has no limit.

func (*LRU) Add

func (l *LRU) Add(_ context.Context, k Key, e *Entry, ttl time.Duration) error

Add adds the entry to the cache.

func (*LRU) Del

func (l *LRU) Del(_ context.Context, k Key) error

Del deletes an entry from the cache.

func (*LRU) Get

func (l *LRU) Get(_ context.Context, k Key) (*Entry, error)

Get gets an entry from the cache.

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. Users that use this option should wrap the *http.Request context with the cache value as follows:

ctx = entcache.NewContext(ctx)

ctx = entcache.NewContext(ctx, entcache.NewLRU(128))

func Hash

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

Hash configures an optional Hash function for converting a query and its arguments to a cache key.

func Levels

func Levels(levels ...AddGetDeleter) Option

Levels configure the Driver to work with the given cache levels. For example, in process LRU cache and a remote Redis cache.

func TTL

func TTL(ttl time.Duration) Option

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

func WithSingleflight added in v0.4.0

func WithSingleflight(enabled bool) Option

WithSingleflight enables or disables request coalescing for concurrent identical queries. When enabled, if multiple goroutines request the same uncached query simultaneously, only one will execute the database query and the result will be shared with all callers. This prevents cache stampedes where many concurrent requests hit the database for the same data.

type Options

type Options struct {
	// TTL defines the period of time that an Entry
	// is valid in the cache.
	TTL time.Duration

	// Cache defines the GetAddDeleter (cache implementation)
	// for holding the cache entries. If no cache implementation
	// was provided, an LRU cache with no limit is used.
	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)

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

	// Singleflight enables request coalescing for concurrent identical queries.
	// When enabled, concurrent queries with the same cache key will be deduplicated,
	// with only one query executed and the result shared among all callers.
	// Default is false.
	Singleflight bool
}

Options wrap the basic configuration cache options.

type QueryOption added in v0.3.0

type QueryOption func(*ctxOptions)

QueryOption configures cache behavior for a query.

func CacheOnly added in v0.3.0

func CacheOnly() QueryOption

CacheOnly configures the driver to skip database execution. When used alone, reads from cache and returns empty result if not cached. When combined with Evict(), invalidates the cache without executing the query.

// Read from cache only (no DB fallback)
users, err := client.User.Query().All(entcache.Cache(ctx, CacheOnly()))

// Invalidate without executing query
_, err := client.User.Query().Where(...).All(entcache.Cache(ctx, CacheOnly(), Evict()))

func Evict

func Evict() QueryOption

Evict invalidates the cache entry after determining its key. When used alone, executes the query and invalidates the cached result. When combined with CacheOnly(), invalidates without executing.

// Execute and invalidate
users, err := client.User.Query().All(entcache.Cache(ctx, Evict()))

// Invalidate only (no execution)
_, err := client.User.Query().Where(...).All(entcache.Cache(ctx, CacheOnly(), Evict()))

func WithKey

func WithKey(key Key) QueryOption

WithKey sets a custom cache key instead of generating one from the query. Note that this option should not be used if the ent.Client query involves more than 1 SQL query (e.g., eager loading).

users, err := client.User.Query().All(entcache.Cache(ctx, WithKey("my-key")))

func WithTTL

func WithTTL(ttl time.Duration) QueryOption

WithTTL sets a custom TTL for this cache entry.

users, err := client.User.Query().All(entcache.Cache(ctx, WithTTL(time.Hour)))

type Redis

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

Redis provides a remote cache backed by Redis and implements the SetGetter interface.

func NewRedis

func NewRedis(c rueidis.Client) *Redis

NewRedis returns a new Redis cache level from the given Redis connection.

func (*Redis) Add

func (r *Redis) Add(ctx context.Context, k Key, e *Entry, ttl time.Duration) error

Add adds the entry to the cache.

func (*Redis) Del

func (r *Redis) Del(ctx context.Context, k Key) error

Del deletes an entry from the cache.

func (*Redis) Get

func (r *Redis) Get(ctx context.Context, k Key) (*Entry, error)

Get gets an entry from the cache.

type Stats

type Stats struct {
	Gets      uint64
	Hits      uint64
	Errors    uint64
	Coalesced uint64 // Number of queries that were coalesced via singleflight
}

Stats represent the cache statistics of the driver.

Jump to

Keyboard shortcuts

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