-
Notifications
You must be signed in to change notification settings - Fork 27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[discussion] Java Async #175
Comments
thanks for bringing this up. A lot of the thinking comes from the Microsoft Graph SDK work (pre-kiota) and was kind of recaped here and there. Long story short; completable future seemed to be the best compromise at the time, only raising API level to 24 (we rose to 26 because of the DateTime and other things we needed). It's not ideal, but its the one with the broader compatibility and broader support. Lastly, we're currently wrapping the call using enqueue which OkHttp will defer to another thread or not depending on whether other requests are being executed, and depending on the dispatcher. That gives full control to the caller (they can implement/swap their dispatcher) rather than implementing our own custom logic. |
Thanks a lot for the context @baywet , I think that your answer perfectly recaps the reasoning behind the current decisions/implementation 👍 |
do you think further discussion is needed or should we close this issue at this time? |
Perfectly fine closing this, thanks for taking the time to share the background. |
We briefly touched on this point during the community meeting today. Reopening to gather additional feedback from @papegaaij and @mikrethor 🙏 |
For additional context we have one precedent of a language not being async: go. There is no primitive for that in go because usually defer the routines at the highest level. |
In Java land, there is an exciting turnaround with Project Loom and part of it is going to land in Java 21 (going to be released GA in September). It would be interesting to do a quick experiment to see how much we can generalize some utility methods in an external "extension" to wrap the most relevant calls with async. |
I don't think you can actually wrap a synchronous call in an async utility. For true async, you need to be async all the way down. I don't know how good the async implementation for OkHttp actually is, but in theory a good http client using Http 2 could multiplex multiple http requests in parallel over a limited set of connections using only a few threads. There is no way to get this if the SDK performs the calls sync. That being said, very few people actually use async in Java and using it "the right way" is very hard. Many SDKs in Java provide both options. Many Java developers really prefer a sync API. A suggestion would be something like |
The current Microsoft Graph SDK offers both API surfaces for the last couple of years. However it's built in a different way (using our older generator) and we don't have telemetry over which API surface people are using. Our current plan for the Microsoft Graph Java SDK is to release a preview based of kiota and see what kind of feedback we get. As per okHttp, we're calling enqueue which in turns calls enqueue on the dispatcher. |
These pools are one of the major issues with using async APIs. We've had several major production outages to some of our other applications due to pools getting exhausted and the whole thing deadlocking. We use the current Graph SDK in Topicus KeyHub and we use the sync API as do other applications in our company AFAIK. |
Maybe someone like @brunoborges (on Microsoft side) and @Sanne (on RH) are interested in weighting in on this decision before we go GA 🙏 |
Hi all - I'm over on the Azure SDK side of the Microsoft house. We've been building client libraries since 2018, and have a fairly large amount of experience in this area. We did a lot of user testing and investigations back when we started, and we ended up using Project Reactor for our async story, but we ship sync and async clients side-by-side (in the same library). We have full sync and full async stacks. This allows developers to choose to use sync or async, as their needs dictate. Whilst I have zero evidence of this, my gut feeling is that most developers choose to use our sync stack implementation, for its simplicity and debuggability. I imagine only our largest customers bother with the complexities of async, to eek out the final few percentage points worth of performance. Obviously when we started the concept of virtual threads was still a long way off, and even then, it feels too low-level of an abstraction to give to developers, as it would be asking them to manage threads (and, realistically, block on them). Similarly, CompletableFuture is good in concept for many use cases, but in some places (in particular, streaming cases), it falls down. This is why we ended up going with Reactor for our async story - it gave a complete story for developers to understand, for all async cases. Looking ahead to the future, I've done some early thinking about how I would do things today, and it would probably be a custom async API that wasn't based on a third party dependency. This is because Reactor does break more often than we would like, both in terms of implementation and public-facing API. If I had more time back in the 2018 time frame, I would probably have dived more into this, but as it stands I've only got high level thoughts (and a lot of investigations I would like to do). I should also add, it sounds like you are working on a number of problems that we have already worked through in our azure-core library. I do wonder if there is any benefit to you making use of our libraries, or finding better ways to collaborate, rather than duplicate a lot of effort? |
@JonathanGiles thanks a lot for chipping into this! Re: Virtual Threads, they are not such a low level abstraction in the Loom incarnation ... |
Interestingly enough, project loom seems to be aligning on futures as well with the executor pattern. The main difference with the current solution being the future is instantiated at the highest level over a sync API surface (where we instantiate the Future as close as possible to the actual HTTP request). This pattern looks a little like go co-routines. As to providing both APIs (sync and async) this is going to be a challenge in terms of binary size for large APIs (like Microsoft Graph), hence the necessity to align on a single API surface. The current Java SDK (previous generator) provides both but it has serious limitations in terms of which endpoints/operations are supported, reducing the size, which is already a concern. And no, we don't have telemetry over who's using the sync vs async API today. |
Do you really think this will make that much of a difference in jar size? Looking at the code, it should be enough to duplicate the method pairs like 'get' and 'post'. A quick test gave an increase in size of about 600 bytes per operation in the class file, which would be less after compression in the jar. I doubt it would be more than 1 or 2% in the total size for an API like the graph API. |
Microsoft graph v1 has about 20k operations last time I counted and beta around 35k. At that scale those seemingly "tiny" changes make a substantial difference. |
I've asked @maisarissi, our Java SDK PM, to engage with existing customers so we understand better what usage of the API surface they are doing today. But the feedback here helps a lot! Thanks everyone! |
I checked some numbers, and the graph API indeed has about 20k operations. All class files together are about 100MB, the resulting jar is about 42MB. I've then taken one of the generated request builders and modified the source in 2 steps:
For Some observations I made: moving common code to a new method often increases the size of the class file. Exception handling (and throwing) takes a lot of bytes. In my opinion, if 46MB is too big (and this can probably be brought back to about 43-44MB), 42MB also is. I think the solution would be to split the jar, not to try to save space on not providing both sync and async. |
Thanks for leading that investigation! We should probably fork off to another thread to focus on size optimization. The baseline for the previous generation of SDK is 11MB (5.66), but it supports far fewer endpoints (about 3 times fewer). Mapping overloads with fewer parameters to their "complete" equivalents with default values instead: very good idea. I'm not sure why we did it for request generator methods and not for request executor methods. Let me create an issue after I post that reply. UriSyntaxException. It all comes from here, I didn't realize exceptions were so costly in Java. kiota-java/components/abstractions/src/main/java/com/microsoft/kiota/RequestInformation.java Line 52 in 1a1fc24
We should probably review that method to wrap these specific exceptions in runtime exception (bad practice I know) and simplify a great deal the generated code. (let me create another issue for that) Splitting the SDK: in the case of Microsoft Graph there's no good split we could assume for the customers, because it's a Graph and we can't assume in advance what endpoints/models/properties people are going to need. The functional boundaries are inexistent as well. Best case scenario we'd end up with a lot of duplication and mapping code on the application side. Worst case we'd end up with gaps in the API surface area. This is actually one of the requirements that got kiota started! :-) The long term play would be to have people choose between an SDK (easy and ready to go) or generating their own client for Microsoft Graph (tailored to their needs, lower footprint). But on the Microsoft Graph we still have infrastructure and tooling to allow that new experience to be seamless. |
Looking again at this discussion with fresh eyes and I completely understand that this decision is completely dominated by the fact that we are excluding "a-priori" supporting both sync and async.
All in all, this seems to be:
A possible approach here might be:
I can see that, having to decide upfront to limit the flags in Kiota, seems to generate lengthy conversations and opinion-sharing other than actual "data-based" directions. |
Thanks for the additional input here. I'm all for data driven decisions. The challenge we've faced with questions that are so specific is that answering to them based only on data from our own telemetry/experiments is lengthy at best, often times inaccurate:
This is why for questions that are this specific we usually rely on readily industry data (yes that includes experts like yourselves opinions), interview with customers, experience from other teams at Microsoft... The fact that server frameworks like spring, ORMs like hibernate and reactive libs like project reactor all integrate with Futures strongly leads me to think the best way forward will be future based. This will cover scenarios like "inbound request, query to an API, query to a database" non-blocking all the way. People who really need to block will be able to do so over the completablefuture. and one of the added benefit is to be able to cancel requests, which a sync API makes much more difficult. Right now we'll probably focus on the size improvements before we come back to this topic, which should give time to @maisarissi to interview with customers and give us some additional feedback. |
Hello all again, apologies I'm definitely not an expert about kiota, but since I was asked to shed some light on our experience I feel the need to clarify some points here. I hope this migh e useful for the greater good but feel free to ignore me of course :)
It's really great that you all are attempting to make data driven decision, but please allow me to warn about the metrics that I'm seeing being called out here (several posts earlier too): they are fluctuating over time, and most old data shouldn't be taken into account. For example - the fact that the vast majority of artifacts in Maven Central is using "javax." prefix rather than "jakarta." prefix is totally expected as "javax." has been a pillar of integration among libraries for 20+ years. But most such libraries are out of date and abandoned; I'm fully confident that it won't be in the future, so please try to sample data by taking trends into account, and also that while many libraries in the Java ecosystem are fully committed into migrating to jakarta, several haven't done so yet as it's a slow ongoing process which will take years: the existing API is very well ingrained, although also totally not future proof for legal reasons (I hope you're aware: the package name is trademarked so it can't be evolved further). Also to bear in mind: some popular libraries, for example the Java security APIs, are not part of the "enterprise libraries" but part of the base platform, so in those cases they will retain the javax. prefix as they part of the Java API. That's also expected, so you won't see any transition to a new naming strategy in this important area, and needless to say many such packages need to integrate with those APIs (on top, or exclusively).
I can't speak for the other libraries, but I'm the lead architect of Hibernate and designed the API you're liking to here; might be useful to bear in mind that we offer two "reactive" APIs: one based on the CompletionStage and one based on the more modern Mutiny library as described in this section. The rationale at the time (4+ years ago) was that we wished to make it easy for users to integrate with various other reactive libraries, and the Nowadays, I regret integrating with CompletionStage as it has been extemely tricky to handle correctly and we regularly received feedback from users shooting themselves in the foot as they don't always understand all nuances of this very complex API. However, basing one's integration on CompletableFuture is very tricky as the futures might (or might not) be executed right away, implying it's very hard to control several important semantics of asynchronous operations, I would suggest to stay clear of it unless your needs are extremely basic cc/ @jponge HTH |
Thank you for joining the discussion. This kind of library maintainer feedback is gold! Yes, the caller does not have control over the scheduling of the work. This is because at the end of the day we end up calling enqueue from OkHttp, and OkHttp itself does the scheduling on a thread pool. kiota-java/components/http/okHttp/src/main/java/com/microsoft/kiota/http/OkHttpRequestAdapter.java Line 660 in 0886948
We might be able to control the execution by implementing our own dispatcher and/or executor service here and make it so the request execution only begins when the chain of Futures is being started and/or asked for a result. But I don't think scheduling here was the primary goal, offering non-blocking APIs (for the main thread) was. As for Munity it seems to be mixing two concerns: async programing and dataset streaming, like a lot of the afore-mentioned options. This later capability is not something kiota clients will need as in most cases API results are either objects or small collections. Additionally for large collections, most Java JSON serialization libraries lack the support of data streaming deserialization as far as I understand, so streaming the items coming from a big memory block would not help performance wise. All this feedback, and those options, make a great base for discussion with customers for @maisarissi while we make good progress on the size optimization aspects. Keep it coming. |
One of the arguments I haven't seen in this discussion so far is the reliability and scalability of an async API when used in a synchronous way (i.e. calling We do however, occasionally need to use an SDK that only provides an async API (like what's kiota offering at the moment). These have turned out to be really problematic because they often require careful management of a separate thread pool. Let's say the application has 500 task threads. Every task might need to use the async API at some point. Often, these APIs by default use really small thread pools (like 10), which will then be a major bottleneck (this has even resulted in outage). However, you cannot make the thread pool too large either, as threads are expensive to create and keep around. Also, in many cases it's not a good idea to share thread pool between different API. In the end, you are wasting a large amount of resources on threads, spread over many different pools, that are idle most of the time and performance is still suffering when a large amount of concurrent requests needs to be processed because that particular pool is not large enough. |
Update: the code reduction improvement yielded marginal gains, we could still implement a third one, but we're not expecting much bigger gains either. So unless we can identify more size reduction opportunities, the questions we should answer are most likely sync OR async API? (not both) (in addition to selecting the right async "framework"). @maisarissi is out for personal reasons for the next couple of weeks, maybe talking to existing Java customers is something @sebastienlevert could take over to understand whether they are writing async code, why or why not, and which framework do they employ? |
What about a totally different solution: providing both sync and async behavior via the same API? It should not be that difficult to change the implementation to return completed |
are you suggesting that all layer are sync, and we only have 2 executor methods, one sync (default) and one async that calls the sync one wrapping things in a CompletableFuture? |
When having both blocking (sync) and asynchronous capabilities in the same stack, we try really hard to avoid the two needing to interact: every time an asynchronous operation needs to invoke a blocking one it's a headache of integration and design issues, and every time a blocking method needs to invoke an asynchronous one it needs to, at very least, switch the physical carrier thread which comes at a significant cost of performance. I wouldn't recommend that. The one reason in other frameworks we exposed "natively asynch" APIs is precisly for these reasons; if you think you'd better be off with a very thin adaptation layer, I'd recommend rather expose only one of them and let the users deal with it explicitly - it would be a better experience as they would be aware and responsible for how exactly the translation layer works. |
@baywet I'm suggesting that you can flip the http abstraction layer in sync-mode, so it will do the http call in the caller thread and simply return The main problem I'm having with an async API in an application that's designed to use sync behavior is the need for a separate thread pool to handle the async calls. If too small, you will get significant congestion on these calls (even up to the point of deadlocks), if too big, they will waste resources (even up to the point of OutOfMemoryError). We've seen both in our applications. |
@Sanne yep I was aware of the kind of issues sync over async creates. And async over sync doesn't provide much value when compared to only sync. @papegaaij I don't think returning a completed future over a sync call would provide much value. And we've already ruled out providing both APIs due to a size constraint/duplicating the pipelines. The decision is sync XOR async API. And if we maintain async (with completable futures or something else) we should make sure it plays nicely with the HTTP client and its thread pool scheduler (if it uses any). Another aspect we discussed when looking at this issue if we switch to a sync API surface, we'd probably have to declare the throws of the custom exceptions generated from the description. |
If the actual async behavior is being handled in the OK layer (using a thread pool maintained by OK), then does that mean users/community can solve the problem @papegaaij has by contributing their own HTTP client layer? I haven't dug into the library the way that @andreaTP has, but I know that was discussed. We were interested because we wanted to e.g. provide a Quarkus specific HTTP client implementation. But if we can do that, then someone could presumably implement the approach @papegaaij suggested, by providing an HTTP client that actually does the call synchronously and then just wraps the result in an already-completed future. Personally, as a consumer of the generated SDKs, I would prefer a synchronous API because that's just easier to use and debug. And for the use-cases I care about, client scalability isn't really much of an issue. But I understand why async is being used now - it's probably only possible to practically adapt async->sync, not the other way around: as mentioned, adapting a sync API to async doesn't really accomplish the point of async for scalability - it needs to be actually non-blocking/async all the way down. |
We briefly touched on the subject another couple of times, I think that my mind is settled now, on 2 opposite directions 🙂 :
|
And I think it is safe to assume that with the introduction of virtual threads in Java 21, async will become more of niche. Virtual threads bring most of the benefits of async without the downsides of unreadable and impossible to debug code. Go has adopted virtual threads from the start and uses blocking sync APIs everywhere (at least that's what I see in the code I work with). Threads (or goroutines) are cheap and you can spawn as many as you like. There's no problem in having lots of them blocking in a sync call. I wouldn't be surprised if Java is heading the same way when more application servers and frameworks start supporting virtual threads. |
Hello everyone! Thanks for all the inputs and discussion here! Things like this is what help us to make the best product and better decisions. After some considerations like the introduction of virtual thread in Java 21 and conversations with @JonathanGiles @brunoborges and internally on Graph DevX team, we believe that sync only is the best way to go. With that, we can improve debugging capabilities as well as decrease complexity on development. |
Thank you @maisarissi for going the extra mile here and getting both quantitative data, and experts input in addition to the amazing feedback we got here. @ramsessanchez please go ahead and clean up completable futures from the API surface here, also remove any async suffix we might have + make the relevant generation change (kiota/snippets/raptor). |
I think it's worth having this conversation even just to keep track of the reasoning behind the decisions.
When I looked at the codebase, at first, I was pretty impressed by the fact that we are using async calls everywhere for Java.
This is a subject pretty much full of nuances in Java land as far as I know it.
Let's try to summarize a few key points:
CompletableFuture
API is not great and doesn't play well with most of the rest of the standard library, also, using it makes the code much much harder to understand and follow (e.g. we miss anasync
/await
construct)CompletableFuture
interface is going to play nicely with all the frameworks adopting this paradigm (Akka, Vert-X etc. etc.)CompletableFuture
s might not be the ideal solution given the number of options that comes (scheduling on which Thread Pool? Keep separated IO blocking operations from in-process computing etc. etc.)The text was updated successfully, but these errors were encountered: