Sangria and Future
Sangria is a Scala library implementing GraphQL on the server side.
To use it, one defines the GraphQL schema by defining types, fields, and, for each field, how to solve it.
When Oleg started this library, he used the Scala Future
as main representation for asynchronous computation.
The main entry point is the sangria.execution.Executor
. Here is a simplified version:
case class Executor[Ctx, Root](schema: Schema[Ctx, Root])(implicit executionContext: ExecutionContext) {
def execute(queryAst: ast.Document)(implicit scheme: ExecutionScheme): scheme.Result[Ctx, marshaller.Node] = {...}
}
where scheme.Result
is by default implemented as a Future
.
implicit object Default extends ExecutionScheme {
type Result[Ctx, Res] = Future[Res]
}
The resolution of the GraphQL query is delegated to sangria.execution.Resolver
, which is using Future
internally:
class Resolver[Ctx](queryAst: ast.Document)(implicit executionContext: ExecutionContext) {
def processFinalResolve(resolve: Resolve): Future[(Vector[RegisteredError], marshaller.Node)] = {...}
}
Future
is not the only option
In the Scala
world, Future
is not the only option to work with asynchronous computations.
And at the time I'm writing this post, cats-effect 3.x is being developed, providing a set of very interesting features and performance improvements.
I've also used Sangria in application using the Monix Task
instead of Future
. The code integrating Sangria is converting back and forth between Task
and Future
. It's not a major issue, but still does not feel optimal.
I think that there is value for a library like Sangria to better support alternatives to Future
.
Supporting alternatives to Future
So I'm trying to enhance Sangria to be able to support any alternatives to Future
.
By looking at the code, I see some usages of future.map
, future.sequence
, etc.
Abstracting with a functional library
My first reflex is to use a functional library like cats
and to make the code abstract by using type classes like Monad
, Applicative
and so on.
But Oleg wanted the Sangria library to have minimal dependencies. He has not used any functional library.
So I'll respect this decision and try another approach.
Abstracting over Future
.
To make the Sangria code not depending on Future
directly, I'm introducing a trait that should abstract Future
away:
trait Effect[F[_]] {
def pure[A](a: A): F[A]
def map[A, B](fa: F[A])(f: A => B): F[B]
}
To stay compatible with Future
, I also provide a default implicit implementation for it.
If my experiment goes well, at the end, this should be the only place where Sangria is using Future
.
object Effect {
implicit def FutureEffect(implicit ec: ExecutionContext): Effect[Future] =
new Effect[Future] {
override def pure[A](a: A): Future[A] = Future.successful(a)
override def map[A, B](fa: Future[A])(f: A => B): Future[B] = fa.map(f)
}
}
I know it would be better to first have flatMap
and unit
as map
could be expressed in flatMap
of unit
but I don't care for now. The name is also not optimal.
My goal is first to check if I can quickly make Sangria using Effect
instead of Future
.
Once Sangria can compile with it, and if the current tests relying on Future
pass, I can refine Effect
.
So I'm starting using Effect
. The compiler is calling me names.
But I am changing code step by step, and this is feeling good. I can remove Future
completely from some classes:
-import scala.concurrent.{ExecutionContext, Future}
- case class DeferredResult(deferred: Vector[Future[Vector[Defer]]], futureValue: Future[Result])
+ case class DeferredResult[F[_]: Effect](
+ deferred: Vector[F[Vector[Defer]]],
+ futureValue: F[Result])
extends Resolve {
def appendErrors(
path: ExecutionPath,
errors: Vector[Throwable],
- position: Option[AstLocation]) =
+ position: Option[AstLocation]): DeferredResult[F] =
if (errors.nonEmpty)
- copy(futureValue = futureValue.map(_.appendErrors(path, errors, position)))
+ copy(futureValue = Effect[F]().map(futureValue)(_.appendErrors(path, errors, position)))
else this
}
The code is a bit more complex but still manageable:
- def resolveValue(
+ def resolveValue[F[_]: Effect](
path: ExecutionPath,
astFields: Vector[ast.Field],
tpe: OutputType[_],
@@ -1231,23 +1239,23 @@ class Resolver[Ctx](
resolveSimpleListValue(simpleRes, path, optional, astFields.head.location)
else {
// this is very hot place, so resorting to mutability to minimize the footprint
- val deferredBuilder = new VectorBuilder[Future[Vector[Defer]]]
- val resultFutures = new VectorBuilder[Future[Result]]
+ val deferredBuilder = new VectorBuilder[F[Vector[Defer]]]
+ val resultFutures = new VectorBuilder[F[Result]]
val resIt = res.iterator
while (resIt.hasNext)
resIt.next() match {
case r: Result =>
- resultFutures += Future.successful(r)
- case dr: DeferredResult =>
+ resultFutures += Effect[F]().pure(r)
+ case dr: DeferredResult[F] =>
resultFutures += dr.futureValue
deferredBuilder ++= dr.deferred
}
DeferredResult(
deferred = deferredBuilder.result(),
- futureValue = Future
+ futureValue = Effect[F]()
.sequence(resultFutures.result())
.map(resolveSimpleListValue(_, path, optional, astFields.head.location))
)
As I abstract more and more functions on Future
, the Effect
abstraction is growing:
trait Effect[F[_]] {
def pure[A](a: A): F[A]
def failed[T](exception: Throwable): F[T]
def map[A, B](fa: F[A])(f: A => B): F[B]
def sequence[A](vector: Vector[F[A]]): F[Vector[A]]
def recover[A, U >: A](fa: F[A])(pf: PartialFunction[Throwable, U]): F[U]
}
It's not beautiful but I don't consider this as an issue for now. Once everything compiles, I will take time to refine it and to reduce the surface of this abstraction.
Promise
on the way
But then I encounter some code using Promise
, like:
case class ChildDeferredContext[F[_]: Effect](promise: Promise[Vector[F[Vector[Defer]]]]) {
def resolveDeferredResult(uc: Ctx, res: DeferredResult[F]): F[Result] = {
promise.success(res.deferred)
res.futureValue
}
Promise
is a standard Scala class that allows to build a Future
based on side effects.
It's low level, and easy to make mistakes when using it.
I don't know why Oleg has decided to use it. I guess it was for performance optimizations to avoid mapping over Future
everywhere. Something to check later.
But it's quite a shock. I was not expecting that.
This makes an abstraction over Future
much more complicated now.
What to do now
Encountering Promise
is a new challenge.
I see two options from here:
option 1 - abstracting over Promise
The first option is to extend the abstraction to also abstract over Promise
.
Frankly I doubt this will lead to somewhere. The resulting code might be quite complex. And it might be much more difficult to provide alternative implementations for those abstractions.
option 2 - removing Promise
The second option is to remove the usage of Promise
. For that I first need to understand why it was used.
Removing Promise
could lead to more functional code at the end. But I'm afraid it is a big change.
Sangria 1 - 0 Yann
So I've tried to abstract over Future
for sangria, but it was more complex than expected.
Time to stop this first round.
If you have some suggestions how to deal with Promise
, I'm all hear on Twitter or Mastodon.