Skip to content

Caching🔗

Undine can cache work at three levels.

  1. Response caching stores the result of an operation.
  2. Visibility caching stores which parts of the schema a user can see.
  3. Document caching stores the parsed and validated form of a GraphQL document.

Every level is disabled by default and is enabled on its own.

Response caching🔗

Responses from Entrypoints connected to the Query RootType can be cached by giving the cache_time argument to the Entrypoint.

1
2
3
4
5
6
7
from undine import Entrypoint, RootType


class Query(RootType):
    @Entrypoint(cache_time=60)
    def testing(self, name: str) -> str:
        return f"Hello, {name}!"

This caches the response for the given number of seconds in the cache set by the REQUEST_CACHE_ALIAS setting.

Note that response caching cannot be used for requests that use incremental delivery. Also, only responses without errors are cached, since an error can come from a transient issue such as a database connection being down.

Entrypoints are not cached unless you set the cache_time argument. To cache every Entrypoint of the Query root type by default, use the ENTRYPOINT_DEFAULT_CACHE_TIME setting.

1
2
3
UNDINE = {
    "ENTRYPOINT_DEFAULT_CACHE_TIME": 30,
}

An Entrypoint that sets its own cache_time keeps it. Set cache_time=0 on an Entrypoint to leave it out of the default.

Per-user caching🔗

Use the cache_per_user argument to cache the response for each user separately.

1
2
3
4
5
6
7
from undine import Entrypoint, RootType


class Query(RootType):
    @Entrypoint(cache_time=60, cache_per_user=True)
    def testing(self, name: str) -> str:
        return f"Hello, {name}!"

Responses for authenticated and anonymous users are cached separately even without cache_per_user. A schema commonly returns different results for unauthenticated users.

Rules on other entities🔗

You can also set caching rules on individual Fields, QueryTypes, InterfaceTypes, InterfaceFields and UnionTypes. An entity that sets no rules of its own is cached using the Entrypoint's rules. When an operation includes several entities that set rules, Undine uses the most restrictive rules it finds.

For example, a Field can set a stricter rule than its Entrypoint.

from undine import Entrypoint, Field, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task], auto=False):
    name = Field(cache_time=10, cache_per_user=True)


class Query(RootType):
    task = Entrypoint(TaskType, cache_time=60)

Queried like this:

1
2
3
4
5
query {
  task(id: 1) {
    name
  }
}

The name Field has a cache time of 10 seconds and the Entrypoint has 60 seconds, so the operation is cached for 10 seconds. The name Field also sets per-user caching, so the operation is cached for each user separately.

If a Field's reference sets a caching rule, but the Field itself does not, the reference's rule is used. This applies to Fields only, not to Entrypoints.

from undine import Entrypoint, Field, QueryType, RootType

from .models import Project, Task


class ProjectType(QueryType[Project], auto=False, cache_time=10):
    name = Field()


class TaskType(QueryType[Task], auto=False):
    project = Field(ProjectType)


class Query(RootType):
    task = Entrypoint(TaskType, cache_time=60)

Queried like this:

1
2
3
4
5
6
7
query {
  task(id: 1) {
    project {
      name
    }
  }
}

The project Field uses ProjectType, which is cached for 10 seconds, so the operation is cached for 10 seconds instead of the Entrypoint's 60 seconds.

Cache keys🔗

A response is cached based on the GraphQL source document, the variables, the operation name, the GraphQL operation extensions, and the user's authentication status. Per-user caching adds the user's primary key.

If your responses vary on something else, such as the accepted language, add that data to the cache key with the REQUEST_CACHE_EXTRA_CONTEXT setting.

1
2
3
4
5
6
7
from typing import Any

from undine.hooks import LifecycleHookContext


def extra_context(context: LifecycleHookContext) -> dict[str, Any]:
    return {"lang": context.request.headers.get("Accept-Language", "en")}

Read and write predicates🔗

Use the REQUEST_CACHE_READ_PREDICATE and REQUEST_CACHE_WRITE_PREDICATE settings to control whether a given request reads from or writes to the cache.

1
2
3
4
5
6
7
8
9
from undine.hooks import LifecycleHookContext


def should_read_from_cache(context: LifecycleHookContext) -> bool:
    return context.request.headers.get("X-Cache-Read", "false").lower() == "true"


def should_write_to_cache(context: LifecycleHookContext) -> bool:
    return context.request.headers.get("X-Cache-Write", "false").lower() == "true"

Client caching🔗

Response caching also sends Cache-Control and Age headers to the client, so that browser caches and CDN caches can store the response.

Visibility caching🔗

In a schema that uses visibility, response caching is forced to be per-user when the operation reaches an entity that uses visibility. This makes sure that hidden data cannot leak between users through a cached response.

You can also cache a user's introspection response by setting VISIBILITY_CACHE_TIMEOUT. Cache keys are derived from the user's primary key plus any extra context given by VISIBILITY_CACHE_EXTRA_CONTEXT.

Document caching🔗

Parsing a document to an AST and validating that AST are pure functions of the document and the schema. Undine has two built-in lifecycle hooks that reuse those results between requests.

  • undine.hooks.ParseCacheHook caches the parsed AST. Enable it by setting PARSE_CACHE_MAX_SIZE above zero.
  • undine.hooks.ValidationCacheHook caches the validation outcome. Enable it by setting VALIDATION_CACHE_MAX_SIZE above zero.

Both keep their results in the memory of the process, so each worker fills its own cache. A shared cache would send the document over the network on each request, which costs more than the parsing and the validation that it saves.

The setting is also the size limit. When a cache is full, it discards the document that was used least recently. Keep the limit at a size that the process can hold, because a client can send an unlimited number of different documents. With PERSISTED_DOCUMENTS_ONLY, the set of documents that a client can send is known in advance, which makes the limit easier to choose.

Documents that fail to parse or fail to validate are not cached.

The validation cache is skipped for schemas that use visibility. Visibility is resolved against the user that makes the request and against the values of the variables that they send, so the outcome is not a function of the document and the schema alone. Custom rules in ADDITIONAL_VALIDATION_RULES must also depend on the document and the schema only, or the validation cache must stay disabled.