`IOLocal`: MTL `Local` instance & semantics
@bpholt recently brought up the issue of providing an MTL Local instance for IOLocal in https://github.com/armanbilge/oxidized/issues/9#issuecomment-1405824000. That repo exists because Cats MTL can't provide it (since it creates a dep cycle with CE), and Cats Effect hasn't provided it (so far). Here are some reasons we should reconsider.
- As noted, Cats Effect already has a dependency on MTL, via the testkit. So it's not technically increasing our bincompat vulnerability.[^1]
- http4s is pursuing MTL-based middleware for 1.0. So MTL will likely end up on the classpath of all practical Typelevel applications anyway.
Furthermore: there is a lot of frustration about the semantics to expect from IOLocal e.g. https://github.com/typelevel/cats-effect/issues/3100, https://github.com/typelevel/fs2/issues/2842.
The interesting thing is, if you use IOLocal only as a Local (i.e. none of its lower-level methods) I am pretty sure you won't run into weird behavior like that. Local encodes semantics that make it safe to use IOLocal, without leaking implementation details about how Stream or whatever uses Fibers under-the-hood. Of course, it is less powerful when used this way, but that's par-for-the-course.
So I think there's an argument to push Local as the blessed way to interact with an IOLocal, unless you are specifically doing low-level stuff. Think .background to .start. And that could be another good reason to provide such an instance directly.
[^1]: although I suppose in the current state we could break the testkit if MTL had to break, and it would be less bad than breaking core.
Would you construct it from an extant IOLocal or create an encapsulated one from an initial state so nobody else can do weird shit with it?
create an encapsulated one from an initial state so nobody else can do weird shit with it?
IMO this should be the "blessed" way, but I think the alternative constructor is good to have around too.
An attractive feature of MTL local is that it surfaces the inherent limitations at compile time. For example, scoping the use of a resource. By signature, you can't!
def localK[F[_], E](f: E => E)(implicit L: Local[F, E]) =
new (F ~> F) {
def apply[A](fa: F[A]): F[A] =
L.local(fa)(f)
}
implicit def effectLocal[E](implicit ioLocal: IOLocal[E]) = new Local[IO, E] {
val applicative = Applicative[IO]
def ask[E2 >: E] = ioLocal.get
def local[A](fa: IO[A])(f: E => E): IO[A] =
ioLocal.modify(e => (f(e), e)).bracket(_ => fa)(ioLocal.set)
}
implicit def streamLocal[E](implicit ioLocal: IOLocal[E]) = new Local[Stream[IO, *], E] {
val applicative = Applicative[Stream[IO, *]]
def ask[E2 >: E]: Stream[IO, E2] = Stream.eval(ioLocal.get)
def local[A](stream: Stream[IO, A])(f: E => E): Stream[IO, A] =
stream.translate(localK(f)(effectLocal(ioLocal)))
}
implicit def resourceLocal[E](implicit ioLocal: IOLocal[E]) = new Local[Resource[IO, *], E] {
val applicative = Applicative[Resource[IO, *]]
def ask[E2 >: E]: Resource[IO, E2] = Resource.eval(ioLocal.get)
def local[A](r: Resource[IO, A])(f: E => E): Resource[IO, A] =
r.mapK(localK(f)(effectLocal(ioLocal)))
}
IOLocal(0).flatMap { implicit ioLocal =>
val r = Local[Resource[IO, *], Int].scope(Resource.unit)(1)
// use/surround has to happen outside scope, will be 0
.surround(Local[IO, Int].ask[Int])
val s = Local[Stream[IO, *], Int].scope(
// ask is inside scope, will be a stream of 1
Local[Stream[IO, *], Int].ask[Int])(1)
.compile.toVector
(r, s).tupled
}
I'm increasingly convinced this is a good idea. Would a concrete PR to discuss be helpful?
I'm obviously in favor of moving forward with this, and I think a PR would be a great next step :)
A question at the back of my mind has been where to expose this, so that it doesn't get muddied with the lower-level IOLocal. I wonder if we should put this on the IO companion object? That seems like a friendly, "high-level" location for it.
object IO {
def local[A](initial: A): IO[Local[IO, A]] = ???
}
What is the current state of this issue? Is there any chance we can have it in the upcoming 3.6.0 release?
I'm still hemming and hawing about the direct Cats MTL dependency from Core. But the reality is this is probably a very good idea.