Repository navigation
Add @cacheControl and @cacheTag support - #13
Merged
Merged
Conversation
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Gives this subgraph Apollo Server's cache-hint semantics:
@cacheControlannotations in the schema become aCache-Controlresponse header, and@cacheTagis passed through to the federated SDL for the router to use for invalidation.Previously the bundle emitted no cache headers at all —
GraphControlleralways returned a bareJsonResponseand 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:
@cacheControlCache-Controlresponse header@cacheTagScope
SDL and YAML only (no PHP attributes/annotations), static directives only (no
setCacheHint()equivalent). Ships opt-in behindredeye_graphql.cache_control.enabled, because switching it on gives a header to responses that previously had none — includingno-storefor anything unannotated.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'swillResolveField. 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_entitiesresolver.The response policy is the smallest
maxAgeamong fields that decided one,privateif 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._entitiesQuery._entitiesis a root field, so the default rule would pin every federated entity fetch atmaxAge: 0and 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
@linkmoves from federation v2.0 to v2.12, which is where@cacheTagis defined.@cacheTagarrives through that import and gets no local definition (having both is a composition error);@cacheControlis not a federation directive, so it is declared locally together with itsCacheControlScopeargument type.Verification
GetUserTesterrors, caused bySymfony\Component\Security\Core\Securitybeing removed in Symfony 7 — they should not appear on the CI matrix, which pins 5.4–6.4.tests/Functional/CacheControl/: cacheable, type-level hint,privatescope, unannotated, errored, batch min,if-cacheable, and disabled.rover supergraph composesucceeds on Federation 2.12, with@cacheTagsurviving into the supergraph as@join__directive(graphs: [POSTS], name: "federation__cacheTag", args: {format: "post-{$key.id}"}).@cacheControlcorrectly does not survive — the router learns the TTL from the header. One cosmetic hint:CacheControlScopeis "defined but unused".Incidental fixes
Three pre-existing bugs in code this change makes load-bearing:
DirectiveNodebroke out of its loop after@deprecated, silently dropping every directive written after it on the same field. Regression test included.TracingReferenceExecutorcalled->startChild()on a span thatSentrySdk::getCurrentHub()->getSpan()may legitimately return asnull.Webmozart\Assertimport for a package the bundle never required.Notes for review
no-store, private, not bareno-store. Symfony'sResponseHeaderBagappends, privateto anyCache-Controlnaming neitherpublic,privatenors-maxage. Not reachable through Symfony'sResponseAPI (setCache()goes the same route) and means the same thing to every cache.phpunit.xml.distgets an explicitmemory_limit. The suite boots a kernel per functional test case and already sat at ~124M of the 128M default before this branch.vendor/symfony/*). No findings in the new code andphpstan-baseline.neonis unchanged, but CI is the first real run.Docs:
docs/definitions/cache-control.md.