Lifetime from the schema
GraphQL only A plain HTTP response takes its lifetime from the origin header only.
@cacheControl lets you state cache lifetimes in your GraphQL schema, the same way Apollo
Server’s cache-control plugin
does. Annotate a type or a field with a maxAge, and GraphPilot derives a lifetime for every
response that selects it, without any change to your server code.
It is for origins that do not compute a Cache-Control header themselves. An origin header that
states a lifetime always wins over the schema; see
precedence. The
same annotation also carries a scope, which decides who shares the entry; that half is on
scopes in the schema.
Declaring the directive
Section titled “Declaring the directive”directive @cacheControl( maxAge: Int scope: CacheControlScope inheritMaxAge: Boolean swr: Int) on OBJECT | INTERFACE | UNION | FIELD_DEFINITION
enum CacheControlScope { PUBLIC PRIVATE}Declare the directive in your SDL like this. Every argument, the default maxAge, what the
deployment build reports and how to rename the directive are on the directive
reference. None of the build’s findings fails a deployment;
they are recorded as diagnostics.
Locations
Section titled “Locations”A hint can be placed on a type (OBJECT, INTERFACE, UNION) or on a field
(FIELD_DEFINITION):
- Type-level hint: applies to every field in the schema that returns this type, unless that field has its own hint.
- Field-level hint: applies to that field only, and always takes precedence over a type-level hint on its return type for the arguments it sets; the rest come from the type, see below.
type Book @cacheControl(maxAge: 60) { title: String}
type Query { # Inherits Book's type-level hint: maxAge 60 book: Book
# Field-level hint overrides Book's type-level hint for this field only freshBook: Book @cacheControl(maxAge: 0)}A field hint overrides only the arguments it sets
Section titled “A field hint overrides only the arguments it sets”When a field carries its own @cacheControl and its return type has one too, the two are merged
argument by argument, as in Apollo Server. Each argument the field hint sets wins; each one it
leaves out (maxAge, scope, swr, inheritMaxAge) is taken from the type’s hint. The return
type is the named type under any list and non-null wrappers.
type User @cacheControl(maxAge: 300, scope: PRIVATE) { email: String}
type Query { # maxAge 60 from the field, scope PRIVATE from the type viewer: User @cacheControl(maxAge: 60)}inheritMaxAge follows Apollo Server too. On the field it takes effect only when the type states
no maxAge; a type that does state one wins. On the type it carries over to a field that states
no maxAge of its own. A missing scope counts as PUBLIC only when neither hint states one.
The merge is done when your schema is built into a deployment, so it applies to a service from its next deployment on.
Resolution order
Section titled “Resolution order”For every field in the query, GraphPilot resolves an effective maxAge using Apollo’s own rules,
checked in this order:
- An explicit
maxAgeon this field’s hint always wins. - Else, if
inheritMaxAge: trueis set and this is not a root field: take the parent field’s already-resolved effectivemaxAge. - Else, if this is a root field or a field returning a composite type (an object, interface,
or union): fall back to the default
maxAge(0, not cacheable, unless you set one, see DefaultmaxAge). - Else (a non-root scalar leaf field with no hint): inherit the parent field’s effective
maxAge.
The response’s overall policy is then the minimum maxAge across every field selected by the
query, so the response can only be as fresh as its most restrictive field. Scopes accumulate
instead; see how scopes compose.
Worked examples
Section titled “Worked examples”type Query { book: Book cachedBook: Book @cacheControl(maxAge: 60) reader: Reader @cacheControl(maxAge: 40)}
type Book { title: String cachedTitle: String @cacheControl(maxAge: 30)}
type Reader { book: Book @cacheControl(inheritMaxAge: true)}| Query | Effective maxAge | Why |
|---|---|---|
{ book { cachedTitle } } | 0 | book is an unannotated root field returning a composite type: rule 3 gives it 0. That 0 is the minimum across the whole response regardless of cachedTitle’s own hint. |
{ cachedBook { title } } | 60 | cachedBook has an explicit hint (rule 1). title is a non-root scalar with no hint, so it inherits cachedBook’s effective value (rule 4). Minimum of the two is 60. |
{ cachedBook { cachedTitle } } | 30 | cachedBook resolves to 60 (rule 1). cachedTitle has its own explicit hint of 30 (rule 1), which is stricter. The minimum wins: 30. |
{ reader { book { title } } } | 40 | reader resolves to 40 (rule 1). book sets inheritMaxAge: true with no explicit maxAge, so it takes reader’s effective value, 40 (rule 2). title inherits book’s value (rule 4). All three agree: 40. |
In the demo catalog
Section titled “In the demo catalog”The GraphPilot demo API runs this schema
behind GraphPilot, so each row below can be checked against gp-cache-max-age on a HIT:
type Product @cacheControl(maxAge: 3600, scope: PUBLIC, swr: 3600) { id: ID! name: String! price: Int! inventory: Inventory! @cacheControl(maxAge: 5, scope: PUBLIC)}
type Category @cacheControl(maxAge: 86400, scope: PUBLIC, swr: 604800) { id: ID! name: String! products: [Product!]! @cacheControl(inheritMaxAge: true, scope: PUBLIC)}
type Activity @cacheControl(maxAge: 5, scope: PUBLIC, swr: 60) { observedAt: String! entries: [ActivityEntry!]! @cacheControl(inheritMaxAge: true, scope: PUBLIC)}
type ActivityEntry { productId: ID! at: String!}
type Query { product(id: ID!): Product categories: [Category!]! @cacheControl(maxAge: 86400, scope: PUBLIC) activity(first: Int = 5): Activity! @cacheControl(maxAge: 5, scope: PUBLIC, swr: 60)}| Query | gp- | Why |
|---|---|---|
{ product(id: "p01") { id name price } } | 3600 | product states no hint and takes Product’s, and the scalars inherit it (rule 4). |
{ product(id: "p01") { id name price inventory { available } } } | 5 | inventory states its own 5 (rule 1), and the response takes the minimum. |
{ categories { id name products { id name } } } | 3600 | products asks to inherit the category’s day, but Product states its own hour, and a type that states a maxAge wins over inheritMaxAge. |
{ activity { observedAt entries { productId at } } } | 5 | ActivityEntry states nothing, so inheritMaxAge takes the feed’s 5. Without it, entries would fall back to the default of 0 (rule 3) and nothing would be cached. |
The second row also shows how the stale window resolves: inventory
states no swr, so the response keeps Product’s hour. Five seconds after it was stored, the
entry is served STALE while a refresh runs, and a reader can see a stock level that is older
than five seconds. If stock must never be served stale, state swr: 0 on the field: a stated 0
wins the minimum.
A field returning a union or interface
Section titled “A field returning a union or interface”For a field returning a union or an interface, the concrete type is known only once your origin has answered, and the policy is resolved from the query alone. The two arguments are therefore treated differently:
scope: every member counts. The scope of every possible type (each member of a union, each object type implementing an interface) joins the field’s, as a second field in the same selection would; see how scopes compose. OnePRIVATEmember makes the field private, whichever member the query selects and whatever the field or the union states. Ascope: PUBLICon the field or on the union does not override it, and there is no opt-out. A named scope on a member adds the names it partitions on.maxAge: the members are not read. ThemaxAgecomes from the field itself or from the union or interface type. When a member states a lowermaxAgethan the field resolves to, the deployment build reports a warning naming the field and those members; the response is still cached for the field’s value.
union SearchResult = Book | Account
type Book @cacheControl(maxAge: 300) { title: String}
type Account @cacheControl(maxAge: 30, scope: PRIVATE) { email: String}
type Query { search: SearchResult @cacheControl(maxAge: 60, scope: PUBLIC)}Here { search { ... on Book { title } } } resolves a maxAge of 60, from the field, and is
private, because Account is: the query names only Book, but the origin decides what search
returns. The build warns that Account states 30, below the 60 of Query.search. To cache
search for at most 30 seconds, state that on the field or on the union.
A member’s scope is part of a schema policy, not one of its own: a query that selects no
annotated field, as with an unannotated search above and nothing else, resolves no schema policy
at all, as before. Without an origin header the response is not cached
(CACHE_SKIPPED_NO_CACHE_POLICY). If the query also selects an annotated field elsewhere, an
unannotated search counts as the default maxAge by rule 3, and its members’ scopes still join
the response’s.
The member scopes are read when your schema is built into a deployment, so the change applies to a service from its next deployment on.
The portal’s Cache rules and the schemaAnalysis of a deployment show
this effective scope: a field returning a union or an interface, and the union or interface’s own
hint, read private when any member is private, or the named scope its members add, rather than
the scope written on the field.
A hint on a member’s own field is still read through its type condition: in
{ search { ... on Book { publisher { name } } } }, a @cacheControl on Book.publisher, or on
the type publisher returns, applies as usual.
A field the deployed schema does not declare
Section titled “A field the deployed schema does not declare”The policy is read from the schema you deployed to GraphPilot, not from the one your origin runs.
A query that selects a field that schema does not declare, at any depth, or a fragment on a type
it does not know, has no hint to resolve for that selection and no scope to partition it by. Such
a response is not stored (CACHE_SKIPPED_UNRESOLVED_SELECTION), whatever the rest of the query
resolves to and whatever your origin’s Cache-Control says, and its policy drops to a maxAge
of 0 with no stale window. The usual cause is an origin deployed ahead of its schema: deploy the
schema to GraphPilot first, as described under deployments.
Seeing what the schema decided
Section titled “Seeing what the schema decided”A resolved maxAge of zero, which is also the default when your directive declares none, means
the response is not cached: CACHE_SKIPPED_NO_MAX_AGE. A mutation or subscription is never
cached, whatever its hints; see when a response is
cached.
When the schema decided, your logs carry the CACHE_POLICY_SCHEMA log
line with:
max_age: the resolved lifetime,max_age_set_by: the fields that set it (only those that declared the value rather than inherited it),- whether the result is private,
scope_defaulted:truewhen a selected hint stated no scope and was read asPUBLIC. It is the first thing to check when one caller reports another’s data.
Renaming or turning it off
Section titled “Renaming or turning it off”The directive name and its argument names are configurable, and directives = [] under
[cache_control] switches schema hints off entirely. See renaming
directives.