packages feed

keiro-0.2.0.0: src/Keiro/Timer/Schema.hs

{- | The @keiro_timers@ table: storage and claim logic for durable timers.

Holds one row per scheduled timer with its 'TimerStatus' lifecycle.
'scheduleTimerTx' inserts (or re-arms a still-@Scheduled@ timer with the same
id) inside the caller's transaction; 'claimDueTimer' atomically picks the
single earliest due timer with @FOR UPDATE SKIP LOCKED@ and moves it to
@Firing@, so competing workers never claim the same timer; 'markTimerFired'
records completion and the produced event id. Stale @Firing@ rows are requeued
by 'requeueStuckTimers' so a crashed worker does not strand a timer forever.

Callers normally use the re-exports from "Keiro.Timer" rather than this
module directly.
-}
module Keiro.Timer.Schema (
    -- * Rows and status
    TimerStatus (..),
    TimerRow (..),

    -- * Storage
    scheduleTimerTx,
    scheduleTimerOnceTx,
    claimDueTimer,
    markTimerFired,

    -- * Read-only counts
    countDueTimers,
    countStuckTimers,

    -- * Recovery
    StuckTimerFilter (..),
    anyStuckTimer,
    findStuckTimers,
    requeueStuckTimers,
    requeueStuckTimer,
    cancelTimer,
    deadLetterTimer,
)
where

import Contravariant.Extras (contrazip2, contrazip6)
import Data.Time (NominalDiffTime, addUTCTime)
import Data.UUID (UUID)
import Effectful (Eff, (:>))
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Keiro.Prelude
import Keiro.Timer.Types (TimerId (..), TimerRequest (..))
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (EventId (..))
import "hasql-transaction" Hasql.Transaction qualified as Tx

{- | A timer's lifecycle state.

* 'Scheduled' — waiting for its 'fireAt'; claimable.
* 'Firing' — claimed by a worker and being processed; stale rows become
  claimable again when 'requeueStuckTimers' moves them back to 'Scheduled'.
* 'Fired' — successfully fired; terminal.
* 'Cancelled' — withdrawn before firing.
* 'Dead' — abandoned after exceeding the attempt ceiling; terminal; carries an
  optional @last_error@ describing why it was given up on.
-}
data TimerStatus
    = Scheduled
    | Firing
    | Fired
    | Cancelled
    | Dead
    deriving stock (Generic, Eq, Show)

{- | A timer row as stored: the original 'TimerRequest' fields plus the live
'status', the 'attempts' count (incremented on each claim), and the
'firedEventId' recorded once it fires.
-}
data TimerRow = TimerRow
    { timerId :: !TimerId
    , processManagerName :: !Text
    , correlationId :: !Text
    , fireAt :: !UTCTime
    , payload :: !Value
    , status :: !TimerStatus
    , attempts :: !Int
    , firedEventId :: !(Maybe EventId)
    }
    deriving stock (Generic, Eq, Show)

{- | Criteria selecting timers stranded in 'Firing'. A row is "stuck" when its
'status' is @firing@ and it matches every set bound: 'minAge' (it has been
firing at least this long, measured from @updated_at@) and 'minAttempts' (it
has been claimed at least this many times). Both unset selects every @firing@
row.
-}
data StuckTimerFilter = StuckTimerFilter
    { minAge :: !(Maybe NominalDiffTime)
    , minAttempts :: !(Maybe Int)
    }
    deriving stock (Generic, Eq, Show)

-- | Select every @firing@ row regardless of age or attempts.
anyStuckTimer :: StuckTimerFilter
anyStuckTimer = StuckTimerFilter Nothing Nothing

{- | Schedule a timer inside the caller's transaction (typically a process
manager's append). Upserts on 'timerId': a conflicting row is re-armed only
while it is still @Scheduled@, so a timer that has already fired or been
cancelled is not resurrected.
-}
scheduleTimerTx :: TimerRequest -> Tx.Transaction ()
scheduleTimerTx request =
    Tx.statement
        ( timerIdToUuid (request ^. #timerId)
        , request ^. #processManagerName
        , request ^. #correlationId
        , request ^. #fireAt
        , request ^. #payload
        , statusToText Scheduled
        )
        scheduleTimerStmt

{- | Schedule a timer only if no row with the same 'timerId' already exists.

This is for callers whose first arm must win, such as durable workflow sleeps:
every resume pass re-runs the sleep arm until the timer fires, and preserving
the original 'fireAt' keeps the sleep measured from the first arm. Process
managers that intentionally push a deadline back should keep using
'scheduleTimerTx'.
-}
scheduleTimerOnceTx :: TimerRequest -> Tx.Transaction ()
scheduleTimerOnceTx request =
    Tx.statement
        ( timerIdToUuid (request ^. #timerId)
        , request ^. #processManagerName
        , request ^. #correlationId
        , request ^. #fireAt
        , request ^. #payload
        , statusToText Scheduled
        )
        scheduleTimerOnceStmt

{- | Atomically claim the single earliest timer due at @now@, moving it to
@Firing@ and bumping its attempt count. Uses @FOR UPDATE SKIP LOCKED@ so
concurrent workers each get a distinct timer. Returns 'Nothing' when none is
due.
-}
claimDueTimer :: (Store :> es) => UTCTime -> Eff es (Maybe TimerRow)
claimDueTimer now =
    runTransaction $
        Tx.statement now claimDueTimerStmt

{- | Mark a claimed timer @Fired@, recording the id of the event its firing
produced. Returns 'False' when the row left @Firing@ while the fire action was
running (for example, it was requeued, cancelled, or dead-lettered).
-}
markTimerFired :: (Store :> es) => TimerId -> EventId -> Eff es Bool
markTimerFired timerId eventId =
    runTransaction $
        Tx.statement (timerIdToUuid timerId, eventIdToUuid eventId) markTimerFiredStmt

{- | Count timers that are @scheduled@ and already due at @now@ — the timer
backlog. Read-only; mirrors 'claimDueTimer''s WHERE clause but counts rather
than locking, so it never claims or mutates a row.
-}
countDueTimers :: (Store :> es) => UTCTime -> Eff es Int
countDueTimers now =
    runTransaction $
        Tx.statement now countDueTimersStmt

{- | Count timers stranded in @Firing@ that match the given 'StuckTimerFilter' —
the same "stuck" predicate 'findStuckTimers' lists, evaluated against @now@.
Read-only. 'anyStuckTimer' counts every @firing@ row.
-}
countStuckTimers :: (Store :> es) => UTCTime -> StuckTimerFilter -> Eff es Int
countStuckTimers now stuckFilter =
    runTransaction $
        Tx.statement (cutoff, fmap fromIntegral (stuckFilter ^. #minAttempts)) countStuckTimersStmt
  where
    cutoff = fmap (\age -> addUTCTime (negate age) now) (stuckFilter ^. #minAge)

{- | List timers stranded in @Firing@ that match the given 'StuckTimerFilter'.
The @minAge@ bound is evaluated against @now@: a row qualifies when its
@updated_at@ is at least @minAge@ in the past (cutoff @now - minAge@). Results
are ordered oldest-first by @updated_at@.
-}
findStuckTimers ::
    (Store :> es) => UTCTime -> StuckTimerFilter -> Eff es [TimerRow]
findStuckTimers now stuckFilter =
    runTransaction $
        Tx.statement (cutoff, fmap fromIntegral (stuckFilter ^. #minAttempts)) findStuckTimersStmt
  where
    cutoff = fmap (\age -> addUTCTime (negate age) now) (stuckFilter ^. #minAge)

{- | Move every timer stranded in @Firing@ for at least @olderThan@ back to
@Scheduled@. The statement preserves @fire_at@, so a due timer becomes
claimable on the same worker pass. Returns the number of rows requeued.
-}
requeueStuckTimers :: (Store :> es) => NominalDiffTime -> UTCTime -> Eff es Int
requeueStuckTimers olderThan now =
    runTransaction $
        Tx.statement cutoff requeueStuckTimersStmt
  where
    cutoff = addUTCTime (negate olderThan) now

{- | Move a timer from @Firing@ back to @Scheduled@ so the ordinary claim loop
re-fires it. Leaves @fire_at@ unchanged, so a due timer becomes immediately
re-claimable. Idempotent: only @firing@ rows match, so re-running on an
already-requeued row affects nothing. Returns 'True' when a row changed.
-}
requeueStuckTimer :: (Store :> es) => TimerId -> Eff es Bool
requeueStuckTimer timerId =
    runTransaction $
        Tx.statement (timerIdToUuid timerId) requeueStuckTimerStmt

{- | Move a timer from @Scheduled@ or @Firing@ to the terminal @Cancelled@
state so it never fires. Terminal rows (@fired@, @cancelled@, @dead@) are left
untouched. Idempotent. Returns 'True' when a row changed.
-}
cancelTimer :: (Store :> es) => TimerId -> Eff es Bool
cancelTimer timerId =
    runTransaction $
        Tx.statement (timerIdToUuid timerId) cancelTimerStmt

{- | Move a timer from @Scheduled@ or @Firing@ to the terminal @Dead@ state,
recording @reason@ in @last_error@ so an operator can see why it was abandoned
(@SELECT * FROM keiro_timers WHERE status = 'dead'@). Terminal rows are left
untouched. Idempotent. Returns 'True' when a row changed.
-}
deadLetterTimer :: (Store :> es) => TimerId -> Text -> Eff es Bool
deadLetterTimer timerId reason =
    runTransaction $
        Tx.statement (timerIdToUuid timerId, reason) deadLetterTimerStmt

scheduleTimerStmt :: Statement (UUID, Text, Text, UTCTime, Value, Text) ()
scheduleTimerStmt =
    preparable
        """
        INSERT INTO keiro.keiro_timers
          (timer_id, process_manager_name, correlation_id, fire_at, payload, status)
        VALUES
          ($1, $2, $3, $4, $5, $6)
        ON CONFLICT (timer_id) DO UPDATE
          SET process_manager_name = EXCLUDED.process_manager_name,
              correlation_id = EXCLUDED.correlation_id,
              fire_at = EXCLUDED.fire_at,
              payload = EXCLUDED.payload,
              status = EXCLUDED.status,
              updated_at = now()
          WHERE keiro_timers.status = 'scheduled'
        """
        ( contrazip6
            (E.param (E.nonNullable E.uuid))
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.timestamptz))
            (E.param (E.nonNullable E.jsonb))
            (E.param (E.nonNullable E.text))
        )
        D.noResult

scheduleTimerOnceStmt :: Statement (UUID, Text, Text, UTCTime, Value, Text) ()
scheduleTimerOnceStmt =
    preparable
        """
        INSERT INTO keiro.keiro_timers
          (timer_id, process_manager_name, correlation_id, fire_at, payload, status)
        VALUES
          ($1, $2, $3, $4, $5, $6)
        ON CONFLICT (timer_id) DO NOTHING
        """
        ( contrazip6
            (E.param (E.nonNullable E.uuid))
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.timestamptz))
            (E.param (E.nonNullable E.jsonb))
            (E.param (E.nonNullable E.text))
        )
        D.noResult

claimDueTimerStmt :: Statement UTCTime (Maybe TimerRow)
claimDueTimerStmt =
    preparable
        """
        WITH due AS (
          SELECT timer_id
          FROM keiro.keiro_timers
          WHERE status = 'scheduled'
            AND fire_at <= $1
          ORDER BY fire_at, timer_id
          LIMIT 1
          FOR UPDATE SKIP LOCKED
        )
        UPDATE keiro.keiro_timers kt
        SET status = 'firing',
            attempts = kt.attempts + 1,
            updated_at = now()
        FROM due
        WHERE kt.timer_id = due.timer_id
        RETURNING kt.timer_id, kt.process_manager_name, kt.correlation_id, kt.fire_at,
          kt.payload, kt.status, kt.attempts, kt.fired_event_id
        """
        (E.param (E.nonNullable E.timestamptz))
        (D.rowMaybe timerRowDecoder)

markTimerFiredStmt :: Statement (UUID, UUID) Bool
markTimerFiredStmt =
    preparable
        """
        UPDATE keiro.keiro_timers
        SET status = 'fired',
            fired_event_id = $2,
            updated_at = now()
        WHERE timer_id = $1
          AND status = 'firing'
        """
        ( contrazip2
            (E.param (E.nonNullable E.uuid))
            (E.param (E.nonNullable E.uuid))
        )
        ((> 0) <$> D.rowsAffected)

countDueTimersStmt :: Statement UTCTime Int
countDueTimersStmt =
    preparable
        """
        SELECT count(*)
        FROM keiro.keiro_timers
        WHERE status = 'scheduled'
          AND fire_at <= $1
        """
        (E.param (E.nonNullable E.timestamptz))
        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))

countStuckTimersStmt :: Statement (Maybe UTCTime, Maybe Int64) Int
countStuckTimersStmt =
    preparable
        """
        SELECT count(*)
        FROM keiro.keiro_timers
        WHERE status = 'firing'
          AND ($1::timestamptz IS NULL OR updated_at <= $1)
          AND ($2::bigint IS NULL OR attempts >= $2)
        """
        ( contrazip2
            (E.param (E.nullable E.timestamptz))
            (E.param (E.nullable E.int8))
        )
        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))

findStuckTimersStmt :: Statement (Maybe UTCTime, Maybe Int64) [TimerRow]
findStuckTimersStmt =
    preparable
        """
        SELECT timer_id, process_manager_name, correlation_id, fire_at,
          payload, status, attempts, fired_event_id
        FROM keiro.keiro_timers
        WHERE status = 'firing'
          AND ($1::timestamptz IS NULL OR updated_at <= $1)
          AND ($2::bigint IS NULL OR attempts >= $2)
        ORDER BY updated_at, timer_id
        """
        ( contrazip2
            (E.param (E.nullable E.timestamptz))
            (E.param (E.nullable E.int8))
        )
        (D.rowList timerRowDecoder)

requeueStuckTimersStmt :: Statement UTCTime Int
requeueStuckTimersStmt =
    preparable
        """
        UPDATE keiro.keiro_timers
        SET status = 'scheduled',
            updated_at = now()
        WHERE status = 'firing'
          AND updated_at <= $1
        """
        (E.param (E.nonNullable E.timestamptz))
        (fromIntegral <$> D.rowsAffected)

requeueStuckTimerStmt :: Statement UUID Bool
requeueStuckTimerStmt =
    preparable
        """
        UPDATE keiro.keiro_timers
        SET status = 'scheduled',
            updated_at = now()
        WHERE timer_id = $1
          AND status = 'firing'
        """
        (E.param (E.nonNullable E.uuid))
        ((> 0) <$> D.rowsAffected)

cancelTimerStmt :: Statement UUID Bool
cancelTimerStmt =
    preparable
        """
        UPDATE keiro.keiro_timers
        SET status = 'cancelled',
            updated_at = now()
        WHERE timer_id = $1
          AND status IN ('scheduled', 'firing')
        """
        (E.param (E.nonNullable E.uuid))
        ((> 0) <$> D.rowsAffected)

deadLetterTimerStmt :: Statement (UUID, Text) Bool
deadLetterTimerStmt =
    preparable
        """
        UPDATE keiro.keiro_timers
        SET status = 'dead',
            last_error = $2,
            updated_at = now()
        WHERE timer_id = $1
          AND status IN ('scheduled', 'firing')
        """
        ( contrazip2
            (E.param (E.nonNullable E.uuid))
            (E.param (E.nonNullable E.text))
        )
        ((> 0) <$> D.rowsAffected)

timerRowDecoder :: D.Row TimerRow
timerRowDecoder =
    TimerRow
        <$> (TimerId <$> D.column (D.nonNullable D.uuid))
        <*> D.column (D.nonNullable D.text)
        <*> D.column (D.nonNullable D.text)
        <*> D.column (D.nonNullable D.timestamptz)
        <*> D.column (D.nonNullable D.jsonb)
        <*> D.column (D.nonNullable (D.refine statusFromText D.text))
        <*> (fromIntegral <$> D.column (D.nonNullable D.int8))
        <*> (fmap EventId <$> D.column (D.nullable D.uuid))

statusToText :: TimerStatus -> Text
statusToText = \case
    Scheduled -> "scheduled"
    Firing -> "firing"
    Fired -> "fired"
    Cancelled -> "cancelled"
    Dead -> "dead"

statusFromText :: Text -> Either Text TimerStatus
statusFromText = \case
    "scheduled" -> Right Scheduled
    "firing" -> Right Firing
    "fired" -> Right Fired
    "cancelled" -> Right Cancelled
    "dead" -> Right Dead
    other -> Left ("unknown keiro_timers.status: " <> other)

timerIdToUuid :: TimerId -> UUID
timerIdToUuid (TimerId uuid) = uuid

eventIdToUuid :: EventId -> UUID
eventIdToUuid (EventId uuid) = uuid