packages feed

keiro-0.16.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.
--
-- ID-only completion, cancellation, dead-lettering and requeueing refuse all
-- token-bearing foreground claims, including expired claims awaiting recovery.
--
-- Callers normally use the re-exports from "Keiro.Timer" rather than this
-- module directly.
module Keiro.Timer.Schema
  ( -- * Rows and status
    TimerStatus (..),
    TimerRow (..),

    -- * Read-only inspection
    TimerInspection (..),
    TimerReasonFilter (..),
    DeadTimerFilter (..),
    anyDeadTimer,
    DeadTimerPageRequest (..),
    DeadTimerReadError (..),
    DeadTimerPage (..),
    lookupTimerInspection,
    findDeadTimers,

    -- * Guarded foreground resume
    DeadTimerClaimRequest (..),
    TimerResumeError (..),
    TimerResumeClaim,
    resumeClaimTimer,
    resumeClaimLeaseUntil,
    claimDeadTimer,
    renewTimerResume,
    completeTimerResume,
    parkTimerResume,
    cancelTimerResume,
    recoverExpiredTimerResumes,

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

    -- * Read-only counts
    countDueTimers,
    countStuckTimers,

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

import Contravariant.Extras (contrazip2, contrazip5, contrazip6)
import Data.Int (Int32)
import Data.Time (NominalDiffTime, addUTCTime)
import Data.UUID (UUID)
import Data.UUID.V4 qualified as UUIDv4
import Effectful (Eff, IOE, (:>))
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' — parked or abandoned; guarded foreground resume is possible; carries an
--   optional @last_error@ describing why it was given up on.
data TimerStatus
  = Scheduled
  | Firing
  | Fired
  | Cancelled
  | Dead
  -- 'Enum'/'Bounded' let a consumer enumerate the complete lifecycle rather than
  -- restate it; keiro-dsl's cross-package vocabulary test relies on this.
  deriving stock (Generic, Eq, Show, Enum, Bounded)

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

-- | Original timer metadata and the full stored reason. NULL and empty text
-- remain distinct. Reading does not claim work or authorize its disclosure.
data TimerInspection = TimerInspection
  { timer :: !TimerRow,
    lastError :: !(Maybe Text)
  }
  deriving stock (Generic, Eq, Show)

-- | Case-sensitive literal reason matching. An empty prefix matches every
-- non-NULL reason; percent, underscore, and backslash are ordinary characters.
data TimerReasonFilter
  = AnyTimerReason
  | ReasonAbsent
  | ReasonExact !Text
  | ReasonPrefix !Text
  deriving stock (Generic, Eq, Show)

-- | Dead rows matching both the optional exact owner and the reason predicate.
-- The owner label is not an application authorization credential.
data DeadTimerFilter = DeadTimerFilter
  { processManagerName :: !(Maybe Text),
    reason :: !TimerReasonFilter
  }
  deriving stock (Generic, Eq, Show)

-- | Select all dead timers.
anyDeadTimer :: DeadTimerFilter
anyDeadTimer = DeadTimerFilter Nothing AnyTimerReason

-- | Request 1 through 100 rows, strictly after an optional UUID cursor.
-- Restart without a cursor when changing filters.
data DeadTimerPageRequest = DeadTimerPageRequest
  { pageSize :: !Int,
    afterTimerId :: !(Maybe TimerId)
  }
  deriving stock (Generic, Eq, Show)

-- | Invalid sizes are rejected before database access, without clamping.
data DeadTimerReadError = InvalidDeadTimerPageSize !Int
  deriving stock (Generic, Eq, Show)

-- | Ascending UUID order, not chronological order. Continuation exists only
-- when another matching row was observed. Requests see current eligibility,
-- not a shared snapshot: newly eligible IDs behind the cursor are not revisited.
data DeadTimerPage = DeadTimerPage
  { timers :: ![TimerInspection],
    nextAfterTimerId :: !(Maybe TimerId)
  }
  deriving stock (Generic, Eq, Show)

-- | Inspect any lifecycle state without mutations or row-claim locks.
lookupTimerInspection :: (Store :> es) => TimerId -> Eff es (Maybe TimerInspection)
lookupTimerInspection timerId =
  runTransaction $ Tx.statement (timerIdToUuid timerId) lookupTimerInspectionStmt

-- | Observe a bounded page of dead timers. Limits bound returned rows, not
-- database search cost. Callers must decode and authorize before rendering,
-- following storage continuation even if authorization removes an entire page.
findDeadTimers ::
  (Store :> es) =>
  DeadTimerFilter ->
  DeadTimerPageRequest ->
  Eff es (Either DeadTimerReadError DeadTimerPage)
findDeadTimers deadFilter request
  | size < 1 || size > 100 = pure (Left (InvalidDeadTimerPageSize size))
  | otherwise = do
      rows <-
        runTransaction $
          Tx.statement
            ( deadFilter ^. #processManagerName,
              mode,
              reasonText,
              timerIdToUuid <$> request ^. #afterTimerId,
              fromIntegral size + 1
            )
            findDeadTimersStmt
      let selected = take size rows
          continuation = case drop size rows of
            [] -> Nothing
            _ -> case reverse selected of
              lastRow : _ -> Just (lastRow ^. #timer . #timerId)
              [] -> Nothing
      pure (Right (DeadTimerPage selected continuation))
  where
    size = request ^. #pageSize
    (mode, reasonText) = case deadFilter ^. #reason of
      AnyTimerReason -> (0, Nothing)
      ReasonAbsent -> (1, Nothing)
      ReasonExact value -> (2, Just value)
      ReasonPrefix value -> (3, Just value)

-- | Exact dead-row guards and an explicit total attempt ceiling. Lease seconds
-- must be between 1 and 2147483647, avoiding interval conversion overflow.
data DeadTimerClaimRequest = DeadTimerClaimRequest
  { timerId :: !TimerId,
    processManagerName :: !Text,
    expectedReason :: !Text,
    maxAttempts :: !Int,
    leaseSeconds :: !Int
  }
  deriving stock (Generic, Eq, Show)

data TimerResumeError
  = InvalidTimerResumeMaxAttempts !Int
  | InvalidTimerResumeLeaseSeconds !Int
  deriving stock (Generic, Eq, Show)

-- | Opaque storage ownership, not application authorization.
data TimerResumeClaim = TimerResumeClaim !TimerRow !UUID !UTCTime

-- | Original work as claimed, including the incremented attempt count.
resumeClaimTimer :: TimerResumeClaim -> TimerRow
resumeClaimTimer (TimerResumeClaim row _ _) = row

-- | Claim-time snapshot only. Renewals retain the token and update the database;
-- schedule renewals by the requested interval, not this old snapshot.
resumeClaimLeaseUntil :: TimerResumeClaim -> UTCTime
resumeClaimLeaseUntil (TimerResumeClaim _ _ deadline) = deadline

validResumeLease :: Int -> Bool
validResumeLease seconds = seconds > 0 && toInteger seconds <= toInteger (maxBound :: Int32)

-- | Claim only the exact owner and non-NULL reason. Refusal consumes no attempt.
-- Authorize and establish session availability before calling; execute outside
-- the retried SQL transaction, only after receiving ownership.
claimDeadTimer :: (IOE :> es, Store :> es) => DeadTimerClaimRequest -> Eff es (Either TimerResumeError (Maybe TimerResumeClaim))
claimDeadTimer request
  | request ^. #maxAttempts < 0 = pure (Left (InvalidTimerResumeMaxAttempts (request ^. #maxAttempts)))
  | not (validResumeLease (request ^. #leaseSeconds)) = pure (Left (InvalidTimerResumeLeaseSeconds (request ^. #leaseSeconds)))
  | otherwise = do
      token <- liftIO UUIDv4.nextRandom
      Right
        <$> runTransaction
          ( do
              locked <- lockTimerResumeTx (request ^. #timerId)
              if not locked
                then pure Nothing
                else
                  Tx.statement
                    ( timerIdToUuid (request ^. #timerId),
                      request ^. #processManagerName,
                      request ^. #expectedReason,
                      fromIntegral (request ^. #maxAttempts),
                      token,
                      fromIntegral (request ^. #leaseSeconds)
                    )
                    claimDeadTimerStmt
          )

-- Lock in a separate statement: subsequent predicates and clock_timestamp()
-- observe the committed winner after a ReadCommitted lock wait. A missing row
-- must return immediately: a later insert must not bypass the initial lock.
lockTimerResumeTx :: TimerId -> Tx.Transaction Bool
lockTimerResumeTx tid = isJust <$> Tx.statement (timerIdToUuid tid) lockTimerResumeStmt

lockTimerResumeStmt :: Statement UUID (Maybe UUID)
lockTimerResumeStmt =
  preparable
    "SELECT timer_id FROM keiro.keiro_timers WHERE timer_id = $1 FOR UPDATE"
    (E.param (E.nonNullable E.uuid))
    (D.rowMaybe (D.column (D.nonNullable D.uuid)))

claimDeadTimerStmt :: Statement (UUID, Text, Text, Int64, UUID, Int32) (Maybe TimerResumeClaim)
claimDeadTimerStmt =
  preparable
    """
    WITH stamp AS MATERIALIZED (SELECT clock_timestamp() AS now)
    UPDATE keiro.keiro_timers kt
    SET status = 'firing', attempts = attempts + 1,
        resume_claim_token = $5, resume_lease_until = stamp.now + $6::integer * interval '1 second',
        updated_at = stamp.now
    FROM stamp
    WHERE timer_id = $1 AND status = 'dead'
      AND process_manager_name COLLATE "C" = $2
      AND last_error COLLATE "C" = $3 AND attempts < $4
    RETURNING kt.timer_id, kt.process_manager_name, kt.correlation_id, kt.fire_at,
      kt.payload, kt.status, kt.attempts, kt.fired_event_id, kt.resume_claim_token, kt.resume_lease_until
    """
    ( contrazip6
        (E.param (E.nonNullable E.uuid))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int8))
        (E.param (E.nonNullable E.uuid))
        (E.param (E.nonNullable E.int4))
    )
    (D.rowMaybe (TimerResumeClaim <$> timerRowDecoder <*> D.column (D.nonNullable D.uuid) <*> D.column (D.nonNullable D.timestamptz)))

-- | Extend from database time. False means ownership was lost; an expired claim
-- cannot be revived. Stop local work where possible on loss of ownership.
renewTimerResume :: (Store :> es) => TimerResumeClaim -> Int -> Eff es (Either TimerResumeError Bool)
renewTimerResume claim seconds
  | not (validResumeLease seconds) = pure (Left (InvalidTimerResumeLeaseSeconds seconds))
  | otherwise = Right <$> mutateTimerResume claim "firing" Nothing (Just (fromIntegral seconds))

-- | Complete with the resulting event. False must not be reported as successful
-- timer completion. External effects still need caller-owned idempotency.
completeTimerResume :: (Store :> es) => TimerResumeClaim -> EventId -> Eff es Bool
completeTimerResume claim event = mutateTimerResume claim "fired" (Just (eventIdToUuid event)) Nothing

-- | Return to Dead with the original reason and incremented attempts retained.
parkTimerResume :: (Store :> es) => TimerResumeClaim -> Eff es Bool
parkTimerResume claim = mutateTimerResume claim "dead" Nothing Nothing

-- | Explicit abandonment by the current owner.
cancelTimerResume :: (Store :> es) => TimerResumeClaim -> Eff es Bool
cancelTimerResume claim = mutateTimerResume claim "cancelled" Nothing Nothing

mutateTimerResume :: (Store :> es) => TimerResumeClaim -> Text -> Maybe UUID -> Maybe Int32 -> Eff es Bool
mutateTimerResume (TimerResumeClaim row token _) target event seconds = runTransaction $ do
  locked <- lockTimerResumeTx (row ^. #timerId)
  if not locked
    then pure False
    else
      Tx.statement (timerIdToUuid (row ^. #timerId), token, target, event, seconds) mutateTimerResumeStmt

mutateTimerResumeStmt :: Statement (UUID, UUID, Text, Maybe UUID, Maybe Int32) Bool
mutateTimerResumeStmt =
  preparable
    """
    WITH stamp AS MATERIALIZED (SELECT clock_timestamp() AS now)
    UPDATE keiro.keiro_timers
    SET status = $3,
        fired_event_id = CASE WHEN $3 = 'fired' THEN $4 ELSE fired_event_id END,
        resume_claim_token = CASE WHEN $5::integer IS NULL THEN NULL ELSE resume_claim_token END,
        resume_lease_until = CASE WHEN $5::integer IS NULL THEN NULL ELSE stamp.now + $5 * interval '1 second' END,
        updated_at = stamp.now
    FROM stamp
    WHERE timer_id = $1 AND resume_claim_token = $2 AND status = 'firing'
      AND resume_lease_until > stamp.now
    """
    ( contrazip5
        (E.param (E.nonNullable E.uuid))
        (E.param (E.nonNullable E.uuid))
        (E.param (E.nonNullable E.text))
        (E.param (E.nullable E.uuid))
        (E.param (E.nullable E.int4))
    )
    ((> 0) <$> D.rowsAffected)

-- | Re-park expired foreground work directly to Dead. Foreground-only hosts
-- must run this periodically and before discovery/resume. Ordinary worker passes
-- also run it, independently of their ordinary stale-claim recovery option.
recoverExpiredTimerResumes :: (Store :> es) => Eff es Int
recoverExpiredTimerResumes = runTransaction $ do
  -- Deterministic lock ordering, with a fresh predicate in the second statement.
  ids <- Tx.statement () lockExpiredTimerResumesStmt
  sum <$> traverse (\tid -> Tx.statement tid recoverExpiredTimerResumesStmt) ids

lockExpiredTimerResumesStmt :: Statement () [UUID]
lockExpiredTimerResumesStmt =
  preparable
    """
    SELECT timer_id FROM keiro.keiro_timers
    WHERE status = 'firing' AND resume_claim_token IS NOT NULL
      AND resume_lease_until <= clock_timestamp()
    ORDER BY timer_id FOR UPDATE
    """
    mempty
    (D.rowList (D.column (D.nonNullable D.uuid)))

recoverExpiredTimerResumesStmt :: Statement UUID Int
recoverExpiredTimerResumesStmt =
  preparable
    """
    UPDATE keiro.keiro_timers
    SET status = 'dead', resume_claim_token = NULL, resume_lease_until = NULL,
        updated_at = clock_timestamp()
    WHERE timer_id = $1 AND status = 'firing' AND resume_claim_token IS NOT NULL
      AND resume_lease_until <= clock_timestamp()
    """
    (E.param (E.nonNullable E.uuid))
    (fromIntegral <$> D.rowsAffected)

-- | 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'. Returns 'True' when this call inserted the row and 'False'
-- when an existing timer won.
scheduleTimerOnceTx :: TimerRequest -> Tx.Transaction Bool
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

-- | Look up one timer by its stable identifier without claiming or mutating it.
--
-- Operational tooling uses this to render an exact preview before invoking one
-- of the guarded lifecycle transitions below.
lookupTimer :: (Store :> es) => TimerId -> Eff es (Maybe TimerRow)
lookupTimer timerId =
  runTransaction $
    Tx.statement (timerIdToUuid timerId) lookupTimerStmt

-- | 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
-- through 'lookupTimerInspection' or 'findDeadTimers'. 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) Bool
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))
    )
    ((> 0) <$> D.rowsAffected)

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)

lookupTimerStmt :: Statement UUID (Maybe TimerRow)
lookupTimerStmt =
  preparable
    """
    SELECT timer_id, process_manager_name, correlation_id, fire_at,
      payload, status, attempts, fired_event_id
    FROM keiro.keiro_timers
    WHERE timer_id = $1
    """
    (E.param (E.nonNullable E.uuid))
    (D.rowMaybe timerRowDecoder)

lookupTimerInspectionStmt :: Statement UUID (Maybe TimerInspection)
lookupTimerInspectionStmt =
  preparable
    """
    SELECT timer_id, process_manager_name, correlation_id, fire_at,
      payload, status, attempts, fired_event_id, last_error
    FROM keiro.keiro_timers
    WHERE timer_id = $1
    """
    (E.param (E.nonNullable E.uuid))
    (D.rowMaybe timerInspectionDecoder)

findDeadTimersStmt :: Statement (Maybe Text, Int32, Maybe Text, Maybe UUID, Int64) [TimerInspection]
findDeadTimersStmt =
  preparable
    """
    SELECT timer_id, process_manager_name, correlation_id, fire_at,
      payload, status, attempts, fired_event_id, last_error
    FROM keiro.keiro_timers
    WHERE status = 'dead'
      AND ($1::text IS NULL OR process_manager_name COLLATE "C" = $1)
      AND (CASE $2::integer
        WHEN 0 THEN TRUE
        WHEN 1 THEN last_error IS NULL
        WHEN 2 THEN last_error COLLATE "C" = $3::text
        WHEN 3 THEN left(last_error, char_length($3::text)) COLLATE "C" = $3::text
        ELSE FALSE END)
      AND ($4::uuid IS NULL OR timer_id > $4)
    ORDER BY timer_id ASC
    LIMIT $5::bigint
    """
    ( contrazip5
        (E.param (E.nullable E.text))
        (E.param (E.nonNullable E.int4))
        (E.param (E.nullable E.text))
        (E.param (E.nullable E.uuid))
        (E.param (E.nonNullable E.int8))
    )
    (D.rowList timerInspectionDecoder)

timerInspectionDecoder :: D.Row TimerInspection
timerInspectionDecoder =
  TimerInspection <$> timerRowDecoder <*> D.column (D.nullable D.text)

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'
      AND resume_claim_token IS NULL
    """
    ( 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 resume_claim_token IS NULL
      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'
      AND resume_claim_token IS NULL
    """
    (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')
      AND resume_claim_token IS NULL
    """
    (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')
      AND resume_claim_token IS NULL
    """
    ( 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