Fix IO#syncStep double-execution#2859
Conversation
| case IO.Map(ioe, f, _) => | ||
| interpret(ioe).map { | ||
| case Left(_) => Left(io) | ||
| case Left(io) => Left(io.map(f)) |
There was a problem hiding this comment.
but I suspect more bugs
@armanbilge why do you suspect more bugs?
There was a problem hiding this comment.
It's because the same mistake was made in 4 places :) I think I fixed all of them now.
There was a problem hiding this comment.
Is it my code? I remember going back and forth on this one and possibly mucking it up. Sorry.
There was a problem hiding this comment.
Eh, blame the reviewer 😉
Anyway, thanks to @t3hnar for catching this, I'm about the invest the CE.js world pretty deep into syncStep.
There was a problem hiding this comment.
syncStep is critical functional for projects in my company as well. Mostly because it is used for integration with akka/kafka/cassandra/java libraries, and things could not be done without it, as otherwise you would be either loosing order guarantees or blocking threads.
There was a problem hiding this comment.
@t3hnar I'd be interested to see a specific example of your use-case. I wonder if the work in #2854 will be applicable to you? It provides an alternate mode for Dispatcher which guarantees ordering of effects.
Actually, syncStep was primarily introduced to help with JavaScript interop. In JVM-world, I think use-cases should be much more rare and Dispatcher is probably a better tool.
There was a problem hiding this comment.
Any code where you have java listeners called in exact order that should start executing IO based code.
And ordering guaranteed are modelled inside of IO composition for instance via Ref (not crossing async boundary)
Also in cases where you cannot use IO.async - because you want to have IO[IO[A]] instead of classic IO[A] - outer IO is about java api being called, inner IO is about execution is completed.
def javaApi: JavaFuture[A] = ???
def scalaApi: IO[IO[A]] = {
for {
future <- IO { javaApi } // because this gives order guarantee during method call and does not cross async boundary
} yield {
future.javaFutureToIO
}
}There was a problem hiding this comment.
Any code where you have java listeners called in exact order that should start executing IO based code.
This really sounds like exactly what #2854 was designed to fix.
Also in cases where you cannot use IO.async - because you want to have IO[IO[A]] instead of classic IO[A] - outer IO is about java api being called, inner IO is about execution is completed.
Fwiw, you might want to consider modeling this as Resource[IO, IO[A]] if javaApi involves any scarce resources. I definitely get what you're going at though. This type of pattern is annoying precisely because older-style APIs (such as in Java) often model these types of lifecycles implicitly ("kick off a running future and which you can ask for later") rather than explicitly (IO[IO[A]]).
There was a problem hiding this comment.
This really sounds like exactly what #2854 was designed to fix.
already subscribed and looking forward
djspiewak
left a comment
There was a problem hiding this comment.
This is a really nice catch! Honestly I'm surprised we didn't see it before, but I'm even more surprised that it caused doubled execution. That's the part that really doesn't make sense to me.
| case IO.FlatMap(ioe, f, _) => | ||
| interpret(ioe).flatMap { | ||
| case Left(_) => SyncIO.pure(Left(io)) | ||
| case Left(io) => SyncIO.pure(Left(io.flatMap(f))) |
There was a problem hiding this comment.
I have no intuition as to why this would cause doubled execution…
There was a problem hiding this comment.
because we returned original io as IO and synchronous part of it as SyncIO
and executing one after another leads to double execution of synchronous part
There was a problem hiding this comment.
Here's an example:
val io1 = IO(println(...))
val io2 = io1 *> IO.cede
val io3 = io2.voidIf you run io3.syncStep.unsafeRunSync, roughly this happens:
- We pattern match
io3 @ IO.Map(io2, _ => ()) - We interpret
io2:- We pattern match
io2 @ IO.FlatMap(io1, _ => IO.cede) - We interpret
io1:- We pattern match
io1 @ IO.delay(println(...)) - We run it synchronously, and return
Right(())
- We pattern match
- We apply the flatMap
fto()and getIO.cede. - We interpret
IO.cede, but can't, so we returnLeft(IO.cede)
- We pattern match
- We get
Left(IO.cede), so we have to bail. Now, we have an interesting question: should we return theLeft(io.void)with theiowe've just been given, or should we returnLeft(io3)that we started the iteration with? The old code was doing the latter.
Hope that makes sense.
|
@armanbilge @djspiewak any plans on releasing this fix anytime soon? :) |
|
@t3hnar If this is urgent for you, I can see about getting it out later today |
|
@djspiewak would be lovely! thanks a lot! |
WIP. Starts by adding a reproducer.