DEV Community

Cover image for Events are best served hot
Mike Solomon
Mike Solomon

Posted on

Events are best served hot

In most functional programming languages, a signal or event type is defined as such:

newtype Event a = Event ((a -> Effect Unit) -> Effect (Effect Unit))
Enter fullscreen mode Exit fullscreen mode

The contract is saying: "If you give me a way to report events a -> Effect Unit, I'll give you a way to turn on and off the event stream Effect (Effect Unit). The outer effect turns on the stream, and the inner one turns it off.

This definition has been used to build many functional frameworks, including purescript-hyrule, purescript-halogen-subscriptions and almost all of the Rx implementations.

The definition has also given rise to a distinction between hot and cold events. A "hot" event is one through which information is already flowing when you subscribe to it, whereas a "cold" event starts the flow of information upon subscription. The Rx documentation does a good job describing this in the “Hot” and “Cold” Observables section.

In this article, I'll show some examples of hot and cold events. I'll then argue that cold events are evil and represent 99% of the problems folks have with event-based libraries. Lastly, I'll propose a definition of event that only admits hot events.

Events hot and cold

In this section, I'll present two common examples of hot and cold events as well as an example of an event that straddles this divide, much to the chagrin of many a developer.

Some like it hot

In PureScript, a hot event is created with the create function.

create :: forall a. Effect { event :: Event a, push :: a -> Effect Unit
Enter fullscreen mode Exit fullscreen mode

When using create, you get an event and a pusher. This event is hot, meaning that anything that subscribes to it only gets things pushed to push after the subscription is created.

Hot events are useful when, for example, you're building an event bus. You send the pusher to whatever corner of your application needs to push information, and you subscribe wherever you need to receive it.

Purity is cold

The most common example of a cold event is pure via its applicative instance.

instance Applicative Event where
  pure a = Event \k -> k a *> pure (pure unit)
Enter fullscreen mode Exit fullscreen mode

Here, every time someone subscribes to an event by giving it a k, it immediately emits an a. It is the epitome of a cold event, replaying the exact same sequence for each subscription.

Intervals

Whenever a programmer encounters one of PureScript's event libraries for the first time, if they spend enough time working with it, they eventually encounter:

interval :: Int -> Event Instant
Enter fullscreen mode Exit fullscreen mode

This emits the current time at intervals of Int milliseconds.

interval is cold, meaning every time you subscribe to it, a new interval starts. And, given the finickiness of most programming languages, even if you do traverse interval [5, 5, 5, 5], which in theory should create four perfectly aligned events that go off every 5 milliseconds, the start times will be slightly offset and the gaps will only get worse as time goes on. This is because, instead of using the same interval 5 times, we are creating four different intervals.

Cold events are evil

Robert Frost said it best:

Some say the world will end in fire,
Some say in ice.
From what I’ve tasted of desire
I hold with those who favor fire.
But if it had to perish twice,
I think I know enough of hate
To say that for destruction ice
Is also great
And would suffice.

Cold events destroy programs. They potentially tuck away all sorts of side effects in the subscription phase, which happens during the first Effect of Effect (Effect Unit) in the definition of Event. While these side effects are cleaned up on unsubscription, they encourage composing together objects in a pure way that, at scale, dangerously masks what side effects are actually going on.

To drive the point home, consider a "today's news" event that, on subscription, pays 5 cents to your local newsmonger and starts the flow of headlines. On unsubscribe, it stops the flow of headlines.

Can you spot what's different and what's the same between these two programs?

Program 1

void $ subscribe (sequence (repeat 100_000 today'sNews) log)
Enter fullscreen mode Exit fullscreen mode

Program 2

do
  { event, push } <- create
  void $ subscribe today'sNews push
  subscribe (sequence (repeat 100_000 event) log)
Enter fullscreen mode Exit fullscreen mode

In both programs, we'll have each headline from today's news spammed to the log 100_000 times. But in the second program, we only pay 5 cents for today's news, whereas in the first one, we pay 5,000 dollars.

Now, imagine that you're working on a large team and you're working on an API that takes an event as a parameter. Is it safe to subscribe to? Will doing so incur a cost? Scratch your car? Kick your dog? This is why cold events, like Robert Frost's ice, are the great destroyer. They are footguns that do nothing that a hot event can't do with a bit of proper planning.

We can do better!

So why did the magnanimous inventors of events create cold events? Let's look at the signature of Event again:

newtype Event a = Event ((a -> Effect Unit) -> Effect (Effect Unit))
Enter fullscreen mode Exit fullscreen mode

The thing that makes cold events so dangerous is the Effect (Effect Unit), where we can introduce an arbitrary side effect upon subscription and another one upon unsubscription. But we do need side effects on subscription and unsubscription, even for hot events. Let's look at the implementation of a function like create, which as we remember from above is how we make a hot event.

create
  :: forall a
   . Event { push :: a -> Effect Unit, event :: Event a }
create_ = do
  -- here, FOST is Foreign.Object.ST
  -- which allows you to work with mutable objects
  subscribers <- liftST FOST.new
  idx <- liftST $ STRef.new 0
  pure
    { event:
        Event \k -> liftST do
          rk <- STRef.new k
          ix <- STRef.read idx
          void $ FOST.poke (show ix) rk subscribers
          void $ STRef.modify (_ + 1) idx
          pure $ liftST do
            void $ STRef.write mempty rk
            void $ FOST.delete (show ix) subscribers
    , push:
        \a -> do
          o <- liftST $ FOST.unfreeze subscribers
          for_ subscribers \rk -> do
            k <- liftST $ STRef.read rk
            k a
    }
Enter fullscreen mode Exit fullscreen mode

In event, we are adding a listener to a subscriber cache on subscribe and removing it on unsubscribe. Then, on push, we iterate over the subscriber cache and push our value to each subscriber.

It's clear, then, that even in hot events, we need effects on subscription and unsubscription. But wait a second, do we really need Effect as our effect? Looking at the code, it looks like both the subscriber and unsubscriber are lifted from ST Global to Effect. So what if we dispensed with Effect and instead made the signature of Event.

newtype Event a = Event ((a -> Effect Unit) -> ST Global (ST Global Unit))
Enter fullscreen mode Exit fullscreen mode

We still have an Effect in the first argument to Event because push in the example above needs to be effectful and it can contain arbitrary side effects (like, for example, pushing the result of an API call). However, the subscription and unsubscription mechanism is entirely achievable in the ST Global monad because it is only ever creating and manipulating references to stateful objects.

As promised, this is the new definition of Event that I offer for folks's humble consideration. In this setup, anything of type Event a can only ever be hot because it's impossible to trigger any effect on subscription on unsubscription. The worst conundrum we can get in if we do something exotic like roll our own create is to forget either subscribing or unsubscribing, but this is far more anodine than the bomb detonators and Rick-and-Morty-esque "one is now zero" mechanisms one can implement using Effect.

So if we're bidding a farewell to cold events, what are we losing. The most bittersweet farewell is to Event's Applicative instance because we no longer can trigger an event upon subscription. But did we ever really need applicative? Applicative only makes sense insofar as we have some computation where we need to "prime the pump" of an event so that there's something there when another event goes off. For example, let's look at the definition of fold, which folds over an event in time to create an accumulator.

-- | Fold over values received from some `Event`, creating a new `Event`.
fold :: foral a b. (b -> a -> b) -> b -> Event a -> Event b
fold f b e =
  fix \i -> sampleOnRight (i <|> pure b) ((flip f) <$> e)
Enter fullscreen mode Exit fullscreen mode

Here, we use pure in so that the following sequence of events (ha!) occurs:

  1. pure b goes off.
  2. e goes off.
  3. sampleOnRight responds to e going off as it drives the sampling, and picks up that pure b has already gone off, so it emits.
  4. This emission causes i (the fixed point) to emit, as the input is the output.
  5. When i emits, as e hasn't emitted again, we wait for the next e.
  6. Now when e emits again, it will combine with its previous incarnation (aka i). The pure b will never go off again: it just primed the pump so that the loop could start.

But do we really need pure here? We don't need an event to trigger immediately upon subscription, we just need it to trigger sometime before e. Or in other words, if 0 is the moment a system is subscribed to and t is the moment e fires, for the six-part sequence above to hold, we need pure b to fire anytime between 0 and t - ϵ, where ϵ is arbitrarily small. So what can get us an event at t - ϵ? e can!

"Woah woah waoh," you may protest, "Hold your horses. If e happens at time t, how the heck can it happen at t - ϵ? Are you a time traveler?" Yes, I am. The future sent me to your era to teach you about FRP. But no, I didn't need to use that superpower to conjure an event at time t - ϵ from an event at time t. Because events are read from left to right, any event that fires on the left side of an expression will, fire before the right side. This is because, at the end of the day, events just translate to imperative-looking effectful code, and two bits of effectful code that execute "simultaneously" still have to run before another. To reinforce that, let's look at the definition of sampleOnRight.

sampleOnRight :: forall a b. Event a -> Event (a -> b) -> Event b
sampleOnRight (Event e1) (Event e2) =
  Event $ \k -> do
    latest <- Ref.new Nothing
    c1 <-
      e1 \a -> do
        Ref.write (Just a) latest
    c2 <-
      e2 \f -> do
        o <- Ref.read latest
        for_ o (\a -> runEffectFn1 k (f a))
    pure do
      c1
      c2
Enter fullscreen mode Exit fullscreen mode

If e1 and e2 get an emission at the same time, the top of the do block will run before the bottom of the do block. That's the nature of imperative code.

So we almost have our Applicative instance back, but not quite. Instead of pure b, we could write:

fold :: foral a b. (b -> a -> b) -> b -> Event a -> Event b
fold f b e =
  fix \i -> sampleOnRight (i <|> (e $> b)) ((flip f) <$> e)
Enter fullscreen mode Exit fullscreen mode

Now the e on the left will fire slightly before the e on the right and we have our event at time t - ϵ. But we have another problem now. This does much more than pure because it will keep firing every time e fires. We only want it to fire once. Drats. So how do we pull that off? Again, ST to the rescue!

once :: forall a. Event a -> Event a
once (Event e) =
  Event $\k -> do
    latest <- STRef.new Nothing
    u <- STRef.new $ pure unit
    c <-
      e \a -> do
        o <- liftST $ STRef.read latest
        case o of
          Nothing -> do
            void $ liftST $ STRef.write (Just a) latest
            k a
            liftST $ join (STRef.read u)
          Just _ -> pure unit
    void $ STRef.write c u
    o <- liftST $ STRef.read latest
    case o of
      Just _ -> c
      _ -> pure unit
    pure do
      c
Enter fullscreen mode Exit fullscreen mode

We can use ST to monitor how many times an event is invoked and unsubscribe it automatically after the first emission. With this, we finally have something like pure back:

fold :: foral a b. (b -> a -> b) -> b -> Event a -> Event b
fold f b e =
  fix \i -> sampleOnRight (i <|> once (e $> b)) ((flip f) <$> e)
Enter fullscreen mode Exit fullscreen mode

So the most important cold event, pure, is achievable with respect to any arbitrary event e because we can always emit an event slightly before e using once that will achieve the same effect as pure did.

As for other cold events like interval or today'sNews, we can always create something akin to a subscription or unsubscription in an Effect block. Now you may protest: "I'm working with Events and I'm not in the Effect monad. How am I gonna do something like interval if I need to be in Effect. The answer is that, if you are working with events, you'll have to subscribe to them at some point, and when you subscribe to them, it will be in an Effect monad. So long as you can pipe information through your system from this Effect monad, for example by using a Reader over Event, you're fine.

So, in conclusion, there is nothing you can achieve with cold events that you can't achieve in a hot world with a bit of elbow grease. And, for the price of that grease, you will avoid a plethora of pitfalls plus have a more consistent and manageable API.

Top comments (0)