keiro-0.6.0.0: src/Keiro/Timer.hs
-- | Durable timers for process managers.
--
-- A process manager schedules a timer ('scheduleTimerTx', in its own append
-- transaction) to be woken at a future time — a saga timeout, a retry delay, a
-- deadline. The 'runTimerWorker' loop claims one due timer at a time with
-- @FOR UPDATE SKIP LOCKED@ (so multiple workers can run safely), hands it to a
-- caller-supplied @fire@ action that typically dispatches a command back into
-- the manager, and marks it fired once the resulting event id is known. A
-- timer left @Firing@ by a crash becomes claimable again after the worker's
-- configured stale-claim timeout, giving at-least-once firing.
--
-- The wire types live in "Keiro.Timer.Types" and the SQL storage in
-- "Keiro.Timer.Schema"; both are re-exported here so most callers need only
-- import @Keiro.Timer@.
module Keiro.Timer
( -- * Timer types
TimerId (..),
TimerRequest (..),
TimerRow (..),
TimerStatus (..),
-- * Storage
scheduleTimerTx,
scheduleTimerOnceTx,
claimDueTimer,
markTimerFired,
countDueTimers,
countStuckTimers,
-- * Recovery
StuckTimerFilter (..),
anyStuckTimer,
findStuckTimers,
requeueStuckTimers,
requeueStuckTimer,
cancelTimer,
deadLetterTimer,
-- * Worker
TimerWorkerOptions (..),
TimerWorkerConfigError (..),
defaultTimerWorkerOptions,
mkTimerWorkerOptions,
runTimerWorker,
runTimerWorkerWith,
)
where
import Data.Text qualified as Text
import Data.Time.Clock (NominalDiffTime, diffUTCTime)
import Effectful (Eff, IOE, (:>))
import Keiro.Prelude
import Keiro.Telemetry
( KeiroMetrics,
recordTimerAttempts,
recordTimerBacklog,
recordTimerFireLag,
recordTimerRequeued,
recordTimerStuck,
)
import Keiro.Timer.Schema
import Keiro.Timer.Types
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Types (EventId)
-- | Options controlling 'runTimerWorkerWith'.
data TimerWorkerOptions = TimerWorkerOptions
{ -- | When @Just n@, a claimed timer whose post-claim @attempts@ exceeds @n@ is
-- moved to 'Dead' (via 'deadLetterTimer') instead of being fired. @Nothing@
-- never auto-dead-letters (the historical behavior).
maxAttempts :: Maybe Int,
-- | When @Just ttl@, each worker pass first moves @Firing@ timers whose
-- @updated_at@ is at least @ttl@ old back to 'Scheduled'. A fire action that
-- runs longer than this timeout may be fired again; timer handlers must be
-- idempotent under keiro's at-least-once timer contract. @Nothing@ disables
-- automatic requeue for callers that run their own recovery.
requeueStuckAfter :: !(Maybe NominalDiffTime)
}
deriving stock (Generic, Eq, Show)
data TimerWorkerConfigError
= InvalidTimerMaxAttempts !Int
| InvalidTimerRequeueStuckAfter !NominalDiffTime
deriving stock (Generic, Eq, Show)
-- | The default worker policy: never auto-dead-letter; requeue stale firings after five minutes.
defaultTimerWorkerOptions :: TimerWorkerOptions
defaultTimerWorkerOptions = TimerWorkerOptions {maxAttempts = Nothing, requeueStuckAfter = Just 300}
-- | Validate timer worker options before starting a worker loop.
mkTimerWorkerOptions :: TimerWorkerOptions -> Either TimerWorkerConfigError TimerWorkerOptions
mkTimerWorkerOptions opts =
case (opts ^. #maxAttempts, opts ^. #requeueStuckAfter) of
(Just attempts, _) | attempts < 0 -> Left (InvalidTimerMaxAttempts attempts)
(_, Just ttl) | ttl <= 0 -> Left (InvalidTimerRequeueStuckAfter ttl)
_ -> Right opts
-- | Claim and fire at most one timer due at @now@, applying the given
-- 'TimerWorkerOptions'.
--
-- Atomically claims the earliest due timer (marking it @Firing@). If the options
-- set @maxAttempts = Just n@ and the timer's post-claim @attempts@ exceeds @n@, it
-- is dead-lettered ('Dead', with an explanatory @last_error@) instead of fired —
-- rather than ping-ponging forever on a timer that never completes. Before
-- claiming, the worker requeues stale @Firing@ rows according to
-- 'requeueStuckAfter'. Otherwise the caller's @fire@ action runs and — if it
-- returns the id of the event it produced — the timer is marked @Fired@. Returns
-- the claimed 'TimerRow' (the row as claimed, before any dead-letter or fire
-- UPDATE), or 'Nothing' when nothing is due. A @fire@ that returns 'Nothing'
-- leaves the timer @Firing@ until it becomes stale and is requeued on a later
-- worker pass.
--
-- A timer may fire more than once if a worker crashes after the external action
-- but before 'markTimerFired', or if @fire@ takes longer than 'requeueStuckAfter'.
-- Handlers must therefore be idempotent.
--
-- Note 'claimDueTimer' increments @attempts@ before this check, so the comparison
-- sees the post-claim count: with @maxAttempts = Just 0@ the very first claim
-- dead-letters; with @Just 2@ the third claim does.
runTimerWorkerWith ::
(IOE :> es, Store :> es) =>
Maybe KeiroMetrics ->
TimerWorkerOptions ->
UTCTime ->
(TimerRow -> Eff es (Maybe EventId)) ->
Eff es (Maybe TimerRow)
runTimerWorkerWith metrics options now fire = do
for_ (options ^. #requeueStuckAfter) $ \ttl -> do
requeued <- requeueStuckTimers ttl now
recordTimerRequeued metrics (fromIntegral requeued)
-- Gauges recorded once per pass, before the claim, off the counts the worker
-- already needs its 'Store' for: the backlog as the worker sees it at the
-- start of the pass (including the row it is about to claim), and the number
-- of rows stranded in 'Firing' by earlier passes that never completed. Each
-- is a no-op under a 'Nothing' handle.
backlog <- countDueTimers now
recordTimerBacklog metrics (fromIntegral backlog)
stuck <- countStuckTimers now anyStuckTimer
recordTimerStuck metrics (fromIntegral stuck)
due <- claimDueTimer now
case due of
Nothing -> pure Nothing
Just timer -> do
-- Histograms for the claimed timer: how late it fired and how many
-- attempts it has now taken. EP-33 declared 'keiro.timer.fire.lag' in
-- milliseconds, so the seconds 'diffUTCTime' yields are scaled by 1000.
-- The lag is non-negative because only fire_at <= now rows are claimable.
recordTimerFireLag metrics (realToFrac (now `diffUTCTime` (timer ^. #fireAt)) * 1000)
recordTimerAttempts metrics (fromIntegral (timer ^. #attempts))
case options ^. #maxAttempts of
Just attemptCeiling
| (timer ^. #attempts) > attemptCeiling -> do
_ <-
deadLetterTimer
(timer ^. #timerId)
("timer exceeded attempt ceiling of " <> Text.pack (show attemptCeiling))
pure (Just timer)
_ -> do
fired <- fire timer
for_ fired (\eventId -> void (markTimerFired (timer ^. #timerId) eventId))
pure (Just timer)
-- | Claim and fire at most one timer due at @now@ using
-- 'defaultTimerWorkerOptions' (no attempt ceiling). Equivalent to
-- @'runTimerWorkerWith' 'defaultTimerWorkerOptions'@; the default has no attempt
-- ceiling and requeues claims left @Firing@ for five minutes. See
-- 'runTimerWorkerWith' for the full semantics.
runTimerWorker ::
(IOE :> es, Store :> es) =>
Maybe KeiroMetrics ->
UTCTime ->
(TimerRow -> Eff es (Maybe EventId)) ->
Eff es (Maybe TimerRow)
runTimerWorker metrics = runTimerWorkerWith metrics defaultTimerWorkerOptions