Problem/Motivation
The get_route data producer
(Drupal\graphql_core_schema\Plugin\GraphQL\DataProducer\Route)
and its companion SubRequestBuffer are on the hot path of every
decoupled page load: menus, breadcrumbs, link lists, related content,
canonical URLs — all resolve through them. Profiling shows a number of
avoidable overheads per resolve that add up to 5–20 ms each on a warm cache:
-
A full HTTP sub-request is fired for every unique URL.
The deferred callback insideRoute::resolve()only calls
$url->toString(TRUE),$url->access()and
optionally constructs a redirect. None of this actually requires a
sub-request unlessurl.source === LanguageNegotiationUrl::CONFIG_DOMAIN
is active. Every resolve pays the full
KernelEvents::REQUEST→RESPONSElistener
pipeline — this is by far the dominant cost.
Related maintainer comment:
drupal-graphql/graphql#1356. -
The same
Urlobject is serialised to string three times per resolve:SubRequestBuffer::getBufferId()→$item['url']->toString(TRUE)SubRequestBuffer::resolveBufferArray()→reset($buffer)['url']->toString(TRUE)Route::resolve()deferred callback →$url->toString(TRUE)->getGeneratedUrl()
Each call re-runs URL generation + outbound path processors. The last call
also discards theGeneratedUrlcacheability metadata. -
Redirect lookups run before the URL is even validated.
BothdomainPathRedirectRepository->findMatchingRedirect()
andredirectRepository->findMatchingRedirect()issue DB
queries on every resolve, even for the 99% case where the incoming path
equals the canonical URL. Neither repository has a request-level cache. -
Per-resolve service / config lookups that are process-scoped invariants:
languageNegotiator->isNegotiationMethodEnabled('language-url'),
getNegotiationMethodInstance('language-url'),
configFactory->get('language.negotiation'), and
entityTypeManager->hasDefinition('redirect')are all
resolved on every call. -
A transient
redirectentity is created via
$redirectStorage->create()
just to pass metadata back out of the deferred callback. Entity
instantiation triggers field init, hooks, bundle resolution — none of
which is needed for a non-persisted value object. -
Request::create($value)is heavy for what it
is used for (inbound path processing only) and itsquery->all()+
getQueryString()duplicate query-string parsing on each resolve.
Steps to reproduce
- Enable
graphql_core_schemawith the routing extension. - Run a GraphQL query that resolves the
routefield for a
representative page (e.g. a frontend page with a 30-item menu):query { items: menuItems { link { url { ... on EntityCanonicalUrl { path } } } } } - Profile with Blackfire / XHProf and measure time spent in
HttpKernel::handle,Url::toString, and
RedirectRepository::findMatchingRedirect.
Proposed resolution
Addressed together in a single MR, in order of estimated impact on median throughput:
-
Skip the sub-request for the common case. Only enqueue
theSubRequestBufferwhen the target URL needs
request-bound context that cannot be produced on the main request —
primarily domain-based language negotiation
(CONFIG_DOMAIN). For canonical URLs on non-domain setups,
perform$url->access()and
$url->toString(TRUE)directly and propagate cacheability.
Expected saving: 5–20 ms per resolve. -
Memoise
$url->toString(TRUE). Compute the
GeneratedUrlonce inSubRequestBuffer::add(),
store it alongside the buffer item, and reuse it in
getBufferId(),resolveBufferArray()and the
extract callback. Also attach theGeneratedUrlto the
FieldContextso URL-level cache tags bubble up correctly
(currently discarded on line 211). -
Reorder redirect lookups. Call
pathValidator->getUrlIfValidWithoutAccessCheck()first;
consult redirect repositories only when the generated URL diverges from
the input ($value !== $target_url) — the same condition
already used inside the deferred callback. Add per-request
memoisation keyed on
(path, language, domain, queryHash). -
Cache service / config lookups on the instance. Resolve
isNegotiationMethodEnabled,
getNegotiationMethodInstance('language-url'),
configFactory->get('language.negotiation')and
hasDefinition('redirect')once (constructor or lazy-once) and
reuse. -
Replace transient redirect entity with a value object.
Introduce a lightweight
Drupal\graphql_core_schema\ValueObject\TransientRedirect
and update theUrltype resolver in
RoutingExtensionto recognise it. Avoids entity init +
hooks on every non-canonical URL. -
Drop
Request::create($value). Parse the
query string once viaparse_url+parse_str
and reuse a single prototype request (or just pass the path / query
directly where possible).
Remaining tasks
- Implement changes behind conservative defaults in one MR.
- Add Kernel tests covering: domain-based language negotiation path, redirect module integration, access-denied redirects, frontpage redirect skipping.
- Benchmark before/after on a 30-URL menu query and attach numbers.
User interface changes
None.
API changes
Additive. SubRequestBuffer::add() keeps its signature;
internal buffer item shape gains a memoised GeneratedUrl.
A new public TransientRedirect value object is added to the
ValueObject namespace. No changes to the GraphQL schema.
Data model changes
None.
Environment
- Drupal core: 11.3.8
- graphql_core_schema: 1.0.24 (current stable: 1.0.26)
- graphql: 4.13.0 (8.x-4.13)
- PHP: 8.3.30
Issue fork graphql_core_schema-3588240
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments