kiroku-store-0.12.0.0: src/Kiroku/Store/Subscription/Stream.hs
-- | Streamly bridges that turn a push-based subscription into a pull-based
-- @Stream IO@, with a bounded 'Control.Concurrent.STM.TBQueue' providing
-- backpressure between the Kiroku worker and a downstream stream consumer.
--
-- Two bridges are provided:
--
-- * 'subscriptionStream' — the plain bridge. The bridge handler enqueues each
-- event and immediately returns 'Continue', so the worker checkpoints at the
-- batch tail independently of whether the downstream consumer has processed
-- the event. Use it when the stream is an in-process handoff and per-event
-- acknowledgement is not required.
-- * 'subscriptionAckStream' — the __ack-coupled__ bridge. Each emitted 'AckItem'
-- carries a one-shot reply variable; the bridge handler blocks until the
-- consumer replies with a 'SubscriptionResult', and only then does the worker
-- checkpoint \/ retry \/ dead-letter. This is the path the
-- @shibuya-kiroku-adapter@ builds on so a Shibuya @AckRetry@\/@AckDeadLetter@
-- drives a real Kiroku disposition before the checkpoint advances.
--
-- Both honour the subscription's worker-side filters
-- ('Kiroku.Store.Subscription.Types.eventTypeFilter' and
-- 'Kiroku.Store.Subscription.Types.selector'): a filtered-out event never reaches
-- either bridge.
--
-- Both streams end normally when the subscription worker stops cleanly or is
-- cancelled. If the worker dies for any other reason, the next stream pull
-- rethrows the worker's exception to the stream consumer.
module Kiroku.Store.Subscription.Stream
( -- * Plain pull stream
subscriptionStream,
-- * Ack-coupled pull stream
AckItem (..),
InvalidStreamBufferSize (..),
StreamBufferSize,
mkStreamBufferSize,
streamBufferSizeValue,
defaultStreamBufferSize,
subscriptionAckStream,
)
where
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.STM
( STM,
TMVar,
TVar,
atomically,
newEmptyTMVarIO,
newTBQueueIO,
newTVarIO,
orElse,
putTMVar,
readTBQueue,
readTVar,
retry,
takeTMVar,
writeTBQueue,
writeTVar,
)
import Control.Exception (SomeException, fromException, mask, onException, throwIO)
import Data.IORef (atomicModifyIORef', newIORef)
import Kiroku.Store.Connection (KirokuStore)
import Kiroku.Store.Subscription (subscribe)
import Kiroku.Store.Subscription.Types
( SubscriptionConfig,
SubscriptionConfigM (..),
SubscriptionHandleM (..),
SubscriptionResult (..),
)
import Kiroku.Store.Types (EventId, RecordedEvent (..))
import Numeric.Natural (Natural)
import Streamly.Data.Stream (Stream)
import Streamly.Data.Stream qualified as Stream
-- | One ack-coupled item emitted by 'subscriptionAckStream'.
--
-- The Kiroku subscription worker enqueues an 'AckItem' for each delivered event and
-- then __blocks__ until the consumer writes a 'SubscriptionResult' into 'ackReply'.
-- Only then does the worker act on it (checkpoint on 'Continue'\/'Stop', redeliver
-- on 'Retry', record-and-advance on 'DeadLetter'). This is what lets a downstream
-- consumer (the @shibuya-kiroku-adapter@) drive Kiroku's per-event disposition from
-- its own acknowledgement decision.
data AckItem = AckItem
{ -- | the delivered event
ackEvent :: !RecordedEvent,
-- | zero-based redelivery count for this event: @0@ on first delivery, @1@
-- on the first redelivery after a 'Retry', and so on. Tracked by the bridge
-- (the worker redelivers the same event consecutively while retrying).
ackAttempt :: !Word,
-- | one-shot reply the consumer must fill exactly once
ackReply :: !(TMVar SubscriptionResult)
}
-- | Construction error from 'mkStreamBufferSize' for a zero capacity.
--
-- A zero-capacity 'TBQueue' would make the bridge handler block forever on its
-- first delivery, before a stream consumer can ever see the event or reply to it.
newtype InvalidStreamBufferSize = InvalidStreamBufferSize Natural
deriving stock (Eq, Show)
-- | Positive capacity, validated before allocating a bridge or starting a worker.
newtype StreamBufferSize = StreamBufferSize Natural
deriving stock (Eq, Show)
mkStreamBufferSize :: Natural -> Either InvalidStreamBufferSize StreamBufferSize
mkStreamBufferSize n
| n >= 1 = Right (StreamBufferSize n)
| otherwise = Left (InvalidStreamBufferSize n)
streamBufferSizeValue :: StreamBufferSize -> Natural
streamBufferSizeValue (StreamBufferSize n) = n
defaultStreamBufferSize :: StreamBufferSize
defaultStreamBufferSize = StreamBufferSize 256
data BridgeTermination
= BridgeClosedCleanly
| BridgeCrashed !SomeException
closeBridge :: TVar (Maybe BridgeTermination) -> BridgeTermination -> STM ()
closeBridge closedVar termination =
readTVar closedVar >>= \case
Nothing -> writeTVar closedVar (Just termination)
Just _ -> pure ()
-- | Create a pull-based 'Stream' from a kiroku subscription.
--
-- A bounded 'TBQueue' sits between kiroku's push-based handler and the
-- returned Streamly stream. The stream terminates normally when the underlying
-- subscription stops cleanly or the returned cancel action runs. If the
-- underlying worker crashes, the next stream pull rethrows the worker exception
-- to the consumer.
--
-- The @handler@ field in the provided 'SubscriptionConfig' is ignored —
-- the bridge provides its own handler.
--
-- This is the plain, non-acknowledging bridge: every event is replied 'Continue'
-- as soon as the consumer pulls it, preserving the original semantics. It is
-- implemented in terms of 'subscriptionAckStream'; consumers that need to drive
-- retry\/dead-letter dispositions should use 'subscriptionAckStream' directly.
--
-- The config's
-- 'Kiroku.Store.Subscription.Types.eventTypeFilter' is honored: the worker
-- applies it before the bridge handler runs, so the stream yields only matching
-- events while the checkpoint still advances past filtered-out ones. The stream's
-- element type is unchanged ('RecordedEvent') — filtering only removes elements,
-- it does not reshape them.
--
-- The returned cancel action cancels the underlying subscription and wakes any
-- blocked reader without writing to the bounded queue.
subscriptionStream ::
KirokuStore ->
SubscriptionConfig ->
-- | TBQueue capacity for the bridge; must be at least 1.
StreamBufferSize ->
IO (Stream IO RecordedEvent, IO ())
subscriptionStream store config bufferSize = do
(ackStream, cancelAction) <- subscriptionAckStream store config bufferSize
let stream =
Stream.mapM
( \item -> do
atomically (putTMVar (ackReply item) Continue)
pure (ackEvent item)
)
ackStream
pure (stream, cancelAction)
-- | Create an ack-coupled pull 'Stream' from a kiroku subscription.
--
-- Each emitted 'AckItem' carries a one-shot reply variable; the underlying Kiroku
-- worker blocks in its handler until the consumer fills 'ackReply' with a
-- 'SubscriptionResult'. The worker then checkpoints, redelivers, dead-letters, or
-- stops accordingly — so the consumer controls Kiroku checkpointing per event. The
-- @handler@ field in the provided 'SubscriptionConfig' is ignored; the bridge
-- installs its own.
--
-- The returned cancel action cancels the underlying subscription (interrupting any
-- worker blocked waiting for a reply) and wakes any blocked reader without
-- writing to the bounded queue. A clean worker stop or cancellation ends the
-- stream normally; any other worker exception is rethrown from the next stream
-- pull. This includes overflow, handler exceptions, dead-letter database errors,
-- and decode-hook exceptions.
subscriptionAckStream ::
KirokuStore ->
SubscriptionConfig ->
-- | TBQueue capacity for the bridge; must be at least 1.
StreamBufferSize ->
IO (Stream IO AckItem, IO ())
subscriptionAckStream store config bufferSize = mask $ \restore -> do
queue <- newTBQueueIO (streamBufferSizeValue bufferSize)
closedVar <- newTVarIO Nothing
-- Tracks the previous (eventId, attempt) so a consecutive redelivery of the
-- same event (the worker's bounded retry) is reported with an incremented
-- attempt. The worker delivers events one at a time and blocks on the reply,
-- so there is no concurrent access to this ref.
attemptRef <- newIORef (Nothing :: Maybe (EventId, Word))
let bridgeHandler :: RecordedEvent -> IO SubscriptionResult
bridgeHandler event = do
attempt <- atomicModifyIORef' attemptRef $ \mPrev ->
case mPrev of
Just (eid, n)
| eid == eventId event -> (Just (eid, n + 1), n + 1)
_ -> (Just (eventId event, 0), 0)
reply <- newEmptyTMVarIO
atomically (writeTBQueue queue (AckItem event attempt reply))
atomically (takeTMVar reply)
let bridgeConfig = config {handler = bridgeHandler}
subHandle <- restore (subscribe store bridgeConfig)
monitor <-
( Async.async $ do
outcome <- wait subHandle
atomically . closeBridge closedVar $ case outcome of
Right () -> BridgeClosedCleanly
Left e
| Just Async.AsyncCancelled <- fromException e -> BridgeClosedCleanly
| otherwise -> BridgeCrashed e
)
`onException` cancel subHandle
let cancelAction = do
cancel subHandle
Async.wait monitor
atomically (closeBridge closedVar BridgeClosedCleanly)
let step :: () -> IO (Maybe (AckItem, ()))
step () = do
next <-
atomically $
(Right <$> readTBQueue queue)
`orElse` (readTVar closedVar >>= maybe retry (pure . Left))
case next of
Right item -> pure (Just (item, ()))
Left BridgeClosedCleanly -> pure Nothing
Left (BridgeCrashed e) -> throwIO e
pure (Stream.unfoldrM step (), cancelAction)