packages feed

keiro-0.6.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, throws 'WorkflowChildFailed' from the durable child
--   row if it failed, 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 (..), lookupInstance, 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) == ChildFailed -> do
                mInstance <- lookupInstance childNm childWid
                let reason =
                      case row ^. #failureReason of
                        Just recorded -> recorded
                        Nothing ->
                          fromMaybe
                            "child workflow failed (reason not recorded)"
                            (mInstance >>= (^. #lastError))
                throwIO (WorkflowChildFailed childNm childWid reason)
            | (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))