packages feed

rdkafka-hs-1.1.0.0: src/Kafka/Metadata.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}

-----------------------------------------------------------------------------

-----------------------------------------------------------------------------

-- |
-- Module with metadata types and functions.
module Kafka.Metadata
  ( KafkaMetadata (..),
    BrokerMetadata (..),
    TopicMetadata (..),
    PartitionMetadata (..),
    WatermarkOffsets (..),
    GroupMemberId (..),
    GroupMemberInfo (..),
    GroupProtocolType (..),
    GroupProtocol (..),
    GroupState (..),
    GroupInfo (..),
    allTopicsMetadata,
    topicMetadata,
    watermarkOffsets,
    watermarkOffsets',
    partitionWatermarkOffsets,
    offsetsForTime,
    offsetsForTime',
    topicOffsetsForTime,
    allConsumerGroupsInfo,
    consumerGroupInfo,
  )
where

import Control.Exception (bracket)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Data.Bifunctor (bimap)
import Data.ByteString (ByteString, pack)
import Data.List (find)
import Data.Maybe (listToMaybe, mapMaybe)
import Data.Text (Text)
import Foreign (ForeignPtr, Storable (peek), finalizeForeignPtr, peekArray, withForeignPtr)
import GHC.Generics (Generic)
import Kafka.Consumer.Convert (fromNativeTopicPartitionList'', nativeTopicPartitionErrors, toNativeTopicPartitionList)
import Kafka.Consumer.Types (ConsumerGroupId (..), Offset (..), PartitionOffset (..), TopicPartition (..))
import Kafka.Internal.RdKafka (RdKafkaGroupInfoT (..), RdKafkaGroupListT (..), RdKafkaGroupListTPtr, RdKafkaGroupMemberInfoT (..), RdKafkaMetadataBrokerT (..), RdKafkaMetadataPartitionT (..), RdKafkaMetadataT (..), RdKafkaMetadataTPtr, RdKafkaMetadataTopicT (..), RdKafkaRespErrT (..), RdKafkaTPtr, destroyUnmanagedRdKafkaTopic, newUnmanagedRdKafkaTopicT, peekCText, rdKafkaListGroups, rdKafkaMetadata, rdKafkaOffsetsForTimes, rdKafkaQueryWatermarkOffsets)
import Kafka.Internal.Setup (HasKafka (..), Kafka (..))
import Kafka.Internal.Shared (kafkaErrorToMaybe, validateCStringInput, validateTopicNameInput, validateTopicPartitions)
import Kafka.Types (BrokerId (..), ClientId (..), KafkaError (..), Millis (..), PartitionId (..), Timeout (..), TopicName (..))

import Data.Set qualified as S
import Data.Text qualified as Text

data KafkaMetadata = KafkaMetadata
  { kmBrokers :: [BrokerMetadata],
    kmTopics :: [TopicMetadata],
    kmOrigBroker :: !BrokerId
  }
  deriving stock (Show, Eq, Generic)

data BrokerMetadata = BrokerMetadata
  { bmBrokerId :: !BrokerId,
    bmBrokerHost :: !Text,
    bmBrokerPort :: !Int
  }
  deriving stock (Show, Eq, Generic)

data PartitionMetadata = PartitionMetadata
  { pmPartitionId :: !PartitionId,
    pmError :: Maybe KafkaError,
    pmLeader :: !BrokerId,
    pmReplicas :: [BrokerId],
    pmInSyncReplicas :: [BrokerId]
  }
  deriving stock (Show, Eq, Generic)

data TopicMetadata = TopicMetadata
  { tmTopicName :: !TopicName,
    tmPartitions :: [PartitionMetadata],
    tmError :: Maybe KafkaError
  }
  deriving stock (Show, Eq, Generic)

data WatermarkOffsets = WatermarkOffsets
  { woTopicName :: !TopicName,
    woPartitionId :: !PartitionId,
    woLowWatermark :: !Offset,
    woHighWatermark :: !Offset
  }
  deriving stock (Show, Eq, Generic)

newtype GroupMemberId = GroupMemberId Text
  deriving stock (Show, Eq, Read, Ord)

data GroupMemberInfo = GroupMemberInfo
  { gmiMemberId :: !GroupMemberId,
    gmiClientId :: !ClientId,
    gmiClientHost :: !Text,
    gmiMetadata :: !ByteString,
    gmiAssignment :: !ByteString
  }
  deriving stock (Show, Eq, Generic)

newtype GroupProtocolType = GroupProtocolType Text
  deriving stock (Show, Eq, Read, Ord, Generic)

newtype GroupProtocol = GroupProtocol Text
  deriving stock (Show, Eq, Read, Ord, Generic)

data GroupState
  = -- | Group is preparing to rebalance
    GroupPreparingRebalance
  | -- | Group has no more members, but lingers until all offsets have expired
    GroupEmpty
  | -- | Group is awaiting state assignment from the leader
    GroupAwaitingSync
  | -- | Group is stable
    GroupStable
  | -- | Group has no more members and its metadata is being removed
    GroupDead
  deriving stock (Show, Eq, Read, Ord, Generic)

data GroupInfo = GroupInfo
  { giGroup :: !ConsumerGroupId,
    giError :: Maybe KafkaError,
    giState :: !GroupState,
    giProtocolType :: !GroupProtocolType,
    giProtocol :: !GroupProtocol,
    giMembers :: [GroupMemberInfo]
  }
  deriving stock (Show, Eq, Generic)

-- | Returns metadata for all topics in the cluster
allTopicsMetadata :: (MonadIO m, HasKafka k) => k -> Timeout -> m (Either KafkaError KafkaMetadata)
allTopicsMetadata k (Timeout timeout) =
  liftIO $
    withNativeResult k (rdKafkaMetadata (getKafkaPtr k) True Nothing timeout) (fmap Right . fromKafkaMetadataPtr)

-- | Returns metadata only for specified topic
topicMetadata :: (MonadIO m, HasKafka k) => k -> Timeout -> TopicName -> m (Either KafkaError KafkaMetadata)
topicMetadata _ _ topic | Just failure <- validateTopicNameInput topic = pure (Left failure)
topicMetadata k (Timeout timeout) (TopicName tn) = liftIO $ withForeignPtr (getKafkaPtr k) $ \_ ->
  bracket mkTopic clTopic $ \mbt -> case mbt of
    Left err -> return (Left $ KafkaError (Text.pack err))
    Right t -> do
      withNativeResult k (rdKafkaMetadata (getKafkaPtr k) False (Just t) timeout) (fmap Right . fromKafkaMetadataPtr)
  where
    mkTopic = newUnmanagedRdKafkaTopicT (getKafkaPtr k) (Text.unpack tn) Nothing
    clTopic = either (return . const ()) destroyUnmanagedRdKafkaTopic

-- | Query broker for low (oldest/beginning) and high (newest/end) offsets for a given topic.
watermarkOffsets :: (MonadIO m, HasKafka k) => k -> Timeout -> TopicName -> m [Either KafkaError WatermarkOffsets]
watermarkOffsets k timeout t = do
  meta <- topicMetadata k timeout t
  case meta of
    Left err -> return [Left err]
    Right tm -> case kmTopics tm of
      [] -> return []
      (t' : _) -> watermarkOffsets' k timeout t'

-- | Query broker for low (oldest/beginning) and high (newest/end) offsets for a given topic.
watermarkOffsets' :: (MonadIO m, HasKafka k) => k -> Timeout -> TopicMetadata -> m [Either KafkaError WatermarkOffsets]
watermarkOffsets' k timeout tm = case tmError tm of
  Just failure -> pure [Left failure]
  Nothing -> liftIO $ traverse query (tmPartitions tm)
    where
      query partition = case pmError partition of
        Just failure -> pure (Left failure)
        Nothing -> partitionWatermarkOffsets k timeout (tmTopicName tm) (pmPartitionId partition)

-- | Query broker for low (oldest/beginning) and high (newest/end) offsets for a specific partition
partitionWatermarkOffsets :: (MonadIO m, HasKafka k) => k -> Timeout -> TopicName -> PartitionId -> m (Either KafkaError WatermarkOffsets)
partitionWatermarkOffsets _ _ topic partition | Just failure <- validateTopicPartitions [(topic, partition)] = pure (Left failure)
partitionWatermarkOffsets k (Timeout timeout) (TopicName t) (PartitionId p) = liftIO $ do
  offs <- rdKafkaQueryWatermarkOffsets (getKafkaPtr k) (Text.unpack t) p timeout
  return $ bimap KafkaResponseError toWatermark offs
  where
    toWatermark (l, h) = WatermarkOffsets (TopicName t) (PartitionId p) (Offset l) (Offset h)

-- | Look up the offsets for the given topic by timestamp.
--
-- The returned offset for each partition is the earliest offset whose
-- timestamp is greater than or equal to the given timestamp in the
-- corresponding partition.
topicOffsetsForTime :: (MonadIO m, HasKafka k) => k -> Timeout -> Millis -> TopicName -> m (Either KafkaError [TopicPartition])
topicOffsetsForTime k timeout timestamp topic = do
  meta <- topicMetadata k timeout topic
  case meta of
    Left err -> return $ Left err
    Right meta' -> case listToMaybe (mapMaybe tmError (kmTopics meta') <> concatMap (mapMaybe pmError . tmPartitions) (kmTopics meta')) of
      Just failure -> pure (Left failure)
      Nothing ->
        let tps = [(tmTopicName t, pmPartitionId p) | t <- kmTopics meta', p <- tmPartitions t]
         in offsetsForTime k timeout timestamp tps

-- | Look up the offsets for the given metadata by timestamp.
--
-- The returned offset for each partition is the earliest offset whose
-- timestamp is greater than or equal to the given timestamp in the
-- corresponding partition.
offsetsForTime' :: (MonadIO m, HasKafka k) => k -> Timeout -> Millis -> TopicMetadata -> m (Either KafkaError [TopicPartition])
offsetsForTime' k timeout timestamp t = case tmError t of
  Just failure -> pure (Left failure)
  Nothing -> case listToMaybe (mapMaybe pmError (tmPartitions t)) of
    Just failure -> pure (Left failure)
    Nothing ->
      let tps = [(tmTopicName t, pmPartitionId p) | p <- tmPartitions t]
       in offsetsForTime k timeout timestamp tps

-- | Look up the offsets for the given partitions by timestamp.
--
-- The returned offset for each partition is the earliest offset whose
-- timestamp is greater than or equal to the given timestamp in the
-- corresponding partition.
offsetsForTime :: (MonadIO m, HasKafka k) => k -> Timeout -> Millis -> [(TopicName, PartitionId)] -> m (Either KafkaError [TopicPartition])
offsetsForTime _ _ _ ps | Just failure <- validateTopicPartitions ps = pure (Left failure)
offsetsForTime k (Timeout timeout) (Millis t) tps = liftIO $ withForeignPtr (getKafkaPtr k) $ \_ -> bracket (toNativeTopicPartitionList (mkTopicPartition <$> uniqueTps)) finalizeForeignPtr $ \ntps -> do
  res <- rdKafkaOffsetsForTimes (getKafkaPtr k) ntps timeout
  case res of
    RdKafkaRespErrNoError -> do
      errors <- nativeTopicPartitionErrors ntps
      case find (/= RdKafkaRespErrNoError) errors of
        Just failure -> pure (Left (KafkaResponseError failure))
        Nothing -> Right <$> fromNativeTopicPartitionList'' ntps
    err -> return $ Left (KafkaResponseError err)
  where
    uniqueTps = S.toList . S.fromList $ tps
    -- rd_kafka_offsets_for_times reuses `offset` to specify timestamp :(
    mkTopicPartition (tn, p) = TopicPartition tn p (PartitionOffset t)

-- | List and describe all consumer groups in cluster. Unsupported native
-- group-state names return a descriptive error.
allConsumerGroupsInfo :: (MonadIO m, HasKafka k) => k -> Timeout -> m (Either KafkaError [GroupInfo])
allConsumerGroupsInfo k (Timeout t) =
  liftIO $
    withNativeResult k (rdKafkaListGroups (getKafkaPtr k) Nothing t) fromGroupInfoListPtr

-- | Describe a given consumer group. Unsupported native group-state names
-- return a descriptive error; a missing group returns an empty list.
consumerGroupInfo :: (MonadIO m, HasKafka k) => k -> Timeout -> ConsumerGroupId -> m (Either KafkaError [GroupInfo])
consumerGroupInfo _ _ (ConsumerGroupId name) | Just failure <- validateCStringInput "consumer group" name = pure (Left failure)
consumerGroupInfo k (Timeout timeout) (ConsumerGroupId gn) =
  liftIO $
    withNativeResult k (rdKafkaListGroups (getKafkaPtr k) (Just (Text.unpack gn)) timeout) fromGroupInfoListPtr

-------------------------------------------------------------------------------
getKafkaPtr :: (HasKafka k) => k -> RdKafkaTPtr
getKafkaPtr k = let (Kafka k') = getKafka k in k'
{-# INLINE getKafkaPtr #-}

-- Retain the client until its complete result has been copied and destroyed.
-- Mask acquisition through ForeignPtr registration, including native return.
withNativeResult :: (HasKafka k) => k -> IO (Either RdKafkaRespErrT (ForeignPtr a)) -> (ForeignPtr a -> IO (Either KafkaError b)) -> IO (Either KafkaError b)
withNativeResult client acquire decode = withForeignPtr (getKafkaPtr client) $ \_ ->
  bracket acquire (either (const (pure ())) finalizeForeignPtr) $
    either (pure . Left . KafkaResponseError) decode

fromGroupInfoListPtr :: RdKafkaGroupListTPtr -> IO (Either KafkaError [GroupInfo])
fromGroupInfoListPtr ptr =
  withForeignPtr ptr $ \realPtr -> do
    gl <- peek realPtr
    pgis <- peekArray (groupCnt'RdKafkaGroupListT gl) (groups'RdKafkaGroupListT gl)
    sequence <$> traverse fromGroupInfoPtr pgis

fromGroupInfoPtr :: RdKafkaGroupInfoT -> IO (Either KafkaError GroupInfo)
fromGroupInfoPtr gi = do
  -- bmd <- peek (broker'RdKafkaGroupInfoT gi) -- >>= fromBrokerMetadataPtr
  -- xxx <- fromBrokerMetadataPtr bmd
  cid <- peekCText $ group'RdKafkaGroupInfoT gi
  stt <- peekCText $ state'RdKafkaGroupInfoT gi
  prt <- peekCText $ protocolType'RdKafkaGroupInfoT gi
  pr <- peekCText $ protocol'RdKafkaGroupInfoT gi
  mbs <- peekArray (memberCnt'RdKafkaGroupInfoT gi) (members'RdKafkaGroupInfoT gi)
  mbl <- mapM fromGroupMemberInfoPtr mbs
  pure $ do
    state <- groupStateFromKafkaString stt
    pure
      GroupInfo
        { -- giBroker = bmd
          giGroup = ConsumerGroupId cid,
          giError = kafkaErrorToMaybe $ KafkaResponseError (err'RdKafkaGroupInfoT gi),
          giState = state,
          giProtocolType = GroupProtocolType prt,
          giProtocol = GroupProtocol pr,
          giMembers = mbl
        }

fromGroupMemberInfoPtr :: RdKafkaGroupMemberInfoT -> IO GroupMemberInfo
fromGroupMemberInfoPtr mi = do
  mid <- peekCText $ memberId'RdKafkaGroupMemberInfoT mi
  cid <- peekCText $ clientId'RdKafkaGroupMemberInfoT mi
  hst <- peekCText $ clientHost'RdKafkaGroupMemberInfoT mi
  mtd <- peekArray (memberMetadataSize'RdKafkaGroupMemberInfoT mi) (memberMetadata'RdKafkaGroupMemberInfoT mi)
  ass <- peekArray (memberAssignmentSize'RdKafkaGroupMemberInfoT mi) (memberAssignment'RdKafkaGroupMemberInfoT mi)
  return
    GroupMemberInfo
      { gmiMemberId = GroupMemberId mid,
        gmiClientId = ClientId cid,
        gmiClientHost = hst,
        gmiMetadata = pack mtd,
        gmiAssignment = pack ass
      }

fromTopicMetadataPtr :: RdKafkaMetadataTopicT -> IO TopicMetadata
fromTopicMetadataPtr tm = do
  tnm <- peekCText (topic'RdKafkaMetadataTopicT tm)
  pts <- peekArray (partitionCnt'RdKafkaMetadataTopicT tm) (partitions'RdKafkaMetadataTopicT tm)
  pms <- mapM fromPartitionMetadataPtr pts
  return
    TopicMetadata
      { tmTopicName = TopicName tnm,
        tmPartitions = pms,
        tmError = kafkaErrorToMaybe $ KafkaResponseError (err'RdKafkaMetadataTopicT tm)
      }

fromPartitionMetadataPtr :: RdKafkaMetadataPartitionT -> IO PartitionMetadata
fromPartitionMetadataPtr pm = do
  reps <- peekArray (replicaCnt'RdKafkaMetadataPartitionT pm) (replicas'RdKafkaMetadataPartitionT pm)
  isrs <- peekArray (isrCnt'RdKafkaMetadataPartitionT pm) (isrs'RdKafkaMetadataPartitionT pm)
  return
    PartitionMetadata
      { pmPartitionId = PartitionId (id'RdKafkaMetadataPartitionT pm),
        pmError = kafkaErrorToMaybe $ KafkaResponseError (err'RdKafkaMetadataPartitionT pm),
        pmLeader = BrokerId (leader'RdKafkaMetadataPartitionT pm),
        pmReplicas = BrokerId . fromIntegral <$> reps,
        pmInSyncReplicas = BrokerId . fromIntegral <$> isrs
      }

fromBrokerMetadataPtr :: RdKafkaMetadataBrokerT -> IO BrokerMetadata
fromBrokerMetadataPtr bm = do
  host <- peekCText (host'RdKafkaMetadataBrokerT bm)
  return
    BrokerMetadata
      { bmBrokerId = BrokerId (id'RdKafkaMetadataBrokerT bm),
        bmBrokerHost = host,
        bmBrokerPort = port'RdKafkaMetadataBrokerT bm
      }

fromKafkaMetadataPtr :: RdKafkaMetadataTPtr -> IO KafkaMetadata
fromKafkaMetadataPtr ptr =
  withForeignPtr ptr $ \realPtr -> do
    km <- peek realPtr
    pbms <- peekArray (brokerCnt'RdKafkaMetadataT km) (brokers'RdKafkaMetadataT km)
    bms <- mapM fromBrokerMetadataPtr pbms
    ptms <- peekArray (topicCnt'RdKafkaMetadataT km) (topics'RdKafkaMetadataT km)
    tms <- mapM fromTopicMetadataPtr ptms
    return
      KafkaMetadata
        { kmBrokers = bms,
          kmTopics = tms,
          kmOrigBroker = BrokerId $ fromIntegral (origBrokerId'RdKafkaMetadataT km)
        }

groupStateFromKafkaString :: Text -> Either KafkaError GroupState
groupStateFromKafkaString s = case s of
  "PreparingRebalance" -> Right GroupPreparingRebalance
  "CompletingRebalance" -> Right GroupAwaitingSync
  "AwaitingSync" -> Right GroupAwaitingSync
  "Stable" -> Right GroupStable
  "Dead" -> Right GroupDead
  "Empty" -> Right GroupEmpty
  _ -> Left (KafkaError ("Unsupported Kafka consumer group state: " <> s))