packages feed

keiro-0.2.0.0: src/Keiro/Workflow/Child.hs

{- | Child workflows: spawn, wait on, and cancel a workflow from inside another.

== What this gives you

Fan-out\/fan-in composition of durable workflows. A /parent/ workflow spawns a
/child/ (a second workflow with its own journal stream), waits for the child's
result, and may cancel a child it no longer needs — with the whole relationship
surviving a crash, because the spawn is recorded in the parent's journal and the
link is stored in @keiro_workflow_children@.

@
parent :: ('Workflow' ':>' es, 'Store' ':>' es, 'IOE' ':>' es) => Eff es Text
parent = do
  h      <- 'spawnChild' (WorkflowName \"ship-order\") (WorkflowId \"ord-7\") shipWorkflow
  result <- 'awaitChild' h                  -- SUSPEND until the child completes
  _      <- 'Keiro.Workflow.step' (StepName \"notify\") (pure (\"shipped: \" <> result))
  pure result
@

== How it works (contract recap for downstream plans)

* __'spawnChild' is a journaled step.__ It records a @StepRecorded
  \"child:\<childId\>\"@ in the /parent/ journal (so a replay short-circuits the
  spawn and never re-spawns) and inserts a @running@ row in
  @keiro_workflow_children@ linking the child back to the parent plus the
  parent-journal step the parent awaits (@child:\<childId\>:result@). It does
  __not__ run the child inline — the EP-42 resume worker drives the child to
  completion from the application's @WorkflowRegistry@, so __a child's
  'WorkflowName' must be registered there__, exactly like any resumable
  workflow. (The @childDef@ argument is taken for authoring ergonomics; the
  registry, keyed by 'WorkflowName', is the source of truth for the child's
  body.)
* __'awaitChild' reuses EP-38's suspension primitive__ — it is
  @'awaitStep' (StepName \"child:\<childId\>:result\") arm@. On the miss path it
  re-delivers a completed child's stored result onto the current parent
  generation (attach semantics), throws 'WorkflowChildCancelled' if the child
  was cancelled meanwhile, and otherwise re-asserts nothing new (the spawn
  already registered the link). On the hit path
  it decodes a tagged parent-journal envelope: @{"ok": result}@ returns the
  child result, @{"cancelled": true}@ throws 'WorkflowChildCancelled',
  @{"failed": reason}@ throws 'WorkflowChildFailed', and legacy raw values are
  still decoded as pre-envelope successful results.
* __'runChildWorkflow'__ is what the resume worker selects (instead of bare
  'Keiro.Workflow.runWorkflowWith') for any workflow that is some parent's child:
  on 'Completed' it runs 'childCompletionHook', which flips the child row to
  @completed@ and appends @{"ok": result}@ to the parent's
  @child:\<childId\>:result@ step. If it finds a historically
  @cancelled@-but-unmarked row, it ensures the child cancellation marker and
  parent sentinel before returning 'Cancelled'.
* __'cancelChild'__ flips the child row to @cancelled@ and, in the same
  transaction, writes a 'Keiro.Workflow.WorkflowCancelled' marker to the
  /child/ journal plus a @{"cancelled": true}@ sentinel as the parent's
  await-step result. Retrying after a historical row-only cancel repairs both
  markers even though the return value remains 'False' ("this call did not
  transition the row").
* @'Keiro.Workflow.Child.Schema.countActiveChildren'@ backs a potential
  @keiro.workflow.children.active@ gauge for EP-44.
-}
module Keiro.Workflow.Child (
    -- * Child handles and reserved step names
    ChildHandle (..),
    childSpawnStepName,
    childResultStepName,

    -- * Authoring surface (inside a parent workflow)
    spawnChild,
    awaitChild,

    -- * External control
    cancelChild,

    -- * Driving a child to completion (used by the resume worker)
    runChildWorkflow,
    childCompletionHook,

    -- * Errors
    WorkflowChildCancelled (..),
    WorkflowChildFailed (..),
)
where

import Control.Exception (Exception)
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Text qualified as Text
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error)
import Effectful.Exception (throwIO)
import Keiro.Prelude
import Keiro.Workflow (
    JournalAppendOutcome (..),
    StepName (..),
    Workflow,
    WorkflowError (..),
    WorkflowId (..),
    WorkflowJournalEvent (..),
    WorkflowName (..),
    WorkflowOutcome (..),
    WorkflowRunOptions,
    appendJournalEntry,
    awaitStep,
    childStepPrefix,
    currentGeneration,
    currentWorkflow,
    prepareJournalAppend,
    runWorkflowWith,
    step,
 )
import Keiro.Workflow.Child.Schema (
    ChildRow,
    ChildStatus (..),
    lookupChild,
    markChildCancelledTx,
    markChildResultTx,
    registerChildTx,
 )
import Keiro.Workflow.Instance (WorkflowStatus (..), upsertInstanceTx)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Transaction (runTransaction)
import "hasql-transaction" Hasql.Transaction qualified as Tx

-- ---------------------------------------------------------------------------
-- Handles and reserved step names
-- ---------------------------------------------------------------------------

{- | An in-memory handle to a spawned child, returned by 'spawnChild' and taken
by 'awaitChild' \/ 'cancelChild'. The phantom type parameter @a@ records the
child's result type so 'awaitChild' decodes without an extra annotation; the
handle itself carries only the child's name and id (the child journal is
@wf:\<childName\>-\<childWfId\>@).
-}
data ChildHandle a = ChildHandle
    { childName :: !WorkflowName
    , childWfId :: !WorkflowId
    }
    deriving stock (Eq, Show)

{- | The reserved /spawn/ step name a child is recorded under in its parent's
journal: @child:\<childId\>@ (uses EP-38's 'childStepPrefix').
-}
childSpawnStepName :: WorkflowId -> Text
childSpawnStepName (WorkflowId wid) = childStepPrefix <> wid

{- | The reserved /result/ step name the parent awaits and the child's
completion is propagated under: @child:\<childId\>:result@.
-}
childResultStepName :: WorkflowId -> Text
childResultStepName (WorkflowId wid) = childStepPrefix <> wid <> ":result"

-- ---------------------------------------------------------------------------
-- Errors
-- ---------------------------------------------------------------------------

{- | Thrown out of 'awaitChild' when the awaited child was 'cancelChild'led. A
cancelled child never produces a result, so suspending forever would be wrong
and fabricating a result would be wrong; the parent author can @catch@ this to
run compensation. If uncaught, the resume worker records the parent attempt,
backs it off, and eventually marks the parent failed at its configured ceiling.
Mirrors EP-40's 'Keiro.Workflow.Awakeable.WorkflowAwakeableCancelled'.
-}
data WorkflowChildCancelled = WorkflowChildCancelled WorkflowName WorkflowId
    deriving stock (Eq, Show)

instance Exception WorkflowChildCancelled

{- | Thrown out of 'awaitChild' when the child was terminally failed by the
resume worker. Carries the child's identity plus the persisted failure reason.
-}
data WorkflowChildFailed = WorkflowChildFailed WorkflowName WorkflowId Text
    deriving stock (Eq, Show)

instance Exception WorkflowChildFailed

-- ---------------------------------------------------------------------------
-- Authoring surface
-- ---------------------------------------------------------------------------

{- | Spawn a child workflow. Records a @StepRecorded \"child:\<childId\>\"@ in
the /parent/ journal (so a replay short-circuits and never re-spawns) and
inserts an idempotent @running@ link row, then returns a 'ChildHandle'. The
child is driven to completion by the resume worker from the registry, so
@childNm@ __must be registered there__; @childDef@ is accepted for authoring
ergonomics but is not run here.

A child id names one execution globally. Spawning an id whose child row already
completed attaches to that execution: 'awaitChild' re-delivers the stored
result onto the parent's current generation. To run a fresh child after
'Keiro.Workflow.continueAsNew', derive a fresh child id from the carried seed.
-}
spawnChild ::
    (Workflow :> es, Store :> es) =>
    -- | The child's name (must be in the resume worker's registry).
    WorkflowName ->
    -- | The child's id (names the child journal).
    WorkflowId ->
    -- | The child workflow definition (for authoring; the registry actually runs it).
    Eff (Workflow : es) a ->
    Eff es (ChildHandle a)
spawnChild childNm childWid _childDef = do
    (parentNm, parentWid) <- currentWorkflow
    let spawnStep = StepName (childSpawnStepName childWid)
        resultStep = childResultStepName childWid
    -- Journaled as a step in the PARENT journal: a parent replay short-circuits
    -- this body, and the ON CONFLICT DO NOTHING register collapses on re-run.
    _ <-
        step spawnStep $ do
            runTransaction $
                registerChildTx
                    (unWorkflowId childWid)
                    (unWorkflowName childNm)
                    (unWorkflowId parentWid)
                    (unWorkflowName parentNm)
                    resultStep
                    *> upsertInstanceTx
                        (unWorkflowId childWid)
                        (unWorkflowName childNm)
                        0
                        WfRunning
                        Nothing
            pure ()
    pure (ChildHandle childNm childWid)

{- | Suspend the parent until the child completes, then return the child's
result. This is EP-38's 'awaitStep' on the @child:\<childId\>:result@ step:
'childCompletionHook' journals that step into the parent when the child
finishes, so the next parent run replays past the wait and decodes the result.

If the child was cancelled or failed, throws 'WorkflowChildCancelled' or
'WorkflowChildFailed'. Parent-journal values are tagged envelopes
(@{"ok": ...}@, @{"cancelled": true}@, @{"failed": reason}@) with a legacy raw
success fallback; a decode mismatch throws 'WorkflowStepDecodeError'.
-}
awaitChild ::
    (Workflow :> es, Store :> es, IOE :> es, FromJSON a) =>
    ChildHandle a ->
    Eff es a
awaitChild (ChildHandle childNm childWid) = do
    let resultStep = StepName (childResultStepName childWid)
        arm = do
            mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)
            case mrow of
                Just row
                    | (row ^. #status) == ChildCancelled ->
                        throwIO (WorkflowChildCancelled childNm childWid)
                    | (row ^. #status) == ChildCompleted
                    , Just resultValue <- row ^. #result ->
                        appendJournalEntry
                            (WorkflowName (row ^. #parentName))
                            (WorkflowId (row ^. #parentId))
                            StepRecorded
                                { stepName = row ^. #awaitStep
                                , result = childOkEnvelope resultValue
                                , recordedAt = fromMaybe (row ^. #updatedAt) (row ^. #completedAt)
                                }
                -- The spawn already registered the link; nothing more to (re-)arm.
                _ -> pure ()
    raw <- awaitStep resultStep arm
    decodeChildResult childNm childWid (unStepName resultStep) raw

-- ---------------------------------------------------------------------------
-- External control
-- ---------------------------------------------------------------------------

{- | Cancel a child. For a @running@ child, flips its
@keiro_workflow_children@ row to @cancelled@ and, in the same transaction,
writes both the child's 'WorkflowCancelled' marker and the parent's
@{"cancelled": true}@ await-step sentinel. Returns 'True' only when this call
performed the row transition. For an already-@cancelled@ row, returns 'False'
but still ensures both markers, repairing historical crashes that committed the
row flip before either journal append. Already completed/failed/unknown
children return 'False' and no marker is fabricated.
-}
cancelChild ::
    (IOE :> es, Store :> es) =>
    ChildHandle a ->
    Eff es Bool
cancelChild (ChildHandle childNm childWid) = do
    mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)
    case mrow of
        Nothing -> pure False
        Just row -> do
            (transitioned, childOutcome, parentOutcome) <- ensureChildCancelled row
            throwOnAppendConflict childOutcome
            throwOnAppendConflict parentOutcome
            pure transitioned

-- ---------------------------------------------------------------------------
-- Completion propagation
-- ---------------------------------------------------------------------------

{- | Drive a child workflow to completion, propagating its result to the parent.
This is 'runWorkflowWith' followed by 'childCompletionHook' on 'Completed': the
resume worker selects this (instead of bare 'runWorkflowWith') for any workflow
that is some parent's child, so a finished child wakes its waiting parent. A
'Suspended' child propagates nothing. A child row already marked 'ChildCancelled'
is repaired by ensuring the child cancellation marker and parent sentinel, then
returns 'Cancelled'; a 'ChildFailed' row returns 'Failed'.
-}
runChildWorkflow ::
    (IOE :> es, Store :> es, Error StoreError :> es, ToJSON a) =>
    WorkflowRunOptions ->
    WorkflowName ->
    WorkflowId ->
    Eff (Workflow : es) a ->
    Eff es (WorkflowOutcome a)
runChildWorkflow opts childNm childWid action = do
    mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)
    case fmap (^. #status) mrow of
        Just ChildCancelled -> do
            for_ mrow $ \row -> do
                (_, childOutcome, parentOutcome) <- ensureChildCancelled row
                throwOnAppendConflict childOutcome
                throwOnAppendConflict parentOutcome
            pure Cancelled
        Just ChildFailed -> pure Failed
        _ -> do
            outcome <- runWorkflowWith opts childNm childWid action
            case outcome of
                Completed result -> do
                    childCompletionHook childNm childWid (toJSON result)
                    pure (Completed result)
                other -> pure other

{- | Propagate a finished child's result to its parent: flip the child row to
@completed@ (storing the raw result in the child row) and append an
@{"ok": result}@ @child:\<childId\>:result@ 'StepRecorded' to the /parent/
journal — exactly the wake source the parent's 'awaitChild' resolves on. The
running-row transition and parent append happen in one transaction. An
already-@completed@ row re-appends from the stored result, repairing historical
wedges; cancelled/failed children do not fabricate a success. Normally invoked
via 'runChildWorkflow'.
-}
childCompletionHook ::
    (IOE :> es, Store :> es) =>
    WorkflowName ->
    WorkflowId ->
    Aeson.Value ->
    Eff es ()
childCompletionHook childNm childWid resultValue = do
    mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)
    for_ mrow $ \row -> case row ^. #status of
        Running -> do
            now <- liftIO getCurrentTime
            let parentName = WorkflowName (row ^. #parentName)
                parentId = WorkflowId (row ^. #parentId)
            gen <- currentGeneration parentName parentId
            appendTx <-
                prepareJournalAppend
                    parentName
                    parentId
                    gen
                    StepRecorded
                        { stepName = row ^. #awaitStep
                        , result = childOkEnvelope resultValue
                        , recordedAt = now
                        }
            appendOutcome <-
                runTransaction $ do
                    transitioned <- markChildResultTx (unWorkflowId childWid) (unWorkflowName childNm) resultValue now
                    if transitioned
                        then do
                            appendOutcome <- appendTx
                            condemnOnAppendConflict appendOutcome
                            pure appendOutcome
                        else pure (JournalAlreadyPresent resultValue)
            throwOnAppendConflict appendOutcome
        ChildCompleted ->
            for_ (row ^. #result) $ \stored ->
                appendJournalEntry
                    (WorkflowName (row ^. #parentName))
                    (WorkflowId (row ^. #parentId))
                    StepRecorded
                        { stepName = row ^. #awaitStep
                        , result = childOkEnvelope stored
                        , recordedAt = fromMaybe (row ^. #updatedAt) (row ^. #completedAt)
                        }
        ChildCancelled -> pure ()
        ChildFailed -> pure ()

condemnOnAppendConflict :: JournalAppendOutcome -> Tx.Transaction ()
condemnOnAppendConflict = \case
    JournalAppendConflict{} -> Tx.condemn
    _ -> pure ()

throwOnAppendConflict :: JournalAppendOutcome -> Eff es ()
throwOnAppendConflict = \case
    JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))
    _ -> pure ()

ensureChildCancelled ::
    (IOE :> es, Store :> es) =>
    ChildRow ->
    Eff es (Bool, JournalAppendOutcome, JournalAppendOutcome)
ensureChildCancelled row = do
    now <- liftIO getCurrentTime
    let childNm = WorkflowName (row ^. #childName)
        childWid = WorkflowId (row ^. #childId)
        parentNm = WorkflowName (row ^. #parentName)
        parentWid = WorkflowId (row ^. #parentId)
    childGen <- currentGeneration childNm childWid
    parentGen <- currentGeneration parentNm parentWid
    childAppendTx <- prepareJournalAppend childNm childWid childGen WorkflowCancelled{recordedAt = now}
    parentAppendTx <-
        prepareJournalAppend
            parentNm
            parentWid
            parentGen
            StepRecorded
                { stepName = row ^. #awaitStep
                , result = cancelledSentinel
                , recordedAt = now
                }
    runTransaction $ do
        transitioned <-
            if row ^. #status == Running
                then markChildCancelledTx (row ^. #childId) (row ^. #childName)
                else pure False
        if transitioned || row ^. #status == ChildCancelled
            then do
                childOutcome <- childAppendTx
                parentOutcome <- parentAppendTx
                condemnOnAppendConflict childOutcome
                condemnOnAppendConflict parentOutcome
                pure (transitioned, childOutcome, parentOutcome)
            else pure (False, JournalAlreadyPresent Aeson.Null, JournalAlreadyPresent Aeson.Null)

-- ---------------------------------------------------------------------------
-- The cancellation sentinel
-- ---------------------------------------------------------------------------

{- | The @{"cancelled": true}@ value 'cancelChild' writes as a cancelled child's
await-step result so 'awaitChild' can detect it and throw.
-}
cancelledSentinel :: Aeson.Value
cancelledSentinel = Aeson.object ["cancelled" Aeson..= True]

childOkEnvelope :: Aeson.Value -> Aeson.Value
childOkEnvelope value = Aeson.object ["ok" Aeson..= value]

decodeChildResult ::
    (FromJSON a) =>
    WorkflowName ->
    WorkflowId ->
    Text ->
    Aeson.Value ->
    Eff es a
decodeChildResult childNm childWid key raw =
    case raw of
        Aeson.Object obj
            | Just okValue <- KeyMap.lookup "ok" obj ->
                decodeOrThrow okValue
            | Just (Aeson.Bool True) <- KeyMap.lookup "cancelled" obj ->
                throwIO (WorkflowChildCancelled childNm childWid)
            | Just (Aeson.String reason) <- KeyMap.lookup "failed" obj ->
                throwIO (WorkflowChildFailed childNm childWid reason)
        _ -> decodeOrThrow raw
  where
    decodeOrThrow value =
        case Aeson.fromJSON value of
            Aeson.Success a -> pure a
            Aeson.Error e -> throwIO (WorkflowStepDecodeError key (Text.pack e))