typelevel / typelevel/cats-effect

IO.race doesn't propagate winner's IOLocal context

Open
#3,100 10 comments 0 reactions 0 assignees View on GitHub

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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.