Skip to content

Add @cacheControl and @cacheTag support - #13

Merged
magnusnordlander merged 4 commits into
mainfrom
feature/cache-control-directives
Aug 19, 2026
Merged

magnusnordlander merged 4 commits into
mainfrom
feature/cache-control-directives

Conversation

@magnusnordlander

Copy link
Copy Markdown
Member

Gives this subgraph Apollo Server's cache-hint semantics: @cacheControl annotations in the schema become a Cache-Control response header, and @cacheTag is passed through to the federated SDL for the router to use for invalidation.

Previously the bundle emitted no cache headers at all — GraphController always returned a bare JsonResponse and the only header it ever set was CORS — so a router in front of it could cache nothing.

Apollo splits the two directives between the parties, and so does this:

Directive Interpreted by Effect
@cacheControl this subgraph produces the Cache-Control response header
@cacheTag the router tags data for invalidation; the subgraph only publishes it

Scope

SDL and YAML only (no PHP attributes/annotations), static directives only (no setCacheHint() equivalent). Ships opt-in behind redeye_graphql.cache_control.enabled, because switching it on gives a header to responses that previously had none — including no-store for anything unannotated.

redeye_graphql:
    cache_control:
        enabled: true
        default_max_age: 0
        calculate_http_headers: always   # always | if-cacheable | never

How it works

The per-field hint is collected in TracingReferenceExecutor::resolveFieldValueOrError, which graphql-php calls once per field per parent object — the direct equivalent of Apollo's willResolveField. The recorder travels in the GraphQL context value rather than being injected, since graphql-php builds the executor through a static factory with no container access; that also makes it reachable from the _entities resolver.

The response policy is the smallest maxAge among fields that decided one, private if any of them said so. A field that never decides contributes nothing — which is exactly how inheritance works, since non-root scalar fields stay undecided.

_entities

Query._entities is a root field, so the default rule would pin every federated entity fetch at maxAge: 0 and no entity response could ever be cached — defeating the point of the feature for a subgraph. It is exempt from the root default and instead takes the hint from the concrete type of each representation, matching Apollo.

Published SDL

The @link moves from federation v2.0 to v2.12, which is where @cacheTag is defined. @cacheTag arrives through that import and gets no local definition (having both is a composition error); @cacheControl is not a federation directive, so it is declared locally together with its CacheControlScope argument type.

Verification

  • 801 tests, up from 697. The only failures are 5 pre-existing GetUserTest errors, caused by Symfony\Component\Security\Core\Security being removed in Symfony 7 — they should not appear on the CI matrix, which pins 5.4–6.4.
  • Real HTTP responses asserted in tests/Functional/CacheControl/: cacheable, type-level hint, private scope, unannotated, errored, batch min, if-cacheable, and disabled.
  • rover supergraph compose succeeds on Federation 2.12, with @cacheTag surviving into the supergraph as @join__directive(graphs: [POSTS], name: "federation__cacheTag", args: {format: "post-{$key.id}"}). @cacheControl correctly does not survive — the router learns the TTL from the header. One cosmetic hint: CacheControlScope is "defined but unused".

Incidental fixes

Three pre-existing bugs in code this change makes load-bearing:

  • DirectiveNode broke out of its loop after @deprecated, silently dropping every directive written after it on the same field. Regression test included.
  • TracingReferenceExecutor called ->startChild() on a span that SentrySdk::getCurrentHub()->getSpan() may legitimately return as null.
  • A dead Webmozart\Assert import for a package the bundle never required.

Notes for review

  • no-store, private, not bare no-store. Symfony's ResponseHeaderBag appends , private to any Cache-Control naming neither public, private nor s-maxage. Not reachable through Symfony's Response API (setCache() goes the same route) and means the same thing to every cache.
  • phpunit.xml.dist gets an explicit memory_limit. The suite boots a kernel per functional test case and already sat at ~124M of the 128M default before this branch.
  • PHPStan could not be run locally — 0.12.100 fails to parse Symfony 7 syntax (386 parse errors on a clean checkout, all in vendor/symfony/*). No findings in the new code and phpstan-baseline.neon is unchanged, but CI is the first real run.

Docs: docs/definitions/cache-control.md.

magnusnordlander and others added 4 commits August 18, 2026 16:12
Gives this subgraph Apollo Server's cache-hint semantics: @CacheControl
annotations in the schema are turned into a Cache-Control response header,
and @cacheTag is passed through to the federated SDL for the router to use
for invalidation. Previously the bundle emitted no cache headers at all, so
a router in front of it could cache nothing.

The per-field hint is collected in TracingReferenceExecutor, whose
resolveFieldValueOrError override is the direct equivalent of Apollo's
willResolveField. The recorder travels in the GraphQL context value, since
graphql-php builds the executor through a static factory with no container
access; that also makes it reachable from the _entities resolver.

Query._entities is a root field, so the default rule would pin every
federated entity fetch at maxAge 0 and no entity response could ever be
cached. It is exempt from the root default and instead takes the hint from
the concrete type of each representation, matching Apollo.

The published SDL now links federation v2.12, which is where @cacheTag is
defined. @cacheTag arrives through that import and gets no local definition;
@CacheControl is not a federation directive, so it is declared locally along
with its CacheControlScope argument type. Verified with rover supergraph
compose: composition succeeds and @cacheTag survives into the supergraph.

Ships opt-in behind redeye_graphql.cache_control.enabled, because switching
it on gives a header to responses that previously had none, including
no-store for anything unannotated.

Also fixes three pre-existing bugs in code this makes load-bearing:

- DirectiveNode broke out of its loop after @deprecated, silently dropping
  every directive written after it on the same field.
- TracingReferenceExecutor called ->startChild() on a span that
  SentrySdk::getCurrentHub()->getSpan() may legitimately return as null.
- A dead Webmozart\Assert import for a package the bundle never required.

phpunit.xml.dist gets an explicit memory_limit: the suite boots a kernel per
functional test case and already sat at ~124M of the 128M default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Port ApolloServerPluginInlineTrace: when the gateway sends the header
`apollo-federation-include-trace: ftv1`, respond with `extensions.ftv1`
carrying a base64-encoded protobuf Trace of the resolver tree.

There is no published FTV1 spec, so docs/federation/inline-trace.md derives
one from Apollo's reports.proto and the Apollo Server / federation-jvm
implementations.

The trace is assembled at three points, mirroring Apollo's plugin lifecycle:
InlineTraceListener starts it on graphql.pre_executor, TracingReferenceExecutor
records per-field timings, and the listener encodes it on graphql.post_executor
at priority 100 -- ahead of ErrorHandlerListener, which rebuilds errors and
drops their extensions.

State lives in the per-execution context ArrayObject rather than on the
listener service, so batched queries each get their own trace. This is the same
approach @CacheControl already uses for its recorder; the two hooks sit side by
side in resolveFieldValueOrError, cache-control first because it only reads the
field, inline trace last because it starts a clock and should not be charged for
the Sentry setup above it.

Notable details:

- Field end times use ReferenceExecutor::getPromise(), not the promise
  adapter: DataLoader returns a GraphQL\Executor\Promise\Promise, which
  SyncPromiseAdapter::isThenable() rejects, so convertThenable() would throw
  on the most common async path in this bundle.
- The end callback captures the trace node, never the ResolveInfo:
  completeListValue() mutates the shared ResolveInfo::path per list item.
- `include_errors: unmodified` reports the client-visible message via
  FormattedError, not $error->getMessage(). The bundle's ErrorHandler masks
  at response-format time, so the raw exception text (SQL, DSNs, paths) is
  still on the Error object while the trace is built.
- The federation default is resolved in a root validate() callback. Symfony
  normalizes config fragments individually before merging, so a
  beforeNormalization would not see both keys.

Generated protobuf classes live in proto/gen/, outside the paths php-cs-fixer
and PHPStan scan, so neither needs exclusion config. A CI job regenerates with
a pinned protoc and fails on drift.

Verified against a golden ftv1 payload captured from a real Apollo Server
subgraph, against raw protobuf tag bytes, and by decoding this bundle's own
output with `protoc --decode_raw`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 5.x PHP runtime is not a drop-in for 3.x: protoc's PHP backend now emits
Google\Protobuf\RepeatedField instead of Google\Protobuf\Internal\RepeatedField
and adds native parameter types to setters, so the generated code only works
against a matching runtime. proto/gen is therefore regenerated with protoc 35.1,
the release that corresponds to the 5.35.1 PHP package, and the CI drift job's
pin moves with it.

Raising the floor to 8.2 drops PHP 8.0 and 8.1 from the test matrix, which also
retires the two 8.0-with-Symfony-6.2/6.4 excludes. The two `include` legs (the
optional-dependency check and the coverage run) move to 8.2. The coding-standard
and static-analysis jobs, the AppVeyor build, and the docs version table follow.

CacheScope's comment claimed a native enum was impossible because the bundle
supports PHP 8.0. That is no longer true, so the comment says so; the class is
otherwise left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add Apollo Federation inline tracing (ftv1)
@magnusnordlander
magnusnordlander merged commit 58b2b1a into main Aug 19, 2026
3 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant