packages feed

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

-- | Operational adapters for the Keiro outbox and dispatch dead letters.
--
-- Mutations and previews use only the public owning-library operations required
-- by ADR 28; this module never reaches into either schema directly.
module Keiro.Ops.Outbox
  ( Command (..),
    ListOptions (..),
    commandParser,
    isMutation,
    runCommand,
  )
where

import Data.Aeson (Value, object, (.=))
import Data.Aeson qualified as Aeson
import Data.Int (Int64)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Data.Text.Encoding.Error qualified as Text.Error
import Data.Time (NominalDiffTime, UTCTime, getCurrentTime)
import Data.UUID qualified as UUID
import Effectful (Eff, IOE)
import Effectful.Error.Static (Error)
import Keiro.DeadLetter
import Keiro.Integration.Event (IntegrationEvent (..))
import Keiro.Ops.Env (OpsEnv (..), OutputMode (..))
import Keiro.Ops.Parse (durationReader, positiveIntReader)
import Keiro.Ops.Render
import Keiro.Outbox
import Kiroku.Store.Effect (Store, runStoreIO)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Types (EventId (..), GlobalPosition (..), StreamName (..))
import Options.Applicative hiding (action, value)
import Options.Applicative qualified as Opt

data ListOptions = ListOptions
  { source :: !Text,
    status :: !(Maybe OutboxStatus),
    destination :: !(Maybe Text),
    limit :: !Int
  }
  deriving stock (Eq, Show)

data Command
  = Backlog
  | List !ListOptions
  | Show !OutboxId
  | RequeueStuck !NominalDiffTime !Int
  | GcSent !NominalDiffTime
  | MaintenancePass
  | DispatchDeadLetters !Text !Int
  deriving stock (Eq, Show)

commandParser :: Parser Command
commandParser =
  hsubparser
    ( command "backlog" (info (pure Backlog) (progDesc "Count claimable outbox rows"))
        <> command "list" (info listParser (progDesc "List outbox rows for a source"))
        <> command "show" (info (Show <$> outboxIdArgument) (progDesc "Inspect one outbox row"))
        <> command "requeue-stuck" (info requeueParser (progDesc "Preview or reclaim stale publishing rows"))
        <> command "gc-sent" (info gcParser (progDesc "Preview or delete retained sent rows"))
        <> command "maintenance-pass" (info (pure MaintenancePass) (progDesc "Preview or run one default outbox maintenance pass"))
        <> command
          "dead-letters"
          (info (hsubparser (command "list" (info deadLettersParser (progDesc "List rejected process-manager or router dispatches")))) (progDesc "Inspect rejected process-manager or router dispatches"))
    )
  where
    listParser =
      List
        <$> ( ListOptions
                <$> textOption "source" "SOURCE" "Producing bounded-context source"
                <*> optional (option statusReader (long "status" <> metavar "STATUS" <> help "pending, publishing, sent, failed, or dead"))
                <*> optional (textOption "destination" "DESTINATION" "Destination filter")
                <*> option positiveIntReader (long "limit" <> metavar "N" <> Opt.value 100 <> showDefault <> help "Maximum rows")
            )
    requeueParser =
      RequeueStuck
        <$> option durationReader (long "older-than" <> metavar "DURATION" <> Opt.value 300 <> showDefaultWith (const "5m") <> help "Minimum publishing age")
        <*> option positiveIntReader (long "max-attempts" <> metavar "N" <> Opt.value 10 <> showDefault <> help "Attempt ceiling; exhausted rows become dead")
    gcParser =
      GcSent
        <$> option durationReader (long "older-than" <> metavar "DURATION" <> Opt.value 2592000 <> showDefaultWith (const "30d") <> help "Sent-row retention age")
    deadLettersParser =
      DispatchDeadLetters
        <$> textOption "dispatcher" "NAME" "Process-manager or router dispatcher name"
        <*> option positiveIntReader (long "limit" <> metavar "N" <> Opt.value 100 <> showDefault <> help "Maximum rows")

textOption :: String -> String -> String -> Parser Text
textOption name metavarText helpText =
  Text.pack <$> strOption (long name <> metavar metavarText <> help helpText)

outboxIdArgument :: Parser OutboxId
outboxIdArgument =
  OutboxId <$> argument uuidReader (metavar "OUTBOX_ID")

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

statusReader :: ReadM OutboxStatus
statusReader = eitherReader (firstText . parseStatus . Text.pack)
  where
    firstText = either (Left . Text.unpack) Right

isMutation :: Command -> Bool
isMutation = \case
  Backlog -> False
  List {} -> False
  Show {} -> False
  DispatchDeadLetters {} -> False
  RequeueStuck {} -> True
  GcSent {} -> True
  MaintenancePass -> True

runCommand :: OpsEnv -> Command -> IO OpsOutcome
runCommand env = \case
  Backlog -> runAction env countOutboxBacklog (Succeeded . countResult "outbox_backlog")
  List options -> runAction env (listOutbox options.source) (Succeeded . outboxListResult . applyListOptions options)
  Show outboxId -> runAction env (lookupOutbox outboxId) (Succeeded . maybe emptyResult (outboxListResult . pure))
  RequeueStuck olderThan maxAttempts -> runRequeue env olderThan maxAttempts
  GcSent olderThan -> runGc env olderThan
  MaintenancePass -> runMaintenance env
  DispatchDeadLetters dispatcher limit ->
    runAction env (listDispatchDeadLetters dispatcher) (Succeeded . dispatchListResult . take limit)

applyListOptions :: ListOptions -> [OutboxRow] -> [OutboxRow]
applyListOptions options =
  take options.limit
    . filter (maybe (const True) (\expected row -> row.status == expected) options.status)
    . filter (maybe (const True) (\expected row -> row.event.destination == expected) options.destination)

runRequeue :: OpsEnv -> NominalDiffTime -> Int -> IO OpsOutcome
runRequeue env olderThan maxAttempts = do
  now <- getCurrentTime
  if env.force
    then runAction env (requeueStuckOutbox maxAttempts olderThan now) $ \(requeued, deadLettered) ->
      Succeeded
        OpsResult
          { headers = ["requeued", "dead_lettered"],
            rows = [[showText requeued, showText deadLettered]],
            jsonValue = object ["requeued" .= requeued, "dead_lettered" .= deadLettered]
          }
    else runAction env (listStuckOutbox olderThan now) $ \rows ->
      PreviewRequired
        (outboxPreviewResult maxAttempts rows)
        (forceInvocation env ["outbox", "requeue-stuck", "--older-than", durationText olderThan, "--max-attempts", showText maxAttempts])

runGc :: OpsEnv -> NominalDiffTime -> IO OpsOutcome
runGc env olderThan = do
  now <- getCurrentTime
  if env.force
    then runAction env (garbageCollectSent olderThan now) (Succeeded . countResult "deleted")
    else runAction env (listSentOutboxGcCandidates olderThan now) $ \rows ->
      PreviewRequired
        (outboxListResult rows)
        (forceInvocation env ["outbox", "gc-sent", "--older-than", durationText olderThan])

runMaintenance :: OpsEnv -> IO OpsOutcome
runMaintenance env = do
  now <- getCurrentTime
  let options = defaultMaintenanceOptions
  if env.force
    then runAction env (outboxMaintenancePass options Nothing) $ \summary ->
      Succeeded
        OpsResult
          { headers = ["requeued", "dead_lettered", "backlog"],
            rows = [[showText summary.requeued, showText summary.deadLettered, showText summary.backlog]],
            jsonValue = object ["requeued" .= summary.requeued, "dead_lettered" .= summary.deadLettered, "backlog" .= summary.backlog]
          }
    else runAction env (listStuckOutbox options.publishingTimeout now) $ \rows ->
      PreviewRequired
        (outboxPreviewResult options.maxAttempts rows)
        (forceInvocation env ["outbox", "maintenance-pass"])

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

countResult :: Text -> Int -> OpsResult
countResult label count =
  OpsResult [label] [[showText count]] (object ["count" .= count, "metric" .= label])

outboxListResult :: [OutboxRow] -> OpsResult
outboxListResult outboxRows =
  OpsResult
    { headers = ["id", "source", "destination", "status", "attempts", "created_at", "last_error"],
      rows = map outboxRow outboxRows,
      jsonValue = Aeson.toJSON (map outboxJson outboxRows)
    }

outboxRow :: OutboxRow -> [Text]
outboxRow row =
  [ outboxIdText row.outboxId,
    row.event.source,
    row.event.destination,
    statusText row.status,
    showText row.attemptCount,
    timeText row.createdAt,
    maybe "" (truncateCell 120) row.lastError
  ]

outboxJson :: OutboxRow -> Value
outboxJson row =
  object
    [ "outbox_id" .= outboxIdText row.outboxId,
      "message_id" .= row.event.messageId,
      "source" .= row.event.source,
      "destination" .= row.event.destination,
      "key" .= row.event.key,
      "event_type" .= row.event.eventType,
      "schema_version" .= row.event.schemaVersion,
      "source_event_id" .= fmap eventIdText row.event.sourceEventId,
      "source_global_position" .= fmap globalPositionInt row.event.sourceGlobalPosition,
      "payload" .= payloadValue row.event,
      "status" .= statusText row.status,
      "attempt_count" .= row.attemptCount,
      "next_attempt_at" .= row.nextAttemptAt,
      "last_error" .= row.lastError,
      "published_at" .= row.publishedAt,
      "created_at" .= row.createdAt,
      "updated_at" .= row.updatedAt
    ]

payloadValue :: IntegrationEvent -> Value
payloadValue event =
  either
    (const (Aeson.String (Text.Encoding.decodeUtf8With Text.Error.lenientDecode event.payloadBytes)))
    id
    (Aeson.eitherDecodeStrict' event.payloadBytes)

outboxPreviewResult :: Int -> [OutboxRow] -> OpsResult
outboxPreviewResult maxAttempts outboxRows =
  OpsResult
    { headers = ["id", "current_status", "disposition", "attempts"],
      rows =
        [ [outboxIdText row.outboxId, statusText row.status, disposition row, showText row.attemptCount]
        | row <- outboxRows
        ],
      jsonValue =
        Aeson.toJSON
          [ object ["outbox" .= outboxJson row, "disposition" .= disposition row]
          | row <- outboxRows
          ]
    }
  where
    disposition row
      | row.attemptCount >= maxAttempts = "would_dead_letter"
      | otherwise = "would_requeue"

dispatchListResult :: [DispatchDeadLetterRecord] -> OpsResult
dispatchListResult records =
  OpsResult
    { headers = ["id", "kind", "dispatcher", "correlation", "target", "error", "attempts", "created_at"],
      rows = map dispatchRow records,
      jsonValue = Aeson.toJSON (map dispatchJson records)
    }

dispatchRow :: DispatchDeadLetterRecord -> [Text]
dispatchRow row =
  [ showText row.deadLetterId,
    dispatcherKindText row.dispatcherKind,
    row.dispatcherName,
    row.correlationId,
    streamNameText row.targetStreamName,
    row.errorClass <> ": " <> truncateCell 100 row.errorDetail,
    showText row.attemptCount,
    timeText row.createdAt
  ]

dispatchJson :: DispatchDeadLetterRecord -> Value
dispatchJson row =
  object
    [ "dead_letter_id" .= row.deadLetterId,
      "dispatcher_kind" .= dispatcherKindText row.dispatcherKind,
      "dispatcher_name" .= row.dispatcherName,
      "correlation_id" .= row.correlationId,
      "source_event_id" .= eventIdText row.sourceEventId,
      "source_global_position" .= globalPositionInt row.sourceGlobalPosition,
      "emit_index" .= row.emitIndex,
      "target_stream_name" .= streamNameText row.targetStreamName,
      "error_class" .= row.errorClass,
      "error_detail" .= row.errorDetail,
      "attempt_count" .= row.attemptCount,
      "created_at" .= row.createdAt
    ]

dispatcherKindText :: DispatcherKind -> Text
dispatcherKindText = \case
  DispatcherProcessManager -> "process_manager"
  DispatcherRouter -> "router"

outboxIdText :: OutboxId -> Text
outboxIdText (OutboxId value) = UUID.toText value

eventIdText :: EventId -> Text
eventIdText (EventId value) = UUID.toText value

globalPositionInt :: GlobalPosition -> Int64
globalPositionInt (GlobalPosition value) = value

streamNameText :: StreamName -> Text
streamNameText (StreamName value) = value

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

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

durationText :: NominalDiffTime -> Text
durationText = showText . (realToFrac :: NominalDiffTime -> Double)

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 <> "'"