kiroku-store-0.12.0.0: src/Kiroku/Store/Subscription/Fsm.hs
{-# OPTIONS_GHC -Werror=incomplete-patterns #-}
-- | The explicit finite state machine driving a subscription worker.
--
-- A subscription worker is, at every instant, in exactly one named
-- 'SubscriptionState'. A single, exhaustively pattern-matched transition
-- function 'step' names every legal move: given the current state and an
-- 'Input' (a thing that just happened — a batch arrived, a fetch failed, the
-- queue overflowed, …) it returns the next state and a list of 'Effect's the
-- /driver/ ('Kiroku.Store.Subscription.Worker.runWorker') must perform.
--
-- Keeping the transition table in one pure function has two payoffs:
--
-- * It is the single, documented /extension seam/ for the sibling plan
-- @docs\/plans\/40-...@ (per-event retry \/ dead-letter). When that plan adds
-- a 'Kiroku.Store.Subscription.Types.SubscriptionResult' constructor it will
-- also add a matching 'Input' constructor; because 'step' is exhaustively
-- pattern-matched on 'Input' within each driving state and this module is
-- compiled with @-Werror=incomplete-patterns@, that addition fails to
-- compile until every relevant clause handles it, rather than falling
-- through silently.
-- * There is exactly one place to read the current state for observability
-- (the worker mirrors it into a @TVar@ — see M4).
--
-- This module is deliberately a near-leaf in the dependency graph: it depends
-- only on 'Kiroku.Store.Types' and @hasql-pool@, never on
-- 'Kiroku.Store.Subscription.Types' or 'Kiroku.Store.Observability'. That keeps
-- the graph acyclic once 'Kiroku.Store.Subscription.Types' grows a handle field
-- of type 'SubscriptionState'. The terminal-reason enumeration
-- 'SubscriptionStopReason' lives here for the same reason and is re-exported by
-- 'Kiroku.Store.Observability' so the public event API is unchanged.
module Kiroku.Store.Subscription.Fsm
( -- * State
SubscriptionState (..),
ResumeCondition (..),
stateCursor,
stateName,
-- * Terminal reasons
SubscriptionStopReason (..),
-- * Per-event dispositions (retry / dead-letter)
RetryDelay (..),
retryDelayMicros,
DeadLetterReason (..),
deadLetterSummary,
deadLetterReasonJson,
-- * Driver alphabet
Input (..),
Effect (..),
-- * Transition function
step,
)
where
import Control.Exception (SomeException)
import Data.Aeson (Value, object, (.=))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time (NominalDiffTime)
import Hasql.Pool qualified as Pool
import Kiroku.Store.Settings (DecodeFailure (..), DecodedBatch, decodedBatchLastEvent, decodedBatchLength)
import Kiroku.Store.Types (EventId (..), GlobalPosition (..), RecordedEvent (..))
-- | Why a subscription's worker thread stopped.
--
-- Defined here (rather than in 'Kiroku.Store.Observability') so the FSM's
-- 'Stopped' state can carry it without 'Kiroku.Store.Subscription.Fsm' having to
-- import the observability module — which would close a dependency cycle once
-- 'Kiroku.Store.Subscription.Types' references 'SubscriptionState'.
-- 'Kiroku.Store.Observability' re-exports this type, so the @KirokuEvent@ API is
-- unchanged.
data SubscriptionStopReason
= -- | The handler returned 'Kiroku.Store.Subscription.Types.Stop' for some
-- event. The normal completion path; the checkpoint is saved at that event.
StopHandlerRequested
| -- | The caller invoked @cancel@. No checkpoint advance is guaranteed;
-- in-flight events replay on the next restart.
StopCancelled
| -- | The publisher marked the subscription overflowed under
-- 'Kiroku.Store.Subscription.Types.DropSubscription' and the worker
-- surfaced 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed'.
StopOverflowed
| -- | The worker thread died from an uncaught exception (typically a handler
-- exception). The 'SomeException' carries the cause.
StopWorkerCrashed !SomeException
| -- | Default undecodable-event retries exhausted; checkpoint remains before it.
StopUndecodable !DecodeFailure
deriving stock (Show)
-- | How long to wait before redelivering a retried event.
--
-- The handler's @Kiroku.Store.Subscription.Types.Retry@ disposition carries one of
-- these; the worker sleeps for it before the next redelivery. Mirrors Shibuya's
-- @Shibuya.Core.Ack.RetryDelay@ shape (a 'NominalDiffTime') so the
-- @shibuya-kiroku-adapter@ can translate @AckRetry@ without loss, but is a
-- Kiroku-owned type so @kiroku-store@ does not depend on @shibuya-core@.
newtype RetryDelay = RetryDelay NominalDiffTime
deriving stock (Eq, Show)
-- | The retry delay in microseconds, clamped at @0@, suitable for
-- 'Control.Concurrent.threadDelay'. Fractional seconds are rounded.
retryDelayMicros :: RetryDelay -> Int
retryDelayMicros (RetryDelay d) = max 0 (round (realToFrac d * 1_000_000 :: Double))
-- | Why an event was dead-lettered (recorded in @kiroku.dead_letters@ while the
-- subscription advances past it). Kiroku-owned so @kiroku-store@ stays independent
-- of @shibuya-core@; the adapter converts Shibuya's @DeadLetterReason@ into one of
-- these. 'deadLetterSummary' produces the @reason_summary@ text column and
-- 'deadLetterReasonJson' the structured @reason@ JSONB column.
data DeadLetterReason
= -- | Permanently unprocessable; the 'Text' is operator-facing detail.
DeadLetterPoison !Text
| -- | The event payload failed validation/parsing; the 'Text' is the detail.
DeadLetterInvalid !Text
| -- | The bounded retry budget was exhausted after the given number of total deliveries.
DeadLetterMaxAttempts !Int
| -- | A custom reason: a summary plus structured JSON detail.
DeadLetterOther !Text !Value
| -- | A consumer explicitly chose to skip an undecodable event.
DeadLetterDecodeFailure !DecodeFailure
deriving stock (Eq, Show)
-- | The short operator-facing summary stored in @dead_letters.reason_summary@.
deadLetterSummary :: DeadLetterReason -> Text
deadLetterSummary = \case
DeadLetterPoison detail -> "poison: " <> detail
DeadLetterInvalid detail -> "invalid payload: " <> detail
DeadLetterMaxAttempts n -> "max retry attempts exceeded (" <> T.pack (show n) <> ")"
DeadLetterOther summary _ -> summary
DeadLetterDecodeFailure failure -> "decode failure: " <> decodeFailureReason failure
-- | The structured JSON detail stored in @dead_letters.reason@ (JSONB).
deadLetterReasonJson :: DeadLetterReason -> Value
deadLetterReasonJson = \case
DeadLetterPoison detail -> object ["kind" .= ("poison" :: Text), "detail" .= detail]
DeadLetterInvalid detail -> object ["kind" .= ("invalid_payload" :: Text), "detail" .= detail]
DeadLetterMaxAttempts n -> object ["kind" .= ("max_attempts_exceeded" :: Text), "attempts" .= n]
DeadLetterOther summary detail -> object ["kind" .= ("other" :: Text), "summary" .= summary, "detail" .= detail]
DeadLetterDecodeFailure failure -> object ["kind" .= ("decode_failure" :: Text), "event_id" .= (case decodeFailureEventId failure of EventId eid -> show eid), "detail" .= decodeFailureReason failure]
-- | What unblocks a 'Paused' worker.
--
-- In M1 there is one resume condition: the bounded queue has drained enough that
-- the publisher cleared the subscriber's @Paused@ flag. The constructor is named
-- rather than a bare unit so a future backpressure source (e.g. an explicit
-- resume signal) can be added without changing the 'SubscriptionState' shape.
data ResumeCondition
= -- | Resume once the consumer drains its bounded queue.
ResumeOnDrain
deriving stock (Eq, Show)
-- | The named state a subscription worker is in.
--
-- * 'CatchingUp' — reading history directly from PostgreSQL in batches from
-- @cursor@ until it reaches the publisher's last-published position.
-- @attempt@ counts consecutive failed catch-up fetches and drives the
-- capped exponential backoff; it resets to 0 on any successful fetch.
-- * 'Live' — caught up; receiving new events (from the publisher's bounded
-- queue for AllStreams, or by re-querying for Category / consumer-group
-- members).
-- * 'Paused' — recoverable backpressure: a slow consumer filled its bounded
-- queue, so the worker stopped pulling and waits for 'resumeWhen' (added in
-- M2; in M1 this state is defined but never entered).
-- * 'Reconnecting' — recovering from a lost database connection while live;
-- backs off and re-enters 'CatchingUp' from @cursor@ (added in M3; in M1
-- defined but never entered).
-- * 'Retrying' — a handler returned
-- 'Kiroku.Store.Subscription.Types.Retry' for the event at @cursor@; the
-- worker is redelivering it (@attempt@ counts redeliveries so far) and has not
-- advanced the checkpoint past it. Added by the sibling retry\/dead-letter plan
-- (@docs\/plans\/40-...@). It is a /surfaced observability state/: the worker's
-- delivery primitive writes it into the observable state @TVar@ while a
-- redelivery is pending and restores the prior driving state afterward, so it is
-- visible through @currentState@ but is never itself a driving state fed back to
-- 'step' (the @Retrying@ clause in 'step' is therefore defensive).
-- * 'Stopped' — terminal, carrying the 'SubscriptionStopReason'.
data SubscriptionState
= CatchingUp {cursor :: !GlobalPosition, attempt :: !Int}
| Live {cursor :: !GlobalPosition}
| Paused {cursor :: !GlobalPosition, resumeWhen :: !ResumeCondition}
| Reconnecting {cursor :: !GlobalPosition, attempt :: !Int}
| Retrying {cursor :: !GlobalPosition, attempt :: !Int}
| Stopped {reason :: !SubscriptionStopReason}
deriving stock (Show)
-- | The cursor of a non-terminal state, or 'GlobalPosition' @0@ for 'Stopped'.
-- Used by the driver to attach the current position to emitted events.
stateCursor :: SubscriptionState -> GlobalPosition
stateCursor = \case
CatchingUp c _ -> c
Live c -> c
Paused c _ -> c
Reconnecting c _ -> c
Retrying c _ -> c
Stopped _ -> GlobalPosition 0
-- | A stable, low-cardinality label for the state's /name/, independent of its
-- payload (cursor, attempt, reason). Suitable as a metric label value or an admin
-- column. The strings are fixed identifiers, not the derived 'Show' output, so
-- they will not drift if a constructor's fields change.
stateName :: SubscriptionState -> Text
stateName = \case
CatchingUp {} -> "catching_up"
Live {} -> "live"
Paused {} -> "paused"
Reconnecting {} -> "reconnecting"
Retrying {} -> "retrying"
Stopped {} -> "stopped"
-- | A thing that just happened, fed to 'step' to compute the next state.
--
-- The set is closed in M1; the sibling retry\/dead-letter plan extends it. Adding
-- a constructor here forces a compile error in every driving-state clause of
-- 'step' that does not yet handle it (this module is @-Werror=incomplete-patterns@).
data Input
= -- | A non-empty history\/live batch arrived.
BatchFetched !DecodedBatch
| -- | A scanned namespace window; progress remains speculative until delivery.
NamespaceWindowFetched !DecodedBatch !GlobalPosition !Bool
| -- | Every applicable disposition in the window has completed.
NamespaceWindowCompleted !GlobalPosition !Bool
| -- | A fetch returned no rows (catch-up is complete).
FetchEmpty
| -- | A fetch hit a database error.
FetchFailed !Pool.UsageError
| -- | The cursor reached the publisher position; switch to live.
CaughtUp
| -- | The handler returned 'Stop' at this position.
HandlerStopped !GlobalPosition
| -- | The bounded queue filled under the fail-fast 'DropSubscription' policy
-- (terminal).
QueueOverflowed
| -- | The bounded queue filled under the recoverable 'PauseAndResume' policy:
-- pause rather than terminate.
QueueBackpressured
| -- | The worker drained the stale queue and is ready to recover (re-catch-up).
QueueDrained
| -- | The worker lost its database pool while live.
ConnectionLost !GlobalPosition !Pool.UsageError
| -- | The caller cancelled the worker.
Cancelled
deriving stock (Show)
-- | A side effect the driver must perform after a transition.
--
-- Some constructors (e.g. 'FetchHistory', 'RunLive') describe work the driver
-- already performs by inspecting the next state; they exist so the effect list is
-- a complete description of the transition for future drivers and for readers.
data Effect
= -- | Read a catch-up batch from the given cursor.
FetchHistory !GlobalPosition
| -- | Obtain the next live batch via the active live strategy.
RunLive
| -- | Call the handler per event, checkpointing at the batch tail.
DeliverBatch !DecodedBatch
| -- | Deliver before publishing the window frontier as completed state.
DeliverNamespaceWindow !DecodedBatch !GlobalPosition !Bool
| -- | Persist the checkpoint at this position. (Named 'Checkpoint' rather
-- than @SaveCheckpoint@ to avoid clashing with the
-- 'Kiroku.Store.Observability.SubscriptionDbPhase' constructor of that name.)
Checkpoint !GlobalPosition
| -- | Block until the consumer drains the queue.
WaitForDrain
| -- | Sleep for the backoff schedule of this attempt number.
Backoff !Int
| -- | Emit @KirokuEventSubscriptionCaughtUp@.
EmitCaughtUp
| -- | Emit @KirokuEventSubscriptionPaused@ (M4).
EmitPaused
| -- | Emit @KirokuEventSubscriptionResumed@ (M4).
EmitResumed
| -- | Emit @KirokuEventSubscriptionReconnecting@ with this attempt (M4).
EmitReconnecting !Int
| -- | Terminal: stop the driver with this reason (return or rethrow).
Halt !SubscriptionStopReason
deriving stock (Show)
-- The last event's position in a batch, falling back to the given cursor when
-- the batch is empty (the driver never feeds 'step' an empty 'BatchFetched',
-- so the fallback is defensive).
lastPos :: GlobalPosition -> DecodedBatch -> GlobalPosition
lastPos fallback evs
| decodedBatchLength evs == 0 = fallback
| otherwise = globalPosition (decodedBatchLastEvent evs)
-- | The single transition function. Given the current state and an input,
-- return the next state and the effects to perform.
--
-- Exhaustively pattern-matched on 'Input' within the two /driving/ states
-- ('CatchingUp', 'Live') so that extending 'Input' (the retry\/dead-letter
-- sibling plan) forces a compile error here. The recoverable states 'Paused' and
-- 'Reconnecting' carry their M2\/M3 transitions plus defensive self-loops; the
-- terminal 'Stopped' state ignores further input.
step :: SubscriptionState -> Input -> (SubscriptionState, [Effect])
step st input = case st of
CatchingUp c n -> case input of
NamespaceWindowFetched evs frontier done -> (CatchingUp c n, [DeliverNamespaceWindow evs frontier done])
NamespaceWindowCompleted frontier done -> if done then (Live frontier, [EmitCaughtUp]) else (CatchingUp frontier 0, [])
BatchFetched evs -> (CatchingUp (lastPos c evs) 0, [DeliverBatch evs])
FetchEmpty -> (Live c, [EmitCaughtUp])
FetchFailed _ -> (CatchingUp c (n + 1), [Backoff n])
CaughtUp -> (Live c, [EmitCaughtUp])
HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])
QueueOverflowed -> (Stopped StopOverflowed, [Halt StopOverflowed])
QueueBackpressured -> (CatchingUp c n, []) -- defensive: catch-up reads the DB, not the queue
QueueDrained -> (CatchingUp c n, [])
ConnectionLost observed _ -> (Reconnecting (max c observed) 1, [EmitReconnecting 1, Backoff 1])
Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])
Live c -> case input of
NamespaceWindowFetched evs frontier done -> (Live c, [DeliverNamespaceWindow evs frontier done])
NamespaceWindowCompleted frontier _ -> (Live frontier, [])
BatchFetched evs -> (Live (lastPos c evs), [DeliverBatch evs])
FetchEmpty -> (Live c, [RunLive])
FetchFailed _ -> (Reconnecting c 1, [EmitReconnecting 1, Backoff 1])
CaughtUp -> (Live c, [])
HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])
QueueOverflowed -> (Stopped StopOverflowed, [Halt StopOverflowed])
QueueBackpressured -> (Paused c ResumeOnDrain, [EmitPaused])
QueueDrained -> (Live c, [RunLive])
ConnectionLost observed _ -> (Reconnecting (max c observed) 1, [EmitReconnecting 1, Backoff 1])
Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])
Paused c rc -> case input of
-- The worker drained the stale queue and cleared the pause flag; recover
-- by re-catching-up from the checkpoint so any events the publisher
-- skipped while full are re-read from the database. No loss; the live
-- queue's stale filter drops the now-superseded queued entries on return.
QueueDrained -> (CatchingUp c 0, [EmitResumed])
Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])
HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])
_ -> (Paused c rc, [])
Reconnecting c n -> case input of
NamespaceWindowFetched evs frontier done -> (CatchingUp c 0, [DeliverNamespaceWindow evs frontier done])
NamespaceWindowCompleted frontier done -> if done then (Live frontier, [EmitCaughtUp]) else (CatchingUp frontier 0, [])
BatchFetched evs -> (CatchingUp (lastPos c evs) 0, [DeliverBatch evs])
FetchEmpty -> (Live c, [])
FetchFailed _ -> (Reconnecting c (n + 1), [EmitReconnecting (n + 1), Backoff (n + 1)])
ConnectionLost observed _ -> (Reconnecting (max c observed) (n + 1), [EmitReconnecting (n + 1), Backoff (n + 1)])
CaughtUp -> (CatchingUp c 0, [])
HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])
Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])
QueueOverflowed -> (Reconnecting c n, [])
QueueBackpressured -> (Reconnecting c n, [])
QueueDrained -> (Reconnecting c n, [])
-- 'Retrying' is a surfaced observability state managed inside the worker's
-- delivery primitive (it sets the observable @TVar@ to 'Retrying' while a
-- redelivery is pending and restores the prior driving state afterward); it
-- is never a driving state fed back to 'step'. This clause is therefore
-- defensive — only cancellation / a handler stop could plausibly race in, and
-- both terminate; anything else returns to 'Live' at the same cursor.
Retrying c _ -> case input of
Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])
HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])
_ -> (Live c, [])
Stopped r -> (Stopped r, [])