Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MonadUnliftIO support? #73

Open
ocharles opened this issue May 30, 2019 · 56 comments
Open

MonadUnliftIO support? #73

ocharles opened this issue May 30, 2019 · 56 comments

Comments

@ocharles
Copy link

ocharles commented May 30, 2019

#36 talks about interop with mtl, but could we also support MonadUnliftIO? I haven't tried anything at all yet, just raising this from the lack of instance MonadUnliftIO (Sem r). I mostly need this from the context of writing interpret with something that uses MonadUnliftIO. Fortunately at the moment I can actually use liftIO to use MonadUnliftIO IO, but that won't always work.

@isovector
Copy link
Member

I think the solution is to use @adamConnerSax's new absorb machinery to magick up an instance of MoandUnliftIO given a natural transformation forall a. Sem r a -> IO a

@isovector
Copy link
Member

I'd definitely accept a PR in https://github.com/isovector/polysemy-zoo for this. See the examples in polysemy-research/polysemy-zoo#8 for inspiration.

@ocharles
Copy link
Author

Ok, jamming some code into Polysemy.MTL I got this:

data UnliftIO m a where
  UnliftIO :: ( ( forall x. m x -> IO x ) -> IO a ) -> UnliftIO m a

makeSem ''UnliftIO
    
absorbMonadUnliftIO :: forall r a. Member UnliftIO r
  => (S.MonadUnliftIO (Sem r) => Sem r a) -> Sem r a
absorbMonadUnliftIO = absorb @S.MonadUnliftIO

instance ReifiableConstraint1 S.MonadUnliftIO where
  data Dict1 S.MonadUnliftIO m = MonadUnliftIO
    { unliftIO_ :: forall a. ( ( forall x. m x -> IO x ) -> IO a ) -> m a
    }
  reifiedInstance = Sub Dict

instance ( Monad m
         , R.Reifies s' (Dict1 S.MonadUnliftIO m)
         ) => S.MonadIO (ConstrainedAction S.MonadUnliftIO m s') where
  liftIO io = ConstrainedAction $ unliftIO_ (R.reflect $ Proxy @s') (\_ -> io)

instance ( Monad m
         , S.MonadIO (ConstrainedAction S.MonadUnliftIO m s')
         , R.Reifies s' (Dict1 S.MonadUnliftIO m)
         ) => S.MonadUnliftIO (ConstrainedAction S.MonadUnliftIO m s') where
  withRunInIO f =
    ConstrainedAction $ unliftIO_ (R.reflect $ Proxy @s') (\k -> f (\(ConstrainedAction a) -> k a))


instance Member UnliftIO r => IsCanonicalEffect S.MonadUnliftIO r where
  canonicalDictionary = MonadUnliftIO unliftIO


runUnliftIO
  :: Member ( Lift IO ) r
  => (forall x. Sem r x -> IO x)
  -> Sem (UnliftIO ': r) a
  -> Sem r a
runUnliftIO lower =
  interpretH $ \( UnliftIO f ) -> do
    f' <- pureT f
    foo <- bindT f
    _ (foo f')

But I'm stuck writing runUnliftIO. Any ideas?

@isovector
Copy link
Member

We can't actually give exactly this type sig, since we're not sure the entire stack is unliftable (maybe there's an Error in there somewhere). Some of the ideas in #69 would let us verify that statically, but it doesn't exist yet.

What we can do instead is to give this type to UnliftIO: ((forall x. m x -> IO (Maybe x)) -> IO a) -> UnliftIO m a (note the Maybe). Doing so is a little involved with the internal machinery, but is doable:

runUnliftIO
  :: Member ( Lift IO ) r
  => (forall x. Sem r x -> IO x)
  -> Sem (UnliftIO ': r) a
  -> Sem r a
runUnliftIO lower (Sem m) = Sem $ \k -> m $ \u ->
  case decomp u of
    Left x -> k $ hoist (runUnliftIO lower) x
    Right (Yo (UnliftIO pp) s d f v) -> fmap f $ usingSem k $ do
      a <- sendM $ pp $ \mm -> do
        x <- lower $ runUnliftIO lower $ d $ mm <$ s
        pure $ v x
      pure $ a <$ s

@ocharles
Copy link
Author

ocharles commented May 31, 2019 via email

@isovector
Copy link
Member

What's the larger use case here? Why do you want this instance in the first place?

@ocharles
Copy link
Author

ocharles commented May 31, 2019 via email

@ocharles
Copy link
Author

That said, one could imagine wanting to re use http://hackage.haskell.org/package/unliftio-pool-0.2.1.0/docs/UnliftIO-Pool.html without having to rewrite the implementation to work with polysemy (other than a MonadUnliftIO implementation). I note that fused-effects now has such an instance (on LiftC).

@isovector
Copy link
Member

I'm not convinced; MonadUnliftIO and equivalent things are pretty clearly a kludge; MonadUnliftIO can't work with Error effects, and MonadBaseControl will silently do the wrong thing. Polysemy explicitly annotates the types, and makes you acknowledge when you're doing something smelly.

The Pool effect could be pretty trivially written via Resource, we'd just have to bite the bullet and not use an existing implementation. It would be more work in the short term, but I think will pay off when the community's Stockholm syndrome around monad transformers wears off.

@isovector
Copy link
Member

Although after looking at resource-pool, I'm pretty sure it could be lifted into polysemy by explicitly using the Resource effect in place of MonadBaseControl.

@ocharles
Copy link
Author

ocharles commented May 31, 2019 via email

@isovector
Copy link
Member

I hear that, but what's the right solution when the existing code is wrong?

@ocharles
Copy link
Author

ocharles commented May 31, 2019 via email

@isovector
Copy link
Member

I guess wrong is the wrong word. But there's no way to safely give an instance of MonadUnliftIO for Sem. I'm firmly of the opinion that it's a bad abstraction!

@isovector isovector added the wontfix This will not be worked on label Jun 1, 2019
@ocharles
Copy link
Author

ocharles commented Jun 4, 2019

I feel this is fairly connected to the async stuff in #80, and I think wontfix is too strong. There are times when we want to say "my interpreters are (monadically) stateless", which is really what this issue is about.

@isovector
Copy link
Member

isovector commented Jun 5, 2019

Maybe it's just because it's 3 in the morning, but in #84 I realized that maybe the bug here is that our interpreters are badly documented. runState indeed drops state outside of the main execution line, because it probably should be called runStatePurelyAndDropStateWheneverYouWant. This is a quirk of the runState interpreter, but not of the State effect as a whole (cf. runStateInIORef).

But now suppose we live our lives completely in IO --- we use IORefs for State, we throw IO exceptions for Error --- then we can indeed do MonadUnliftIO, because there is nothing but IO!

This has what we might call negative repercussions for pure code, since after all, one of the great benefits of this approach is that we don't want to do everything in IO! But what if the interpreters were smart enough to determine if you're running in IO or not, and do the right thing?

i.e., if runState could branch on whether Lift IO is in scope or not, and call runStatePurelyAndDropStateWheneverYouWant or runStateInIORef, then we'd be golden. (And likewise for any other interesting stateful effect).

@isovector isovector added deep pondering necessary and removed wontfix This will not be worked on labels Jun 5, 2019
@ocharles
Copy link
Author

ocharles commented Jun 5, 2019

I have actually been playing with a version of polysemy (in my head) that has no f at all - everything is done in IO under the hood. Even if you have Sem [State s] a you can have runState :: s -> Sem [State s] a -> (a, s) by just using unsafePerformIO - the types have shown you it's safe, after all! I'm not huge on interpreters changing their behavior based on which other interpreters/effects you use though

@isovector
Copy link
Member

isovector commented Jun 5, 2019 via email

@ocharles
Copy link
Author

ocharles commented Jun 5, 2019

Sem [Fork, Error, Lift IO] would likely have the semantics that forkIO (throw BOOM) gives you an Async a that when inspected with waitCatch returns Left BOOM. So the thread itself died with an exception. This behaviour would come from runErrorAsException :: Member (Lift IO) r => Sem (Error ': r) a -> Sem r a.

@isovector
Copy link
Member

isovector commented Jun 6, 2019

Taking a step back from the discussion in #84, it seems to me the fundamental issue is this:

Passing around pieces of pure state doesn't work very well when dealing with computations that run outside the main top-down execution context. Effects like bracket, async and catch will all discard partial pieces of state.

I decided to reread Effect Handlers in Scope and see if they have any advice. Indeed they do, and section 8 is explicitly on combining Error with State. It concludes:

Here we see that local state behaviour occurs whenrunStateis runfirst, and global state behaviour when it is last.

They later give a cooperative multithreading example that can also have local/global semantics based on when it is run compared to the State effect. Though notably, they write their own scheduler to do so, and have a fork/yield rather than fork/join algebra. It's not clear how to extend this work to IO-based implementations. Dead end on that front, I think.

Back to polysemy. From what I can see, there are several big problems here:

  1. Having semantics tied to when effects get run means the common pattern of runState s . reinterpret will likewise have local/global scopes depending on when its run in the stack. This might be desirable in some cases, but I suspect you're almost always going to want that runState to be global.
  2. Furthermore, these local/global considerations will mean you need to be super careful when composing effect stacks. Two expressions could both type check in the same place, but have drastically different semantics. On second thought, I think this is the selling point of effect systems.
  3. The strategy of "just shuffle around when you run your effects" doesn't work for (non-IO) effects that need to MonadUnliftIO their way directly down to IO. In order to shuffle your effects here, you'd need to compose them into the Sem r ~> IO bit, but this part is necessarily invoked multiple times.
  4. We could get around the above by giving IO versions of pure functions. For example, runStateInIO, which could be run underneath runBracket. However due to the way function composition works here, we're unable to initialize a unique IORef for s in this code, and such a thing would need to be passed in explicitly by the calling code. (.@!) a little bit gets around this, but only by using ImpredicativeTypes.

Does this accurately describe the state of affairs? If we had solutions for 1-4, would polysemy be the promised library?

@isovector
Copy link
Member

Something I did when working on the bracketing problem for freer-effects was to introduce a SafeIO effect, with the interpretation Eff '[IO, Error SomeException] ~> IO. Notice here that IO isn't the last effect in the stack! The idea was that any errors left unhandled in the final Error effect would get thrown as IO exceptions during runSafeIO. with the promise that any IO exceptions thrown before then would land in that effect.

I wonder if this approach could help for points 3 and 4 above. What if there were some approach for eliminating effects underneath IO? Some sort of runInIO :: (forall x. e m x -> Sem (Lift IO ': r) x) -> Sem (Lift IO ': e ': r) a -> Sem (Lift IO ': r) a.

This is a promising solution for 3, since once Lift IO is the head of your effect stack, you can never get rid of it. Thus we have an invariant that lets us differentiate interpreations that must be "IO-approved".

This also seems like a promising solution to 4, since now we have a unique place to run initialization of IORefs etc.

@ocharles
Copy link
Author

ocharles commented Jun 7, 2019

With regards to your point 2:

Furthermore, these local/global considerations will mean you need to be super careful when composing effect stacks. Two expressions could both type check in the same place, but have drastically different semantics.

This is nothing new to polysemy. runStateT . runExceptT means something very different to \m -> runExceptT . runStateT m.

@lexi-lambda
Copy link

This comment is essentially unrelated to the original topic of this issue, but I think the extreme inconsistency around when and how state is committed or unwound is confusing to the point of being dangerous. I was playing with this library the other day, but I realized there’s basically no way to write a meaningful bracketOnError function that works with arbitrary effects. The problem is that short-circuiting effect operations like throw ought to trigger the error handler, but they won’t. Given this, it seems like any code that needs bracketOnError probably needs to operate entirely in IO.

@isovector
Copy link
Member

I was playing with this library the other day, but I realized there’s basically no way to write a meaningful bracketOnError function that works with arbitrary effects. The problem is that short-circuiting effect operations like throw ought to trigger the error handler, but they won’t. Given this, it seems like any code that needs bracketOnError probably needs to operate entirely in IO.

Not true! Consider the following implementation of runBracketPurely (which admittedly should exist in the library):

runResourcePurely
    :: forall r a
     . Sem (Resource ': r) a
    -> Sem r a
runResourcePurely = interpretH $ \case
  Bracket alloc dealloc use -> do
    a <- runT  alloc
    d <- bindT dealloc
    u <- bindT use

    resource <- raise $ runResourcePurely a
    result <- raise $ runResourcePurely $ u resource
    _ <- raise $ runResourcePurely $ d resource
    pure result

We can use it:

foo
    :: ( Member (Error ()) r
       , Member Resource r
       , Member (State String) r
       , Member (Lift IO) r
       )
    => Sem r ()
foo =
  bracket
    (put "allocated" >> pure ())
    (\() -> do
      sendM $ putStrLn "in finalizer: "
      get >>= sendM . putStrLn
      put "finalized"
    )
    (\() -> do
      get >>= sendM . putStrLn
      put "starting block"
      throw ()
      put "don't get here"
    )

test1 :: IO ()
test1 = do
  r <- runM . runState "" . runResourcePurely . runError @() $ foo
  print r

with result

allocated
in finalizer:
starting block
("finalized",Left ())

The finalizer runs after throw, and the state is successfully threaded through the entire application.

You're right that running runError after runResourcePurely will result in the finalizer not getting called (because by the time it's being run, there is nothing to finalize!) But this again just that "order of operations is important."

@lexi-lambda
Copy link

I said bracketOnError, not bracket! Sometimes you need it, or its cousin onException, but polysemy only provides bracket.

@isovector
Copy link
Member

@lexi-lambda the bracket updates are out in polysemy-0.4.0.0 including finally, bracketOnError, onException and a pure interpretation for such.

@ocharles
Copy link
Author

fused-effects/fused-effects#306 defines a new Lift effect for fused-effects, which subsumes MonadUnliftIO - this might be possible for polysemy too!

@KingoftheHomeless
Copy link
Collaborator

KingoftheHomeless commented Oct 28, 2019

fused-effects's new Lift is our Final. It doesn't subsume MonadUnliftIO; you can't get a natural transformation forall x. Sem r x -> IO x using liftWith/withWeavingToFinal.

@L7R7
Copy link

L7R7 commented Oct 13, 2020

What's the current status of the MonadUnliftIO support? I'd like to use pooledMapConcurrentlyN in a Polysemy project and I'm not sure what's the ideal way to do implement that. Should I go ahead and try to implement a MonadUnliftIO instance for Sem r? Or should I model it as an effect (like it is sketched out above)?

I also described my problem in this stackoverflow question. I'd appreciate any help on that

@KingoftheHomeless
Copy link
Collaborator

Now that I know more about the topic, I can safely say this won't happen as long as we stick with the current core mechanism of our library; and we won't switch from it any time soon.
You can try to use Final IO (which is rather complicated) or the unsafe withLowerToIO, but we won't be able to support MonadUnliftIO straight-up.

@googleson78
Copy link
Member

I'm not sure what @L7R7 can do here - his alternative is to use pooledMapConcurrentlyIO :: Traversable t => Int -> (a -> IO b) -> t a -> IO (t b), which doesn't have MonadUnliftIO, but he requires that the actions that he runs concurrently (so the a -> IO bs) are actually polysemy actions themselves (so a -> Sem r bs instead).

So we need to somehow produce an IO b from a Sem r b. Is there a way to do this with Final IO? (with*ToFinal?)

@tek
Copy link
Member

tek commented Oct 13, 2020

withLowerToIO :: Member (Embed IO) r => ((∀ x. Sem r x -> IO x) -> IO () -> IO a) is exactly how to do this!

@tek
Copy link
Member

tek commented Oct 13, 2020

probably not too performant though, I would assume 🙂

@KingoftheHomeless
Copy link
Collaborator

KingoftheHomeless commented Oct 14, 2020

More to the point, withLowerToIO is unsafe.

Here's how you could implement pooledMapConcurrently using Final IO:

pooledMapConcurrently :: (Member (Final IO) r, Traversable t) => Int -> (a -> Sem r b) -> t a -> Sem r (t (Maybe b))
pooledMapConcurrently i f t = withWeavingToFinal $ \s wv ins ->
  (<$ s) <$> pooledMapConcurrentlyIO i (\a -> ins <$> wv (f a <$ s)) t

You can't get rid of the Maybes; again, we can't support MonadUnliftIO straight-up.

@L7R7
Copy link

L7R7 commented Oct 14, 2020

Thanks for your input! @KingoftheHomeless the pooledMapConcurrently looks interesting. Dealing with the Maybes should be doable somehow. Now I still have some type tetris to do to get the effect working. I tried the following:

data ParTraverse m a where
  TraverseP :: (Traversable t) => (a -> m b) -> t a -> ParTraverse m (t (Maybe b))

makeSem ''ParTraverse

parTraverseToIO :: (Member (Final IO) r) => Sem (ParTraverse ': r) a -> Sem r a
parTraverseToIO = interpretH $ \case
   TraverseP fa ta -> pooledMapConcurrentlySem 42 fa ta

the implementation of parTraverseToIO is rather naive, but something along those lines should do it, right?

@tek
Copy link
Member

tek commented Oct 14, 2020

don't know if there's a way around, but you need another level of Maybes:

pooledMapConcurrently :: Member (Final IO) r => Int -> (a -> Sem r b) -> [a] -> Sem r [Maybe b]
pooledMapConcurrently num f ta =
  ...

data ParTraverse m a where
  TraverseP :: (a -> m b) -> [a] -> ParTraverse m [b]

makeSem ''ParTraverse

parTraverseToIO :: (Member (Final IO) r) => InterpreterFor ParTraverse r
parTraverseToIO =
  interpretH \case
   TraverseP f ta -> do
     taT <- traverse pureT ta
     fT <- bindT f
     tb <- raise (parTraverseToIO (pooledMapConcurrently 1 fT taT))
     ins <- getInspectorT
     pureT (catMaybes (inspect ins <$> catMaybes tb))

with Traversable you can maybe use Monoid??

@L7R7
Copy link

L7R7 commented Oct 15, 2020

@tek Thank you very much! That works 👍 I still have to dig deeper into what's going on exactly, and I think it should be possible to use Traversable and Monoid instead of a list. I'll give that a try.
Btw, I would accept this as an answer to my stackoverflow question mentioned above if you're interested

@tek
Copy link
Member

tek commented Oct 15, 2020

hm, I've never done that before! why not!

@tek
Copy link
Member

tek commented Oct 15, 2020

I've posted an answer, including some explanations for all the combinators used!

@L7R7
Copy link

L7R7 commented Oct 25, 2020

@tek coming back to the interpreter for the ParTraverse effect: I'm wondering whether it's possible to write an interpreter that just uses traverse (without any concurrency) so that I can use it in a pure environment. For example, I'd like to write a test for some piece of code that uses this effect. The test doesn't do any IO, so the effect implementation from above can't be used as it embeds the effect in IO.
Is it possible to do something like this? I kinda was hoping for something along the lines of:

parTraversePure :: InterpreterFor ParTraverse r
parTraversePure =
  interpretH $ \case
    TraverseP f ta -> traverse f ta

@tek
Copy link
Member

tek commented Oct 25, 2020

@L7R7 that's an easy one! since we don't have to treat the individual thunks separately, we can run the traverse action in the initial effect stack (m ~ Sem rInitial), which doesn't need us to fiddle with the monadic state:

parTraversePure :: InterpreterFor ParTraverse r
parTraversePure =
  interpretH \case   
    TraverseP f ta ->
      raise . parTraversePure =<< runT (traverse f ta)

after that, it's just the obligatory lifting into Tactical with runT, then the recursion to execute any nested ParTraverse, and raise to align the types.

@L7R7
Copy link

L7R7 commented Oct 25, 2020

Dang, that's close to what I tried! This totally makes sense, however it doesn't compile just yet. The error message says


    • Could not deduce (Applicative m) arising from a use of ‘traverse’
      from the context: Functor f
        bound by a type expected by the context:
                   forall x (m :: * -> *).
                   ParTraverse m x -> Tactical ParTraverse m r x
        at test/Spec.hs:(45,14)-(47,56)
      or from: x ~ [b]
        bound by a pattern with constructor:
                   TraverseP :: forall a1 (m :: * -> *) b.
                                (a1 -> m b) -> [a1] -> ParTraverse m [b],
                 in a case alternative
        at test/Spec.hs:46:7-20
      Possible fix:
        add (Applicative m) to the context of
          the data constructor ‘TraverseP’
          or a type expected by the context:
               forall x (m :: * -> *).
               ParTraverse m x -> Tactical ParTraverse m r x
    • In the first argument of ‘runT’, namely ‘(traverse f ta)’
      In the second argument of ‘(=<<)’, namely ‘runT (traverse f ta)’
      In the expression: raise . parTraversePure =<< runT (traverse f ta)
   |        
47 |         raise . parTraversePure =<< runT (traverse f ta)
   |                                           ^^^^^^^^^^^^^

which is weird. Shoudln't m be Sem r in this case? If so, there would be an instance for Applicative. And where does the Functor context come from?
If my assumption is correct, I would need to add a type to traverse so the compiler knows what I want, something like .... (traverse f ta :: Sem r [b]) where [b] would be the a in the function's signature?

oh, and btw:

, then the recursion to execute any nested ParTraverse

You casually answered a question I asked myself a couple of times already (Why is there a recursion?)! I didn't think about nested effects before. Pretty obvious now 😃

@tek
Copy link
Member

tek commented Oct 25, 2020

looks like your Polysemy version is too old! interpretH used to operate on m instead of Sem rInitial.
If you don't want to use master, you can still use the same method as in the IO variant, you'll just have to substitute traverse for pooledMapConcurrently.

and regarding the recursion: I did address it in the SO answer! 😄

@L7R7
Copy link

L7R7 commented Oct 25, 2020

Ah, I see! I was using the version I'm getting with the latest stackage resolver. I switched to the latest commit on the master branch and it works now. Thank you!

(re-read your SO answer. You're absolutely right, somehow that didn't click when I first read it, my bad 😅)

@tek
Copy link
Member

tek commented Oct 25, 2020

(re-read your SO answer. You're absolutely right, somehow that didn't click when I first read it, my bad 😅)

it's easy to overlook something when reading about Tactics 🙂

@googleson78
Copy link
Member

As a future note @L7R7 - these kinds of discussions often happen over at zulip - not to discourage them, but this is now veering a bit off-topic from the original issue, and you'd probably even have faster a "feedback loop" over there (seeing as how it's a "chat" and not a "forum")

@isovector
Copy link
Member

I think this is solved by @KingoftheHomeless's new machinery for tactics in v2, where we have a bonafide MonadBaseControl equivalent.

@isovector isovector mentioned this issue Oct 19, 2021
@spacekitteh
Copy link
Contributor

So is this possible now?

@tek
Copy link
Member

tek commented Nov 27, 2022

still in the works.

@spacekitteh
Copy link
Contributor

Ah, ok. I'm trying to write an effect for OpenTelemetry, and it needs an UnliftIO instance :( It'll have to wait, then :3

@KingoftheHomeless
Copy link
Collaborator

KingoftheHomeless commented Nov 28, 2022

You can get a scoped-restricted MonadBaseControl instance (not MonadTransControl, mind you) using my work, but that's it. Sorry to say, but MonadUnliftIO will never be possible unless we decide to move away from a weave-based higher-order effect system to something else entirely, like reformulation into primitives, but I don't see it happening, as we'll inevitably lose something (e.g. reformulation into primitives would incur A LOT of user-visible complexity.)

@googleson78
Copy link
Member

Do you want to share where in particular you need the MonadUnliftIO?

@spacekitteh
Copy link
Contributor

@googleson78 I was going to link you to https://hackage.haskell.org/package/hs-opentelemetry-api-0.0.3.6/docs/OpenTelemetry-Trace-Core.html#g:4 (I guess I just did anyway), but then I noticed that the various inSpans were implemented in terms of createSpan, which doesn't have the constraint, soooo.... I don't need it. ^_^

@tek
Copy link
Member

tek commented Dec 24, 2022

yeah often it's not that much effort to get around MonadUnliftIO, if the library exposes its internals

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants