Skip to content

Skip data loaders with no queued keys in ticker-mode dispatchAll - #293

Open
Meemaw wants to merge 1 commit into
graphql-java:masterfrom
Meemaw:ticker-mode-skip-idle-loaders
Open

Meemaw wants to merge 1 commit into
graphql-java:masterfrom
Meemaw:ticker-mode-skip-idle-loaders

Conversation

@Meemaw

@Meemaw Meemaw commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #292.

In ticker mode, dispatchAll() ran a full dispatch() on every registered data loader, including loaders with nothing queued. A request's registry usually holds many more loaders than any one dispatchAll() has work for, and graphql-java calls dispatchAll() many times per request. Most of the work, and roughly 48 bytes of allocation per idle loader per call, went to loaders with empty queues. #292 has the profile data.

Change

  • In ticker mode, dispatchAllWithCount() now:
    • re-arms the ticker once, before dispatching, so a throwing loader or predicate still can't stop it;
    • then dispatches only loaders with dispatchDepth() > 0 whose predicate passes. dispatchDepth() is a single atomic read of the queue head.
  • Non-ticker mode is unchanged. There, a loader whose predicate says no keeps its own reschedule chain, and that behaviour is left as is.

Behaviour

  • Preserved: after the first dispatchAll(), every loader whose predicate passes is dispatched at least every schedule until close(). A key queued after the depth check is dispatched by the next tick, the same window as a key queued just after a dispatch today.
  • Preserved: the close() semantics from Keep one pending ticker task per ScheduledDataLoaderRegistry #291, and the ticker surviving a throwing predicate.
  • Changed, idle loaders only:
    • Predicates are no longer evaluated for loaders with nothing queued.
    • beginDispatch instrumentation no longer fires for them.
    • Their lastDispatchTime is no longer refreshed. The only built-in reader is DispatchPredicate.dispatchIfLongerThan. With it, the first key queued after an idle period now dispatches on the next dispatchAll(), rather than waiting duration after the last empty dispatch. "Time since dispatch" then means time since the last dispatch that did something.

Benchmark

This adds ScheduledDataLoaderRegistryDispatchPerformance (JMH). It builds a ticker-mode registry the way DGS does: DISPATCH_NEVER for the registry, DISPATCH_ALWAYS per loader, and an hour-long schedule so the ticker never fires during a measurement. Caching is off so every load is queued. Each operation queues one key on N loaders, then calls dispatchAllWithCount().

The runs were on JDK 21, Apple M-series, with -prof gc. Each variant ran twice (one fork, 3×2 s warmup, 5×2 s measurement), interleaving master and this branch. Another JMH workload was running on the machine at the same time, so treat the absolute numbers as indicative. The ranges below span both runs.

Registered loaders Loaders with queued keys master (da59d1d) This PR Speed-up
20 0 853–881 ns, 960 B 59–60 ns, 0 B ~14×
200 0 9,199–9,468 ns, 9,600 B 677–688 ns, 0 B ~14×
20 5 1,129–1,199 ns, 3,920 B 575–610 ns, 2,840 B ~1.9×
200 5 9,766–10,069 ns, 16,600–16,880 B 1,560–1,588 ns, 2,840 B ~6.3×

With this change, the cost of a dispatchAll() that has work no longer depends on how many loaders are registered: 2,840 B per call with 20 or 200 loaders. Run the benchmark with ./gradlew jmhJar, then java -jar build/libs/*-jmh.jar ScheduledDataLoaderRegistryDispatchPerformance -prof gc.

End-to-end. We ran a graphql-java 25 workload with DGS-style ticker registries: 200 loaders, a query fanning out to 12 of them over three nested levels, 3 ms simulated I/O, and 48 concurrent requests. With this change applied:

  • one idle dispatchAll() went from 32.3 µs to 2.5 µs;
  • process CPU per request went from 25.1 to 23.4 ms (−7%, mean of 3 runs).

We've applied the same change as a local patch to our service. Based on the profile in #292, we expect it to remove most of the ~3% of CPU that went to empty dispatches.

Tests

  • New: ticker_mode_dispatch_all_only_visits_data_loaders_with_queued_keys. It uses 20 loaders with one queued key, and asserts:

    • only that loader's predicate runs;
    • its load completes;
    • the count is 1;
    • one tick is pending.

    Without the dispatchDepth() check it fails, because the predicate runs for all 20 loaders.

  • The existing ticker tests from Keep one pending ticker task per ScheduledDataLoaderRegistry #291 still pass: one pending task, rescheduleNow, ticking after a throwing predicate, and close.

  • ./gradlew build on JDK 21 passes.

A registry typically holds many more data loaders than a single
dispatchAll() has work for, and graphql-java calls dispatchAll() many
times per request. In ticker mode, dispatch only the data loaders whose
dispatchDepth() is non-zero; a key queued after the check is picked up
by the next tick.
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.

ScheduledDataLoaderRegistry ticker mode dispatches every registered data loader on each dispatchAll(), even when nothing is queued

1 participant