Skip to content

Surrogate keys

A surrogate key is a label on a cached response. The cache key identifies exactly one cached response; a surrogate key can sit on many of them. Purging a surrogate key removes every response that carries it, so the keys decide what an invalidation can reach.

ThroughWhat it controls
Your originKeys of your own in the Surrogate-Key header.
Your GraphQL schema@surrogateKey: key fields (of), fixed keys (static), and fields left out (ignore).
gpilot.tomlWhich scalars and field names count as key fields, and which types and fields are left out (automatic key fields).
The API, the CLI and the portalPurging by key: gpilot purge <key>, purgeBySurrogateKey, or POST /purge with keys (purge on demand).
KeyExampleComes fromPlain HTTPPurged by a write’s response
Cache keyck:GET:/products/42, ck:9f86d0The key the entry is stored underYesNo
Origin keycatalog-v3The Surrogate-Key header on your origin’s responseYesYes, on a plain HTTP write and on a GraphQL mutation
Entity keyProduct:id:p01A key field found in a GraphQL responseNoYes, on a mutation
Empty-list key![Review]A list in a GraphQL response that came back emptyNoOnly as a target
Type keyProductThe types a GraphQL operation selects, whether or not any came backNoNo
Operation keyq:product, m:setPriceThe root field a GraphQL operation namesNoNo
Static keycatalogue@surrogateKey(static:) in your schemaNoNo

A key that is not purged by a write’s response is still a purge target: name it through the purge API or the CLI when you want that reach. What a GraphQL mutation purges on its own is the subject of automatic invalidation.

With response.surrogate_key_header switched on, every response lists its keys in gp-surrogate-key. A product page that asks for one product, its category and its reviews comes back with:

gp-surrogate-key: ck:95f9f0f4…72b0 q:product Category Product Query Review catalogue Category:id:audio Product:category.id:audio Product:id:p01 Review:id:r01 Review:productId:p01
KeyKindA purge of it reaches
ck:95f9f0f4…72b0Cache keyThis query, with every variable set.
q:productOperation keyEvery cached product(...) answer, whatever the id.
Category, Product, Query, ReviewType keysEvery cached response that selected that type.
catalogueStatic key, from @surrogateKey(static: "catalogue")Everything the schema groups under it: every product and category.
Category:id:audio, Product:id:p01, Review:id:r01Entity keysEvery cached response that contains that object.
Product:category.id:audioEntity key, from @surrogateKey(of: "category.id")Every cached response that contains a product of the audio category.
Review:productId:p01Entity key, from @surrogateKey(of: "productId") on ReviewEvery cached response that contains a review of p01.

Purging Product:id:p01 does not reach Review:productId:p01: a purge matches keys exactly, type name included. To clear a product and its reviews in one call, name both.

purgeAll and gpilot purge --all remove every stored entry; there is no key to name for that.

Every stored response carries its own cache key as a surrogate key, with a ck: prefix, so one response can be purged on its own:

  • Plain HTTP: ck:<METHOD>:<path>, the method and the path the request was sent to, query string included, for example ck:GET:/products/42?color=blue. A request that carries a body adds # and a SHA-256 of the body. Scheme and hostname are not part of the key, so one path is one entry whether the client used the service hostname or a custom domain. An origin that answers the same path differently per hostname can put them back with host = "include": the key is then the full URL, such as ck:GET:https://catalog-api.k7m2qp.graphpilot.cloud/products/42?color=blue, and each hostname keeps its own entry. This is how you purge one plain HTTP response without a key of your own. There is no purge-by-URL call; you name the cache key, and the surest way to get its spelling is to copy it from the request detail in the portal or from the gp-surrogate-key header.
  • GraphQL: ck:<queryHash> covers every variable set of a query, and a longer form that adds the operation name and the variables hash (ck:<queryHash>#<operationName>:<variablesHash>, the variables hash being 32 hex digits) addresses exactly one cached response. The variables hash here is a keyed digest that differs per service (see what the entry is keyed by), so copy it from the request detail in the portal or from request telemetry; it cannot be computed from the variables. Neither form carries the path or the query string the response was stored under, so a purge reaches it on every route: one purge of ck:<queryHash> clears that query on /graphql, /graphql/acme and /graphql?locale=de alike.

A cache key becomes a surrogate key only when it is printable ASCII and at most 1024 bytes. A normal URL is both; an entry stored under a longer one carries no ck: key, and only a key your origin named or a purge-all reaches it. See purging one cached response by its cache key.

Your origin adds keys with a Surrogate-Key header on its response. On a read they tag the entry, on a write they purge. The header and its format are on the Surrogate-Key header.

GraphQL only For plain HTTP, name keys in the Surrogate-Key header on your origin's response.

GraphPilot generates these keys from your schema and from the response data, without any annotation. You can declare more with the @surrogateKey directive.

Every query response carries q:<field> for its root field, for example q:product. It lets you purge a query regardless of which arguments were used. It is the field name the schema defines, not an alias the client chose.

A mutation response carries m:<field>, for example m:setPrice. It describes the response, so its root field is visible in telemetry and on the response. Nothing is ever stored under it, because a mutation is never cached.

Every type an operation selects adds a key with its name, such as Product, whether or not any object of that type came back. An operation that returns an empty list still carries it. Under a field of an interface or union type, the type selected is the interface or union, plus each member the query names in a fragment: { media { title } } carries Medium and no Book, whatever the response holds.

<typename>:<keyField>:<keyValue> names one object. For a type Product whose id field is p01, the key is Product:id:p01.

Without any directive, two rules decide which fields are key fields: a field whose type is a key scalar (ID by default) and a field whose name is a key name (id, _id and key by default). Both are configurable, and both can be switched off where they pick a field that identifies nothing; see ignore.

A key can combine several fields (PriceList:currency:EUR:region:de) or reach through a related object (Product:category.id:audio). Both are entity keys. A composite key is only reached by a purge naming it in full; see composite keys.

A list that came back empty adds ![<typename>], so creating the first object of that type can clear the cached “there are none” answer. See why the empty-list key exists.

Every key GraphPilot stores is printable ASCII (0x21 to 0x7E). How a key gets there depends on where it comes from.

Keys from your schema and your data are folded

Section titled “Keys from your schema and your data are folded”

This covers GraphQL keys built from field values, static keys from the directive, and keys from your origin’s Surrogate-Key header. Every character is folded into printable ASCII:

  • Printable ASCII (! through ~) is kept exactly as written, casing included. :, {, -, _ and every other printable symbol pass through unchanged.
  • Latin letters with a diacritic fold to their base letter: Müller becomes Muller, Łódź becomes Lodz, Ångström becomes Angstrom. The decomposed spelling (a base letter followed by a combining mark) reaches the same key, because the combining mark is dropped.
  • Ligatures and similar letters take their conventional ASCII spelling: ß becomes ss, æ becomes ae, œ becomes oe, þ becomes th.
  • Every other character becomes one underscore: a space, a tab, a non-breaking space, ×, Cyrillic, CJK, emoji. A product titled Blue Shirt is tagged Product:title:Blue_Shirt.

The folding is deliberately lossy. Blue Shirt and Blue_Shirt share a key, as do Müller and Muller, and two Cyrillic words of the same length collapse onto the same run of underscores. A collision means a purge clears a little more than you named, never less.

Casing is never changed, neither in your schema’s field names nor in the response data, so Product:sku:AB1 and Product:sku:ab1 are two different keys.

Keys from your origin are folded the same way

Section titled “Keys from your origin are folded the same way”

Keys in the Surrogate-Key header go through the same folding, so Brand:Müller from your origin and Brand:Müller from your data reach the same key. ASCII spaces, tabs and commas separate keys in the header, so none of them can be part of one. See the header’s format.

The purge API accepts any characters and folds them before it runs, so you can purge with a value exactly as it appears in your data: Product:title:Blue Shirt reaches the entry tagged Product:title:Blue_Shirt.

A key your schema derives is never shortened. One that is longer than 1024 bytes after folding is still attached to the response, but a purge naming it is refused. (A cache key works differently: one that long is not attached at all, as above.)

Keep a key under 1024 bytes after folding: a few characters fold to two (ß becomes ss). Free text such as titles makes a poor key in any case; prefer an identifier.

The keys of one response share a budget of 16,000 bytes. GraphPilot stores them as short hashes to fit, and a response whose keys do not fit even then is not cached rather than cached with keys missing. The hash widths, how many keys each holds and what to do about a response that does not fit are on limits.