typelevel / typelevel/cats-effect
IO.race doesn't propagate winner's IOLocal context
Nobody has claimed this yet.
- Dominant language
- Scala
- Stars
- 2.2k
- Forks
- 576
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 18
Description
We're using IOLocal for trace id propagation. It worked for us perfectly as expected when dealing just with IO instances. However, currently we can't use IOLocal magic with fs2 streams, due to them using IO.race to handle interruptions.
Here's a cats-effect example:
import cats.effect.IO
import cats.effect.IOLocal
import cats.effect.unsafe.implicits.global
val effectWithoutRace = for {
local <- IOLocal[Option[String]](None)
_ <- local.set(Some("uhh... hi!"))
_ <- local.get.flatMap(IO.println)
} yield ()
effectWithoutRace.unsafeRunSync() // prints Some(uhh... hi!)
def withRace[A](effect: IO[A]): IO[A] = effect.race(IO.never).map(_.merge)
val effectWithRace = for {
local <- withRace(IOLocal[Option[String]](None))
_ <- withRace(local.set(Some("uhh... hi!")))
_ <- withRace(local.get.flatMap(IO.println))
} yield ()
effectWithRace.unsafeRunSync() // prints None
Scastie for this code snippet: https://scastie.scala-lang.org/jshavzY0STWiAESgLgTqHg
Wrapping IOLocal.set statement in IO.race leads to context being enriched with a new value and then immediately discarded on exiting the race.
And here's an example with fs2 that we've actually encountered in the wild:
import cats.effect.IO
import cats.effect.IOLocal
import cats.effect.unsafe.implicits.global
val stream = for {
local <- fs2.Stream.eval(IOLocal[Option[String]](None))
_ <- fs2.Stream.eval(local.set(Some("uhh... hi!")))
_ <- fs2.Stream.eval(local.get.flatMap(IO.println))
} yield ()
stream.compile.drain.unsafeRunSync() // prints Some(uhh... hi!)
stream.interruptWhen(IO.never[Either[Throwable, Unit]]).compile.drain.unsafeRunSync() // prints None
Scastie for this code snippet: https://scastie.scala-lang.org/LghgY5G4RMWx0f4CRmUIqw
So any usage of interruptWhen on a fs2.Stream wraps every eval into F.race to handle actual interruptions.
A possible solution to make IOLocal usable with fs2 would be preserving IOLocal changes made within the race if they're made from an effect that won the race. While it makes sense to avoid merging contexts after joining back from IO.both, IO.race should be less problematic, as no merging is required here, just preserving winner's context.
Shoutout to @danielleontiev for debugging this issue with me
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by inspecting the IO.race and IOLocal implementations and reproduce the supplied examples, especially the fs2 interruptWhen case. Determine how the winning race fiber's IOLocal changes are handled, then add or update focused race-context tests so a winner's changes are preserved while the examples produce the expected trace value.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- scala
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100