packages feed

atelier-core-0.1.0.0: src/Atelier/Effects/Publishing.hs

-- | A typed publish/subscribe effect pair.
--
-- 'Pub' publishes events of a given type; 'Sub' subscribes and delivers each
-- published event to a listener. 'runPubSub' wires the two together over an
-- internal broadcast channel, propagating tracing context from publisher to
-- listener; 'runPubWriter' instead records published events to a 'Writer', for
-- tests.
--
-- Subscriptions are established asynchronously, so a listener you intend to
-- publish to should be started with 'forkListener' or 'forkListener_', which
-- block until the subscription is live and therefore cannot miss early events.
module Atelier.Effects.Publishing
    ( Pub
    , Sub
    , listen
    , listen_
    , listenWith
    , listenWith_
    , listenOnce
    , listenOnce_
    , forkListener
    , forkListener_
    , publish
    , runPubSub
    , runPubWriter
    )
where

import Data.Time (UTCTime)
import Effectful (Effect)
import Effectful.Concurrent.STM
    ( Concurrent
    , atomically
    , newEmptyTMVar
    , putTMVar
    , takeTMVar
    )
import Effectful.Dispatch.Dynamic (interpretWith, interpretWith_, interpret_, localSeqUnlift)
import Effectful.Error.Static (runErrorNoCallStack, throwError)
import Effectful.TH (makeEffect)
import Effectful.Writer.Static.Shared (Writer, tell)

import Text.Show qualified as S

import Atelier.Effects.Chan (Chan)
import Atelier.Effects.Clock (Clock)
import Atelier.Effects.Conc (Conc, fork_)
import Atelier.Effects.Monitoring.Tracing (SpanContext, Tracing)

import Atelier.Effects.Chan qualified as Chan
import Atelier.Effects.Clock qualified as Clock
import Atelier.Effects.Monitoring.Tracing qualified as Tracing


-- | Effect for publishing events of type @event@.
data Pub (event :: Type) :: Effect where
    -- | Publish an event to all current subscribers.
    Publish :: event -> Pub event m ()


-- | Effect for subscribing to events of type @event@.
data Sub (event :: Type) :: Effect where
    -- | Subscribe, then run @onSubscribed@ once the subscription is established
    -- — after the internal channel has been duplicated and before any event is
    -- delivered — and thereafter deliver every published event to the listener,
    -- forever. The @onSubscribed@ hook lets a caller synchronize on "subscribed"
    -- so a concurrently-started publisher cannot race ahead of the subscription
    -- and have its events missed. Most callers want 'listen' (no hook); a caller
    -- that forks the listener and then publishes must wait on this hook first.
    ListenWith :: m () -> (UTCTime -> event -> m ()) -> Sub event m Void


makeEffect ''Pub
makeEffect ''Sub


-- | Subscribe and deliver every published event to the listener, forever.
-- Defined in terms of 'listenWith' with a no-op subscribed hook.
listen :: (Sub event :> es) => (UTCTime -> event -> Eff es ()) -> Eff es Void
listen = listenWith (pure ())


-- | Like 'listen', but the listener ignores the event timestamp.
listen_ :: (Sub event :> es) => (event -> Eff es ()) -> Eff es Void
listen_ listener = listen $ \_timestamp event -> listener event


-- | Like 'listen_', but runs @onSubscribed@ once the subscription is
-- established and before any event is delivered. See 'ListenWith'.
listenWith_ :: (Sub event :> es) => Eff es () -> (event -> Eff es ()) -> Eff es Void
listenWith_ onSubscribed listener = listenWith onSubscribed $ \_timestamp event -> listener event


-- | Fork a background listener and block until it has actually subscribed,
-- then return. The listener runs until the enclosing 'Conc' scope closes.
--
-- This is the safe way to start a listener you intend to publish to: a plain
-- @'fork_' . 'listen'@ followed by a 'publish' races the subscription (which
-- happens asynchronously in the forked thread) and, under scheduler pressure,
-- can drop early events and wedge the listener forever. 'forkListener' closes
-- that window by waiting on the subscribed hook before returning.
forkListener
    :: forall event es
     . (Conc :> es, Concurrent :> es, Sub event :> es)
    => (UTCTime -> event -> Eff es ())
    -> Eff es ()
forkListener listener = do
    subscribed <- atomically newEmptyTMVar
    fork_ $ listenWith (atomically (putTMVar subscribed ())) listener
    atomically (takeTMVar subscribed)


-- | Like 'forkListener', but the listener ignores the timestamp.
forkListener_
    :: forall event es
     . (Conc :> es, Concurrent :> es, Sub event :> es)
    => (event -> Eff es ())
    -> Eff es ()
forkListener_ listener = forkListener @event (\_timestamp event -> listener event)


-- | Wait for a single event and then return said event.
listenOnce :: forall event es. (Sub event :> es) => Eff es (UTCTime, event)
listenOnce = do
    res <- runErrorNoCallStack
        $ listen
        $ \timestamp event -> throwError $ OnceEx (timestamp, event)
    case res of
        Left (OnceEx x) -> pure x
        Right v -> absurd v


-- | Same as 'listenOnce', but discards the timestamp.
listenOnce_ :: (Sub event :> es) => Eff es event
listenOnce_ = snd <$> listenOnce


data OnceEx ev = OnceEx ev
instance Show (OnceEx ev) where show _ = "OnceEx"


-- | Internal wrapper for events with trace context
data TracedEvent event = TracedEvent
    { event :: event
    , timestamp :: UTCTime
    , publisherSpanContext :: Maybe SpanContext
    }


-- | Runs Pub and Sub effects with an internal channel for a specific event type.
-- Automatically captures span context from the publisher and creates linked spans in listeners.
runPubSub
    :: forall event es a
     . ( Chan :> es
       , Clock :> es
       , Tracing :> es
       )
    => Eff (Pub event : Sub event : es) a -> Eff es a
runPubSub action = do
    (inChan, _) <- Chan.newChan @(TracedEvent event)

    let handlePub eff = interpretWith_ eff \case
            Publish event -> do
                timestamp <- Clock.currentTime
                -- Capture the current span context from the publisher
                publisherSpanContext <- Tracing.getSpanContext
                Chan.writeChan inChan TracedEvent {event, timestamp, publisherSpanContext}

        handleSub eff = interpretWith eff \env -> \case
            ListenWith onSubscribed listener -> localSeqUnlift env \unlift -> do
                chan <- Chan.dupChan inChan
                unlift onSubscribed
                forever do
                    TracedEvent {event, timestamp, publisherSpanContext} <- Chan.readChan chan
                    Tracing.withLinkPropagation publisherSpanContext $ unlift $ listener timestamp event

    handleSub . handlePub $ action


-- | Handler that uses a provided Writer effect instead of actually publishing.
-- Useful for testing and inspecting what events were published.
runPubWriter :: forall event es a. (Writer [event] :> es) => Eff (Pub event : es) a -> Eff es a
runPubWriter =
    interpret_ \case
        Publish event -> tell [event]