polysemy-research / polysemy

:gemini: higher-order, no-boilerplate monads
BSD 3-Clause "New" or "Revised" License
1.04k stars 73 forks source link

Tactics + Callbacks #63

Closed boj closed 5 years ago

boj commented 5 years ago

Similar to last time I still feel like I am not grokking how to use Tactics (or how to track the proper monadic state in my head, which seems like an unnecessary burden).

Playing with the hedis library:

data MessageReader m a where
  WaitMessage :: Integer -> ByteString -> (ByteString -> m ()) -> MessageReader m ()

runMessageReaderIO
  :: forall r a.
     Member (Lift IO) r
  => Connection
  -> (forall x. Sem r x -> IO x)
  -> Sem (MessageReader ': r) a
  -> Sem r a
runMessageReaderIO conn r = interpretH $ \case
  WaitMessage mid dom f -> do
    is <- getInitialStateT
    f' <- bindT f
    sendM $ runRedis conn $ pubSub (subscribe [ mkIdKey dom mid ]) $ \msg -> do
      r .@ runMessageReaderIO conn $ f' (msgMessage msg <$ is)
      pure (unsubscribe [ msgChannel msg ])
src/Service/Redis.hs:142:5: error:
    • Couldn't match type ‘()’ with ‘f ()’
      Expected type: Sem (WithTactics MessageReader f m r) (f x)
        Actual type: Sem (WithTactics MessageReader f m r) ()
    • In a stmt of a 'do' block:
        sendM
          $ runRedis conn
              $ pubSub (subscribe [mkIdKey dom mid])
                  $ \ msg
                      -> do r .@ runMessageReaderIO conn $ f' (msgMessage msg <$ is)
                            pure (unsubscribe [...])
      In the expression:
        do is <- getInitialStateT
           f' <- bindT f
           sendM
             $ runRedis conn
                 $ pubSub (subscribe [mkIdKey dom mid]) $ \ msg -> do ...
      In a case alternative:
          WaitMessage mid dom f
            -> do is <- getInitialStateT
                  f' <- bindT f
                  sendM
                    $ runRedis conn
                        $ pubSub (subscribe [mkIdKey dom mid]) $ \ msg -> ...
    • Relevant bindings include
        f' :: f ByteString -> Sem (MessageReader : r) (f ())
          (bound at src/Service/Redis.hs:141:5)
        is :: f () (bound at src/Service/Redis.hs:140:5)
    |
142 |     sendM $ runRedis conn $ pubSub (subscribe [ mkIdKey dom mid ]) $ \msg -> do
    |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...
isovector commented 5 years ago

Consider an interpreter (runM .@ runMessageReaderIO con) . runState s. Here, we have a State effect in scope inside of any calls to waitMessage. And the question is, "how should that state get threaded back into the original computation?"

Admittedly, this error you've reported is not very concise. What it's trying to tell you by saying that you gave it a () but it wants a f () is that you've discarded statefulness here. Any changes you made to the State effect (or any other effect above it) haven't been propagated outwards.

In the common case of returning an m (), you can simply bite the bullet and just return getInitialStateT --- in effect saying "I acknowledge that I'm ignoring the state changes!". Such a thing will "work," but might have surprising consequences.

It's worth keeping in mind that this is the exact same limitation that MonadBaseControl and MonadUnliftIO have; except that in polysemy the typesystem lets you know when you're doing something sketchy --- rather than just invisibly deleting your effects!

An alternative is to write your effect as being polymorphic in its return type:

  WaitMessage :: Integer -> ByteString -> (ByteString -> m a) -> MessageReader m a

which often seems like a more "honest" type to return. Whether you can get away with this type in your interpreter is another question --- if its running in the same thread, then yes, otherwise probably not.

boj commented 5 years ago

I considered that type signature, but still get the same error with it.

isovector commented 5 years ago

Whether you can get away with this type in your interpreter is another question

Looks like no. pubSub itself discards whatever type you wanted to run inside of it. The documentation suggests why: "It should be noted that Redis Pub/Sub by its nature is asynchronous"

boj commented 5 years ago

Hmm. How would one use such a function in polysemy? I am still trying to build up a good picture of how to build on freer monads for applications.

isovector commented 5 years ago

You just bite the bullet and write your interpreter like this:

runMessageReaderIO
  :: forall r a.
     Member (Lift IO) r
  => Connection
  -> (forall x. Sem r x -> IO x)
  -> Sem (MessageReader ': r) a
  -> Sem r a
runMessageReaderIO conn r = interpretH $ \case
  WaitMessage mid dom f -> do
    is <- getInitialStateT
    f' <- bindT f
    sendM $ runRedis conn $ pubSub (subscribe [ mkIdKey dom mid ]) $ \msg -> do
      r .@ runMessageReaderIO conn $ f' (msgMessage msg <$ is)
      pure (unsubscribe [ msgChannel msg ])
    getInitialStateT
boj commented 5 years ago

Ok, with the callback type back to m () this works. I barely understand why I would have to do this myself, and am slightly worried having to explain this to my team. I suppose more experience with the library will help.

Thanks very much for your assistance!

isovector commented 5 years ago

I barely understand why I would have to do this myself,

Out of genuine curiosity, how would you like this problem to be solved?

isovector commented 5 years ago

PS If you'd like to schedule a call and hash this out in a higher-bandwidth setting, I'd be more than happy!

boj commented 5 years ago

@isovector You know, a call would be great. Do you have any preference as to technology and/or time?

isovector commented 5 years ago

I like google hangouts, where my username is sandy.g.maguire@gmail.com. I'm in UTC -5, and any day in the 12pm-5pm range is fine by me.

boj commented 5 years ago

Low bandwidth version (maybe we can chat at some point, I pinged you via mojobojo@gmail.com):

Out of genuine curiosity, how would you like this problem to be solved?

I honestly don't know. There's still a lot of mechanics in Haskell I don't understand (for example, you mentioned MonadBaseControl and MonadUnliftIO - if I understand correctly they reverse-lift IO things, but still have a hard time conceptualizing when/how to use them).

It seems obvious that at some point one will have to write an effect which uses Tactical, I've come across this twice already with regards to callback functions, this last one slightly more confusing because it was an async call. Maybe a blog post which explains what passing a runner in the form (forall x. Sem r x -> IO x) does, what getInitialStateT does and why it turns everything into a Functor, and how it interacts with runT and bindT? How this internal state is tracked, how it applies to functions and parameters you call in a different monadic context, and how the interaction of it all works?

I suppose I'm imagining someone new to Haskell who asks the question we've heard numerous times, "How do you structure a Haskell app? Why would I pick polysemy over mtl over x?", and then seeing them get stuck on the same thing I have (a somewhat seasoned Haskeller, but after using it for 5 years still realize there is a lot I don't understand - the internals of your library are magic to me).

I wish I had a better answer, but most of this stems from my own ignorance.