packages feed

keiro-ops-0.22.0.0: src/Keiro/Ops/Shard.hs

-- | Operational adapters for sharded-subscription ownership.
--
-- Ownership reads and releases use 'Keiro.Subscription.Shard' exclusively, as
-- required by ADR 28.
module Keiro.Ops.Shard
  ( Command (..),
    commandParser,
    isMutation,
    runCommand,
  )
where

import Data.Aeson (Value, object, (.=))
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (UTCTime)
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.Render
import Keiro.Subscription.Shard
import Kiroku.Store.Effect (Store, runStoreIO)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Subscription.Types (SubscriptionName (..))
import Options.Applicative hiding (action, value)

data Command
  = List
  | Status !Text
  | Relinquish !Text !WorkerId
  deriving stock (Eq, Show)

commandParser :: Parser Command
commandParser =
  hsubparser
    ( command "list" (info (pure List) (progDesc "List sharded subscriptions and live ownership counts"))
        <> command "status" (info statusParser (progDesc "Show shard counts and ownership"))
        <> command "relinquish" (info relinquishParser (progDesc "Preview or release every bucket owned by one worker"))
    )
  where
    statusParser = Status <$> subscriptionOption
    relinquishParser = Relinquish <$> subscriptionOption <*> (WorkerId <$> option uuidReader (long "worker" <> metavar "UUID" <> help "Worker id to release"))

subscriptionOption :: Parser Text
subscriptionOption = Text.pack <$> strOption (long "subscription" <> metavar "NAME" <> help "Sharded subscription name")

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

isMutation :: Command -> Bool
isMutation = \case
  List -> False
  Status {} -> False
  Relinquish {} -> True

runCommand :: OpsEnv -> Command -> IO OpsOutcome
runCommand env = \case
  List -> runAction env listShardedSubscriptions (Succeeded . listResult)
  Status name -> runAction env (shardOwnershipView (SubscriptionName name)) (Succeeded . statusResult)
  Relinquish name worker -> runRelinquish env name worker

runRelinquish :: OpsEnv -> Text -> WorkerId -> IO OpsOutcome
runRelinquish env name worker
  | not env.force =
      runAction env (ownershipSnapshotFor subscription) $ \ownership ->
        let buckets = ownedBuckets worker ownership
         in PreviewRequired
              (relinquishResult True name worker buckets)
              (forceInvocation env ["shard", "relinquish", "--subscription", name, "--worker", workerText worker])
  | otherwise =
      runAction env action $ \buckets ->
        Succeeded (relinquishResult False name worker buckets)
  where
    subscription = SubscriptionName name
    lease = ShardLease subscription worker 0 0
    action = do
      ownership <- ownershipSnapshotFor subscription
      let buckets = ownedBuckets worker ownership
      relinquish lease (Set.fromList buckets)
      pure buckets

ownedBuckets :: WorkerId -> [(Int, Maybe WorkerId, Maybe UTCTime)] -> [Int]
ownedBuckets worker rows = [bucket | (bucket, Just owner, _) <- rows, owner == worker]

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

statusResult :: ShardOwnershipView -> OpsResult
statusResult view =
  OpsResult
    { headers = ["subscription", "bucket", "owner", "lease_expires_at", "lease_state", "configured_shards"],
      rows = map rowText view.buckets,
      jsonValue =
        object
          [ "subscription" .= name,
            "observed_at" .= view.observedAt,
            "shard_counts" .= map countJson counts,
            "ownership" .= map ownershipJson view.buckets
          ]
    }
  where
    SubscriptionName name = view.subscriptionName
    counts = Map.toAscList (Map.fromListWith (+) [(row.shardCount, 1 :: Int) | row <- view.buckets])
    configured = Text.intercalate "," [showText shardCount <> " (" <> showText rowCount <> " rows)" | (shardCount, rowCount) <- counts]
    rowText row =
      [ name,
        showText row.bucket,
        maybe "unowned" workerText row.owner,
        maybe "" timeText row.leaseExpiresAt,
        leaseStateText (leaseStateAt view.observedAt row),
        configured
      ]
    ownershipJson row =
      object
        [ "bucket" .= row.bucket,
          "owner" .= fmap workerText row.owner,
          "lease_expires_at" .= row.leaseExpiresAt,
          "lease_state" .= leaseStateText (leaseStateAt view.observedAt row),
          "heartbeat_at" .= row.heartbeatAt,
          "updated_at" .= row.updatedAt,
          "lease_age_seconds" .= fmap (\age -> realToFrac age :: Double) (leaseAgeAt view.observedAt row)
        ]

countJson :: (Int, Int) -> Value
countJson (shardCount, rowCount) = object ["shard_count" .= shardCount, "rows" .= rowCount]

listResult :: ShardedSubscriptionList -> OpsResult
listResult view =
  OpsResult
    ["subscription", "buckets_total", "buckets_live", "live_owners"]
    [[nameText row.subscriptionName, showText row.bucketsTotal, showText row.bucketsLive, showText row.liveOwners] | row <- view.subscriptions]
    (object ["observed_at" .= view.observedAt, "items" .= map summaryJson view.subscriptions])
  where
    nameText (SubscriptionName name) = name
    summaryJson row =
      object
        [ "subscription" .= nameText row.subscriptionName,
          "shard_counts" .= map countJson row.shardCounts,
          "buckets_total" .= row.bucketsTotal,
          "buckets_live" .= row.bucketsLive,
          "buckets_expired" .= row.bucketsExpired,
          "buckets_unowned" .= row.bucketsUnowned,
          "live_owners" .= row.liveOwners
        ]

leaseStateText :: LeaseState -> Text
leaseStateText = \case LeaseLive -> "live"; LeaseExpired -> "expired"; LeaseUnowned -> "unowned"; LeaseInvalid -> "invalid"

relinquishResult :: Bool -> Text -> WorkerId -> [Int] -> OpsResult
relinquishResult preview name worker buckets =
  OpsResult
    { headers = ["subscription", "worker", "bucket", "disposition"],
      rows = [[name, workerText worker, showText bucket, disposition] | bucket <- buckets],
      jsonValue = object ["preview" .= preview, "subscription" .= name, "worker" .= workerText worker, "buckets" .= buckets, "affected" .= length buckets]
    }
  where
    disposition = if preview then "would_relinquish" else "relinquished"

workerText :: WorkerId -> Text
workerText (WorkerId value) = UUID.toText value

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

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