packages feed

keiro-0.2.0.0: src/Keiro/Workflow/Awakeable/Schema.hs

{- | The @keiro_awakeables@ table: durable storage for awakeables (external
promises a workflow suspends on).

Mirrors the @Keiro.Timer@ \/ @Keiro.Timer.Schema@ split: this module owns the
row type, the 'AwakeableStatus' lifecycle, and the hasql statements;
"Keiro.Workflow.Awakeable" owns the effectful authoring\/completion surface.

* 'registerAwakeableTx' inserts a @pending@ row idempotently (the
  @ON CONFLICT DO NOTHING@ EP-38's @awaitStep@ arming contract requires).
* 'lookupAwakeable' reads a row back.
* 'completeAwakeableTx' transitions a @pending@ row to @completed@ (guarded so
  a double signal is a no-op), and 'cancelAwakeableTx' transitions it to
  @cancelled@.
* 'countPendingAwakeables' counts outstanding promises — the seam EP-44 reads
  for its @keiro.workflow.awakeables.pending@ gauge.

Callers normally use the re-exports / surface from "Keiro.Workflow.Awakeable"
rather than this module directly; EP-44 imports 'countPendingAwakeables' here
without pulling in the effect surface.
-}
module Keiro.Workflow.Awakeable.Schema (
    -- * Rows and status
    AwakeableStatus (..),
    AwakeableRow (..),
    statusToText,
    statusFromText,

    -- * Storage (run inside the caller's transaction)
    registerAwakeableTx,
    completeAwakeableTx,
    cancelAwakeableTx,

    -- * Read-only lookups
    lookupAwakeable,
    countPendingAwakeables,
)
where

import Contravariant.Extras (contrazip3)
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 Kiroku.Store.Effect (Store)
import Kiroku.Store.Transaction (runTransaction)
import "hasql-transaction" Hasql.Transaction qualified as Tx

{- | An awakeable's lifecycle state.

* 'Pending' — allocated and waiting for an external signal; the workflow is
  suspended on it.
* 'Completed' — signalled with a payload; terminal.
* 'Cancelled' — abandoned before it was signalled; terminal; also the decode
  fallback for an unrecognized stored value (the same defensive choice
  "Keiro.Timer.Schema" makes).
-}
data AwakeableStatus
    = Pending
    | Completed
    | Cancelled
    deriving stock (Generic, Eq, Show)

{- | An awakeable row as stored: the deterministic id, the owning workflow's
name and instance id, the live 'status', the signalled 'payload' (JSON, set
only once 'Completed'), and the timestamps.
-}
data AwakeableRow = AwakeableRow
    { awakeableId :: !UUID
    , ownerWorkflowName :: !Text
    , ownerWorkflowId :: !Text
    , status :: !AwakeableStatus
    , payload :: !(Maybe Value)
    , createdAt :: !UTCTime
    , updatedAt :: !UTCTime
    , completedAt :: !(Maybe UTCTime)
    }
    deriving stock (Generic, Eq, Show)

{- | Insert a @pending@ awakeable row inside the caller's transaction.
Idempotent by @ON CONFLICT (awakeable_id) DO NOTHING@ — exactly what EP-38's
"arm must be idempotent" contract needs, since a resumed workflow re-runs the
arming action on every resume until the awakeable resolves.
-}
registerAwakeableTx :: UUID -> Text -> Text -> Tx.Transaction ()
registerAwakeableTx aid name wid =
    Tx.statement (aid, name, wid) registerAwakeableStmt

{- | Transition a @pending@ awakeable to @completed@, storing @payload@ and the
completion time, inside the caller's transaction. The @status = 'pending'@
guard makes a double-signal a no-op; returns 'True' only when this call
performed the transition (so the caller knows whether it was the one that
resolved the promise).
-}
completeAwakeableTx :: UUID -> Value -> UTCTime -> Tx.Transaction Bool
completeAwakeableTx aid result now =
    Tx.statement (aid, result, now) completeAwakeableStmt

{- | Transition a @pending@ awakeable to @cancelled@ inside the caller's
transaction. Only @pending@ rows match, so an already-completed (or
already-cancelled) awakeable is left untouched and the call returns 'False'.
-}
cancelAwakeableTx :: UUID -> Tx.Transaction Bool
cancelAwakeableTx aid =
    Tx.statement aid cancelAwakeableStmt

-- | Read an awakeable row by id. 'Nothing' if no such awakeable exists.
lookupAwakeable :: (Store :> es) => UUID -> Eff es (Maybe AwakeableRow)
lookupAwakeable aid =
    runTransaction (Tx.statement aid lookupAwakeableStmt)

{- | Count awakeables currently @pending@. Read-only. EP-44 backs the
@keiro.workflow.awakeables.pending@ gauge with this.
-}
countPendingAwakeables :: (Store :> es) => Eff es Int
countPendingAwakeables =
    runTransaction (Tx.statement () countPendingAwakeablesStmt)

registerAwakeableStmt :: Statement (UUID, Text, Text) ()
registerAwakeableStmt =
    preparable
        """
        INSERT INTO keiro.keiro_awakeables
          (awakeable_id, owner_workflow_name, owner_workflow_id, status)
        VALUES ($1, $2, $3, 'pending')
        ON CONFLICT (awakeable_id) DO NOTHING
        """
        ( contrazip3
            (E.param (E.nonNullable E.uuid))
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
        )
        D.noResult

completeAwakeableStmt :: Statement (UUID, Value, UTCTime) Bool
completeAwakeableStmt =
    preparable
        """
        UPDATE keiro.keiro_awakeables
        SET status = 'completed',
            payload = $2,
            completed_at = $3,
            updated_at = now()
        WHERE awakeable_id = $1
          AND status = 'pending'
        """
        ( contrazip3
            (E.param (E.nonNullable E.uuid))
            (E.param (E.nonNullable E.jsonb))
            (E.param (E.nonNullable E.timestamptz))
        )
        ((> 0) <$> D.rowsAffected)

cancelAwakeableStmt :: Statement UUID Bool
cancelAwakeableStmt =
    preparable
        """
        UPDATE keiro.keiro_awakeables
        SET status = 'cancelled',
            updated_at = now()
        WHERE awakeable_id = $1
          AND status = 'pending'
        """
        (E.param (E.nonNullable E.uuid))
        ((> 0) <$> D.rowsAffected)

lookupAwakeableStmt :: Statement UUID (Maybe AwakeableRow)
lookupAwakeableStmt =
    preparable
        """
        SELECT awakeable_id, owner_workflow_name, owner_workflow_id, status,
          payload, created_at, updated_at, completed_at
        FROM keiro.keiro_awakeables
        WHERE awakeable_id = $1
        """
        (E.param (E.nonNullable E.uuid))
        (D.rowMaybe awakeableRowDecoder)

countPendingAwakeablesStmt :: Statement () Int
countPendingAwakeablesStmt =
    preparable
        """
        SELECT count(*)
        FROM keiro.keiro_awakeables
        WHERE status = 'pending'
        """
        E.noParams
        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))

awakeableRowDecoder :: D.Row AwakeableRow
awakeableRowDecoder =
    AwakeableRow
        <$> D.column (D.nonNullable D.uuid)
        <*> D.column (D.nonNullable D.text)
        <*> D.column (D.nonNullable D.text)
        <*> (statusFromText <$> D.column (D.nonNullable D.text))
        <*> D.column (D.nullable D.jsonb)
        <*> D.column (D.nonNullable D.timestamptz)
        <*> D.column (D.nonNullable D.timestamptz)
        <*> D.column (D.nullable D.timestamptz)

statusToText :: AwakeableStatus -> Text
statusToText = \case
    Pending -> "pending"
    Completed -> "completed"
    Cancelled -> "cancelled"

statusFromText :: Text -> AwakeableStatus
statusFromText = \case
    "pending" -> Pending
    "completed" -> Completed
    "cancelled" -> Cancelled
    _ -> Cancelled