packages feed

kiroku-store-0.3.0.0: src/Kiroku/Store/Subscription/Worker.hs

{- | The subscription worker: the impure driver behind a running subscription.

'runWorker' is the long-lived loop spawned by
'Kiroku.Store.Subscription.subscribe'. It is the interpreter for the pure state
machine in "Kiroku.Store.Subscription.Fsm": it supplies inputs (a batch was
fetched, the queue overflowed, the pool errored, the handler returned a
disposition) and carries out the resulting effects (deliver a batch, save the
checkpoint, back off, emit a @KirokuEvent@, halt). The worker walks the named
states — @CatchingUp@, @Live@, @Paused@ (recoverable backpressure),
@Reconnecting@ (re-catch-up after a live fetch loses the pool), @Retrying@, and
@Stopped@ — and writes each transition to the @TVar@ exposed through
'Kiroku.Store.Subscription.Types.currentState'.

Per-event delivery (including the worker-side
'Kiroku.Store.Subscription.Types.eventTypeFilter' \/
'Kiroku.Store.Subscription.Types.selector' check applied /before/ the handler,
and the bounded-retry \/ dead-letter disposition mechanics) is concentrated in
the single delivery primitive shared by every live path, so behaviour is
identical for @AllStreams@, @Category@, and consumer-group subscriptions.

'withFetchBatchHookForTest' and 'withLoadCheckpointHookForTest' are test-only
seams for injecting fetch and checkpoint-load failures.
-}
module Kiroku.Store.Subscription.Worker (
    LiveSource (..),
    runWorker,
    configMember,
    withFetchBatchHookForTest,
    withLoadCheckpointHookForTest,
) where

import Contravariant.Extras (contrazip2)
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.STM (TBQueue, TVar, atomically, check, orElse, readTBQueue, readTVar, registerDelay, tryReadTBQueue, writeTVar)
import Control.Exception (SomeException, bracket, fromException, throwIO, try)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Int (Int32, Int64)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Vector (Vector)
import Data.Vector qualified as V
import Data.Word (Word64)
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 (..),
    SubscriptionDeliveryPhase (..),
    SubscriptionGroupContext (..),
    SubscriptionStopReason (..),
    emitOrDrop,
 )
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Settings (StoreSettings, decodeEvents)
import Kiroku.Store.Subscription.EventPublisher (SubscriberStatus)
import Kiroku.Store.Subscription.EventPublisher qualified as Pub
import Kiroku.Store.Subscription.Fsm (
    Effect (..),
    Input (..),
    SubscriptionState (..),
    stateCursor,
    step,
 )
import Kiroku.Store.Subscription.Types
import Kiroku.Store.Types (CategoryName (..), EventId (..), GlobalPosition (..), RecordedEvent (..))
import System.IO.Unsafe (unsafePerformIO)

-- Mirror 'Kiroku.Store.Subscription.EventPublisher.safetyPollMicros': an idle
-- category re-checks at most this often, reconciling NOTIFYs lost while the
-- listener connection was reconnecting. An idle category therefore costs at most
-- one empty fetch per safety interval, not per global publisher tick.
categorySafetyPollMicros :: Int
categorySafetyPollMicros = 30_000_000

type FetchBatchHook =
    SubscriptionConfig ->
    GlobalPosition ->
    IO (Maybe (Either Pool.UsageError (Vector RecordedEvent)))

type LoadCheckpointHook =
    SubscriptionConfig ->
    IO (Maybe (Either Pool.UsageError (Maybe Int64)))

{-# NOINLINE fetchBatchHookRef #-}
fetchBatchHookRef :: IORef (Maybe FetchBatchHook)
fetchBatchHookRef = unsafePerformIO (newIORef Nothing)

{-# NOINLINE loadCheckpointHookRef #-}
loadCheckpointHookRef :: IORef (Maybe LoadCheckpointHook)
loadCheckpointHookRef = unsafePerformIO (newIORef Nothing)

{- | Install a process-local fetch hook for tests that need deterministic
subscription-worker fault injection. Production code leaves the hook unset.
-}
withFetchBatchHookForTest :: FetchBatchHook -> IO a -> IO a
withFetchBatchHookForTest hook action =
    bracket
        ( do
            previous <- readIORef fetchBatchHookRef
            writeIORef fetchBatchHookRef (Just hook)
            pure previous
        )
        (writeIORef fetchBatchHookRef)
        (const action)

{- | Install a process-local checkpoint-load hook for tests that need
deterministic subscription-startup fault injection. Production code leaves the
hook unset.
-}
withLoadCheckpointHookForTest :: LoadCheckpointHook -> IO a -> IO a
withLoadCheckpointHookForTest hook action =
    bracket
        ( do
            previous <- readIORef loadCheckpointHookRef
            writeIORef loadCheckpointHookRef (Just hook)
            pure previous
        )
        (writeIORef loadCheckpointHookRef)
        (const action)

fetchRetryDelayMicros :: Int -> Int
fetchRetryDelayMicros attempt =
    min categorySafetyPollMicros (100_000 * (2 ^ min attempt 9 :: Int))

{- | How a worker obtains live-mode batches, fixed at 'subscribe' time from the
config's (consumerGroup, target) shape.

Only 'LiveFromPublisherQueue' owns a registration with the EventPublisher. The
other shapes are DB-driven and the publisher must do no fan-out work for them.
-}
data LiveSource
    = {- | Non-group AllStreams: read the publisher's bounded queue; the status
      TVar carries Paused/Overflowed backpressure signals.
      -}
      LiveFromPublisherQueue !(TBQueue (Vector RecordedEvent)) !(TVar SubscriberStatus)
    | {- | Non-group Category: wake on the named category's NOTIFY generation
      counter and re-query the database.
      -}
      LiveFromCategoryNotify !Text
    | {- | Consumer-group member, for either target: wake when the global
      position advances and re-query with the partition predicate.
      -}
      LiveFromGroupPolling

{- | Run the subscription worker loop. Two phases:

Phase 1 (catch-up): queries database directly until reaching publisherPosition.
Phase 2 (live): for 'LiveFromPublisherQueue', reads from the bounded TBQueue
the publisher delivers to; for 'LiveFromCategoryNotify' and
'LiveFromGroupPolling', re-queries the database, and no publisher queue exists.

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
  subscription database phases: 'loadCheckpoint', 'fetchBatch', and
  'saveCheckpoint'. 'fetchBatch' errors are retried at the same cursor;
  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 ->
    LiveSource ->
    {- | the worker's current FSM state, written on every transition so callers
    can read it through 'Kiroku.Store.Subscription.Types.currentState'.
    -}
    TVar SubscriptionState ->
    TVar GlobalPosition ->
    {- | per-category wake counter from the Notifier; the @Category@ live loop
    blocks on this category's entry rather than busy-polling the global position.
    -}
    TVar (Map Text Word64) ->
    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 liveSource stateVar pubPosVar catGenVar config mHandler stSettings = liftIO $ do
    let emit = emitOrDrop mHandler
        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)
            -- Drive the explicit FSM from the catch-up state. The pure 'step'
            -- (Kiroku.Store.Subscription.Fsm) decides every lifecycle transition;
            -- this driver supplies the inputs (what just happened) and interprets
            -- the effects (deliver a batch, emit an event, back off, halt). The
            -- three live strategies remain the *mechanism* for obtaining the next
            -- batch within the 'Live' state; the FSM governs the lifecycle.
            loop (CatchingUp checkpoint 0)

        -- One driver iteration: publish the current state for observability
        -- ('currentState'), discover the next 'Input' by performing the state's
        -- natural (possibly blocking) action, then hand it to 'feed'. Recording
        -- the state at loop entry means the value read while the worker blocks in
        -- 'nextInput' (e.g. waiting on the live queue) is the state it is blocked in.
        loop :: SubscriptionState -> IO ()
        loop st = do
            atomically (writeTVar stateVar st)
            nextInput st >>= feed st

        -- Apply 'step' to the (state, input) pair, interpret the resulting
        -- effects, and continue. An effect (delivery, a gate) may itself produce
        -- a follow-up 'Input' (e.g. the handler returned 'Stop'); that is fed back
        -- to 'step' immediately. Otherwise: stop when the new state is terminal,
        -- else loop on the new state.
        feed :: SubscriptionState -> Input -> IO ()
        feed st inp = do
            let (st', effs) = step st inp
            follow <- runEffects st' effs
            case follow of
                Just fInp -> feed st' fInp
                Nothing -> case st' of
                    Stopped _ -> pure ()
                    _ -> loop st'

        -- Produce the next 'Input' for a state by performing its blocking action.
        --   * CatchingUp: if caught up, 'CaughtUp'; else fetch one history batch,
        --     mapping the result to 'BatchFetched' / 'FetchEmpty' (caught up) /
        --     'FetchFailed' (the catch-up retry, escalated by the state's attempt).
        --   * Live (AllStreams, non-group): read the publisher's bounded queue;
        --     'QueueOverflowed' when the publisher signalled overflow, else the
        --     stale-filtered batch ('FetchEmpty' if all stale).
        --   * Live (Category / consumer-group): run the existing live loop to its
        --     natural termination (handler 'Stop'), then report 'HandlerStopped'.
        --     These loops retain their own NOTIFY-generation / global-position
        --     gates and per-fetch retry, exactly as before.
        nextInput :: SubscriptionState -> IO Input
        nextInput = \case
            CatchingUp c _ -> do
                writeIORef posRef c
                pubPos <- atomically (readTVar pubPosVar)
                if c >= pubPos
                    then pure CaughtUp
                    else do
                        fetchResult <- fetchBatch pool config c emit stSettings
                        case fetchResult of
                            Left err -> pure (FetchFailed err)
                            Right events
                                | V.null events -> pure CaughtUp
                                | otherwise -> pure (BatchFetched events)
            Live c -> case liveSource of
                LiveFromPublisherQueue liveQueue statusVar -> do
                    writeIORef posRef c
                    atomically $ do
                        status <- readTVar statusVar
                        case status of
                            Pub.Overflowed -> pure QueueOverflowed
                            Pub.Paused -> pure QueueBackpressured
                            Pub.Active -> do
                                -- A subscription registers its live queue before
                                -- catch-up begins, so events appended during
                                -- catch-up may be both fetched from SQL and waiting
                                -- in the queue. Drop those stale entries so live
                                -- mode cannot replay them or rewind the checkpoint.
                                events <- readTBQueue liveQueue
                                let fresh = V.filter ((> c) . globalPosition) events
                                pure (if V.null fresh then FetchEmpty else BatchFetched fresh)
                LiveFromCategoryNotify cat ->
                    liveExitToInput =<< liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef c stSettings
                LiveFromGroupPolling ->
                    liveExitToInput =<< liveLoopDbDriven pool config stateVar pubPosVar emit posRef c stSettings
            -- Recoverable backpressure: the publisher set 'Paused' because this
            -- subscriber's bounded queue filled. Drain the stale queue (those
            -- events are re-read from the database by the re-catch-up that
            -- 'QueueDrained' triggers) and clear the flag back to 'Active' so the
            -- publisher resumes pushing and the worker is not left waiting. The
            -- AllStreams live path's @> cursor@ filter drops any superseded queued
            -- entries once the worker is live again.
            Paused{} -> do
                case liveSource of
                    LiveFromPublisherQueue liveQueue statusVar -> do
                        atomically $ do
                            drainQueue liveQueue
                            writeTVar statusVar Pub.Active
                        pure QueueDrained
                    -- Defensive totality: only the queue branch can produce
                    -- QueueBackpressured, so DB-driven sources should never
                    -- enter Paused.
                    LiveFromCategoryNotify{} -> pure QueueDrained
                    LiveFromGroupPolling -> pure QueueDrained
            -- Reconnecting: re-probe the database from the checkpoint. A success
            -- re-enters catch-up (delivering everything after the cursor); a
            -- failure stays in 'Reconnecting' for another backed-off attempt; an
            -- empty result means there is nothing new, so return to live.
            Reconnecting c _ -> do
                writeIORef posRef c
                fetchResult <- fetchBatch pool config c emit stSettings
                case fetchResult of
                    Left err -> pure (FetchFailed err)
                    Right events
                        | V.null events -> pure FetchEmpty
                        | otherwise -> pure (BatchFetched events)
            Stopped{} -> pure Cancelled

        -- Interpret a transition's effects against the *new* state. Returns a
        -- follow-up 'Input' when an effect produces one (only 'DeliverBatch', when
        -- the handler returns 'Stop'); 'Nothing' otherwise. 'Halt' terminates the
        -- driver: a handler-requested stop returns cleanly (the outer handler
        -- emits the Stopped event), overflow/crash rethrow so the outer 'try'
        -- classifies and re-emits — preserving today's exact event sequence.
        runEffects :: SubscriptionState -> [Effect] -> IO (Maybe Input)
        runEffects st' = go
          where
            go [] = pure Nothing
            go (e : es) = case e of
                EmitCaughtUp -> do
                    emit (KirokuEventSubscriptionCaughtUp subName (stateCursor st') groupCtx)
                    go es
                EmitPaused -> do
                    emit (KirokuEventSubscriptionPaused subName (stateCursor st') groupCtx)
                    go es
                EmitResumed -> do
                    emit (KirokuEventSubscriptionResumed subName (stateCursor st') groupCtx)
                    go es
                EmitReconnecting n -> do
                    emit (KirokuEventSubscriptionReconnecting subName n groupCtx)
                    go es
                Backoff n -> threadDelay (fetchRetryDelayMicros n) >> go es
                WaitForDrain -> go es
                Checkpoint p -> saveCheckpoint pool config p emit >> go es
                FetchHistory _ -> go es
                RunLive -> go es
                DeliverBatch events -> do
                    result <- processEvents pool config stateVar events emit posRef
                    case result of
                        Nothing -> pure (Just (HandlerStopped (lastPosOf events)))
                        Just _ -> go es
                Halt reason -> case reason of
                    StopHandlerRequested -> pure Nothing
                    StopOverflowed -> throwIO (SubscriptionOverflowed subName)
                    StopCancelled -> throwIO Async.AsyncCancelled
                    StopWorkerCrashed ex -> throwIO ex

        lastPosOf events = globalPosition (V.last events)

        -- Map a DB-driven live loop's exit onto the next FSM input: a clean stop
        -- becomes 'HandlerStopped' (at the last processed position); a fetch error
        -- becomes 'ConnectionLost', driving the FSM into 'Reconnecting'.
        liveExitToInput = \case
            LiveHandlerStopped -> HandlerStopped <$> readIORef posRef
            LiveFetchError err -> pure (ConnectionLost err)

        -- Read and discard every batch currently in the live queue (non-blocking).
        -- Used when resuming from 'Paused': the discarded events are re-read from
        -- the database by the subsequent re-catch-up, so nothing is lost.
        drainQueue q = do
            m <- tryReadTBQueue q
            case m of
                Nothing -> pure ()
                Just _ -> drainQueue q

    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 only when no checkpoint
-- row exists. A database error is emitted and rethrown so startup fails loudly
-- instead of silently re-processing 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
    mHook <- readIORef loadCheckpointHookRef
    injected <- maybe (pure Nothing) (\hook -> hook config) mHook
    result <- case injected of
        Just hooked -> pure hooked
        Nothing -> Pool.use pool (Session.statement (name', mem) SQL.getCheckpointMemberStmt)
    case result of
        Left err -> do
            emit (KirokuEventSubscriptionDbError subName LoadCheckpoint err (groupCtxOf config))
            throwIO err
        Right Nothing -> pure (GlobalPosition 0)
        Right (Just pos) -> pure (GlobalPosition pos)

-- How a DB-driven live loop ('liveLoopCategoryNotify' / 'liveLoopDbDriven')
-- exited. The driver maps these onto FSM inputs: a clean handler stop becomes
-- 'HandlerStopped'; a fetch error becomes 'ConnectionLost', which drives the FSM
-- into 'Reconnecting' (backoff + re-catch-up from the checkpoint) rather than the
-- old in-loop retry. AllStreams live has no entry here because it reads the
-- publisher's queue and never fetches — its reconnect is the publisher's concern.
data LiveExit
    = -- | The handler returned 'Stop'; the loop exited cleanly.
      LiveHandlerStopped
    | -- | A live-mode database fetch failed; the worker should reconnect.
      LiveFetchError !Pool.UsageError

-- Phase 2: live (Category, NOTIFY-driven). Blocks on this category's generation
-- counter, which the Notifier bumps on every NOTIFY for a stream in the category,
-- so an idle category does ZERO DB work while other categories receive traffic.
-- The generation is snapshotted BEFORE an unconditional drain so a notification
-- that arrives during the drain is never missed (it leaves gen > gen0 and the loop
-- drains again on the next iteration). A safety timeout (matching the publisher's
-- 30s safety poll) reconciles notifications lost while the listener connection is
-- reconnecting, preserving at-least-once delivery with bounded latency.
--
-- This loop serves only non-group `Category` subscriptions. Consumer-group members
-- cannot use the per-category signal: their interest is
-- `hashtextextended(stream_id) % size = member`, a Postgres hash the worker cannot
-- cheaply replicate from the payload, so they stay on `liveLoopDbDriven`.
liveLoopCategoryNotify ::
    Pool ->
    SubscriptionConfig ->
    TVar SubscriptionState ->
    TVar (Map Text Word64) ->
    -- | this subscription's category
    Text ->
    (KirokuEvent -> IO ()) ->
    IORef GlobalPosition ->
    GlobalPosition ->
    StoreSettings ->
    IO LiveExit
liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef startPos stSettings = go startPos
  where
    readGen = Map.findWithDefault 0 cat <$> readTVar catGenVar
    go cursor = do
        writeIORef posRef cursor
        -- Snapshot the generation BEFORE draining so a NOTIFY landing mid-drain
        -- is not lost: it leaves gen > gen0 and the gate below re-opens at once.
        gen0 <- atomically readGen
        drainResult <- drainTo cursor
        case drainResult of
            Left err -> pure (LiveFetchError err) -- reconnect: driver re-catches-up
            Right Nothing -> pure LiveHandlerStopped -- handler said Stop
            Right (Just c) -> do
                -- Block until this category is notified again OR the safety timer
                -- fires (reconciling NOTIFYs lost across a listener reconnect).
                timer <- registerDelay categorySafetyPollMicros
                atomically $
                    (readGen >>= \g -> check (g > gen0))
                        `orElse` (readTVar timer >>= check)
                go c
      where
        -- A fetch error bubbles out (no in-loop retry); the FSM's 'Reconnecting'
        -- state owns the backoff and re-catch-up. On success drain to empty.
        drainTo c = do
            fetchResult <- fetchBatch pool config c emit stSettings
            case fetchResult of
                Left err -> pure (Left err)
                Right events -> do
                    emit (KirokuEventSubscriptionFetched (name config) (V.length events) (groupCtxOf config))
                    if V.null events
                        then pure (Right (Just c))
                        else do
                            result <- processEvents pool config stateVar events emit posRef
                            case result of
                                Nothing -> pure (Right Nothing) -- handler said Stop
                                Just newPos -> drainTo newPos

-- Phase 2: live (DB-driven, consumer-group members only). Bypasses the broadcast
-- and re-queries the database when the publisher's GLOBAL position advances,
-- letting `fetchBatch` apply the partition predicate baked into the consumer-group
-- SQL. 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.
--
-- The gate waits for the publisher to advance past the LAST OBSERVED global
-- position (not past `cursor`). A member's partition cursor only moves on events in
-- its slice, but `pubPosVar` moves on every append; gating on the cursor busy-loops
-- whenever another partition is ahead (the original defect). Gating on the last
-- observed `pubPos` blocks until genuinely new global work exists. After the gate
-- opens we drain the partition to empty (not stopping at `pubPos`), which
-- guarantees no lost wakeup: the $all position is strictly monotonic, so any later
-- partition event lands at a position strictly greater than the observed `pubPos`
-- and re-opens the gate.
liveLoopDbDriven ::
    Pool ->
    SubscriptionConfig ->
    TVar SubscriptionState ->
    TVar GlobalPosition ->
    (KirokuEvent -> IO ()) ->
    IORef GlobalPosition ->
    GlobalPosition ->
    StoreSettings ->
    IO LiveExit
liveLoopDbDriven pool config stateVar pubPosVar emit posRef startPos stSettings =
    go startPos (GlobalPosition 0)
  where
    go cursor waitFrom = do
        writeIORef posRef cursor
        pubPos <- atomically $ do
            p <- readTVar pubPosVar
            check (p > waitFrom)
            pure p
        -- A fetch error bubbles out (no in-loop retry); the FSM's 'Reconnecting'
        -- state owns the backoff and re-catch-up. On success drain to empty.
        let drainTo c = do
                fetchResult <- fetchBatch pool config c emit stSettings
                case fetchResult of
                    Left err -> pure (Left err)
                    Right events -> do
                        emit (KirokuEventSubscriptionFetched (name config) (V.length events) (groupCtxOf config))
                        if V.null events
                            then pure (Right (Just c))
                            else do
                                result <- processEvents pool config stateVar events emit posRef
                                case result of
                                    Nothing -> pure (Right Nothing) -- handler said Stop
                                    Just newPos -> drainTo newPos
        drainResult <- drainTo cursor
        case drainResult of
            Left err -> pure (LiveFetchError err)
            Right Nothing -> pure LiveHandlerStopped
            Right (Just c) -> go c pubPos

-- Fetch a batch of events from the database based on subscription target.
-- Surfaces a database error through the event handler and returns the error to
-- the caller so catch-up and DB-driven live loops can retry the same cursor.
fetchBatch ::
    Pool ->
    SubscriptionConfig ->
    GlobalPosition ->
    (KirokuEvent -> IO ()) ->
    StoreSettings ->
    IO (Either Pool.UsageError (Vector RecordedEvent))
fetchBatch pool config cursor@(GlobalPosition pos) emit stSettings = do
    mHook <- readIORef fetchBatchHookRef
    injected <- maybe (pure Nothing) (\hook -> hook config cursor) mHook
    case injected of
        Just result -> handle result
        Nothing ->
            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 (Left err)
        -- 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 -> Right <$> decodeEvents stSettings events

-- Process a batch of events through the handler, resolving each event's
-- disposition. Returns the new cursor position if the batch was fully consumed
-- (every event resolved to 'Continue', 'DeadLetter', or an exhausted 'Retry'),
-- or 'Nothing' if the handler returned 'Stop'.
--
-- This is the single delivery primitive shared by the FSM 'DeliverBatch' effect
-- (catch-up for every target; AllStreams live) and the two DB-driven live loops,
-- so the four dispositions behave identically on every path (EP-2 / MasterPlan 6
-- Decision Log). Checkpointing keeps the existing per-batch model: 'Continue'
-- events advance the checkpoint only at the batch tail; 'Stop' checkpoints at the
-- stopping event; 'DeadLetter' (and exhausted 'Retry') atomically record the
-- event and advance the checkpoint past it via
-- 'SQL.insertDeadLetterAndCheckpointStmt'.
--
-- 'Retry' redelivers the same event after its 'RetryDelay', bounded by the
-- config's 'retryMaxAttempts'; while a redelivery is pending the worker's
-- observable state @TVar@ shows 'Retrying' (restored to the driving state — the
-- value the driver wrote before this batch — once the event resolves).
processEvents ::
    Pool ->
    SubscriptionConfig ->
    TVar SubscriptionState ->
    Vector RecordedEvent ->
    (KirokuEvent -> IO ()) ->
    IORef GlobalPosition ->
    IO (Maybe GlobalPosition)
processEvents pool config stateVar events emit posRef = do
    -- The state the driver wrote for this batch (CatchingUp / Live); restored
    -- after each retry so the observable state does not stick on 'Retrying'.
    driving <- atomically (readTVar stateVar)
    -- Emit one centralized per-batch delivery event for *every* target and both
    -- phases. This is the single delivery primitive, so this one emit uniformly
    -- covers catch-up for every target, AllStreams live, and the DB-driven live
    -- loops (which still also emit KirokuEventSubscriptionFetched per fetch).
    let phase = case driving of
            CatchingUp{} -> DeliveredCatchUp
            _ -> DeliveredLive
    emit (KirokuEventSubscriptionDelivered subName (V.length events) phase groupCtx)
    go driving 0
  where
    subName = name config
    groupCtx = groupCtxOf config
    maxAttempts = retryMaxAttempts (retryPolicy config)

    go driving 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
            if shouldDeliver (eventTypeFilter config) (selector config) event
                then deliver driving i event evtPos 1
                else -- Filtered out by the type filter or the selector: skip the
                -- handler entirely (so a non-matching event never reaches the
                -- bridge and is never retried or dead-lettered), but keep walking
                -- the batch so the batch-tail checkpoint advances the cursor past
                -- it. The subscription never stalls on a long run of filtered-out
                -- events.
                    go driving (i + 1)

    -- Deliver one event; @attempt@ is the 1-based delivery attempt (1 = first).
    deliver driving i event evtPos attempt = do
        result <- handler config event
        case result of
            Continue -> go driving (i + 1)
            Stop -> do
                -- Save checkpoint up to the event we just processed
                saveCheckpoint pool config evtPos emit
                pure Nothing
            DeadLetter reason -> do
                writeDeadLetter pool config evtPos event reason attempt emit
                go driving (i + 1)
            Retry delay
                -- Exhausted the retry budget: dead-letter and advance past it.
                | attempt >= maxAttempts -> do
                    writeDeadLetter pool config evtPos event (DeadLetterMaxAttempts attempt) attempt emit
                    go driving (i + 1)
                -- Redeliver the same event after the requested delay.
                | otherwise -> do
                    atomically (writeTVar stateVar (Retrying evtPos attempt))
                    emit (KirokuEventSubscriptionRetrying subName evtPos attempt groupCtx)
                    threadDelay (retryDelayMicros delay)
                    atomically (writeTVar stateVar driving)
                    deliver driving i event evtPos (attempt + 1)

-- Atomically record an event in @kiroku.dead_letters@ and advance the
-- subscription's checkpoint past it (one statement; the checkpoint does not
-- advance if the insert fails). On a database error the worker surfaces a
-- 'KirokuEventSubscriptionDbError' and rethrows, so the event is neither lost
-- nor silently skipped — it replays from the unadvanced checkpoint on restart.
writeDeadLetter ::
    Pool ->
    SubscriptionConfig ->
    GlobalPosition ->
    RecordedEvent ->
    DeadLetterReason ->
    -- | attempt count to record
    Int ->
    (KirokuEvent -> IO ()) ->
    IO ()
writeDeadLetter pool config gp@(GlobalPosition pos) event reason attempt emit = do
    let subName@(SubscriptionName name') = name config
        mem = configMember config
        EventId uuid = eventId event
        params =
            SQL.DeadLetterParams
                { SQL.dlSubscriptionName = name'
                , SQL.dlMember = mem
                , SQL.dlGlobalPosition = pos
                , SQL.dlEventId = uuid
                , SQL.dlReason = deadLetterReasonJson reason
                , SQL.dlReasonSummary = deadLetterSummary reason
                , SQL.dlAttemptCount = fromIntegral attempt
                }
    result <- Pool.use pool (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)
    case result of
        Left err -> do
            emit (KirokuEventSubscriptionDbError subName SaveCheckpoint err (groupCtxOf config))
            throwIO err
        Right () -> emit (KirokuEventSubscriptionDeadLettered subName gp reason (groupCtxOf config))

-- 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 ()