packages feed

keiro-ops-0.12.0.0: src/Keiro/Ops/Timer.hs

module Keiro.Ops.Timer
  ( Command (..),
    DrainOptions (..),
    StuckListOptions (..),
    TimerFire,
    commandParser,
    commandParserWithDrain,
    isMutation,
    runCommand,
    runCommandWithFire,
  )
where

import Data.Aeson (Value, object, (.=))
import Data.Aeson qualified as Aeson
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (NominalDiffTime, UTCTime, getCurrentTime)
import Data.UUID (UUID)
import Data.UUID qualified as UUID
import Effectful (Eff, IOE)
import Effectful.Error.Static (Error)
import Keiro.Ops.Env (OpsEnv (..), OutputMode (..))
import Keiro.Ops.Parse (durationReader, nonNegativeIntReader, positiveIntReader)
import Keiro.Ops.Render
import Keiro.Timer
import Kiroku.Store.Effect (Store, runStoreIO)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Types (EventId (..))
import Options.Applicative hiding (action, value)
import Options.Applicative qualified as Optparse

data StuckListOptions = StuckListOptions
  { minAge :: !(Maybe NominalDiffTime),
    minAttempts :: !(Maybe Int)
  }
  deriving stock (Eq, Show)

data DrainOptions = DrainOptions
  { limit :: !Int
  }
  deriving stock (Eq, Show)

type TimerFire = TimerRow -> Eff '[Store, Error StoreError, IOE] (Maybe EventId)

data Command
  = StuckList !StuckListOptions
  | Requeue !TimerId
  | Cancel !TimerId
  | DeadLetter !TimerId !Text
  | DrainOnce !DrainOptions
  deriving stock (Eq, Show)

commandParser :: Parser Command
commandParser = commandParserWithDrain False

commandParserWithDrain :: Bool -> Parser Command
commandParserWithDrain includeDrain =
  hsubparser
    ( command
        "stuck"
        ( info
            stuckCommandParser
            (progDesc "Find timers left firing by an interrupted worker")
        )
        <> command
          "requeue"
          ( info
              (Requeue <$> timerIdArgument)
              (progDesc "Preview or return a stuck firing timer to the scheduled queue")
          )
        <> command
          "cancel"
          ( info
              (Cancel <$> timerIdArgument)
              (progDesc "Preview or withdraw a scheduled or firing timer permanently")
          )
        <> command
          "dead-letter"
          ( info
              ( DeadLetter
                  <$> timerIdArgument
                  <*> (Text.pack <$> strOption (long "reason" <> metavar "TEXT" <> help "Operator reason recorded with the terminal transition"))
              )
              (progDesc "Preview or abandon a scheduled or firing timer with a reason")
          )
        <> drainCommand
    )
  where
    drainCommand
      | includeDrain =
          command
            "drain-once"
            ( info
                (DrainOnce . DrainOptions <$> option positiveIntReader (long "limit" <> metavar "N" <> Optparse.value 100 <> showDefault <> help "Maximum due timers to dispatch"))
                (progDesc "Preview or run one bounded pass through the application timer-fire hook")
            )
      | otherwise = mempty

stuckCommandParser :: Parser Command
stuckCommandParser =
  hsubparser
    ( command
        "list"
        ( info
            ( StuckList
                <$> ( StuckListOptions
                        <$> optional (option durationReader (long "min-age" <> metavar "DURATION" <> help "Minimum time in firing, such as 5m or 1h"))
                        <*> optional (option nonNegativeIntReader (long "min-attempts" <> metavar "N" <> help "Minimum claim attempt count"))
                    )
            )
            (progDesc "List stuck timers; requeue transient failures, cancel obsolete work, or dead-letter poison work")
        )
    )

timerIdArgument :: Parser TimerId
timerIdArgument = TimerId <$> argument uuidReader (metavar "TIMER_ID")

uuidReader :: ReadM UUID
uuidReader = eitherReader $ \raw ->
  maybe (Left "expected a UUID timer id") Right (UUID.fromString raw)

isMutation :: Command -> Bool
isMutation = \case
  StuckList {} -> False
  Requeue {} -> True
  Cancel {} -> True
  DeadLetter {} -> True
  DrainOnce {} -> True

runCommand :: OpsEnv -> Command -> IO OpsOutcome
runCommand = runCommandWithFire Nothing

runCommandWithFire :: Maybe TimerFire -> OpsEnv -> Command -> IO OpsOutcome
runCommandWithFire timerFire env = \case
  StuckList options -> runStuckList env options
  Requeue timerId -> runMutation env RequeueOperation timerId Nothing
  Cancel timerId -> runMutation env CancelOperation timerId Nothing
  DeadLetter timerId reason -> runMutation env DeadLetterOperation timerId (Just reason)
  DrainOnce options -> runDrainOnce timerFire env options

runDrainOnce :: Maybe TimerFire -> OpsEnv -> DrainOptions -> IO OpsOutcome
runDrainOnce Nothing _ _ = pure (Failed "timer fire hook is not mounted")
runDrainOnce (Just fire) env options = do
  now <- getCurrentTime
  if env.force
    then
      runAction
        env
        (drainDueTimersWith Nothing defaultTimerWorkerOptions now options.limit fire)
        (Succeeded . drainResult options.limit)
    else runAction env (countDueTimers now) $ \due ->
      PreviewRequired
        (drainPreviewResult options.limit due)
        (forceInvocation env ["timer", "drain-once", "--limit", Text.pack (show options.limit)])

drainPreviewResult :: Int -> Int -> OpsResult
drainPreviewResult limit due =
  OpsResult
    { headers = ["due", "limit", "would_process"],
      rows = [[showText due, showText limit, showText (min (toInteger due) (toInteger limit))]],
      jsonValue =
        object
          [ "preview" .= True,
            "due" .= due,
            "limit" .= limit,
            "would_process" .= min (toInteger due) (toInteger limit)
          ]
    }

drainResult :: Int -> Int -> OpsResult
drainResult limit processed =
  OpsResult
    { headers = ["limit", "processed"],
      rows = [[showText limit, showText processed]],
      jsonValue = object ["limit" .= limit, "processed" .= processed]
    }

showText :: (Show a) => a -> Text
showText = Text.pack . show

runStuckList :: OpsEnv -> StuckListOptions -> IO OpsOutcome
runStuckList env options = do
  now <- getCurrentTime
  runAction env (findStuckTimers now stuckFilter) (Succeeded . timerListResult)
  where
    stuckFilter = StuckTimerFilter options.minAge options.minAttempts

data TimerOperation
  = RequeueOperation
  | CancelOperation
  | DeadLetterOperation

runMutation :: OpsEnv -> TimerOperation -> TimerId -> Maybe Text -> IO OpsOutcome
runMutation env operation timerId reason
  | not env.force =
      runAction env (lookupTimer timerId) $ \row ->
        PreviewRequired
          (timerPreviewResult operation timerId row)
          (forceInvocation env (operationArguments operation timerId reason))
  | otherwise =
      runAction env action $ \(transitioned, row) ->
        Succeeded (timerMutationResult operation timerId transitioned row)
  where
    action = do
      transitioned <- case operation of
        RequeueOperation -> requeueStuckTimer timerId
        CancelOperation -> cancelTimer timerId
        DeadLetterOperation -> deadLetterTimer timerId (maybe "operator dead-letter" id reason)
      row <- lookupTimer timerId
      pure (transitioned, row)

runAction ::
  OpsEnv ->
  Eff '[Store, Error StoreError, IOE] a ->
  (a -> OpsOutcome) ->
  IO OpsOutcome
runAction env action onSuccess = do
  result <- runStoreIO env.store action
  pure $ case result of
    Left storeError -> Failed (Text.pack (show storeError))
    Right value -> onSuccess value

timerListResult :: [TimerRow] -> OpsResult
timerListResult timers =
  OpsResult
    { headers = ["id", "manager", "correlation", "fire_at", "status", "attempts", "payload"],
      rows = map timerRow timers,
      jsonValue = Aeson.toJSON (map timerJson timers)
    }

timerRow :: TimerRow -> [Text]
timerRow row =
  [ timerIdText row.timerId,
    row.processManagerName,
    row.correlationId,
    timeText row.fireAt,
    timerStatusText row.status,
    Text.pack (show row.attempts),
    truncateCell 120 (jsonText row.payload)
  ]

timerJson :: TimerRow -> Value
timerJson row =
  object
    [ "timer_id" .= timerIdText row.timerId,
      "process_manager_name" .= row.processManagerName,
      "correlation_id" .= row.correlationId,
      "fire_at" .= row.fireAt,
      "payload" .= row.payload,
      "status" .= timerStatusText row.status,
      "attempts" .= row.attempts,
      "fired_event_id" .= fmap (\(EventId eventId) -> UUID.toText eventId) row.firedEventId
    ]

timerPreviewResult :: TimerOperation -> TimerId -> Maybe TimerRow -> OpsResult
timerPreviewResult operation timerId row =
  OpsResult
    { headers = ["operation", "id", "disposition", "status"],
      rows = [[operationText operation, timerIdText timerId, disposition, maybe "not_found" (timerStatusText . (.status)) row]],
      jsonValue =
        object
          [ "preview" .= True,
            "operation" .= operationText operation,
            "disposition" .= disposition,
            "timer" .= fmap timerJson row
          ]
    }
  where
    disposition = timerDisposition operation row

timerMutationResult :: TimerOperation -> TimerId -> Bool -> Maybe TimerRow -> OpsResult
timerMutationResult operation timerId transitioned row =
  OpsResult
    { headers = ["operation", "id", "outcome", "status"],
      rows = [[operationText operation, timerIdText timerId, outcome, maybe "not_found" (timerStatusText . (.status)) row]],
      jsonValue =
        object
          [ "operation" .= operationText operation,
            "outcome" .= outcome,
            "transitioned" .= transitioned,
            "timer" .= fmap timerJson row
          ]
    }
  where
    outcome
      | transitioned = "transitioned"
      | otherwise = "not_transitioned"

timerDisposition :: TimerOperation -> Maybe TimerRow -> Text
timerDisposition _ Nothing = "not_found"
timerDisposition operation (Just row) = case operation of
  RequeueOperation
    | row.status == Firing -> "would_requeue"
    | otherwise -> "not_firing"
  CancelOperation
    | row.status `elem` [Scheduled, Firing] -> "would_cancel"
    | otherwise -> "already_terminal"
  DeadLetterOperation
    | row.status `elem` [Scheduled, Firing] -> "would_dead_letter"
    | otherwise -> "already_terminal"

operationArguments :: TimerOperation -> TimerId -> Maybe Text -> [Text]
operationArguments operation timerId reason =
  ["timer", operationText operation, timerIdText timerId]
    <> case operation of
      DeadLetterOperation -> ["--reason", maybe "operator dead-letter" id reason]
      _ -> []

operationText :: TimerOperation -> Text
operationText = \case
  RequeueOperation -> "requeue"
  CancelOperation -> "cancel"
  DeadLetterOperation -> "dead-letter"

timerStatusText :: TimerStatus -> Text
timerStatusText = \case
  Scheduled -> "scheduled"
  Firing -> "firing"
  Fired -> "fired"
  Cancelled -> "cancelled"
  Dead -> "dead"

timerIdText :: TimerId -> Text
timerIdText (TimerId timerId) = UUID.toText timerId

timeText :: UTCTime -> Text
timeText = Text.pack . show

forceInvocation :: OpsEnv -> [Text] -> Text
forceInvocation env arguments =
  Text.unwords (map shellQuote ("keiro-ops" : arguments <> globalFlags <> ["--force"]))
  where
    globalFlags =
      ["--json" | env.outputMode == Json]
        <> ["--allow-schema-drift" | env.allowSchemaDrift]

shellQuote :: Text -> Text
shellQuote value = "'" <> Text.replace "'" "'\"'\"'" value <> "'"