Skip to content

Automatic invalidation

GraphQL only A plain HTTP write purges the keys named in the Surrogate-Key header on its response.

Every mutation that goes through GraphPilot invalidates the cached responses that contained the objects it returned. You write no purge call, annotate nothing, and configure nothing: it follows from the surrogate keys both the query and the mutation were tagged with.

This page states exactly which keys a purge follows, so you can predict what a given mutation clears and, just as importantly, what it does not.

Every response GraphPilot handles is tagged with a set of surrogate keys, and the kind of key decides what a purge can do with it later. Automatic invalidation follows exactly two kinds: entity keys such as Product:id:p01, found in the response body, and origin keys your origin names in a Surrogate-Key header. Empty-list keys (![Product]) are purged as targets. Type keys, operation keys, static keys and cache keys are never purged automatically.

The distinction that matters: an entity key names something that exists, so purging it removes exactly the responses that contained that object. A type key or an operation key names a shape, so purging it would remove every response of that shape at once. GraphPilot never does the second one on your behalf.

Composite and nested keys follow the same rule. A type keyed on two fields produces PriceList:currency:EUR:region:de, and a key field reached through a parent field produces Product:category.id:audio. Both are entity keys and both purge automatically.

So do keys an object inherits from an interface or a union. With interface Medium @surrogateKey(of: "isbn"), a Book carries Medium:isbn:111 and Book:isbn:111 wherever it appears, whether the query asked for media or for book, and so does a mutation that returns it.

A mutation response is tagged exactly like a query response, from the objects it returns. Whenever a mutation response comes back, GraphPilot purges:

  1. Every entity key on it. setPrice returning Product:id:p01 purges every cached response anywhere that contained product p01, whatever operation produced it. A mutation returning a Book whose key is declared on its interface purges Medium:isbn:111 and Book:isbn:111, which reaches the cached media lists and the cached book answers that showed it.
  2. Every origin key on it, so your origin can name additional things to clear by setting a Surrogate-Key header on the mutation response.
  3. ![Typename] for every type the mutation touched, derived both from the entity keys it returned and from the types its selection names. The root type counts too, so every mutation purges ![Mutation] as well, which no cached response carries.

Everything else on the response rides along to describe it and purges nothing. A failed mutation usually returns no objects, so it usually carries nothing to purge.

Take a setPrice response tagged m:setPrice, Product:id:p01, and the type keys Mutation, Product and Review, because the mutation selected the product’s reviews and the product has none yet. It purges exactly four keys:

Product:id:p01 the product that changed
![Mutation] derived from the root type; no cached response carries it
![Product] cached responses whose product list was empty
![Review] cached responses whose review list was empty

m:setPrice is not purged, because nothing is cached under a mutation name. Review is not purged either: the mutation selected reviews, and clearing every cached response that so much as mentions reviews would empty most of your cache on a single write. What it purges instead is ![Review], the entry that claims there are none.

An entity key can only invalidate a response that already contained that object, which leaves one case uncovered: a response that is correct only because the object did not exist yet.

query { product(id: "p01") { reviews { id body } } } # → { "reviews": [] }

That response is tagged ![Review]. When a mutation later creates a review, the new review’s entity key clears nothing (no cached response contained it), but ![Review] clears the cached “there are none” answer. Without it, an empty list would stay empty for its whole TTL and the first item your users create would appear to vanish.

Many schemas wrap the changed object in a payload type, in the Relay style:

type Mutation {
setPrice(input: SetPriceInput!): SetPricePayload
}
type SetPricePayload {
product: Product
clientMutationId: String
}

A client that selects only clientMutationId never receives the Product, so its response would carry no Product:id:… key and purge nothing. To prevent that, GraphPilot looks one level below a mutation’s return type for types that carry surrogate keys, their own or ones inherited from an interface or a union. When it finds one, it adds that field and its key fields to the query it sends your origin, reads the keys from the answer, and purges them, even if the client did not select the field. This works the same way as selection set expansion.

surrogate_key.mutation_payload_depth sets how far down GraphPilot looks:

ValueEffect
0Off. A mutation purges only what the client selected.
1 (default)The payload’s own fields: SetPricePayload.product. This reaches the object the mutation changed.
2One level further: the changed object’s single-valued fields, such as its category.
3 or moreEach further level adds the single-valued fields of the level above it.
gpilot.toml
[surrogate_key]
mutation_payload_depth = 1

Raising it to 2 is a broad tool. It purges the parent itself, so every cached response that mentions that category goes, not only the ones that listed the changed product. If a cached response reads your object through its parent, the narrower fix is a key on the object’s own type that names the parent, for example @surrogateKey(of: "category.id") on Product. See keys through a relation.

The descent has limits:

  • A mutation that returns a keyed type directly gets no descent. setPrice(...): Product is treated as returning the entity itself. Its own keys are purged as usual, but field-level rules on it (such as category: Category @surrogateKey(of: "id")) add nothing the client did not select.
  • A payload type with keys of its own is treated the same way. A payload that has its own id field is taken for an entity, and the object nested inside it is not reached. Select the nested object in your mutation, or return it directly.
  • Fields that require arguments are never added, because GraphPilot cannot know which values to send.

This is the part to read closely.

A create does not appear in a cached list that was not empty

Section titled “A create does not appear in a cached list that was not empty”

A cached list of a product’s reviews holding three reviews is tagged with those three entity keys, the type key Review, and q:product. An addReview mutation creating a fourth review purges the new review’s entity key, which no cached response contains, and ![Review], which does not match a non-empty list. The cached list keeps its three entries until its TTL expires.

Updates and deletes do not have this problem: they operate on an object that is already in the cached response, so its entity key is on both sides.

A mutation that returns nothing purges nothing

Section titled “A mutation that returns nothing purges nothing”

The purge is derived from the mutation’s response body. A mutation declared as deleteReview(id: ID!): Boolean! returns no object, carries no entity key, and therefore invalidates nothing at all. Return the affected object, or its parent, and the key comes with it.

Static keys are never purged automatically

Section titled “Static keys are never purged automatically”

A key from @surrogateKey(static: "catalogue") groups cached responses deliberately and by hand, so it is cleared deliberately and by hand, through the purge API. A mutation carrying the same static key does not sweep it, because a static key usually spans far more than the mutation touched.

Neither Product nor q:products is ever purged automatically. Both are available as purge targets through the API when you decide you want that reach.

Automatic purges are hard: the entry is removed, and the next request for it goes to your origin as a MISS. There is no soft-purge (serve-stale-while-refreshing) option on the automatic path.

The purge also runs when your billing account is over its request quota and the cache has stopped serving. That is deliberate. A mutation during such a window still has to invalidate entries written before it, or they would come back live and stale the moment normal service resumes. It is not recorded in the purge history while the service runs lean.

When request telemetry is stored (on every plan, within its logged-request allowance, and not in lean mode), every automatic purge is recorded in the service’s purge history with the cleartext keys it covered and the mutation request that caused it. You find it on the Purges page of the service in the portal, marked Automatic. The mutation request’s Edge codes, in the Overview tab of its Insights request detail, show a CACHE_PURGED entry for it with the number of keys, not the keys themselves.

A mutation that carried no purgeable key records no purge at all, rather than a purge of zero keys. So if a mutation has no entry in the history, it had nothing to clear, and that is usually the answer to “why is my list still stale”. See verifying a purge.

A product catalog, served by the service shop-api. Its schema keys each product three ways and returns the changed product from the mutation:

schema.graphql
type Product
@cacheControl(maxAge: 3600, scope: PUBLIC, swr: 3600)
@surrogateKey(of: "id")
@surrogateKey(of: "category.id")
@surrogateKey(static: "catalogue") {
id: ID!
name: String!
price: Int!
category: Category!
}
type Mutation {
setPrice(id: ID!, price: Int!): Product!
}
gpilot.toml
[response]
surrogate_key_header = true # show the keys on the response while you try this

1. A reader caches the product. A client asks for the price without selecting id or the category:

Terminal window
curl -sS -D- -o /dev/null "https://$GRAPHPILOT_HOST/graphql" \
-H 'content-type: application/json' \
-d '{"query":"query Price { product(id: \"p02\") { name price } }"}'
gp-cache: MISS
gp-surrogate-key: ck:0ed2ffb7…7884 q:product Product Query catalogue Product:category.id:audio Product:id:p02

Both entity keys are there although the client selected neither field: selection set expansion fetched id and category { id } for them. The same request again answers gp-cache: HIT.

2. An editor changes the price.

Terminal window
curl -sS "https://$GRAPHPILOT_HOST/graphql" \
-H 'content-type: application/json' \
-d '{"query":"mutation { setPrice(id: \"p02\", price: 2450) { id price } }"}'

The response carries the same entity keys, Product:id:p02 and, through the expanded category { id }, Product:category.id:audio, beside m:setPrice, the type keys Mutation and Product, and the static key catalogue. Before the response reaches the editor, GraphPilot purges exactly four keys:

Product:category.id:audio
Product:id:p02
![Mutation]
![Product]

The type keys, m:setPrice and catalogue are not purged.

3. The next reader gets the new price. The Price query from step 1 now answers gp-cache: MISS with "price": 2450, and is cached again.

Note the reach of Product:category.id:audio: every cached response holding any product of the audio category is purged too, not only those holding p02. That is what the category key on Product is for (one purge for a whole category), and it makes every product write a category purge. If a price change should not reach the rest of its category, leave the category key off Product and give the pages that list a category a key of their own; see what purges together.

4. What the portal shows. On the service’s Purges page the purge is listed with the trigger Automatic, and its detail panel lists the keys and links to the setPrice request under Triggered by. In Insights, that request’s Edge codes on the Overview tab show CACHE_PURGED with the number of keys. Both need the request to be recorded; see seeing what was purged.