kiroku-store-0.1.0.0: src/Kiroku/Store/Subscription/Worker.hs
module Kiroku.Store.Subscription.Worker (
runWorker,
) where
import Contravariant.Extras (contrazip2)
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.STM (TBQueue, TVar, atomically, check, readTBQueue, readTVar)
import Control.Exception (SomeException, fromException, throwIO, try)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Foldable (for_)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Int (Int32, Int64)
import Data.Text (Text)
import Data.Vector (Vector)
import Data.Vector qualified as V
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Pool (Pool)
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Kiroku.Store.Observability (
KirokuEvent (..),
SubscriptionDbPhase (..),
SubscriptionGroupContext (..),
SubscriptionStopReason (..),
)
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Settings (StoreSettings, decodeEvents)
import Kiroku.Store.Subscription.EventPublisher (SubscriberStatus (..))
import Kiroku.Store.Subscription.Types
import Kiroku.Store.Types (CategoryName (..), GlobalPosition (..), RecordedEvent (..))
{- | Run the subscription worker loop. Two phases:
Phase 1 (catch-up): queries database directly until reaching publisherPosition.
Phase 2 (live): for AllStreams, reads from the bounded TBQueue the publisher
delivers to; for Category, re-queries the database whenever the publisher
advances (the broadcast TBQueue is unused for category subscriptions).
Runs until the handler returns 'Stop', the thread is cancelled, or the
publisher signals overflow on the subscriber's status TVar (in which
case 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' is thrown
and surfaces through 'Async.waitCatch').
If an 'eventHandler' callback is supplied, the worker emits:
* 'Kiroku.Store.Observability.KirokuEventSubscriptionStarted' once at
startup, after the checkpoint has been read.
* 'Kiroku.Store.Observability.KirokuEventSubscriptionCaughtUp' when
catch-up completes and the worker switches to live mode.
* 'Kiroku.Store.Observability.KirokuEventSubscriptionDbError' in the
three database-error swallowing sites: 'loadCheckpoint', 'fetchBatch',
and 'saveCheckpoint'. The worker still degrades safely; the event is
the operator's structured signal.
* 'Kiroku.Store.Observability.KirokuEventSubscriptionStopped' when the
worker exits, with a reason discriminating handler-stop, cancel,
overflow, and worker-crash.
-}
runWorker ::
(MonadIO m) =>
Pool ->
TBQueue (Vector RecordedEvent) ->
TVar SubscriberStatus ->
TVar GlobalPosition ->
SubscriptionConfig ->
-- | optional event handler for subscription observability
Maybe (KirokuEvent -> IO ()) ->
{- | interpreter-level event hooks; 'Kiroku.Store.Settings.decodeHook'
runs on the catch-up fetch path, mirroring the publisher's
application in live mode.
-}
StoreSettings ->
m ()
runWorker pool liveQueue statusVar pubPosVar config mHandler stSettings = liftIO $ do
let emit evt = for_ mHandler ($ evt)
subName = name config
groupCtx = groupCtxOf config
posRef <- newIORef (GlobalPosition 0)
let body = do
-- Optional startup guardrail: when consumerGroupGuard is on, fail fast
-- if another holder currently holds this (name, member)'s advisory lock.
case (consumerGroupGuard config, consumerGroup config) of
(True, Just (ConsumerGroup m _)) -> guardMember pool subName m
_ -> pure ()
checkpoint <- loadCheckpoint pool config emit
writeIORef posRef checkpoint
emit (KirokuEventSubscriptionStarted subName checkpoint groupCtx)
-- Phase 1: catch-up (returns Nothing if handler said Stop)
result <- catchUp pool config checkpoint pubPosVar emit posRef stSettings
case result of
Nothing -> pure () -- Handler said Stop during catch-up; exit
Just finalPos -> do
writeIORef posRef finalPos
emit (KirokuEventSubscriptionCaughtUp subName finalPos groupCtx)
-- Phase 2: live. The strategy depends on (group, target):
-- * Non-group AllStreams: read pre-broadcast events from
-- `liveQueue`; the publisher delivers every appended event.
-- * Non-group Category: bypass the broadcast and re-query the
-- database whenever `lastPublished` advances.
-- * Any group (Category or AllStreams): the DB-driven loop.
-- A partitioned member must see only the events whose
-- originating stream hashes to its slot; the broadcast
-- `liveQueue` carries unfiltered $all events and there is no
-- in-process stream-id -> member map to filter them with
-- (mirrors the EP-3 F18 rationale). The partition predicate
-- lives in the SQL, so re-querying with the partitioned
-- statement on each publisher tick is the correct fit.
case (consumerGroup config, target config) of
(Nothing, AllStreams) -> liveLoop pool liveQueue statusVar config emit posRef finalPos
(Nothing, Category{}) -> liveLoopDbDriven pool config pubPosVar emit posRef finalPos stSettings
(Just _, _) -> liveLoopDbDriven pool config pubPosVar emit posRef finalPos stSettings
result <- try body
pos <- readIORef posRef
case result of
Right () -> emit (KirokuEventSubscriptionStopped subName pos StopHandlerRequested groupCtx)
Left (e :: SomeException) -> do
emit (KirokuEventSubscriptionStopped subName pos (classifyStopReason e) groupCtx)
throwIO e
-- The consumer-group context for this config's lifecycle events: 'NonGroup' for
-- an ordinary subscription, @GroupMember member size@ for a group member.
groupCtxOf :: SubscriptionConfig -> SubscriptionGroupContext
groupCtxOf config = maybe NonGroup (\(ConsumerGroup m n) -> GroupMember m n) (consumerGroup config)
{- Startup-only conflict probe for the consumer-group guardrail. Uses a
transaction-scoped advisory lock ('pg_try_advisory_xact_lock') which auto-releases
at transaction end, so it only detects a /concurrent/ holder at this instant. The
key is a stable bigint hash of the @name:member@ pair computed in SQL so all
processes agree. NOTE: this does NOT hold the lock for the worker's lifetime; full
mutual exclusion would need a session-level lock on a dedicated connection (the
'Kiroku.Store.Notification.Notifier' pattern), recorded as follow-up in EP-2's
Decision Log. On a database error the probe degrades open (treats it as "no
conflict") so a transient pool error cannot wedge startup. -}
guardMember :: Pool -> SubscriptionName -> Int32 -> IO ()
guardMember pool subName@(SubscriptionName n) mem = do
let probe :: Statement (Text, Int32) Bool
probe =
preparable
"SELECT pg_try_advisory_xact_lock(hashtextextended($1 || ':' || $2::text, 0))"
( contrazip2
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.int4))
)
(D.singleRow (D.column (D.nonNullable D.bool)))
result <- Pool.use pool (Session.statement (n, mem) probe)
case result of
Right True -> pure () -- got the lock; no concurrent holder right now
Right False -> throwIO (ConsumerGroupGuardConflict subName mem)
Left _ -> pure () -- DB error: degrade open (do not block startup)
-- Map an exception to the 'SubscriptionStopReason' the operator should see.
classifyStopReason :: SomeException -> SubscriptionStopReason
classifyStopReason e
| Just (_ :: SubscriptionOverflowed) <- fromException e = StopOverflowed
| Just (_ :: Async.AsyncCancelled) <- fromException e = StopCancelled
| otherwise = StopWorkerCrashed e
-- The consumer-group member index for this config, or 0 for a non-group
-- subscription. We always route checkpoints through the member-aware
-- statements with member 0 for the non-group case, so there is a single code
-- path: EP-1's schema guarantees pre-existing rows are consumer_group_member = 0,
-- so a non-group worker reads and writes the same (name, 0) row it always did.
configMember :: SubscriptionConfig -> Int32
configMember config = maybe 0 member (consumerGroup config)
-- Load the checkpoint from the database, defaulting to 0.
-- Surfaces a database error through the event handler before falling
-- back to the safe default; without the event the operator has no signal
-- that a transient pool error caused a silent re-process from position 0.
-- Keyed by (subscription_name, member) so each group member resumes from its
-- own saved position.
loadCheckpoint ::
Pool ->
SubscriptionConfig ->
(KirokuEvent -> IO ()) ->
IO GlobalPosition
loadCheckpoint pool config emit = do
let subName@(SubscriptionName name') = name config
mem = configMember config
result <- Pool.use pool (Session.statement (name', mem) SQL.getCheckpointMemberStmt)
case result of
Left err -> do
emit (KirokuEventSubscriptionDbError subName LoadCheckpoint err (groupCtxOf config))
pure (GlobalPosition 0)
Right Nothing -> pure (GlobalPosition 0)
Right (Just pos) -> pure (GlobalPosition pos)
-- Phase 1: catch-up. Queries the database directly in batches until we
-- reach the EventPublisher's current position. Returns Nothing if the
-- handler said Stop, or Just position if catch-up completed normally.
catchUp ::
Pool ->
SubscriptionConfig ->
GlobalPosition ->
TVar GlobalPosition ->
(KirokuEvent -> IO ()) ->
IORef GlobalPosition ->
StoreSettings ->
IO (Maybe GlobalPosition)
catchUp pool config startPos pubPosVar emit posRef stSettings = go startPos
where
go cursor = do
writeIORef posRef cursor
pubPos <- atomically (readTVar pubPosVar)
if cursor >= pubPos
then pure (Just cursor)
else do
events <- fetchBatch pool config cursor emit stSettings
if V.null events
then pure (Just cursor)
else do
result <- processEvents pool config events emit posRef
case result of
Nothing -> pure Nothing -- handler said Stop
Just newPos -> go newPos
-- Phase 2: live (AllStreams). Reads from the bounded TBQueue the publisher
-- delivers to. Atomically observes the subscriber's status and surfaces
-- 'SubscriptionOverflowed' if the publisher signalled overflow under
-- 'DropSubscription'.
liveLoop ::
Pool ->
TBQueue (Vector RecordedEvent) ->
TVar SubscriberStatus ->
SubscriptionConfig ->
(KirokuEvent -> IO ()) ->
IORef GlobalPosition ->
GlobalPosition ->
IO ()
liveLoop pool liveQueue statusVar config emit posRef startPos = go startPos
where
go cursor = do
writeIORef posRef cursor
next <- atomically $ do
status <- readTVar statusVar
case status of
Overflowed -> pure (Left ())
Active -> Right <$> readTBQueue liveQueue
case next of
Left () -> throwIO (SubscriptionOverflowed (name config))
Right events
| V.null freshEvents -> go cursor
| otherwise -> do
result <- processEvents pool config freshEvents emit posRef
case result of
Nothing -> pure () -- handler said Stop
Just newPos -> go newPos
where
-- A subscription registers its live queue before catch-up begins.
-- Events appended during catch-up may therefore be both fetched
-- from SQL by catch-up and already waiting in the live queue.
-- Drop those stale queue entries so live mode cannot replay them
-- or move the checkpoint backward.
freshEvents = V.filter ((> cursor) . globalPosition) events
-- Phase 2: live (DB-driven). Bypasses the broadcast and re-queries the database
-- whenever `lastPublished` advances, letting `fetchBatch` apply the source-side
-- filter. Serves both non-group `Category` subscriptions (filter by category) and
-- any consumer-group member (filter by the partition predicate baked into the
-- consumer-group SQL), at the cost of one DB round-trip per publisher tick. A
-- partitioned member cannot read the broadcast `liveQueue` because it carries
-- unfiltered $all events and there is no in-process stream-id -> member map to
-- filter them with. See EP-3 F18 / EP-2 Decision Log for the rationale versus
-- extending RecordedEvent or maintaining an in-process cache.
liveLoopDbDriven ::
Pool ->
SubscriptionConfig ->
TVar GlobalPosition ->
(KirokuEvent -> IO ()) ->
IORef GlobalPosition ->
GlobalPosition ->
StoreSettings ->
IO ()
liveLoopDbDriven pool config pubPosVar emit posRef startPos stSettings = go startPos
where
go cursor = do
writeIORef posRef cursor
-- Wait until the publisher has advanced past our cursor.
atomically $ do
pubPos <- readTVar pubPosVar
check (pubPos > cursor)
events <- fetchBatch pool config cursor emit stSettings
if V.null events
then go cursor
else do
result <- processEvents pool config events emit posRef
case result of
Nothing -> pure () -- handler said Stop
Just newPos -> go newPos
-- Fetch a batch of events from the database based on subscription target.
-- Surfaces a database error through the event handler before falling back
-- to an empty vector; without the event the catch-up loop sees the empty
-- vector as "no more events" and silently switches to live mode at a stale
-- cursor.
fetchBatch ::
Pool ->
SubscriptionConfig ->
GlobalPosition ->
(KirokuEvent -> IO ()) ->
StoreSettings ->
IO (Vector RecordedEvent)
fetchBatch pool config (GlobalPosition pos) emit stSettings =
case (consumerGroup config, target config) of
(Nothing, AllStreams) -> do
result <- Pool.use pool (Session.statement (pos, batchSize config) SQL.readAllForwardStmt)
handle result
(Nothing, Category (CategoryName cat)) -> do
result <- Pool.use pool (Session.statement (pos, cat, batchSize config) SQL.readCategoryForwardStmt)
handle result
(Just (ConsumerGroup m n), AllStreams) -> do
result <- Pool.use pool (Session.statement (pos, m, n, batchSize config) SQL.readAllForwardConsumerGroupStmt)
handle result
(Just (ConsumerGroup m n), Category (CategoryName cat)) -> do
result <- Pool.use pool (Session.statement (pos, cat, m, n, batchSize config) SQL.readCategoryForwardConsumerGroupStmt)
handle result
where
handle = \case
Left err -> do
emit (KirokuEventSubscriptionDbError (name config) FetchBatch err (groupCtxOf config))
pure V.empty
-- Apply decodeHook on the catch-up path so catch-up batches are
-- transformed identically to live batches (which the publisher
-- transforms once before fan-out).
Right events -> decodeEvents stSettings events
-- Process a batch of events through the handler. Returns the new cursor
-- position if all events were processed (handler returned Continue for all),
-- or Nothing if the handler returned Stop.
processEvents ::
Pool ->
SubscriptionConfig ->
Vector RecordedEvent ->
(KirokuEvent -> IO ()) ->
IORef GlobalPosition ->
IO (Maybe GlobalPosition)
processEvents pool config events emit posRef = go 0
where
go i
| i >= V.length events = do
let lastEvent = V.last events
newPos = globalPosition lastEvent
writeIORef posRef newPos
saveCheckpoint pool config newPos emit
pure (Just newPos)
| otherwise = do
let event = events V.! i
evtPos = globalPosition event
writeIORef posRef evtPos
result <- handler config event
case result of
Stop -> do
-- Save checkpoint up to the event we just processed
saveCheckpoint pool config evtPos emit
pure Nothing
Continue -> go (i + 1)
-- Save a checkpoint to the database. Surfaces a database error through
-- the event handler; the worker continues running but the next restart
-- with the same name re-processes events the handler has already seen.
-- Keyed by (subscription_name, member) so each group member persists its own
-- position; non-group subscriptions use member 0 (see 'configMember').
saveCheckpoint ::
Pool ->
SubscriptionConfig ->
GlobalPosition ->
(KirokuEvent -> IO ()) ->
IO ()
saveCheckpoint pool config (GlobalPosition pos) emit = do
let subName@(SubscriptionName name') = name config
mem = configMember config
result <- Pool.use pool (Session.statement (name', mem, pos) SQL.saveCheckpointMemberStmt)
case result of
Left err -> emit (KirokuEventSubscriptionDbError subName SaveCheckpoint err (groupCtxOf config))
Right () -> pure ()