Who shares an entry
A cached response is served to everyone who sends the same request and belongs to the same audience. Partitioning is how you say what that audience is. Ask one question of every cached answer:
Who gets exactly the same answer?
The answer is almost never “only this one person”. A price list is the same for everybody. A deployment list is the same for everyone in an organization. A billing page is the same for every admin in that organization. Each wider audience is one entry serving more callers, which is what keeps a hit rate up. Choose the widest audience that is still correct.
PUBLICOne entry, shared by everyone- everyoneAnaBenCemDiaEli
ORGANIZATIONOne entry per organization- AcmeAnaBenCem
- GlobexDiaEli
ORGANIZATION_ROLEOne entry per organization and role- Acme / adminAna
- Acme / memberBenCem
- Globex / memberDiaEli
PRIVATEOne entry per user- AnaAna
- BenBen
- CemCem
- DiaDia
- EliEli
Ways to control this
Section titled “Ways to control this”| Through | What it controls |
|---|---|
| Your origin | private, public or s-maxage in Cache- (authenticated requests), and the Vary header. |
| Your GraphQL schema | The scope of @cacheControl: PUBLIC, PRIVATE, or a named scope such as an organization. |
gpilot.toml | [cache.: public, private, bypass and vary rules, built from the claims your auth providers verify. |
| The API, the CLI and the portal | No partitioning setting. To take access away before an entry expires, revoke, then purge. |
The ladder
Section titled “The ladder”From widest to narrowest. Each rung keeps a separate entry per distinct value, so each costs hit rate against the one above it. Entries are also kept per edge location.
| Audience | One entry per | Hit rate | Configure it with |
|---|---|---|---|
Everybody (PUBLIC) | cache key | Highest: every caller warms the entry for every other caller | Nothing: a response is shared unless something narrows it. From a signed-in caller, your origin has to say public or s-maxage; see authenticated requests. |
| An organization | key and organization | One miss per organization, then shared by every member | A vary rule on the organization claim; for GraphQL, a named scope such as ORGANIZATION on the types it is true of |
| An organization and role | key, organization and role | One miss per role in each organization | Two vary rules, or one combining both; for GraphQL, a named scope such as ORGANIZATION_ |
| A tenant or a language | key and each value | Divided by the number of values, on every request the rule applies to | A vary rule (auth: for a tenant, the primary language of Accept- for a language, never the raw header; see caching per language), or your origin’s Vary header |
One caller (PRIVATE) | key and caller | Lowest: follows how often the same caller repeats a request, and the first request of each caller always misses | Your origin’s Cache-Control: private, or scope: PRIVATE in the schema, plus a private expression that identifies one caller |
Each rung describes a service configured for that rung. By default a vary rule partitions every
response, so a service that also configures a role rule for ORGANIZATION_ROLE keys its PUBLIC
and ORGANIZATION entries on the role too. Mark the rule
scope_only and each rung holds on its own
for the responses your schema decides: a named scope, and PUBLIC, then partition only on the rules
they list. Configure only the rules some data needs; partitioning your
cache shows the effect with numbers.
Rungs combine. An entry keyed on organization and language is served only to callers who match both, and a query that selects one organization-wide field and one private field is private as a whole.
PRIVATE is the last rung, not the default for anything signed in. Moving a type from
PRIVATE to an organization scope turns one entry per member into one per organization. Reserve
PRIVATE for what really differs per person: a user’s own profile, their inbox, rows only they
own.
Three rungs in one service
Section titled “Three rungs in one service”The GraphPilot demo API, a product
catalog served at https://demo-api.graphpilot.cloud, puts three rungs into one schema:
| Data | Who gets the same answer | Rung |
|---|---|---|
Product, Category, Review | Every visitor: a price is the same for everybody | PUBLIC |
Report (revenue and orders) | Everyone in one organization with the same role, because an admin may see figures a viewer may not | Organization and role, as the named scope ORGANIZATION |
Me (the caller’s profile and favorites) | One person | PRIVATE |
enum CacheControlScope { PUBLIC PRIVATE ORGANIZATION @vary(names: ["x-gp-organization", "x-gp-role"])}
type Product @cacheControl(maxAge: 3600, scope: PUBLIC) { id: ID! name: String! price: Int! }type Report @cacheControl(maxAge: 300, scope: ORGANIZATION) { revenue: Int! orders: Int! }type Me @cacheControl(maxAge: 60, scope: PRIVATE) { id: ID! role: String! }[cache.policy]private = 'auth:demo.sub'
[[cache.policy.vary]]name = "x-gp-organization"value = 'auth:demo.org_id'scope_only = true
[[cache.policy.vary]]name = "x-gp-role"value = 'auth:demo.role'scope_only = trueFour callers ask for the same report. The demo issues its own tokens, so the request can be run as written:
mint() { curl -sS https://demo-api.graphpilot.cloud/auth/token \ -H 'content-type: application/json' \ -d "{\"sub\":\"$1\",\"org\":\"$2\",\"role\":\"$3\"}" | jq -r .token}
for token in "$(mint user-ana org-acme admin)" "$(mint user-ben org-acme admin)" \ "$(mint user-ben org-acme viewer)" "$(mint user-dia org-globex admin)"; do curl -sS -D- -o/dev/null https://demo-api.graphpilot.cloud/graphql \ -H 'content-type: application/json' \ -H "authorization: Bearer $token" \ -d '{"query":"query Numbers { reports(range: MONTH) { organizationId role revenue } }"}' \ | grep -i '^gp-cache:'donegp-cache: MISS Ana, admin at Acme, fills the entrygp-cache: HIT Ben, admin at Acme, is served Ana's entrygp-cache: MISS Ben as a viewer is a different bucketgp-cache: MISS Dia is in a different organizationEach response says only its gp-cache line; the notes on the right are added here. Ben’s
second request shows why the role is in the key: keyed on the organization alone, Ben the viewer
would have been served the admin’s figures, and no permission check would have run. A me query
sent by the four is four entries.
A product query sent by all four is one entry, because PUBLIC names no rule and both rules are
scope_only: Ana and Dia asking for the same product are one miss and one hit, and an anonymous
visitor is served the same entry. Without scope_only, the product query would be stored once per
organization and role as well, which is the cost described under the ladder.
Choosing a rung
Section titled “Choosing a rung”- Preferences, such as a language, a country or a device class, are chosen by the caller, and
a caller who picks the wrong one harms nobody. A
header:orquery:source is fine. - Groups, such as an organization, a customer or a role, are security boundaries. Derive them
from a verified
auth:claim, never from a header the caller sets. - One caller is identified by a verified claim such as
sub, never by a group id. - A value that names a person, such as a user id or an email address, can be wrapped in
pseudonymize. The partition stays exactly as narrow, because one person always derives the same pseudonym, but the entry is partitioned on a 43-character keyed value instead of the id, and aplainorigin receives that value too.
For example, a per-user vary rule that keeps the organization readable, for a
named scope
to list in its @vary:
[[cache.policy.vary]]name = "x-gp-organization-user"value = "auth:demo.org_id + ':' + pseudonymize(auth:demo.sub)"scope_only = trueBen at org-acme gets an entry of his own, as under PRIVATE. Insights records his segment as an
hmac-sha256: digest under the default telemetry.vary_values = "pseudonymized", and as
8:org-acme:43: followed by his pseudonym under vary_values = "clear": the organization readable,
user-ben nowhere.
The trust rules behind this are on identifying callers.
Which bucket an entry goes into
Section titled “Which bucket an entry goes into”Every stored response goes into exactly one bucket, and the bucket is part of what a later lookup matches on:
| Bucket | When |
|---|---|
| Private | The origin header said private, a selected field is scoped PRIVATE, or a signed-in caller’s unmarked response was moved here. Partitioned by your cache. expression. |
| Named | The schema scope named its own audience. Partitioned by the vary rules the scope lists, and by every other configured rule not marked scope_only. When your origin’s Cache- decided the lifetime, every configured vary rule applies, scope_only or not. |
| Public | Everything else, including a schema hint that states no scope. Partitioned by your cache. expression, if you configured one. |
A response that selected fields from more than one of these is keyed on all of them at once, and
is served only to a caller who matches every one. On top of the bucket, the entry keys on whatever
your origin’s Vary names.
Every configured vary rule keys the entry too, whether or not anything names it, except a rule
marked scope_only on a response your schema decided: that rule keys it only when its bucket names
it above. A response whose lifetime your origin’s Cache-Control decided keys on every
configured rule.
A response that cannot be placed is not stored rather than shared: a private or named-scope
response whose discriminator is missing for the request reports
CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR.
Where to go next
Section titled “Where to go next”- Scopes in the schema:
PUBLIC, named scopes andPRIVATEon your GraphQL types. - Identifying callers: the
privateexpression, vary rules, your origin’sVary, and where a value may come from. Works for plain HTTP and GraphQL. - Authenticated requests: what happens when a signed-in caller’s response does not say who may share it.
- Revoking access: taking access away before a cached entry expires.