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
hoistServeralong with a single higher levelrunAppcall. 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!