Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Layer trait and Layer factory methods on Envs, Aborts, Tries, and Options #154

Merged
merged 12 commits into from
Dec 14, 2023

Conversation

johnhungerford
Copy link
Contributor

Introduces new type Layer[Sin, Sout], representing a handler of Sout that requires further handling of Sin and that cannot transform the output of the handled effect. The latter restriction allows for commutative composition, so that layers can be combined in any order without affecting (as far as I can tell) the resulting effect.

Layer provides a mechanism for dependency injection, by allowing you to compose Envs handlers. It should be possible to add a function similar to ZIO's .provide, which automatically wires layers together to satisfy all dependencies.

However, Layer can be used more broadly as a means to simplify certain kinds of effect handling. For instance, it can be used to between different kinds of failures.

@johnhungerford
Copy link
Contributor Author

Addresses issue #153


object layers {

trait Layer[Sin, Sout] { self =>
Copy link
Collaborator

Choose a reason for hiding this comment

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

It was a bit counterintuitive to me that the effects that the layer can handle is "out" and effects it can produce is "in". I image that's thinking of the operations in the pending effect set. What do you think about flipping the meaning of the type parameters? I think it's more intuitive to think of a Layer like an effect-level arrow that takes effects to handle and produces new effects if needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I agree. How about Layer[Handled, S] or something along those lines, where Handled are the effects that are handled and S are the new unhandled effects?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe Input/Output to make it more explicit?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's now Layer[In, Out], where In is the intersection of all effects the layer will handle, and Out is the intersection of the effects that will remain to be handled.

trait Layer[Sin, Sout] { self =>
def run[T, S](effect: T > (S with Sout))(implicit fl: Flat[T]): T > (S with Sin)

final def ++[Sin1, Sout1](other: Layer[Sin1, Sout1]): Layer[Sin with Sin1, Sout with Sout1] =
Copy link
Collaborator

@fwbrasil fwbrasil Dec 12, 2023

Choose a reason for hiding this comment

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

I'd like to avoid symbolic operators in Kyo. Maybe we could use add for ++ and andThen for >>>?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed ++ to add and >>> to chain. I'm avoiding andThen for now, since that could easily mean what is currently add (since add just handles applies the first layer and then the second).

Whether to keep add as add and not change it to andThen will depend on whether it is effectively commutative so long as it does not change the type of T when it handles an effect.

}

final def >>>[Sin1, Sout1](other: Layer[Sin1, Sout1])(implicit
ap: ApplyLayer[Sout, Sin1]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it be possible to express the type-level transformations in the method signature itself instead of relying on the implicit mechanism? I imagine it's not the case, just to double check.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure. Basically some mechanism is needed to extract the intersection between the effects that one layer handles and the effects that another layer needs to be handled, and keep track of the remaining effects of each. I'm pretty sure I can least simplify and clarify ApplyLayer, but I can't think of a way to produce the same functionality with just type bounds. I'll give it some more thought though.

Copy link
Collaborator

Choose a reason for hiding this comment

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

sounds good! the current version is ok with me btw

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wasn't able to figure out an alternative, but I did improve the type class used to chain layers together. Now it's called ChainLayer, and it's easier to follow because it reverses the ZIO approach. We can now think of it in this way: the output effects of the first layer are the input effects of the second layer. The main function of ChainLayer, then, is to derive which outputs effects from the first layer remain unhandled.

@fwbrasil
Copy link
Collaborator

fwbrasil commented Dec 12, 2023

I had forgotten how joyful OSS can be! It's great how well the layers compose 🤯

Regarding effect handling ordering, the result of the computations can change depending on the order but it seems reasonable to assume that ordering is a decision users can make when building layers. We might need better names for the operators, though. ++ can give an impression that ordering isn't important.

I've been working on a similar mechanism but for another purpose. Users currently need to handle other pending effects before forking a fiber. Since Kyo supports arbitrary effects, it's not easy to "propagate" the effect to the forked computation. For this use case, I need more features than only handling the effect so I created a new Joins mechanism that provides three operations:

  1. save: Saves any state necessary to handle the effect in the forked computation. For example, Envs would save the environment value at caller to provide it while executing the fiber.
  2. handle: Handles the effect given the state.
  3. resume: After the fiber returns, this method can suspend the effect again.

The approach works well for methods that don't return a Fiber instance like Fibers.parallel:

    def parallel[T, S](j: Joins[S])(l: Seq[T > (S with Fibers)])(
        implicit f: Flat[T > Fibers]
    ): Seq[T] > (S with Fibers) =
      j.save.map { st =>
        j.handle(st, l).map(parallel(_)).map(j.resume)
      }

But it doesn't work well for Fibers.init. It's not possible to use resume in this case because the M is inside a Fiber value. For now, I'm just returning the Fiber[M[T]] but I don't think that provides good usability.

    def init[T, S](j: Joins[S])( /*inline*/ v: => T > (S with Fibers))(implicit
        f: Flat[T > (S with Fibers)]
    ): Fiber[j.M[T]] > (S with IOs) =
      j.save.map { st =>
        init(j.handle(st, v))
      }

I wonder if we could try to unify the joins and layers. The joins impl is in this branch: https://github.com/getkyo/kyo/blob/joins

@fwbrasil
Copy link
Collaborator

fwbrasil commented Dec 12, 2023

Regarding the build failure, the issue is compilation in Scala 2. The macro for Flat is in the same module as the core code so it can't be invoked given that Scala 2 can't use macros from the same compilation unit. I've been working this around by importing Flat.unsafe. I've created #155 to move the macros to a new module, it's not trivial but seems possible.

@johnhungerford
Copy link
Contributor Author

Re: the build failure, when I run sbt +kyo-core/test or sbt +kyo-core/compile locally it works. Is there another command I should be running to reproduce the failure?

@johnhungerford
Copy link
Contributor Author

johnhungerford commented Dec 12, 2023

I suspect that Layer could be a special case of Join, but we would have to add another type parameter (or inner type) Unhandled, so that

  def handle[T, S](
        s: State,
        v: T > (E with S)
    )(implicit
        f: Flat[T > (E with S)]
    ): M[T] > (S with IOs)

would become

  def handle[T, S](
        s: State,
        v: T > (E with S)
    )(implicit
        f: Flat[T > (E with S)]
    ): M[T] > (S with Unhandled)

In the case of using it for joins, Unhandled would be IOs, but it could be other effects for other use cases.

If we went this route, what you are calling Join and what I'm calling Layer would be special cases of some more general type, let's say HandleArrow:

  trait HandleArrow[E, Unhandled] { self =>

    type M[_]
    type State

    def save: State > E

    def handle[T, S](
        s: State,
        v: T > (E with S)
    )(implicit
        f: Flat[T > (E with S)]
    ): M[T] > (S with Unhandled)

    // Not sure about this one...
    def resume[T, S](v: M[T] > S): T > (E with S)

In this case I think Layer and Join would be:

  type Layer[E, Unhandled] = HandleArrow[E, Unhandled] { type M[X] = Id[X]; type State = Unit }
  type Join[E] = HandleArrow[E, IOs]

For the use cases I have in mind, it is important that Layer have a commutative add method. If there is going to be a mechanism to automatically compose layers, it cannot matter what order they are in (this is not going to be true of andThen, of course). This is one of the reasons layer's run method cannot change T (and why I have type M[X] = Id[X] above). At least in the case of Aborts and Envs, I think that if M = Id, then layer addition would be commutative. Are there edge cases I'm not thinking of though?

@fwbrasil
Copy link
Collaborator

Re: the build failure, when I run sbt +kyo-core/test or sbt +kyo-core/compile locally it works. Is there another command I should be running to reproduce the failure?

I get the same error as the CI when I run sbt +test. I think it could be the state of your local compilation, maybe the macro class files are already generated so the compiler can expand it. Try sbt +clean first.

@johnhungerford
Copy link
Contributor Author

I think the build error is resolved: I had not defined the implicit Flat parameter for Layer.run correctly.

trait Layer[In, Out] { self =>
def run[T, S](effect: T > (S with In))(implicit fl: Flat[T > (S with In)]): T > (S with Out)

final def add[Out1, In1](other: Layer[In1, Out1]): Layer[In with In1, Out with Out1] =
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you rename to andThen? I think it'd be better to communicate that order is important.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. Agreed that this is clearer.

object layers {

trait Layer[In, Out] { self =>
def run[T, S](effect: T > (S with In))(implicit fl: Flat[T > (S with In)]): T > (S with Out)
Copy link
Collaborator

Choose a reason for hiding this comment

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

In Scala 2, methods that remove items from the pending effects need to have the effect to be removed as the first in the type intersection otherwise the compiler isn't able to infer types correctly. You might be able to avoid type annotations if you use effect: T > (In with S).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I pushed this change, although it looks like I still need the type annotations in those places where I've called Layer#run.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I see. It could be that the compiler isn't able to infer the removal based on the generic type parameter.

@fwbrasil
Copy link
Collaborator

fwbrasil commented Dec 13, 2023

Other than the two last minor comments, the change LGTM! Reasoning about Kyo's effect handling and how the layers are composed, I can't see potential issues. I normally try to design effects in Kyo by breaking up problems into smaller reusable pieces that handle specific behaviors. The use case for Layers seems different enough from Joins for them to be separate implementations. We can revisit this later as well.

Regarding ordering, I think if we have layers only for Envs, Aborts, and Tries it isn't relevant because the short-circuiting uses pre-defined fallbacks in the layers instead of M[_] values but this could not be true for other effects. Maybe we could restrict the API by providing methods like Layers.envs[T](...) instead of Envs[T].layer and then making Layer sealed? With this change, we could keep add as well.

Thanks for the contribution! This will be the first meta-effect in Kyo, it's nice to see how it generalized from ZIO's version :)

@johnhungerford
Copy link
Contributor Author

I have renamed add to andThen so that layers are not too constrained. If/when functionality is added to automatically compose layers, we can add restrictions there to accept only layers that can be combined in any order without changing behavior.

@johnhungerford
Copy link
Contributor Author

Ok I think it's good to go. This was fun! (This is my first significant OSS contribution, by the way. Prior to this, I've only made one trivial change to zio-http).

@fwbrasil
Copy link
Collaborator

Nice!! You are definitely off to a good start 🙏

@fwbrasil fwbrasil merged commit 4fbe334 into getkyo:main Dec 14, 2023
2 checks passed
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.

3 participants