packages feed

postgresql-replicant-0.1.0.1: src/Database/PostgreSQL/Replicant/Protocol.hs

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

This module implements the Postgres streaming replication protocol.

See: https://www.postgresql.org/docs/9.5/protocol-replication.html
-}
module Database.PostgreSQL.Replicant.Protocol where

import Control.Concurrent
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Exception.Base
import Control.Monad (forever)
import Data.Aeson (eitherDecode')
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Char8 as B
import Data.Maybe
import Data.Serialize hiding (flush)
import Database.PostgreSQL.LibPQ

import Database.PostgreSQL.Replicant.Exception
import Database.PostgreSQL.Replicant.Message
import Database.PostgreSQL.Replicant.PostgresUtils
import Database.PostgreSQL.Replicant.State
import Database.PostgreSQL.Replicant.Types.Lsn

-- | The information returned by the @IDENTIFY_SYSTEM@ command
-- establishes the stream's log start, position, and information about
-- the database.
data IdentifySystem
  = IdentifySystem
  { identifySystemSytemId  :: ByteString
  , identifySystemTimeline :: ByteString
  , identifySystemLogPos   :: LSN
  , identifySystemDbName   :: Maybe ByteString
  }
  deriving (Eq, Show)

identifySystemCommand :: ByteString
identifySystemCommand = "IDENTIFY_SYSTEM"

-- | Synchronously execute the @IDENTIFY SYSTEM@ command which returns
-- some basic system information about the server.
identifySystemSync :: Connection -> IO (Maybe IdentifySystem)
identifySystemSync conn = do
  result <- exec conn identifySystemCommand
  case result of
    Just r -> do
      resultStatus <- resultStatus r
      case resultStatus of
        TuplesOk -> do
          systemId <- getvalue' r (toRow 0) (toColumn 0)
          timeline <- getvalue' r (toRow 0) (toColumn 1)
          logpos   <- getvalue' r (toRow 0) (toColumn 2)
          dbname   <- getvalue' r (toRow 0) (toColumn 3)
          case (systemId, timeline, logpos, dbname) of
            (Just s, Just t, Just l, d) -> do
              case fromByteString l of
                Left _ -> pure Nothing
                Right logPosLsn -> do
                  pure $ Just (IdentifySystem s t logPosLsn d)
            _ -> pure Nothing
        _ -> do
          err <- fromMaybe "identifySystemSync: unknown error" <$> errorMessage conn
          throwIO $ ReplicantException (B.unpack err)
    _ -> do
      err <- fromMaybe "identifySystemSync: unknown error" <$> errorMessage conn
      throwIO $ ReplicantException (B.unpack err)

-- | Create a @START_REPLICATION_SLOT@ query, escaping the slot name
-- passed in by the user.
startReplicationCommand :: Connection -> ByteString -> LSN -> IO ByteString
startReplicationCommand conn slotName systemLogPos = do
  escapedName <- escapeIdentifier conn slotName
  case escapedName of
    Nothing -> throwIO $ ReplicantException $ "Invalid slot name: " ++ show slotName
    Just escaped ->
      pure $
      B.intercalate
      ""
      [ "START_REPLICATION SLOT "
      , escaped
      , " LOGICAL "
      , (toByteString systemLogPos)
      , " (\"include-lsn\" 'on')"
      ]

-- | This handles the COPY OUT mode messages.  PostgreSQL uses this
-- mode to copy the data from a WAL log file to the socket in the
-- streaming replication protocol.
handleCopyOutData
  :: TChan PrimaryKeepAlive
  -> WalProgressState
  -> Connection
  -> (Change -> IO a)
  -> IO ()
handleCopyOutData chan walState conn cb = forever $ do
  d <- getCopyData conn False
  case d of
    CopyOutRow row -> handleReplicationRow chan walState conn row cb
    CopyOutError   -> handleReplicationError conn
    _              -> handleReplicationNoop

handleReplicationRow
  :: TChan PrimaryKeepAlive
  -> WalProgressState
  -> Connection
  -> ByteString
  -> (Change -> IO a)
  -> IO ()
handleReplicationRow keepAliveChan walState _ row cb =
  case decode @WalCopyData row of
    Left err ->
      throwIO
      $ ReplicantException
      $ "handleReplicationRow (decode error): " ++ err
    Right m  -> case m of
      XLogDataM xlog -> do
        case eitherDecode' @Change $ BL.fromStrict $ xLogDataWalData xlog of
          Left err ->
            throwIO
            $ ReplicantException
            $ "handleReplicationRow (parse error): " ++ err
          Right walLogData -> do
            _ <- updateWalProgress walState (changeNextLSN walLogData)
            _ <- cb walLogData
            pure ()
      KeepAliveM keepAlive -> atomically $ writeTChan keepAliveChan keepAlive

-- | Used to re-throw an exception received from the server.
handleReplicationError :: Connection -> IO ()
handleReplicationError conn = do
  err <- errorMessage conn
  throwIO (ReplicantException $ B.unpack . fromMaybe "Unknown error" $ err)
  pure ()

handleReplicationNoop :: IO ()
handleReplicationNoop = pure ()

-- | Initiate the streaming replication protocol handler.  This will
-- race the /keep-alive/ and /copy data/ handler threads.  It will
-- catch and rethrow exceptions from either thread if any fails or
-- returns.
startReplicationStream :: Connection -> ByteString -> LSN -> Int -> (Change -> IO a) -> IO ()
startReplicationStream conn slotName systemLogPos _ cb = do
  let initialWalProgress = WalProgress systemLogPos systemLogPos systemLogPos
  walProgressState <- WalProgressState <$> newMVar initialWalProgress
  replicationCommandQuery <- startReplicationCommand conn slotName systemLogPos
  result <- exec conn replicationCommandQuery
  case result of
    Nothing -> do
      err <- fromMaybe "startReplicationStream: unknown error starting stream"
        <$> errorMessage conn
      throwIO $ ReplicantException $ "startReplicationStream: " ++ B.unpack err
    Just r  -> do
      status <- resultStatus r
      case status of
        CopyBoth -> do
          keepAliveChan <- atomically newTChan
          race
            (keepAliveHandler conn keepAliveChan walProgressState)
            (handleCopyOutData keepAliveChan walProgressState conn cb)
            `catch`
            \exc -> do
              finish conn
              throwIO @SomeException exc
          return ()
        _ -> do
          err <- fromMaybe "startReplicationStream: unknown error entering COPY mode" <$> errorMessage conn
          throwIO $ ReplicantException $ B.unpack err

-- | This listens on the channel for /primary keep-alive messages/
-- from the server and responds to them with the /update status/
-- message using the current WAL stream state.  It will attempt to
-- buffer prior update messages when the socket is blocked.
keepAliveHandler :: Connection -> TChan PrimaryKeepAlive -> WalProgressState -> IO ()
keepAliveHandler conn msgs walProgressState = forever $ do
  mKeepAlive <- atomically $ tryReadTChan msgs
  case mKeepAlive of
    Nothing -> do
      sendStatusUpdate conn walProgressState
      threadDelay 3000000
    Just keepAlive' -> do
      case primaryKeepAliveResponseExpectation keepAlive' of
        DoNotRespond -> do
          threadDelay 1000
        ShouldRespond -> do
          sendStatusUpdate conn walProgressState

sendStatusUpdate
  :: Connection
  -> WalProgressState
  -> IO ()
sendStatusUpdate conn w@(WalProgressState walState) = do
  (WalProgress received flushed applied) <- readMVar walState
  timestamp <- postgresEpoch
  let statusUpdate =
        StandbyStatusUpdate
        received
        flushed
        applied
        timestamp
        DoNotRespond
  copyResult <- putCopyData conn $ encode statusUpdate
  case copyResult of
    CopyInOk -> do
      flushResult <- flush conn
      case flushResult of
        FlushOk -> pure ()
        FlushFailed -> do
          err <- fromMaybe "sendStatusUpdate: error flushing message to server" <$> errorMessage conn
          throwIO $ ReplicantException $ B.unpack err
        FlushWriting -> tryAgain conn w
    CopyInError -> do
      err <- fromMaybe "sendStatusUpdate: unknown error sending COPY IN" <$> errorMessage conn
      throwIO $ ReplicantException $ B.unpack err
    CopyInWouldBlock -> tryAgain conn w
  where
    tryAgain c ws = do
      mSockFd <- socket c
      case mSockFd of
        Nothing ->
          throwIO $ ReplicantException "sendStatusUpdate: failed to get socket fd"
        Just sockFd -> do
          threadWaitWrite sockFd
          sendStatusUpdate conn ws