Skip to content

Drop the per row context copy in navigation resolvers, and page connections with one query - #1406

Merged
SimonCropp merged 2 commits into
mainfrom
perf-per-row-resolvers
Sep 10, 2026
Merged

Drop the per row context copy in navigation resolvers, and page connections with one query#1406
SimonCropp merged 2 commits into
mainfrom
perf-per-row-resolvers

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

Per row overhead in the navigation resolvers

Measured with RequestSplitBenchmark (EF InMemory, 100 parents with 5 children each), the trees the library builds per request are about 0.2% of a request, as recorded before. What remained was per parent row in the navigation, navigation list and navigation connection resolvers: each built a ResolveEfFieldContext by copying every property of the GraphQL.NET context, which forced the lazily computed SubFields, Path, ResponsePath, Parent and Arguments to be computed and allocated for every row, when the resolver only needs the DbContext and the filters.

  • The three resolvers resolve the DbContext and the filters directly.
  • A field selected without arguments skips the argument reads and the push down lookup, since GraphQL.NET builds the argument dictionary lazily per field per row.
  • The in memory ids and where predicate of a navigation field, on the path where the arguments are not pushed into the query, is compiled once per request per field rather than once per parent row (PredicateCache).
  • A projection expression is analyzed once when its field is registered (ProjectionPaths), the walks over graph type hierarchies are cached per graph type, a selection set of plain fields skips the derived navigation scan, and the filter hierarchy lookup is cached per entity type.
Benchmark Before After
Nested query, 100 parents 2,858 us / 3,989 KB 2,761 us / 3,924 KB
where and orderBy on a navigation evaluated in memory (new FullWithInMemoryArguments) 4,052 us / 4,554 KB 3,311 us / 4,144 KB

The library's allocation beyond GraphQL.NET and EF fell from about 69 KB to about 4 KB per request. Timings carry 5-10% run to run noise on InMemory; the allocations are exact.

One query per connection page

A root connection ran a COUNT query on every request. It now runs only when totalCount is selected, or when last or before place the window from the end. Otherwise the page query reads one row past first: that row answers hasNextPage and is dropped from the page before the filters run. hasPreviousPage without the count is true when the page starts after the first item and has rows; an empty page past the end reports false, which the Relay specification allows when paging forward.

The count scans every row matching the where, however small the page, where the page query stops after first rows. A Relay page fetch, which selects pageInfo and not totalCount, is one query instead of two, at the cost of one extra row per page.

Documented under paging, with tests for items only pages, a page past the end, last, and pageInfo without totalCount on a middle page, the last page and past the end.

…ip the connection count when nothing reads it

The navigation, navigation list and navigation connection resolvers run once per parent row, and each built a ResolveEfFieldContext by copying every property of the GraphQL.NET context. That forced the lazily computed ones, SubFields, Path, ResponsePath, Parent and Arguments, to be computed and allocated for every row, when the resolver only needs the DbContext and the filters. They now resolve those two directly. A field selected without arguments skips the argument reads and the push down lookup, since GraphQL.NET builds the argument dictionary lazily per field per row. The in memory ids and where predicate of a navigation field, on the path where the arguments are not pushed into the query, is compiled once per request per field rather than once per parent row.

A root connection ran a COUNT query on every request. It now runs only when the selection reads it, through totalCount or pageInfo, or when last or before place the window from the end. A selection of only edges or items paged with first and after is a single query. Documented under paging, with tests for each side of the decision.

Smaller per request work moved or cached: a projection expression is analyzed once when its field is registered rather than on every request that selects it, the walks over graph type hierarchies are cached per graph type, a selection set of plain fields skips the derived navigation scan, and the filter hierarchy lookup is cached per entity type.

Measured with RequestSplitBenchmark on EF InMemory, 100 parents with 5 children each, paired runs. The nested query fell from 2,858us and 3,989KB to 2,761us and 3,924KB; the library's allocation beyond GraphQL.NET and EF fell from about 69KB to about 4KB per request. A where and orderBy on a navigation evaluated in memory, the new FullWithInMemoryArguments benchmark, fell from 4,052us and 4,554KB to 3,311us and 4,144KB.
…onger needs the count

A root connection selecting pageInfo without totalCount still ran the COUNT query, since hasNextPage was computed from the count, and that is the selection a Relay client makes on every page. When the count is not needed for totalCount, or to place a window bounded by last or before, the page query now reads one row past first: that row answers hasNextPage and is dropped from the page before the filters run. hasPreviousPage without the count is true when the page starts after the first item and has rows; an empty page past the end reports false, which the Relay specification allows when paging forward.

The count query scans every row matching the where, however small the page, where the page query stops after first rows, so the page fetch is one query instead of two, at the cost of one extra row. Documented under paging, with tests for a middle page, the last page and a page past the end.
@SimonCropp
SimonCropp merged commit 4b8a080 into main Sep 10, 2026
5 checks passed
@SimonCropp
SimonCropp deleted the perf-per-row-resolvers branch September 10, 2026 12:31
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