effectful
effectful copied to clipboard
Runtime error using Effectful + Conduit + Servant + UnliftIO
Hello!
I've been experimenting with using Effectful
alongside Servant
. I also want to use Conduit
for streaming responses. Because Servant
's Conduit
integration expects a handler to return a ConduitT () a IO ()
, I decided to use UnliftIO
to run the side-effects in Conduit
's pipeline in my custom monad. I know, I know, it's confusing.
To make this more clear, I put together an example repo. I've implemented two toy examples -- the first uses newtype App a = App (ReaderT String IO a)
as my custom monad and the second uses type App = Eff '[Reader String, Error ServerError, IOE]
. Both compile just fine, but when I call the streaming endpoint for the Effectful
example, I get the following runtime error:
version (3) /= storageVersion (0)
CallStack (from HasCallStack):
error, called at src/Effectful/Internal/Env.hs:358:5 in effectful-core-2.3.0.1-8gRe1gsiGtKADksLppQ43Q:Effectful.Internal.Env
You can try both versions for yourself. Clone the example repo above, and run the following for the MTL example:
$ stack run -- mtl
You should now be able to do the following:
$ curl localhost:3000/stream
{"itemId":1,"itemText":"Drew"}
{"itemId":2,"itemText":"Drew"}
{"itemId":3,"itemText":"Drew"}
{"itemId":4,"itemText":"Drew"}
{"itemId":5,"itemText":"Drew"}
{"itemId":6,"itemText":"Drew"}
{"itemId":7,"itemText":"Drew"}
{"itemId":8,"itemText":"Drew"}
{"itemId":9,"itemText":"Drew"}
{"itemId":10,"itemText":"Drew"}
Now, try running the effectful version:
$ stack run -- effectful
If we perform the same curl request, we see our runtime error:
$ curl localhost:3000/stream
curl: (52) Empty reply from server
and from the server process:
listening on port 3000
version (3) /= storageVersion (0)
CallStack (from HasCallStack):
error, called at src/Effectful/Internal/Env.hs:358:5 in effectful-core-2.3.0.1-8gRe1gsiGtKADksLppQ43Q:Effectful.Internal.Env
You can view the code responsible for streaming here.
Apologies for the long-winded explanation, but I figured this would be the easiest way to demonstrate the problem. This feels like it may be a bug.
Let me know if you need any more information.
I decided to use UnliftIO to run the side-effects in Conduit's pipeline in my custom monad. I know, I know, it's confusing.
Not really, seems like a perfectly reasonable use case :slightly_smiling_face:
The problem you're hitting is usage of the unlifting function long after the effects it refers to have gone out of scope. Note that it's called when the conduit is run, not when it's constructed and this happens after you return from the handler, so even outside of the call to runEff
.
There are two workarounds:
- Don't use the unlifting function in a conduit. In your case it's easy to solve so far by adjusting
stream
:
stream :: Reader String :> es => Eff es (ConduitT () Item IO ())
stream = do
name <- ask
pure $
C.yieldMany [1 ..]
.| C.takeC 10
.| C.mapMC
( \i -> pure $ Item i name
)
Of course this doesn't work if you actually need to run some effectful function inside the conduit. In this case you need to copy the environment.
A utility function is needed:
import Effectful.Dispatch.Static
import Effectful.Dispatch.Static.Primitive
import Effectful
withClonedEnv :: Eff es a -> Eff es a
withClonedEnv action = unsafeEff $ \es -> unEff action =<< cloneEnv es
and then
stream :: (IOE :> es, Reader String :> es) => Eff es (ConduitT () Item IO ())
stream = withClonedEnv $ do
withSeqEffToIO $ \runIO -> do
pure $
C.yieldMany [1 ..]
.| C.takeC 10
.| C.mapMC
( \i -> runIO $ do
name <- ask
pure $ Item i name
)
will work.
Looks like I missed this use case, so there is no unlifting strategy that would give you this option easily :disappointed:
I think I'll add withClonedEnv
to effectful-core
and adjust the error message you got to refer to this particular case and hinting at its usage.
The problem you're hitting is usage of the unlifting function long after the effects it refers to have gone out of scope. Note that it's called when the conduit is run, not when it's constructed and this happens after you return from the handler, so even outside of the call to
runEff
.
That's what I suspected was happening, though I was confused as to how it was working with ReaderT
.
Of course this doesn't work if you actually need to run some effectful function inside the conduit. In this case you need to copy the environment.
A utility function is needed:
I realize that the example was super artificial, but I was just trying to keep the code small. As you guessed, I would actually like to run these side-effects in my custom monad, but I couldn't come up with a better example :)
Does cloning the env like this have any downsides? For example, if I use this technique with a State
effect, will the State
be "forked" at the time of clone?
Looks like I missed this use case, so there is no unlifting strategy that would give you this option easily 😞
No problem, thanks for the helpful response! It sounds like adding withClonedEnv
to effectful-core
will work out nicely!
Does cloning the env like this have any downsides? For example, if I use this technique with a State effect, will the State be "forked" at the time of clone?
Yes, local flavour of state is forked at this point.
I just stumbled into the same issue with Scotty and a simple StreamingBody result type.
Incidentally I have used Effectful, Servant and Streaming together without issue because I used Servant's hoistServer
along with a single higher level runApp
call. The magic incantation was ...
liftedServer <- withRunInIO $ \toIo -> pure $ hoistServer (Proxy @MyAPI) (Handler . ExceptT . try . toIo) myServer
let
app :: Application
app = serve (Proxy @MyAPI) liftedServer
I don't think Scotty has a hoist so I may have to go with withClonedEnv
.
Sorry, disregard my last reply - I was a bit hasty and didn't fully parse your message @goertzenator !
Aha! I got Scotty working with monadUnliftIO
.
withRunInIO $ \toIo -> scottyT 80 toIo myEffServer
I just stumbled into the same issue with Scotty and a simple StreamingBody result type.
Incidentally I have used Effectful, Servant and Streaming together without issue because I used Servant's
hoistServer
along with a single higher levelrunApp
call. The magic incantation was ...
Thanks for the tip! I tried this out, and it worked -- with a caveat. I was required to use the ConcUnlift
unlifting strategy, otherwise I was greeted with a runtime error. See the relevant line in my gist here. However, this does seem like exactly what UnliftStrategy
is intended for. Good to know!
Were you performing side effects in your Streaming
response? Is there something I'm doing wrong in my example?
Thanks!
Thanks for pointing out withEffToIO
; I should be using that instead.
I am indeed doing side effects in the streaming response. Looking at my code I ejected the withRunInIO
call to a separate function so I can do all my logic in a nice clean Stream (Of a) (Eff es) r
(which I assume is similar to ConduitT () a (Eff es) ()
).
toIOStream :: (MonadUnliftIO m) => Stream (Of a) m r -> m (Stream (Of a) IO r)
toIOStream str = withRunInIO $ \toio -> pure $ hoist toio str
Making that look more concrete gives:
toIOStream :: (... es) => Stream (Of a) (Eff es) r -> Eff es (Stream (Of a) IO r)
toIOStream str = withRunInIO $ \toio -> pure $ hoist toio str
The part that I'm not seeing in your code is the hoist
. I've never used Conduit before, but I suspect it has something similar to Streaming's hoist. Maybe that helps, or maybe what you've written is already equivalent to this. UnliftIO still makes my head spin...
Thanks for the tip! I tried this out but, and it worked -- with a caveat. I was required to use the ConcUnlift unlifting strategy, otherwise I was greeted with a runtime error. See the relevant line in my gist here. However, this does seem like exactly what UnliftStrategy is intended for. Good to know!
Your gist works, but with a caveat. If your attempt to use any servant-handler-local effect in the conduit, you will be back to square one. E.g. if you modify stream
from the gist to:
stream :: (IOE :> es, Reader String :> es) => Eff es (ConduitT () String IO ())
stream = runReader @Int 777 $ do
C.withRunInIO $ \runIO -> do
pure $
C.yieldMany [1 :: Int ..]
.| C.takeC 10
.| C.mapMC
( \i -> runIO $ do
_ <- ask @Int -- makes use of the just-created Reader Int
name <- ask
pure $ name <> " " <> show i
)
you'll get the same error as in the beginning because of the same reason - the Reader Int
will no longer exist when you try to run the conduit.
I think I'll add another unlift strategy for this instead of providing withClonedEnv
as it seems more ergonomic.
Can any of you test if #224 works for you?
I've updated my example repo and this does indeed fix the problem. Thanks!