entcache

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 23, 2022 License: Apache-2.0 Imports: 17 Imported by: 10

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(entcache.Skip(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)
}

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 queries 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 is returned by Get when and Entry does not exist in the cache.

Functions

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 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(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 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) (sql.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, the driver does not synchronize identical queries that are executed concurrently. Hence, if 2 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) (*sql.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 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 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 wraps 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 configures 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.

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 can not be handled.
	Log func(...any)
}

Options wraps the basic configuration cache options.

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 redis.Cmdable) *Redis

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

entcache.NewRedis(redis.NewClient(&redis.Options{
	Addr: ":6379"
}))

entcache.NewRedis(redis.NewClusterClient(&redis.ClusterOptions{
	Addrs: []string{":7000", ":7001", ":7002"},
}))

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
}

Stats represents the cache statistics of the driver.

Jump to

Keyboard shortcuts

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