smur89
smur89

Reputation: 348

FunctionK transformation between two ReaderT instances providing the environment to the result

I have the following definition of effect types that I use in my service:

  type Traced[F[_], A]                 = ReaderT[F, TracingCtx, A]
  type TracedErrorHandling[F[_], E, A] = Traced[EitherT[F, E, *], A]
  type ServiceIO[A]                    = TracedErrorHandling[IO, ServiceError, A]
  type ClientIO[A]                     = TracedErrorHandling[IO, ClientError, A]
  type DatabaseIO[A]                   = TracedErrorHandling[IO, DatabaseError, A]

I would like to define a FunctionK mapping between some of these types, as such I've defined the following helper methods:

  def providing[F[_], A](a: A): Kleisli[F, A, *] ~> F = λ[Kleisli[F, A, *] ~> F](_.run(a))

  def folding[F[_]: Monad, E, E1](f: E1 => E)(implicit F: Raise[F, ? >: E]): EitherT[F, E1, *] ~> F =
    λ[EitherT[F, E1, *] ~> F](_.foldF(e1 => F.raise(f(e1)), a => Applicative[F].pure(a)))

  def translating[F[_]: Functor, E2, E1](translator: E1 => E2): EitherT[F, E1, *] ~> EitherT[F, E2, *] =
    λ[EitherT[F, E1, *] ~> EitherT[F, E2, *]](_.leftMap(translator(_)))

  def leftTapping[F[_]: FlatMap: Handle[*[_], E], E](f: E => F[Unit]): F ~> F =
    λ[F ~> F](_.handleWith[E](e => f(e) >> e.raise))

  def errorLogging[F[_]: Sync: Ask[*[_], TracingCtx]: Handle[*[_], E], E: ToThrowable](message: String): F ~> F =
    leftTapping[F, E](e =>
      Ask.ask.flatMap(implicit ctx => Sync[F].delay(logger.error(message, ToThrowable[E].throwable(e))))

and tie them together like this:

  implicit lazy val svcToIo: ServiceIO ~> IO =
    errorLogging[ServiceIO, ServiceError]("Database Error:") andThen
      providing(TracingCtx.noop) andThen
      folding[IO, Throwable, ServiceError](ToThrowable[ServiceError].throwable)

  implicit lazy val dbToSvc: DatabaseIO ~> ServiceIO =
    errorLogging[DatabaseIO, DatabaseError]("Database Error:") andThen
      providing(TracingCtx.noop) andThen
      translating[IO, ServiceError, DatabaseError](databaseToService) andThen
      Kleisli.liftK

  implicit lazy val clientToSvc: ClientIO ~> ServiceIO =
    errorLogging[ClientIO, ClientError]("Database Error:") andThen
      providing(TracingCtx.noop) andThen
      translating[IO, ServiceError, ClientError](clientToService) andThen
      Kleisli.liftK

// Later in Main 
// client <- myClient[ClientIO].mapK(clientToService)
// service <- myService[ServiceIO](client)

The issue I have is that I always provide a noop tracing ctx in the transformation. I am struggling a bit to define that - how would I get the TracingCtx out of the environment and use it in the transformation?

I had hoped to be able to use something like this

  implicit def svcToIo: ServiceIO ~> IO = new ~>[ServiceIO, IO] {
    override def apply[A](fa: ServiceIO[A]): IO[A] = Ask[ServiceIO, TracingCtx]
      .map(tracingCtx =>
        errorLogging[ServiceIO, ServiceError]("Unhandled Service Error:") andThen
          providing[EitherT[IO, ServiceError, *], TracingCtx](tracingCtx) andThen
          folding[IO, Throwable, ServiceError](ToThrowable[ServiceError].throwable)
      )
  }

But it does not conform to the required shape. Any ideas?

Upvotes: 2

Views: 50

Answers (1)

Mateusz Kubuszok
Mateusz Kubuszok

Reputation: 27535

It doesn't because what you want to do doesn't make sense.

If we get rid of all the CT gibberish what you have here is

// I'll use Scala 3's polymorphic function notation
[A] =>
  (TracingCtx => IO[Either[ServiceError, A]]) => IO[A]

Your working attempt:

  • takes some known TracingCtx (e.g. noop) and applies it
  • obtains IO[Either[ServiceError, A]]
  • converts ServiceError to Throwable and moves it to IO.raiseError, while the right values goes to IO.pure

All MTL does is hides all of that under the type classes. Ask[F, A].ask: F[A] is just accessing that argument of the function, available only when F is some function (ReaderT).

You cannot use Ask to obtain a TracingCtx if you are not calling this function somehow, and your code tries to obtain the argument in a function, outside that function, to call itself, so that it would be just IO. It cannot work. There are some weird cases in Haskell which looks similarly, but they usually only work when there is recursion and laziness on the table. Here you'd have to somehow extract TracingCtx out of IO[A] (potentially possible using IOLocal, if one understands how it works, not supported OOTB using Ask[IO, TracingCtx]), and then you could pass it inside. Something similar to (just a draft I don't want to spend next 40 minutes fixing code):

val ioLocal: IOLocal[TrancingCtx] = ...

new (ServiceIO ~> IO) {
   private def withCtx(ctx: TracingContext) =
     errorLogging[ServiceIO, ServiceError]("Database Error:") andThen
      providing(ctx) andThen
      folding[IO, Throwable, ServiceError](ToThrowable[ServiceError].throwable)

  def apply[A](fa: ServiceIO[A]): IO[A] =
    ioLocal.flatMap { ctx =>
      withCtx(ctx)(fa)
    }
}

Obviously, that ioLocal isn't making things magical - you'd still have to set it up somewhere to something, providing the default value at ioLocal creation. And you'd have to be careful how it works in concurrent context with fibers.

Upvotes: 3

Related Questions