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:

  1. A full HTTP sub-request is fired for every unique URL.
    The deferred callback inside Route::resolve() only calls
    $url->toString(TRUE), $url->access() and
    optionally constructs a redirect. None of this actually requires a
    sub-request unless url.source === LanguageNegotiationUrl::CONFIG_DOMAIN
    is active. Every resolve pays the full
    KernelEvents::REQUESTRESPONSE listener
    pipeline — this is by far the dominant cost.
    Related maintainer comment:
    drupal-graphql/graphql#1356.
  2. The same Url object 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 the GeneratedUrl cacheability metadata.

  3. Redirect lookups run before the URL is even validated.
    Both domainPathRedirectRepository->findMatchingRedirect()
    and redirectRepository->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.
  4. 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.
  5. A transient redirect entity 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.
  6. Request::create($value) is heavy for what it
    is used for (inbound path processing only) and its query->all() +
    getQueryString() duplicate query-string parsing on each resolve.

Steps to reproduce

  1. Enable graphql_core_schema with the routing extension.
  2. Run a GraphQL query that resolves the route field for a
    representative page (e.g. a frontend page with a 30-item menu):
    query { items: menuItems { link { url { ... on EntityCanonicalUrl { path } } } } }
  3. 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:

  1. Skip the sub-request for the common case. Only enqueue
    the SubRequestBuffer when 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.
  2. Memoise $url->toString(TRUE). Compute the
    GeneratedUrl once in SubRequestBuffer::add(),
    store it alongside the buffer item, and reuse it in
    getBufferId(), resolveBufferArray() and the
    extract callback. Also attach the GeneratedUrl to the
    FieldContext so URL-level cache tags bubble up correctly
    (currently discarded on line 211).
  3. 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).
  4. 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.
  5. Replace transient redirect entity with a value object.
    Introduce a lightweight
    Drupal\graphql_core_schema\ValueObject\TransientRedirect
    and update the Url type resolver in
    RoutingExtension to recognise it. Avoids entity init +
    hooks on every non-canonical URL.
  6. Drop Request::create($value). Parse the
    query string once via parse_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
Command icon 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

siegrist created an issue.