packages feed

postgresql-replicant-0.2.0.1: src/Database/PostgreSQL/Replicant/Message.hs

{-# LANGUAGE Strict #-}

{-|
Module      : Database.PostgreSQL.Replicant.Message
Description : Streaming replication message types
Copyright   : (c) James King, 2020, 2021
License     : BSD3
Maintainer  : james@agentultra.com
Stability   : experimental
Portability : POSIX

This module contains the binary protocols messages used in the
streaming replication protocol as well as the messages used in the
body of the logical stream messages.
-}
module Database.PostgreSQL.Replicant.Message where

import Control.Monad
import Data.Aeson
import Data.ByteString (ByteString)
import Data.Scientific (Scientific)
import Data.Serialize
import Data.Text (Text)
import GHC.Generics
import GHC.Int

import Database.PostgreSQL.Replicant.Serialize
import Database.PostgreSQL.Replicant.Types.Lsn

-- WAL Replication Stream messages

-- | Indicates whether the server or the client should respond to the
-- message.
data ResponseExpectation
  = ShouldRespond
  | DoNotRespond
  deriving (Eq, Generic, Show)

instance Serialize ResponseExpectation where
  put ShouldRespond = putWord8 1
  put DoNotRespond  = putWord8 0

  get = do
    responseFlag <- getWord8
    case responseFlag of
      0 -> pure DoNotRespond
      1 -> pure ShouldRespond
      _ -> fail "Unrecognized response expectation flag"

-- | The Postgres WAL sender thread may periodically send these
-- messages.  When the server expects the client to respond it updates
-- its internal state of the client based on the response.  Failure to
-- respond results in the dreaded "WAL timeout" error.
--
-- See StandbyStatusUpdate for the message the client should respond
-- with.
data PrimaryKeepAlive
  = PrimaryKeepAlive
  { primaryKeepAliveWalEnd              :: !Int64
  , primaryKeepAliveSendTime            :: !Int64
  , primaryKeepAliveResponseExpectation :: !ResponseExpectation
  }
  deriving (Eq, Generic, Show)

instance Serialize PrimaryKeepAlive where
  put (PrimaryKeepAlive walEnd sendTime responseExpectation) = do
    putWord8 0x6B -- 'k'
    putInt64be walEnd
    putInt64be sendTime
    put responseExpectation

  get = do
    _ <- getBytes 1
    walEnd <- getInt64be
    sendTime <- getInt64be
    responseExpectation <- get
    pure $ PrimaryKeepAlive walEnd sendTime responseExpectation

-- | Sent by the client.  Can be sent periodically or in response to a
-- PrimaryKeepAlive message from the server.  Gives the server
-- information about the client stream state.
data StandbyStatusUpdate
  = StandbyStatusUpdate
  { standbyStatuUpdateLastWalByteReceived  :: !LSN
  , standbyStatusUpdateLastWalByteFlushed  :: !LSN
  , standbyStatusUpdateLastWalByteApplied  :: !LSN
  , standbyStatusUpdateSendTime            :: !Int64
  , standbyStatusUpdateResponseExpectation :: !ResponseExpectation
  }
  deriving (Eq, Generic, Show)

instance Serialize StandbyStatusUpdate where
  put (StandbyStatusUpdate
       walReceived
       walFlushed
       walApplied
       sendTime
       responseExpectation) = do
    putWord8 0x72 -- 'r'
    put walReceived
    put walFlushed
    put walApplied
    putInt64be sendTime
    put responseExpectation

  get = do
    _ <- getBytes 1 -- should expect 0x72, 'r'
    walReceived         <- get
    walFlushed          <- get
    walApplied          <- get
    sendTime            <- getInt64be
    responseExpectation <- get
    pure $
      StandbyStatusUpdate
      walReceived
      walFlushed
      walApplied
      sendTime
      responseExpectation

-- | Carries WAL segments in the streaming protocol.
data XLogData
  = XLogData
  { xLogDataWalStart :: !LSN
  , xLogDataWalEnd   :: !LSN
  , xLogDataSendTime :: !Int64
  , xLogDataWalData  :: !ByteString
  }
  deriving (Eq, Generic, Show)

instance Serialize XLogData where
  put (XLogData walStart walEnd sendTime walData) = do
    putWord8 0x77 -- 'w'
    put walStart
    put walEnd
    putInt64be sendTime
    putByteString walData

  get = do
    _ <- getBytes 1 -- should expec '0x77', 'w'
    walStart <- get
    walEnd   <- get
    sendTime <- getInt64be
    walData  <- consumeByteStringToEnd
    pure $ XLogData walStart walEnd sendTime walData

-- | Not used yet but enables streaming in hot-standby mode.
data HotStandbyFeedback
  = HotStandbyFeedback
  { hotStandbyFeedbackClientSendTime :: !Int64
  , hotStandbyFeedbackCurrentXmin    :: !Int32
  , hotStandbyFeedbackCurrentEpoch   :: !Int32
  }
  deriving (Eq, Generic, Show)

instance Serialize HotStandbyFeedback where
  put (HotStandbyFeedback clientSendTime currentXMin currentEpoch) = do
    putWord8 0x68
    putInt64be clientSendTime
    putInt32be currentXMin
    putInt32be currentEpoch

  get = do
    _ <- getBytes 1 -- should expect '0x68' 'h'
    clientSendTime <- getInt64be
    currentXmin <- getInt32be
    currentEpoch <- getInt32be
    pure $ HotStandbyFeedback clientSendTime currentXmin currentEpoch

-- | This structure wraps the two messages sent by the server so that
-- we get a Serialize instance for both.
data WalCopyData
  = XLogDataM !XLogData
  | KeepAliveM !PrimaryKeepAlive
  deriving (Eq, Generic, Show)

instance Serialize WalCopyData where
  put (XLogDataM xLogData)   = put xLogData
  put (KeepAliveM keepAlive) = put keepAlive
  get = do
    messageTypeFlag <- lookAhead $ getWord8
    case messageTypeFlag of
      -- 'w' XLogData
      0x77 -> do
        xLogData <- get
        pure $ XLogDataM xLogData
      -- 'k' PrimaryKeepAlive
      0x6B -> do
        keepAlive <- get
        pure $ KeepAliveM keepAlive
      _    -> fail "Unrecognized WalCopyData"

-- WAL Log Data

-- | Wraps Postgres values.  Since this library currently supports
-- only the @wal2json@ logical decoder plugin we have the JSON values
-- much like Aeson does.
data WalValue
  = WalString !Text
  | WalNumber !Scientific
  | WalBool !Bool
  | WalNull
  deriving (Eq, Generic, Show)

instance FromJSON WalValue where
  parseJSON (String txt) = pure $ WalString txt
  parseJSON (Number num) = pure $ WalNumber num
  parseJSON (Bool bool)  = pure $ WalBool bool
  parseJSON Null         = pure WalNull
  parseJSON _            = fail "Unrecognized WalValue"

instance ToJSON WalValue where
  toJSON (WalString txt) = String txt
  toJSON (WalNumber n)   = Number n
  toJSON (WalBool b)     = Bool b
  toJSON (WalNull)       = Null

-- | Represents a single table column.  We only support the `wal2json`
-- logical decoder plugin and make no attempt to parse anything but
-- JSON-like primitives.
data Column
  = Column
  { columnName  :: !Text
  , columnType  :: !Text
  , columnValue :: !WalValue
  }
  deriving (Eq, Show)

data ColumnParseError
  = ColumnLengthMatchError
  deriving (Eq, Show)

-- | Some WAL output plugins encode column values in three, equal
-- length, heterogeneous lists.
columns :: [Text] -> [Text] -> [WalValue] -> Either ColumnParseError [Column]
columns colNames colTypes colValues
  | length colNames == length colTypes && length colTypes == length colValues
  = Right (map toColumn $ zip3 colNames colTypes colValues)
  | otherwise = Left ColumnLengthMatchError
  where
    toColumn (n, t, v) = Column n t v

fromColumn :: Column -> (Text, Text, WalValue)
fromColumn (Column cName cType cValue) = (cName, cType, cValue)

fromColumns :: [Column] -> ([Text], [Text], [WalValue])
fromColumns = unzip3 . map fromColumn

-- | Represents a single insert query in the logical replication
-- format.
data Insert
  = Insert
  { insertSchema  :: !String
  , insertTable   :: !String
  , insertColumns :: ![Column]
  }
  deriving (Eq, Show)

instance FromJSON Insert where
  parseJSON = withObject "Insert" $ \o -> do
    kind         <- o .: "kind"
    unless (kind == String "insert") $ fail "Invalid insert"
    schema       <- o .: "schema"
    table        <- o .: "table"
    columnNames  <- o .: "columnnames"
    columnTypes  <- o .: "columntypes"
    columnValues <- o .: "columnvalues"
    case columns columnNames columnTypes columnValues of
      Left err      -> fail $ show err
      Right columns -> pure $ Insert schema table columns

instance ToJSON Insert where
  toJSON (Insert schema table columns) =
    let (cNames, cTypes, cValues) = fromColumns columns
    in object [ "kind"         .= String "insert"
              , "schema"       .= schema
              , "table"        .= table
              , "columnnames"  .= cNames
              , "columntypes"  .= cTypes
              , "columnvalues" .= cValues
              ]

-- | Represents a single update query in the logical replication
-- format.
data Update
  = Update
  { updateSchema  :: !Text
  , updateTable   :: !Text
  , updateColumns :: ![Column]
  }
  deriving (Eq, Show)

instance FromJSON Update where
  parseJSON = withObject "Insert" $ \o -> do
    kind         <- o .: "kind"
    unless (kind == String "update") $ fail "Invalid update"
    schema       <- o .: "schema"
    table        <- o .: "table"
    columnNames  <- o .: "columnnames"
    columnTypes  <- o .: "columntypes"
    columnValues <- o .: "columnvalues"
    case columns columnNames columnTypes columnValues of
      Left err      -> fail $ show err
      Right columns -> pure $ Update schema table columns

instance ToJSON Update where
  toJSON (Update schema table columns) =
    let (cNames, cTypes, cValues) = fromColumns columns
    in object [ "kind"         .= String "update"
              , "schema"       .= schema
              , "table"        .= table
              , "columnnames"  .= cNames
              , "columntypes"  .= cTypes
              , "columnvalues" .= cValues
              ]

-- | Represents a single delete query in the logical replication format
data Delete
  = Delete
  { deleteSchema  :: !Text
  , deleteTable   :: !Text
  , deleteColumns :: ![Column]
  }
  deriving (Eq, Show)

instance FromJSON Delete where
  parseJSON = withObject "Delete" $ \o -> do
    kind      <- o .: "kind"
    unless (kind == String "delete") $ fail "Invalid delete"
    schema    <- o .: "schema"
    table     <- o .: "table"
    oldKeys   <- o .: "oldkeys"
    keyNames  <- oldKeys .: "keynames"
    keyTypes  <- oldKeys .: "keytypes"
    keyValues <- oldKeys .: "keyvalues"
    case columns keyNames keyTypes keyValues of
      Left err      -> fail $ show err
      Right columns -> pure $ Delete schema table columns

instance ToJSON Delete where
  toJSON (Delete schema table columns) =
    let (cNames, cTypes, cValues) = fromColumns columns
    in object [ "kind"    .= String "delete"
              , "schema"  .= schema
              , "table"   .= table
              , "oldkeys" .= object
                [ "keynames"  .= cNames
                , "keytypes"  .= cTypes
                , "keyvalues" .= cValues
                ]
              ]

-- | Occasionally the server may also send these for informational
-- purposes and can be ignored.  May be used internally.
data Message
  = Message
  { messageTransactional :: !Bool
  , messagePrefix        :: !Text
  , messageContent       :: !Text
  }
  deriving (Eq, Show)

instance FromJSON Message where
  parseJSON = withObject "Message" $ \o -> do
    kind          <- o .: "kind"
    unless (kind == String "message") $ fail "Invalid message"
    transactional <- o .: "transactional"
    prefix        <- o .: "prefix"
    content       <- o .: "content"
    pure $ Message transactional prefix content

instance ToJSON Message where
  toJSON (Message transactional prefix content)
    = object
    [ "kind"          .= String "message"
    , "transactional" .= transactional
    , "prefix"        .= prefix
    , "content"       .= content
    ]

data WalLogData
  = WInsert !Insert
  | WUpdate !Update
  | WDelete !Delete
  | WMessage !Message
  deriving (Eq, Generic, Show)

instance ToJSON WalLogData where
  toJSON = genericToJSON defaultOptions { sumEncoding = UntaggedValue }

instance FromJSON WalLogData where
  parseJSON = genericParseJSON defaultOptions { sumEncoding = UntaggedValue }

data Change
  = Change
  { changeNextLSN :: LSN
    -- ^ Return this LSN in your callback to update the stream state
    -- in replicant
  , changeDeltas  :: [WalLogData]
    -- ^ The list of WAL log changes in this transaction.
  }
  deriving (Eq, Generic, Show)

instance ToJSON Change where
  toJSON = genericToJSON defaultOptions

instance FromJSON Change where
  parseJSON = withObject "Change" $ \o -> do
    nextLSN <- o .: "nextlsn"
    deltas  <- o .: "change"
    pure $ Change nextLSN deltas