Skip to content

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.

Five people in two organizations send the same request on a cold cache. The narrower the audience, the more entries, and the more requests reach your origin.
ThroughWhat it controls
Your originprivate, public or s-maxage in Cache-Control (authenticated requests), and the Vary header.
Your GraphQL schemaThe scope of @cacheControl: PUBLIC, PRIVATE, or a named scope such as an organization.
gpilot.toml[cache.policy]: public, private, bypass and vary rules, built from the claims your auth providers verify.
The API, the CLI and the portalNo partitioning setting. To take access away before an entry expires, revoke, then purge.

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.

AudienceOne entry perHit rateConfigure it with
Everybody (PUBLIC)cache keyHighest: every caller warms the entry for every other callerNothing: 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 organizationkey and organizationOne miss per organization, then shared by every memberA vary rule on the organization claim; for GraphQL, a named scope such as ORGANIZATION on the types it is true of
An organization and rolekey, organization and roleOne miss per role in each organizationTwo vary rules, or one combining both; for GraphQL, a named scope such as ORGANIZATION_ROLE
A tenant or a languagekey and each valueDivided by the number of values, on every request the rule applies toA vary rule (auth: for a tenant, the primary language of Accept-Language for a language, never the raw header; see caching per language), or your origin’s Vary header
One caller (PRIVATE)key and callerLowest: follows how often the same caller repeats a request, and the first request of each caller always missesYour 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.

The GraphPilot demo API, a product catalog served at https://demo-api.graphpilot.cloud, puts three rungs into one schema:

DataWho gets the same answerRung
Product, Category, ReviewEvery visitor: a price is the same for everybodyPUBLIC
Report (revenue and orders)Everyone in one organization with the same role, because an admin may see figures a viewer may notOrganization and role, as the named scope ORGANIZATION
Me (the caller’s profile and favorites)One personPRIVATE
schema.graphql (excerpt)
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! }
gpilot.toml
[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 = true

Four callers ask for the same report. The demo issues its own tokens, so the request can be run as written:

Terminal window
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:'
done
gp-cache: MISS Ana, admin at Acme, fills the entry
gp-cache: HIT Ben, admin at Acme, is served Ana's entry
gp-cache: MISS Ben as a viewer is a different bucket
gp-cache: MISS Dia is in a different organization

Each 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.

  • 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: or query: 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 a plain origin 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:

gpilot.toml
[[cache.policy.vary]]
name = "x-gp-organization-user"
value = "auth:demo.org_id + ':' + pseudonymize(auth:demo.sub)"
scope_only = true

Ben 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.

Every stored response goes into exactly one bucket, and the bucket is part of what a later lookup matches on:

BucketWhen
PrivateThe 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.policy.private expression.
NamedThe 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-Control decided the lifetime, every configured vary rule applies, scope_only or not.
PublicEverything else, including a schema hint that states no scope. Partitioned by your cache.policy.public 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.

  • Scopes in the schema: PUBLIC, named scopes and PRIVATE on your GraphQL types.
  • Identifying callers: the private expression, vary rules, your origin’s Vary, 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.