Skip to content

Fix IO#syncStep double-execution#2859

Merged
djspiewak merged 8 commits intotypelevel:series/3.3.xfrom
armanbilge:issue/2858
Mar 7, 2022
Merged

Fix IO#syncStep double-execution#2859
djspiewak merged 8 commits intotypelevel:series/3.3.xfrom
armanbilge:issue/2858

Conversation

@armanbilge
Copy link
Copy Markdown
Member

WIP. Starts by adding a reproducer.

@armanbilge armanbilge linked an issue Mar 7, 2022 that may be closed by this pull request
case IO.Map(ioe, f, _) =>
interpret(ioe).map {
case Left(_) => Left(io)
case Left(io) => Left(io.map(f))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I suspect more bugs

@armanbilge why do you suspect more bugs?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because the same mistake was made in 4 places :) I think I fixed all of them now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it my code? I remember going back and forth on this one and possibly mucking it up. Sorry.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh, blame the reviewer 😉

Anyway, thanks to @t3hnar for catching this, I'm about the invest the CE.js world pretty deep into syncStep.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
  }
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]]).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This really sounds like exactly what #2854 was designed to fix.

already subscribed and looking forward

@armanbilge armanbilge marked this pull request as ready for review March 7, 2022 09:13
t3hnar
t3hnar previously approved these changes Mar 7, 2022
djspiewak
djspiewak previously approved these changes Mar 7, 2022
Copy link
Copy Markdown
Member

@djspiewak djspiewak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no intuition as to why this would cause doubled execution…

Copy link
Copy Markdown

@t3hnar t3hnar Mar 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an example:

val io1 = IO(println(...))
val io2 = io1 *> IO.cede
val io3 = io2.void

If you run io3.syncStep.unsafeRunSync, roughly this happens:

  1. We pattern match io3 @ IO.Map(io2, _ => ())
  2. We interpret io2:
    1. We pattern match io2 @ IO.FlatMap(io1, _ => IO.cede)
    2. We interpret io1:
      1. We pattern match io1 @ IO.delay(println(...))
      2. We run it synchronously, and return Right(())
    3. We apply the flatMap f to () and get IO.cede.
    4. We interpret IO.cede, but can't, so we return Left(IO.cede)
  3. We get Left(IO.cede), so we have to bail. Now, we have an interesting question: should we return the Left(io.void) with the io we've just been given, or should we return Left(io3) that we started the iteration with? The old code was doing the latter.

Hope that makes sense.

@t3hnar
Copy link
Copy Markdown

t3hnar commented Mar 7, 2022

@armanbilge @djspiewak any plans on releasing this fix anytime soon? :)

@djspiewak
Copy link
Copy Markdown
Member

@t3hnar If this is urgent for you, I can see about getting it out later today

@t3hnar
Copy link
Copy Markdown

t3hnar commented Mar 7, 2022

@djspiewak would be lovely! thanks a lot!

@armanbilge armanbilge dismissed stale reviews from djspiewak and t3hnar via af3c602 March 7, 2022 19:23
@djspiewak djspiewak merged commit 4171faf into typelevel:series/3.3.x Mar 7, 2022
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.

IO.syncStep leads to double execution

5 participants