packages feed

keiro-ops-0.24.0.0: src/Keiro/Ops/Projection.hs

-- | Operational adapters for projection dedup retention.
--
-- Commands call the public Keiro read-model and projection APIs in accordance
-- with ADR 28.
module Keiro.Ops.Projection
  ( Command (..),
    ProjectionStatusOptions (..),
    ConsumersOptions (..),
    prepareConsumers,
    commandParserWithConsumers,
    runCommandWithConsumers,
    projectionGroupStatusJson,
    projectionGroupStatusRowJson,
    validateCommand,
    commandParser,
    isMutation,
    runCommand,
  )
where

import Data.Aeson (Value, object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Encoding qualified as Encoding
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (Pair)
import Data.Int (Int64)
import Data.List (groupBy)
import Data.Map.Lazy qualified as LazyMap
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (UTCTime, defaultTimeLocale, formatTime, parseTimeM)
import Data.Vector qualified as Vector
import Effectful (Eff, IOE)
import Effectful.Error.Static (Error)
import Keiro.Inspection.Consumers qualified as Consumers
import Keiro.Inspection.Cursor (InspectionCursor (..), decodeCursor, encodeCursorBounded)
import Keiro.Ops.Env (OpsEnv (..), OutputMode (..))
import Keiro.Ops.Parse (cursorReader, positiveIntReader)
import Keiro.Ops.Render
import Keiro.Projection (countAsyncProjectionDedupForBefore, pruneAsyncProjectionDedupForBefore)
import Keiro.Projection.Catalog (projectionIdText, projectionRevisionIdText, rebuildGroupIdText, subscriptionIdText)
import Keiro.Projection.Catalog.Operations qualified as Catalog
import Keiro.ReadModel (storeHeadPosition)
import Keiro.ReadModel.Rebuild
import Kiroku.Store.Effect (Store, runStoreIO)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Subscription
  ( SubscriptionCheckpoint (..),
    SubscriptionCheckpointInventory (..),
    SubscriptionName (..),
    subscriptionCheckpointInventory,
  )
import Kiroku.Store.Types (CategoryName (..), GlobalPosition (..))
import Options.Applicative hiding (action)
import Prelude

data ProjectionStatusOptions = ProjectionStatusOptions {limit :: !Int, after :: !(Maybe InspectionCursor)}
  deriving stock (Eq, Show)

data Command
  = Status !ProjectionStatusOptions
  | Consumers !ConsumersOptions
  | Position !Text
  | PruneDedup !Text !UTCTime
  deriving stock (Eq, Show)

commandParser :: Parser Command
commandParser = commandParserWithConsumers False

commandParserWithConsumers :: Bool -> Parser Command
commandParserWithConsumers mounted =
  hsubparser
    ( command "status" (info (Status <$> (ProjectionStatusOptions <$> option positiveIntReader (long "limit" <> value 100 <> metavar "N") <*> optional (option cursorReader (long "after" <> metavar "CURSOR")))) (progDesc "Observe one bounded page of projection-group status"))
        <> (if mounted then command "consumers" (info consumersParser (progDesc "Find declared consumers of one category in mounted configuration")) else mempty)
        <> command "position" (info positionParser (progDesc "Show one subscription's durable member checkpoints and floor"))
        <> command "prune-dedup" (info pruneParser (progDesc "Preview or prune one projection's old dedup rows"))
    )
  where
    consumersParser =
      Consumers
        <$> ( ConsumersOptions
                <$> textOption "category" "CATEGORY" "Exact stream category (case sensitive)"
                <*> option positiveIntReader (long "limit" <> value 100 <> metavar "N" <> help "Page size, 1..500")
                <*> optional (option cursorReader (long "after" <> metavar "CURSOR"))
            )
    positionParser = Position <$> textOption "subscription" "NAME" "Durable Kiroku subscription name"
    pruneParser =
      PruneDedup
        <$> textOption "projection" "NAME" "Async projection name"
        <*> option utcReader (long "before" <> metavar "UTC" <> help "Prune rows older than ISO-8601 UTC, for example 2026-08-01T00:00:00Z")

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

utcReader :: ReadM UTCTime
utcReader = eitherReader $ \raw ->
  maybe
    (Left "expected ISO-8601 UTC such as 2026-08-01T00:00:00Z")
    Right
    (parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%S%QZ" raw)

statusInput :: ProjectionStatusOptions -> ProjectionStatusPageRequest
statusInput options = ProjectionStatusPageRequest options.limit options.after

validateCommand :: Command -> Either (Text, Text) ()
validateCommand (Status options) = either (Left . statusError) (const (Right ())) (validateProjectionStatusRequest (statusInput options))
validateCommand (Consumers options) = validateConsumers options
validateCommand _ = Right ()

statusError :: ProjectionStatusReadError -> (Text, Text)
statusError err =
  ( case err of
      InvalidProjectionStatusPageSize _ -> "invalid_page_size"
      InvalidProjectionStatusCursor _ -> "invalid_cursor"
      ProjectionStatusResultTooLarge -> "inspection_result_too_large",
    Text.pack (show err)
  )

isMutation :: Command -> Bool
isMutation = \case
  Consumers {} -> False
  Status {} -> False
  Position {} -> False
  PruneDedup {} -> True

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

runCommandWithConsumers :: Maybe Catalog.ProjectionCatalogOperations -> Maybe Consumers.DurableConsumerRegistry -> OpsEnv -> Command -> IO OpsOutcome
runCommandWithConsumers catalog durable env = \case
  Consumers options -> pure $ case prepareConsumers catalog durable options of
    Left (code, detail) -> InspectionFailed code detail
    Right (result, _) -> Succeeded result
  Status options -> runAction env action $ \case
    Left err -> uncurry InspectionFailed (statusError err)
    Right (page, head) ->
      Succeeded
        ( OpsResult
            ["group_id", "lifecycle_phase", "reads_allowed", "serving_position_basis"]
            [[rebuildGroupIdText row.status.groupId, row.status.lifecyclePhase, Text.pack (show row.status.readsAllowed), basisText row.status.servingPositionBasis] | row <- page.items]
            ( object
                ( ["observed_at" .= page.observedAt, "visible_store_head" .= positionInt head, "items" .= map projectionGroupStatusRowJson page.items]
                    <> maybe [] (\cursor -> ["next_cursor" .= cursor.cursorText]) page.next
                )
            )
        )
    where
      action = do
        page <- listProjectionGroupStatusesPage (statusInput options)
        case page of
          Left err -> pure (Left err)
          Right value -> Right . (value,) <$> storeHeadPosition
  Position subscription -> runPosition env subscription
  PruneDedup projection before
    | env.force ->
        runAction env (pruneAsyncProjectionDedupForBefore projection before) $ \affected ->
          Succeeded (pruneResult False projection before affected)
    | otherwise ->
        runAction env (countAsyncProjectionDedupForBefore projection before) $ \affected ->
          PreviewRequired
            (pruneResult True projection before affected)
            (forceInvocation env ["projection", "prune-dedup", "--projection", projection, "--before", utcText before])

-- | The frozen v1 owner relation, using ADR-28 position vocabulary.
projectionGroupStatusJson :: ProjectionGroupStatusV1 -> Value
projectionGroupStatusJson = object . projectionGroupStatusPairs

-- | The bounded page item, including registered feeding subscriptions.
projectionGroupStatusRowJson :: ProjectionGroupStatusRow -> Value
projectionGroupStatusRowJson row =
  -- Prepending avoids traversing the frozen row's pair list.
  object (("feeding_subscriptions" .= row.feedingSubscriptions) : projectionGroupStatusPairs row.status)

projectionGroupStatusPairs :: ProjectionGroupStatusV1 -> [Pair]
projectionGroupStatusPairs row =
  [ "group_id" .= rebuildGroupIdText row.groupId,
    "lifecycle_phase" .= row.lifecyclePhase,
    "reads_allowed" .= row.readsAllowed,
    "writes_allowed" .= row.writesAllowed,
    "serving_revision_id" .= fmap projectionRevisionIdText row.servingRevisionId,
    "serving_epoch" .= row.servingEpoch,
    "serving_position_basis" .= basisText row.servingPositionBasis,
    "serving_applied_position" .= fmap positionInt row.servingAppliedPosition,
    "active_run_id" .= fmap rebuildRunIdText row.activeRunId,
    "candidate_revision_id" .= fmap projectionRevisionIdText row.candidateRevisionId,
    "candidate_rebuild_position" .= fmap positionInt row.candidateRebuildPosition,
    "candidate_rebuild_head" .= fmap positionInt row.candidateRebuildHead,
    "query_models" .= row.queryModels,
    "rebuild_started_at" .= row.rebuildStartedAt,
    "last_promoted_at" .= row.lastPromotedAt,
    "failed_at" .= row.failedAt,
    "failure_code" .= row.failureCode,
    "failure_detail" .= row.failureDetail
  ]

basisText :: ServingPositionBasis -> Text
basisText = \case ServingPositionAppend -> "append"; ServingPositionCheckpoint -> "checkpoint"; ServingPositionUnmanaged -> "unmanaged"

runPosition :: OpsEnv -> Text -> IO OpsOutcome
runPosition env subscription =
  runAction env action $ \(visibleHead, inventory) ->
    Succeeded (positionResult subscription visibleHead inventory)
  where
    action = (,) <$> storeHeadPosition <*> subscriptionCheckpointInventory

positionResult :: Text -> GlobalPosition -> SubscriptionCheckpointInventory -> OpsResult
positionResult requested visibleHead inventory =
  OpsResult
    { headers = ["subscription", "member", "checkpoint_position", "checkpoint_updated_at", "store_position", "visible_store_head", "global_position_distance", "minimum_checkpoint_position", "maximum_global_position_distance"],
      rows = humanRows,
      jsonValue =
        object
          [ "subscription" .= requested,
            "store_position" .= positionInt captured,
            "visible_store_head" .= positionInt visibleHead,
            "members" .= map (memberJson visibleHead) members,
            "minimum_checkpoint_position" .= fmap positionInt minimumCheckpoint,
            "maximum_global_position_distance" .= maximumDistance
          ]
    }
  where
    captured = storePosition inventory
    members =
      [ checkpoint
      | checkpoint@(SubscriptionCheckpoint (SubscriptionName name) _member _position _updatedAt) <-
          Vector.toList (checkpoints inventory),
        name == requested
      ]
    minimumCheckpoint = minimumMay [position | SubscriptionCheckpoint _ _ position _ <- members]
    maximumDistance = globalPositionDistance visibleHead <$> minimumCheckpoint
    summaryCells =
      [ maybe "" positionText minimumCheckpoint,
        maybe "" showText maximumDistance
      ]
    humanRows = case members of
      [] -> [[requested, "", "", "", positionText captured, positionText visibleHead, ""] <> summaryCells]
      _ -> map (memberRow captured visibleHead summaryCells) members

memberRow :: GlobalPosition -> GlobalPosition -> [Text] -> SubscriptionCheckpoint -> [Text]
memberRow captured visibleHead summaryCells (SubscriptionCheckpoint (SubscriptionName name) member position updatedAt) =
  [ name,
    showText member,
    positionText position,
    utcText updatedAt,
    positionText captured,
    positionText visibleHead,
    showText (globalPositionDistance visibleHead position)
  ]
    <> summaryCells

memberJson :: GlobalPosition -> SubscriptionCheckpoint -> Value
memberJson visibleHead (SubscriptionCheckpoint (SubscriptionName name) member position updatedAt) =
  object
    [ "subscription" .= name,
      "member" .= member,
      "checkpoint_position" .= positionInt position,
      "checkpoint_updated_at" .= updatedAt,
      "global_position_distance" .= globalPositionDistance visibleHead position
    ]

minimumMay :: (Ord a) => [a] -> Maybe a
minimumMay [] = Nothing
minimumMay values = Just (Prelude.minimum values)

globalPositionDistance :: GlobalPosition -> GlobalPosition -> Int64
globalPositionDistance (GlobalPosition captured) (GlobalPosition checkpoint) = max 0 (captured - checkpoint)

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

positionText :: GlobalPosition -> Text
positionText = showText . positionInt

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

pruneResult :: Bool -> Text -> UTCTime -> Int64 -> OpsResult
pruneResult preview projection before affected =
  OpsResult
    { headers = ["projection", "before", if preview then "would_prune" else "pruned"],
      rows = [[projection, utcText before, showText affected]],
      jsonValue = object ["preview" .= preview, "projection" .= projection, "before" .= before, "affected" .= affected]
    }

utcText :: UTCTime -> Text
utcText = Text.pack . formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%S%QZ"

showText :: (Show a) => a -> Text
showText = 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 <> "'"

-- | One category per request; the host byte cap also bounds nested bindings.
data ConsumersOptions = ConsumersOptions
  { category :: !Text,
    limit :: !Int,
    after :: !(Maybe InspectionCursor)
  }
  deriving stock (Eq, Show)

consumersSchema :: Text
consumersSchema = "keiro/category-consumers/v1"

type ConsumerKey = (Text, Text)

type ConsumerInventory = (Maybe Text, Maybe Text)

type ConsumerCursor = (Text, ConsumerInventory, ConsumerKey)

validateConsumers :: ConsumersOptions -> Either (Text, Text) ()
validateConsumers options
  | Text.null options.category || Text.strip options.category /= options.category = Left ("invalid_category", "category must be nonempty and have no surrounding whitespace")
  | options.limit < 1 || options.limit > 500 = Left ("invalid_page_size", "page size must be 1..500")
  | otherwise = () <$ consumerCursor options

consumerCursor :: ConsumersOptions -> Either (Text, Text) (Maybe ConsumerCursor)
consumerCursor options = traverse decode options.after
  where
    decode cursor = case decodeCursor consumersSchema cursor of
      Right payload@(category, _, (kind, name))
        | category == options.category,
          kind `elem` ["durable_subscriber", "process_manager", "projection_group"],
          not (Text.null name) ->
            Right payload
      _ -> Left ("invalid_cursor", "cursor is malformed or belongs to another category/read")

data ConsumerItem = ConsumerItem !Text !Text !(Maybe Text) ![ConsumerBinding]

data ConsumerBinding = ConsumerBinding !(Maybe Text) !(Maybe Text) !(Maybe Text) !(Maybe Text) !Consumers.ConsumerSource

consumerKey :: ConsumerItem -> ConsumerKey
consumerKey (ConsumerItem kind name _ _) = (kind, name)

-- | One filtered page supplies both presentations. On HTTP the canonical items
-- array stays lazy, and every request encodes afresh inside the host's budget.
prepareConsumers :: Maybe Catalog.ProjectionCatalogOperations -> Maybe Consumers.DurableConsumerRegistry -> ConsumersOptions -> Either (Text, Text) (OpsResult, Encoding.Encoding)
prepareConsumers catalog durable options = do
  validateConsumers options
  if not (isJust catalog || isJust durable)
    then Left ("unsupported_command", "consumer metadata hooks are not mounted")
    else Right ()
  cursor <- consumerCursor options
  let inventory = (Catalog.projectionConsumerInventoryIdentity <$> catalog, Consumers.durableConsumerInventoryIdentity <$> durable)
  case cursor of
    Just (_, previous, _) | previous /= inventory -> Left ("invalid_cursor", "mounted consumer inventory changed; restart from the first page")
    _ -> Right ()
  let matching = maybe [] (durableItems options.category) durable <> maybe [] (projectionItems options.category) catalog
      remaining = case cursor of
        Nothing -> matching
        Just (_, _, lastKey) -> dropWhile ((<= lastKey) . consumerKey) matching
      window = take (options.limit + 1) remaining
      page = take options.limit window
  next <-
    if length window <= options.limit
      then Right Nothing
      else case encodeCursorBounded consumersSchema (options.category, inventory, consumerKey (last page)) of
        Nothing -> Left ("inspection_result_too_large", "consumer continuation exceeds cursor byte limit")
        Just token -> Right (Just token)
  let coverage = object ["projection_catalog" .= isJust catalog, "durable_subscribers" .= isJust durable]
      fields =
        [("schema", Aeson.String consumersSchema), ("category", Aeson.String options.category), ("scope", Aeson.String "mounted_configuration"), ("coverage", coverage), ("items", Aeson.toJSON page)]
          <> maybe [] (\token -> [("next_cursor", Aeson.String token.cursorText)]) next
      canonical = Aeson.Object (KeyMap.fromMap (LazyMap.fromList fields))
      encoding =
        Encoding.pairs
          ( Encoding.pair "schema" (Encoding.text consumersSchema)
              <> Encoding.pair "category" (Encoding.text options.category)
              <> Encoding.pair "scope" (Encoding.text "mounted_configuration")
              <> Encoding.pair "coverage" (Aeson.toEncoding coverage)
              <> Encoding.pair "items" (Aeson.toEncoding page)
              <> foldMap (Encoding.pair "next_cursor" . Encoding.text . (.cursorText)) next
          )
  pure (OpsResult ["kind", "name", "group_id", "subscription", "source"] (concatMap consumerRows page) canonical, encoding)

projectionItems :: Text -> Catalog.ProjectionCatalogOperations -> [ConsumerItem]
projectionItems category catalog =
  [ ConsumerItem
      "projection_group"
      (rebuildGroupIdText group.groupId)
      (Just (rebuildGroupIdText group.groupId))
      [ ConsumerBinding
          (Just (projectionIdText binding.projectionId))
          (Just binding.handlerName)
          (subscriptionIdText <$> binding.subscriptionId)
          binding.subscriptionName
          binding.source
      | binding <- group.bindings
      ]
  | group <- Catalog.projectionConsumers catalog (CategoryName category)
  ]

durableItems :: Text -> Consumers.DurableConsumerRegistry -> [ConsumerItem]
durableItems category registry =
  [ ConsumerItem
      (kindText first.kind)
      first.name
      Nothing
      [ConsumerBinding Nothing Nothing Nothing (Just description.subscriptionName) description.source | description <- descriptions]
  | descriptions@(first : _) <- groupBy sameConsumer (Consumers.durableConsumersForCategory registry (CategoryName category))
  ]
  where
    sameConsumer left right = (left.kind, left.name) == (right.kind, right.name)
    kindText Consumers.DurableSubscriber = "durable_subscriber"
    kindText Consumers.ProcessManagerConsumer = "process_manager"

instance Aeson.ToJSON ConsumerItem where
  toJSON (ConsumerItem kind name group bindings) = object ["kind" .= kind, "name" .= name, "group_id" .= group, "bindings" .= bindings]
  toEncoding (ConsumerItem kind name group bindings) =
    Encoding.pairs
      ( Encoding.pair "kind" (Encoding.text kind)
          <> Encoding.pair "name" (Encoding.text name)
          <> Encoding.pair "group_id" (Aeson.toEncoding group)
          <> Encoding.pair "bindings" (Aeson.toEncoding bindings)
      )

instance Aeson.ToJSON ConsumerBinding where
  toJSON (ConsumerBinding projection handler subscriptionId subscription source) =
    object
      [ "projection_id" .= projection,
        "handler_name" .= handler,
        "delivery" .= delivery subscription,
        "subscription_id" .= subscriptionId,
        "subscription_name" .= subscription,
        "source" .= sourceJson source
      ]
  toEncoding (ConsumerBinding projection handler subscriptionId subscription source) =
    Encoding.pairs
      ( Encoding.pair "projection_id" (Aeson.toEncoding projection)
          <> Encoding.pair "handler_name" (Aeson.toEncoding handler)
          <> Encoding.pair "delivery" (Encoding.text (delivery subscription))
          <> Encoding.pair "subscription_id" (Aeson.toEncoding subscriptionId)
          <> Encoding.pair "subscription_name" (Aeson.toEncoding subscription)
          <> Encoding.pair "source" (sourceEncoding source)
      )

delivery :: Maybe Text -> Text
delivery Nothing = "inline"
delivery (Just _) = "subscription"

sourceJson :: Consumers.ConsumerSource -> Value
sourceJson = \case
  Consumers.AllCategories -> object ["kind" .= ("all" :: Text)]
  Consumers.ExactCategory category -> object ["kind" .= ("category" :: Text), "category" .= category]
  Consumers.CategoryNamespace namespace -> object ["kind" .= ("namespace" :: Text), "namespace" .= namespace]

sourceEncoding :: Consumers.ConsumerSource -> Encoding.Encoding
sourceEncoding source = Encoding.pairs $ case source of
  Consumers.AllCategories -> Encoding.pair "kind" (Encoding.text "all")
  Consumers.ExactCategory category -> Encoding.pair "kind" (Encoding.text "category") <> Encoding.pair "category" (Encoding.text category)
  Consumers.CategoryNamespace namespace -> Encoding.pair "kind" (Encoding.text "namespace") <> Encoding.pair "namespace" (Encoding.text namespace)

consumerRows :: ConsumerItem -> [[Text]]
consumerRows (ConsumerItem kind name group bindings) =
  [[kind, name, maybe "" id group, maybe "" id subscription, sourceText source] | ConsumerBinding _ _ _ subscription source <- bindings]
  where
    sourceText Consumers.AllCategories = "all categories"
    sourceText (Consumers.ExactCategory category) = "category: " <> category
    sourceText (Consumers.CategoryNamespace namespace) = "namespace: " <> namespace