effectful icon indicating copy to clipboard operation
effectful copied to clipboard

Runtime error using Effectful + Conduit + Servant + UnliftIO

Open drewolson opened this issue 9 months ago • 12 comments

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.

drewolson avatar May 12 '24 00:05 drewolson

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:

  1. 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:

arybczak avatar May 12 '24 23:05 arybczak

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.

arybczak avatar May 12 '24 23:05 arybczak

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!

drewolson avatar May 13 '24 01:05 drewolson

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.

arybczak avatar May 13 '24 10:05 arybczak

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.

goertzenator avatar May 22 '24 17:05 goertzenator

Sorry, disregard my last reply - I was a bit hasty and didn't fully parse your message @goertzenator !

ocharles avatar May 22 '24 17:05 ocharles

Aha! I got Scotty working with monadUnliftIO.

withRunInIO $ \toIo -> scottyT 80 toIo myEffServer

goertzenator avatar May 22 '24 17:05 goertzenator

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 ...

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!

drewolson avatar May 23 '24 14:05 drewolson

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...

goertzenator avatar May 23 '24 14:05 goertzenator

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.

arybczak avatar May 23 '24 20:05 arybczak

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?

arybczak avatar Jun 17 '24 23:06 arybczak

I've updated my example repo and this does indeed fix the problem. Thanks!

drewolson avatar Jun 27 '24 13:06 drewolson