packages feed

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

{- | Optional garbage collection for terminal workflow instances.

The hot-path schema modules keep lifecycle writes and lookup statements. This
module owns only cleanup statements used by an operator-scheduled GC pass.
Eligibility is based on the derived @keiro_workflows@ row: terminal instances
older than the retention cutoff are deleted, except completed children whose
parent is still non-terminal and may still attach to their result.
-}
module Keiro.Workflow.Gc (
    WorkflowGcPolicy (..),
    WorkflowGcSummary (..),
    gcWorkflowsOnce,
    runWorkflowGcWorker,
)
where

import Contravariant.Extras (contrazip2, contrazip3, contrazip4)
import Control.Concurrent (threadDelay)
import Control.Monad (forever)
import Data.Int (Int32)
import Data.Time (NominalDiffTime, addUTCTime)
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.Workflow.Schema (currentGeneration)
import Keiro.Workflow.Types (WorkflowId (..), WorkflowName (..), workflowGenerationStreamName)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Lifecycle (hardDeleteStream)
import Kiroku.Store.Read (lookupStreamId)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (StreamId (..))
import "hasql-transaction" Hasql.Transaction qualified as Tx

data WorkflowGcPolicy = WorkflowGcPolicy
    { retention :: !NominalDiffTime
    , batchSize :: !Int
    }
    deriving stock (Generic, Eq, Show)

data WorkflowGcSummary = WorkflowGcSummary
    { scanned :: !Int
    , deleted :: !Int
    }
    deriving stock (Generic, Eq, Show)

gcWorkflowsOnce :: (Store :> es) => UTCTime -> WorkflowGcPolicy -> Eff es WorkflowGcSummary
gcWorkflowsOnce now policy = do
    let cutoff = addUTCTime (negate (policy ^. #retention)) now
        limit = max 0 (policy ^. #batchSize)
    eligible <- runTransaction (Tx.statement (cutoff, fromIntegral limit :: Int32) eligibleWorkflowsStmt)
    deletedCount <- length <$> traverse deleteWorkflow eligible
    pure WorkflowGcSummary{scanned = length eligible, deleted = deletedCount}

runWorkflowGcWorker :: (IOE :> es, Store :> es) => WorkflowGcPolicy -> Int -> Eff es ()
runWorkflowGcWorker policy pollMicros =
    forever $ do
        now <- liftIO getCurrentTime
        void (gcWorkflowsOnce now policy)
        liftIO (threadDelay pollMicros)

deleteWorkflow :: (Store :> es) => (Text, Text) -> Eff es ()
deleteWorkflow (widText, nameText) = do
    let name = WorkflowName nameText
        wid = WorkflowId widText
    gen <- currentGeneration name wid
    for_ [0 .. gen] $ \generation -> do
        let streamName = workflowGenerationStreamName name wid generation
        mStreamId <- lookupStreamId streamName
        for_ mStreamId $ \(StreamId sid) ->
            runTransaction (Tx.statement sid deleteSnapshotStmt)
        void (hardDeleteStream streamName)
    runTransaction $ do
        Tx.statement (widText, nameText) deleteStepsStmt
        Tx.statement (nameText, widText) deleteAwakeablesStmt
        Tx.statement (widText, nameText, widText, nameText) deleteChildrenStmt
        -- Keep this literal in sync with Keiro.Workflow.Sleep.workflowSleepKind.
        Tx.statement (widText, nameText, workflowSleepKindLiteral) deleteTerminalSleepTimersStmt
        Tx.statement (widText, nameText) deleteWorkflowStmt

workflowSleepKindLiteral :: Text
workflowSleepKindLiteral = "keiro.workflow.sleep"

eligibleWorkflowsStmt :: Statement (UTCTime, Int32) [(Text, Text)]
eligibleWorkflowsStmt =
    preparable
        """
        SELECT w.workflow_id, w.workflow_name
        FROM keiro.keiro_workflows w
        WHERE w.status IN ('completed', 'cancelled', 'failed')
          AND w.completed_at IS NOT NULL
          AND w.completed_at <= $1
          AND NOT EXISTS (
            SELECT 1
            FROM keiro.keiro_workflow_children c
            JOIN keiro.keiro_workflows p
              ON p.workflow_id = c.parent_id
             AND p.workflow_name = c.parent_name
            WHERE c.child_id = w.workflow_id
              AND c.child_name = w.workflow_name
              AND p.status NOT IN ('completed', 'cancelled', 'failed')
          )
        ORDER BY w.completed_at
        LIMIT $2
        """
        ( contrazip2
            (E.param (E.nonNullable E.timestamptz))
            (E.param (E.nonNullable E.int4))
        )
        (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))

deleteSnapshotStmt :: Statement Int64 ()
deleteSnapshotStmt =
    preparable
        """
        DELETE FROM keiro.keiro_snapshots
        WHERE stream_id = $1
        """
        (E.param (E.nonNullable E.int8))
        D.noResult

deleteStepsStmt :: Statement (Text, Text) ()
deleteStepsStmt =
    preparable
        """
        DELETE FROM keiro.keiro_workflow_steps
        WHERE workflow_id = $1 AND workflow_name = $2
        """
        ( contrazip2
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
        )
        D.noResult

deleteAwakeablesStmt :: Statement (Text, Text) ()
deleteAwakeablesStmt =
    preparable
        """
        DELETE FROM keiro.keiro_awakeables
        WHERE owner_workflow_name = $1 AND owner_workflow_id = $2
        """
        ( contrazip2
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
        )
        D.noResult

deleteChildrenStmt :: Statement (Text, Text, Text, Text) ()
deleteChildrenStmt =
    preparable
        """
        DELETE FROM keiro.keiro_workflow_children
        WHERE (parent_id = $1 AND parent_name = $2)
           OR (child_id = $3 AND child_name = $4)
        """
        ( contrazip4
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
        )
        D.noResult

deleteTerminalSleepTimersStmt :: Statement (Text, Text, Text) ()
deleteTerminalSleepTimersStmt =
    preparable
        """
        DELETE FROM keiro.keiro_timers
        WHERE correlation_id = $1
          AND process_manager_name = $2
          AND payload->>'kind' = $3
          AND status IN ('fired', 'cancelled', 'dead')
        """
        ( contrazip3
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
        )
        D.noResult

deleteWorkflowStmt :: Statement (Text, Text) ()
deleteWorkflowStmt =
    preparable
        """
        DELETE FROM keiro.keiro_workflows
        WHERE workflow_id = $1 AND workflow_name = $2
        """
        ( contrazip2
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
        )
        D.noResult