Dont NOt Merge : POC code to show how objects and not just CFs could be used as returns values#3323
Dont NOt Merge : POC code to show how objects and not just CFs could be used as returns values#3323bbakerman wants to merge 7 commits into
Conversation
| return (FetchedValue) join; | ||
| } else { | ||
| return (FetchedValue) fetchedField; | ||
| } |
|
I have done some benchmarks on my machine. if anything... its slower in ms/op but faster in thorough put ops/s. But not much and not to any great degree The above benchmarks a test of sync only results. There are no async in it at all. I had expected it to be quicker but its not - interesting... SuspectI suspect the culprint might be the completeObject / completeList methods which jump is back into CF land If you have a So while the each object in the list might be fetched as a non CF object - it ends up being "completed" as a CF no matter what. I think we would have to take this PR further and see if we can have a polymorphic list of CFs or materialised objects come back in the completeValue methods Taking it furtherI took this further and made the completeList / completeObject all returns a Object or CF. At present that does not pass all tests The benchmarking is better however but not amazingly better. |
…h viral support in completeObject adn completeList
…e in async combined builder
| if (exception != null) { | ||
| ExecutionResult executionResult = handleNonNullException(executionContext, overallResult, exception); | ||
| completeListCtx.onCompleted(executionResult, exception); | ||
| return; |
There was a problem hiding this comment.
This is the completeList code - before it did
CompletableFuture<List> resultsFuture = Async.each(fieldValueInfos, FieldValueInfo::getFieldValue);
but this now gets a bit more complicated because the items in the list can be CFs or not.
There was a problem hiding this comment.
So we unwrap that Async.each and do our CF or Object code
| @@ -662,7 +737,7 @@ protected CompletableFuture<ExecutionResult> completeValueForEnum(ExecutionConte | |||
| } catch (NonNullableFieldWasNullException e) { | |||
| return exceptionallyCompletedFuture(e); | |||
| } | |||
There was a problem hiding this comment.
notice here we still use CFs - why? because they can represent exceptions in themselves.
So if the serlisation fails - we returns an exceptional CF otherwise the materialised values
| // Calling this from the executionContext to ensure we shift back from mutation strategy to the query strategy. | ||
|
|
||
| return executionContext.getQueryStrategy().execute(executionContext, newParameters); | ||
| return executionContext.getQueryStrategy().executePolymorphic(executionContext, newParameters); |
There was a problem hiding this comment.
This means that a materialised object can be completed via recursively calling back to the ES but via a special method that allows CFs or materialized values
|
All tests now passing |
|
Awesome to see work in this area 👍 💯 |
|
I can provide real-world benchmarks on this branch vs master if it executes my queries |
Heh, "real-world" and benchmark in the same sentence. What I meant: I can run our load tests on it which involve real queries and provide some comparative indications |
Please do - we would love to see this in a more real world situation |
1 similar comment
Please do - we would love to see this in a more real world situation |
|
I would like to change this PR again to remove the ExecutionResult wrapping that happens. I suspect this was caused by the old I think this would also reduce memory pressure by not allocating an wrapper object for enums and scalars and so on- that is one for every value |
|
I would need to spend more time load testing or measure latency up to P99.9 to really see a difference in throughput/latency. GC is coping right now. But we do see differences in the GC metrics. |
These tests were on Java 21, 16Gb heap, half of which can be used for query processing and G1 GC. |
|
Wow thanks so much for these numbers. This really helps put some perspective on the work. Reducing memory pressure is always a good thing however I think most people would trade more memory usage for less latency and in this case it's just not there. This code is problematic in the sense of code maintenance. It's entering into a scripting language world where where an It also breaks Instrumentation quite a lot because nominally they are So in theory one could "do work on the passed in CF during The I am more bullish on #3330 as a way to reduce memory pressure because there truly is a completely redundant |
|
Hello, this pull request has been inactive for 60 days, so we're marking it as stale. If you would like to continue working on this pull request, please make an update within the next 30 days, or we'll close the pull request. |
|
@bbakerman any chance we could release an snapshot version of this branch ? i am very interested in trying this in graphql-kotlin, we have graphql servers with high load of traffic + very large queries, recently turned off Datadog automatic instrumentation as it was creating spans for every single field, and for very large queries it was creating a lot of objects causing the minor collection time of GC to take a lot, after turning off the DD instrumentation saw this improvement i suspect there will be even more improvement by avoiding creating CFs for fields that are already in memory after being synchronously resolved. |
I am asking the team if we are able to do this. |
|
I have kicked off a custom release : https://github.com/graphql-java/graphql-java/actions/runs/7927320202 The artifact will be called |
|
https://repo1.maven.org/maven2/com/graphql-java/graphql-java/0.0.0-2024-02-16T00-00-00-support_objects_or_cfs/ is now available as a maven artifact |
|
oh right, so i tried the snapshot, and was able to see that now we are not creating CFs for values that are already in memory and those can be synchronously resolved, the problem here, as properly mentioned in the code comments, is the What i would like to propose is to create a new In terms of if the instrumentations are going to break, i dont think they will, batching instrumentation only needs to know if a field was dispatched, it actually does not use the value. |
|
Yup this code is very much a Proof of Concept (POC) And yes I think the InstrumentationContext should be changed anyway to not send in the CF to be completed. Semantically the same thing happens onCompleted could become in theory if you were composing on that CF during onDispatched, you could do the same via onCompleted |
|
Merged into master in separate PR |







This is a POC to show how we could return Objects and not just CompletableFutures from the data fetchers and then beyond
This was driven from my investigations into Java 21 virtual threads where a DF can in theory return a value thats async BUT is materialised by the time the DF returns (eg it ran on a VT inside the data fetcher)
CFs are cheapish but pure objects are (in theory) cheaper.
The price of this PR is the type safety in our code and in callbacks such as
InstrumentationWe have to handle an
Objectreturn type asCompletableFuture<Foo> | Foo- so the compiler is not helping out code.This also breaks the API of the ExecutionStrategy BUT I am comfortable with that. I think on reflection that ExecutionStrategy should be
@InternalcodeI will spend some time to see if this leads to better performance.
ALGORITHM
The general alo here is that at key points we have either a
List<CompleteableFuture<Foo>>or aList<Foo>- or the single arity versions of of those - and since Java can't represent a union type this ends up being methods likeSo when these values need to be consumed you end up with 2 paths via
if (obj instanceOf CompleteableFuture) elseI have called the non promise a materialised object - I am not sure this is the right nomclementure.
The other key part is our async wait code. Since we cant know up front if a result object will be async or materialised we have to use a mechanism to discover that.
In theory we would have a polymorphic list of values like
CF1, object2, CF3, CF4, object5, object6, object7say. That list would require us to async wait on 3 objects while having 4 other materialised values we dont have to.The
Async.CombinedBuildercode was retrofited with anawaitPolymorphic()(I don't love the naming). if ANY of the objects to wait for a CFs then it will truly await then a return aCompleteableFuture<List<Foo>>. If they are all materialised it can skip that and returns aList<Foo>>.Again the consumer of that needs to check via instanceOf if its a CF or not. If its not it can be consumed direct otherwise it must be consumed as a CF chain.
Just like CompleteableFuture this is viral - the method return types become Object - which is bad for clean code understanding but not impossible to understand.
POSSIBLE IMPROVEMENTS
Right now we wrap completed values into
ExecutionResultobjects and then unwrap them and throw that away. I think this is becase when we complete a Object type we call back toexecutionStrategy.execute()and this returns aExecutionResultThis is wasteful for scalars, enums and even lists in maybe ways. This does however provide a common shape to
FieldValueInfowhere the value is today aCF<ExecutionResult>(or a possibleExecutionResultin this PR)A
ExecutionResultcan carry errors and data BUT we never use that so again a waste. This would be a memory saving if we stopped wrapping values in ERsIMPACT
The BIG code impact is in
InstrumentationContextwhich I didn't change (yet)Nominally the field fetch context is no longer
CompletableFuturebut my union of Object or CFDataLoader code needs this onDispatched to work BUT it does not use the value at all.
This calls for a breaking change but how ? TBD
Summary
So there is real food for thought here.
It's faster if we don't use CFs but its only a little bit faster and the code is more complicated and the
InstrumentationContextis a real pain in a breaking code senseThere is less memory pressure if we dont allocated CFs which is something. I didn't spend time on that memory pressure but its a 3-4 CFs per returned field value (as we compose CFs)