packages feed

postgresql-replicant (empty) → 0.1.0.1

raw patch · 18 files changed

+1600/−0 lines, 18 filesdep +aesondep +asyncdep +attoparsecsetup-changed

Dependencies added: aeson, async, attoparsec, base, binary, bits, bytestring, cereal, containers, hspec, keep-alive, postgresql-libpq, postgresql-replicant, scientific, stm, text, time

Files

+ ChangeLog.md view
@@ -0,0 +1,29 @@+All notable changes to this project will be documented in this file.++The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).++## [Unreleased]++## [0.1.0.1] - 2021-06-22++This is a minor update to our experimental release, fixing some pretty+major issues:++### Fixed+- WalSender on the server hanging up because replicant wasn't sending+  updates+- Minor cleanup, throw better errors instead of printing debug+  information++## [0.1.0.0] - 2021-07-27++This is an experimental release to test out this library and get it+ready and polished for 1.0!++### Added+- Initial release!+- `withLogicalStream`+- Protocol types, serialzation instances, etc+- Logical Replication Messages+- Stream state handling
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright James King (c) 2020, 2021++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Author name here nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,115 @@+# postgres-replicant++A PostgreSQL streaming logical replication client for Haskell.++React to database changes in real-time.++This library is currently _EXPERIMENTAL_ and is not ready for+production yet.  Developers are encouraged to test out the library,+add issues, contribute and make it awesome.++## Setup++In order to use a logical replication client you need to set the+`wal_level` in Postgres to `logical`.  You can set that, for example,+in your `postgres.conf` file:++    wal_level = logical++Then restart your server, log in as `postgres` or another super user+and check:++    SHOW wal_level++It should show _logical_.++You will also need a user who is+[allowed](https://www.postgresql.org/docs/9.1/sql-alterrole.html+"PostgreSQL user role documentation") to use replication features.++Then add a database and your user with the `REPLICATION` trait to the+database, grant all on it if you need to.++We haven't added authorization support yet to the library so make sure+in `pg_hba.conf` you `trust` local connections:++    host    all             all             127.0.0.1/32            trust++Caveat emptor.++You will also need to install the `wal2json` plugin for your+PostgreSQL server.  For example in Ubuntu-like distributions you can:++    $ sudo apt install postgresql-12-wal2json++Assuming you have installed PostgreSQL from the package repositories.++You can then setup a basic "hello world" program using this library:++``` haskell+module Main where++import Control.Exception++import Database.PostgreSQL.Replicant++main :: IO ()+main = do+  let settings = PgSettings "my-user" "my-database" "localhost" "5432" "testing"+  withLogicalStream settings $ \change -> do+    print change+      `catch` \err ->+      print err+```++Which should connect, create a `testing` replication slot, and start+sending your callback the changes to your database as they arrive.++### WAL Output Plugins++Presently we only support the `wal2json` WAL output plugin and only+the v1 format.  Support for the v2 format is planned as are more+output plugins.++## Example Program++Included is a simple program, `replicant-example` which should help+you test your connection settings and make sure that you can connect+and receive WAL changes.++You can change the connection settings through environment variables:++    PG_USER=myuser PG_DATABASE=mydb replicant-example++The configuration settings are:++- `PG_USER`: The replication user to connect as+- `PG_DATABASE`: The database to connect to+- `PG_HOST`: The host name of the database to connect to+- `PG_PORT`: The port to connect on+- `PG_SLOTNAME`: The replication slot to create or connect to+- `PG_UPDATEDELAY`: The frequency (in _ms_) to update PostgreSQL on+  the stream status++## FAQ++### Why not use LISTEN/NOTIFY++You don't have to set up triggers for each table with logical+replication.  You also don't have the message size limitation.  That+limitation often forces clients to perform a query for each message to+fetch the data and can use more than one connection.  We only use one+connection.++### What are the trade offs?++This library uses logical replication slots.  If your program loses+the connection to the database and cannot reconnect the server will+hold onto WAL files until your program can reconnect and resume the+replication stream.  On a busy database, depending on your+configuration, this can eat up disk space quickly.++It's a good idea to monitor your replication slots.  If+`postgresql-replicant` cannot reconnect to the slot and resume the+stream you may need to have a strategy for dropping the slot and+recreating it once conditions return to normal.  Have a strategy.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ app/Main.hs view
@@ -0,0 +1,24 @@+module Main where++import Data.Maybe+import Control.Exception+import System.Environment++import Database.PostgreSQL.Replicant++main :: IO ()+main = do+  dbUser <- maybe "postgresql" id <$> lookupEnv "PG_USER"+  dbName <- maybe "postgresql" id <$> lookupEnv "PG_DATABASE"+  dbHost <- maybe "localhost" id <$> lookupEnv "PG_HOST"+  dbPort <- maybe "5432" id <$> lookupEnv "PG_PORT"+  dbSlot <- maybe "replicant_test" id <$> lookupEnv "PG_SLOTNAME"+  dbUpdateDelay <- maybe "3000" id <$> lookupEnv "PG_UPDATEDELAY"+  let settings = PgSettings dbUser dbName dbHost dbPort dbSlot dbUpdateDelay+  withLogicalStream settings $ \change -> do+    putStrLn "Change received!"+    print change+  `catch`+  \exc -> do+    putStrLn "Something bad happened: "+    print @SomeException exc
+ postgresql-replicant.cabal view
@@ -0,0 +1,96 @@+cabal-version: 1.12++-- This file has been generated from package.yaml by hpack version 0.33.0.+--+-- see: https://github.com/sol/hpack+--+-- hash: b9e10bcacd11fef9dd3cf878a5223b111e9355f146fc121702189290e50c23ed++name:           postgresql-replicant+version:        0.1.0.1+synopsis:       PostgreSQL logical streaming replication library+description:    Please see the README on GitHub at <https://github.com/agentultra/postgres-replicant#readme>+category:       Experimental, Database+homepage:       https://github.com/agentultra/postgresql-replicant#readme+bug-reports:    https://github.com/agentultra/postgresql-replicant/issues+author:         James King+maintainer:     james@agentultra.com+copyright:      2020, 2021, James King+license:        BSD3+license-file:   LICENSE+build-type:     Simple+extra-source-files:+    README.md+    ChangeLog.md+    LICENSE++source-repository head+  type: git+  location: https://github.com/agentultra/postgresql-replicant++library+  exposed-modules:+      Database.PostgreSQL.Replicant+      Database.PostgreSQL.Replicant.Exception+      Database.PostgreSQL.Replicant.Message+      Database.PostgreSQL.Replicant.PostgresUtils+      Database.PostgreSQL.Replicant.Protocol+      Database.PostgreSQL.Replicant.Queue+      Database.PostgreSQL.Replicant.ReplicationSlot+      Database.PostgreSQL.Replicant.Serialize+      Database.PostgreSQL.Replicant.State+      Database.PostgreSQL.Replicant.Types.Lsn+      Database.PostgreSQL.Replicant.Util+  other-modules:+      Paths_postgresql_replicant+  hs-source-dirs:+      src+  default-extensions: DeriveGeneric GADTs LambdaCase OverloadedStrings TypeApplications+  build-depends:+      aeson+    , async+    , attoparsec+    , base >=4.7 && <5+    , bits+    , bytestring+    , cereal+    , containers+    , keep-alive+    , postgresql-libpq+    , scientific+    , stm+    , text+    , time+  default-language: Haskell2010++executable replicant-example+  main-is: Main.hs+  other-modules:+      Paths_postgresql_replicant+  hs-source-dirs:+      app+  default-extensions: DeriveGeneric GADTs LambdaCase OverloadedStrings TypeApplications+  ghc-options: -threaded -rtsopts -with-rtsopts=-N+  build-depends:+      base >=4.7 && <5+    , postgresql-libpq+    , postgresql-replicant+  default-language: Haskell2010++test-suite postgres-replicant-test+  type: exitcode-stdio-1.0+  main-is: Spec.hs+  other-modules:+      Paths_postgresql_replicant+  hs-source-dirs:+      test+  default-extensions: DeriveGeneric GADTs LambdaCase OverloadedStrings TypeApplications+  ghc-options: -threaded -rtsopts -with-rtsopts=-N+  build-depends:+      base >=4.7 && <5+    , binary+    , bytestring+    , cereal+    , hspec+    , postgresql-replicant+  default-language: Haskell2010
+ src/Database/PostgreSQL/Replicant.hs view
@@ -0,0 +1,119 @@+{-# LANGUAGE RecordWildCards #-}++{-|+Module      : Database.PostgreSQL.Replicant+Description : A PostgreSQL streaming replication library+Copyright   : (c) James King, 2020, 2021+License     : BSD3+Maintainer  : james@agentultra.com+Stability   : experimental+Portability : POSIX++Connect to a PostgreSQL server as a logical replication client and+receive changes.++The basic API is this:++@+  withLogicalStream defaultSettings $ \change -> do+    print change+    `catch` \err -> do+      show err+@++This is a low-level library meant to give the primitives necessary to+library authors to add streaming replication support.  The API here to+rather simplistic but should be hooked up to something like conduit to+provide better ergonomics.+-}++module Database.PostgreSQL.Replicant+    ( withLogicalStream+    , PgSettings (..)+    ) where++import Control.Concurrent+import Control.Exception+import Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as B+import qualified Data.Text as T+import qualified Data.Text.Read as T+import Database.PostgreSQL.LibPQ+import Network.Socket.KeepAlive+import System.Posix.Types++import Database.PostgreSQL.Replicant.Exception+import Database.PostgreSQL.Replicant.Protocol+import Database.PostgreSQL.Replicant.Message+import Database.PostgreSQL.Replicant.ReplicationSlot+import Database.PostgreSQL.Replicant.Util++data PgSettings+  = PgSettings+  { pgUser        :: String+  , pgDbName      :: String+  , pgHost        :: String+  , pgPort        :: String+  , pgSlotName    :: String+  , pgUpdateDelay :: String -- ^ Controls how frequently the+                            -- primaryKeepAlive thread updates+                            -- PostgresSQL in @ms@+  }+  deriving (Eq, Show)++pgConnectionString :: PgSettings -> ByteString+pgConnectionString PgSettings {..} = B.intercalate " "+  [ "user=" <> (B.pack pgUser)+  , "dbname=" <> (B.pack pgDbName)+  , "host=" <> (B.pack pgHost)+  , "port=" <> (B.pack pgPort)+  , "replication=database"+  ]++-- | Connect to a PostgreSQL database as a user with the replication+-- attribute and start receiving changes using the logical replication+-- protocol.  Logical replication happens at the query level so the+-- changes you get represent the set of queries in a transaction:+-- /insert/, /update/, and /delete/.+--+-- This function will create the replication slot, if it doesn't+-- exist, or reconnect to it otherwise and restart the stream from+-- where the replication slot left off.+--+-- This function can throw exceptions in @IO@ and shut-down the+-- socket in case of any error.+withLogicalStream :: PgSettings -> (Change -> IO a) -> IO ()+withLogicalStream settings cb = do+  conn <- connectStart $ pgConnectionString settings+  mFd <- socket conn+  sockFd <- maybeThrow (ReplicantException "withLogicalStream: could not get socket fd") mFd+  pollResult <- pollConnectStart conn sockFd+  let updateFreq = getUpdateDelay settings+  case pollResult of+    PollingFailed -> throwIO $ ReplicantException "withLogicalStream: Unable to connect to the database"+    PollingOk -> do+      maybeInfo <- identifySystemSync conn+      _ <- maybeThrow (ReplicantException "withLogicalStream: could not get system information") maybeInfo+      repSlot <- setupReplicationSlot conn $ B.pack . pgSlotName $ settings+      startReplicationStream conn (slotName repSlot) (slotRestart repSlot) updateFreq cb+      pure ()+  where+    pollConnectStart :: Connection -> Fd -> IO PollingStatus+    pollConnectStart conn fd@(Fd cint) = do+      pollStatus <- connectPoll conn+      case pollStatus of+        PollingReading -> do+          threadWaitRead fd+          pollConnectStart conn fd+        PollingWriting -> do+          threadWaitWrite fd+          pollConnectStart conn fd+        PollingOk -> do+          _ <- setKeepAlive cint $ KeepAlive True 60 2+          pure PollingOk+        PollingFailed -> pure PollingFailed+    getUpdateDelay :: PgSettings -> Int+    getUpdateDelay PgSettings {..} =+      case T.decimal . T.pack $ pgUpdateDelay of+        Left _ -> 3000+        Right (i, _) -> i
+ src/Database/PostgreSQL/Replicant/Exception.hs view
@@ -0,0 +1,9 @@+module Database.PostgreSQL.Replicant.Exception where++import Control.Exception+import Data.Typeable++newtype ReplicantException = ReplicantException String+  deriving (Show, Typeable)++instance Exception ReplicantException
+ src/Database/PostgreSQL/Replicant/Message.hs view
@@ -0,0 +1,406 @@+{-|+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+  , changeDeltas  :: [WalLogData]+  }+  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
+ src/Database/PostgreSQL/Replicant/PostgresUtils.hs view
@@ -0,0 +1,19 @@+module Database.PostgreSQL.Replicant.PostgresUtils where++import Data.Fixed+import Data.Time+import GHC.Int++postgresEpoch :: IO Int64+postgresEpoch = do+  let epoch = mkUTCTime (2000, 1, 1) (0, 0, 0)+  now <- getCurrentTime+  pure $ round . (* 1000000) . nominalDiffTimeToSeconds $ diffUTCTime now epoch++-- From https://www.williamyaoh.com/posts/2019-09-16-time-cheatsheet.html+mkUTCTime :: (Integer, Int, Int)+          -> (Int, Int, Pico)+          -> UTCTime+mkUTCTime (year, mon, day) (hours, mins, secs) =+  UTCTime (fromGregorian year mon day)+          (timeOfDayToTime (TimeOfDay hours mins secs))
+ src/Database/PostgreSQL/Replicant/Protocol.hs view
@@ -0,0 +1,234 @@+{-|+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
+ src/Database/PostgreSQL/Replicant/Queue.hs view
@@ -0,0 +1,77 @@+{-|+Module      : Database.PostgreSQL.Replicant.Queue+Description : Bounded and unbounded FIFO queues+Copyright   : (c) James King, 2020, 2021+License     : BSD3+Maintainer  : james@agentultra.com+Stability   : experimental+Portability : POSIX++Shared FIFO queues+-}+module Database.PostgreSQL.Replicant.Queue where++import Control.Concurrent.MVar+import Data.Sequence (Seq, ViewR (..), (<|), (|>))+import qualified Data.Sequence as S++data BoundedFifoQueueMeta a+  = BoundedFifoQueueMeta+  { boundedFifoQueueSize :: Int+  , boundedFifoQueue     :: Seq a+  }+  deriving (Eq, Show)++newtype BoundedFifoQueue a = BoundedFifoQueue (MVar (BoundedFifoQueueMeta a))++newtype BoundedQueueException a+  = BoundedQueueOverflow a+  deriving (Eq, Show)++emptyBounded :: Int -> IO (BoundedFifoQueue a)+emptyBounded size =+  BoundedFifoQueue <$> newMVar (BoundedFifoQueueMeta size S.empty)++enqueueBounded :: BoundedFifoQueue a -> a -> IO (Either (BoundedQueueException a) ())+enqueueBounded (BoundedFifoQueue mQueue) x = do+  b@(BoundedFifoQueueMeta size queue) <- takeMVar mQueue+  if size == S.length queue+    then pure $ Left $ BoundedQueueOverflow x+    else do+    putMVar mQueue $ b { boundedFifoQueue = x <| queue }+    pure $ Right ()++newtype FifoQueue a = FifoQueue (MVar (Seq a))++empty :: IO (FifoQueue a)+empty = FifoQueue <$> newMVar S.empty++-- | Return @True@ if the queue is empty+null :: FifoQueue a -> IO Bool+null (FifoQueue mQueue) = do+  queue <- readMVar mQueue+  pure $ S.null queue++-- | Remove an item from the end of the non-empty queue.+dequeue :: FifoQueue a -> IO (Maybe a)+dequeue (FifoQueue mQueue) = do+  queue <- takeMVar mQueue+  case S.viewr queue of+    S.EmptyR -> do+      putMVar mQueue queue+      pure Nothing+    rest :> x -> do+      putMVar mQueue rest+      pure $ Just x++-- | Put an item on the front of the queue.+enqueue :: FifoQueue a -> a -> IO ()+enqueue (FifoQueue mQueue) x = do+  queue <- takeMVar mQueue+  putMVar mQueue $ x <| queue++-- | Put an item on the end of the queue so that it will be dequeued first.+enqueueRight :: FifoQueue a -> a -> IO ()+enqueueRight (FifoQueue mQueue) x = do+  queue <- takeMVar mQueue+  putMVar mQueue $ queue |> x
+ src/Database/PostgreSQL/Replicant/ReplicationSlot.hs view
@@ -0,0 +1,150 @@+{-|+Module      : Database.PostgreSQL.Replicant.ReplicationSlot+Description : Replication slot query commands+Copyright   : (c) James King, 2020, 2021+License     : BSD3+Maintainer  : james@agentultra.com+Stability   : experimental+Portability : POSIX++This module contains the PostgreSQL queries, types, and functions for+working with querying, creating, and working with replication slots.+-}+module Database.PostgreSQL.Replicant.ReplicationSlot where++import Control.Exception+import Data.ByteString (ByteString)+import qualified Data.ByteString as B+import qualified Data.ByteString.Char8 as B8+import Database.PostgreSQL.LibPQ++import Database.PostgreSQL.Replicant.Exception+import Database.PostgreSQL.Replicant.Types.Lsn++data ReplicationSlotInfo+  = ReplicationSlotInfo+  { slotName    :: ByteString+  , slotPlugin  :: ByteString+  , slotType    :: ReplicationSlotType+  , slotActive  :: ReplicationSlotActive+  , slotRestart :: LSN+  }+  deriving (Eq, Show)++data ReplicationSlotType = Logical | Physical | UnknownSlotType+  deriving (Eq, Show)++parseSlotType :: ByteString -> ReplicationSlotType+parseSlotType "logical"  = Logical+parseSlotType "physical" = Physical+parseSlotType _          = UnknownSlotType++data ReplicationSlotActive = Active | Inactive+  deriving (Eq, Show)++parseSlotActive :: ByteString -> ReplicationSlotActive+parseSlotActive "t" = Active+parseSlotActive "f" = Inactive+parseSlotActive _   = Inactive++createReplicationSlotCommand :: Connection -> ByteString -> IO ByteString+createReplicationSlotCommand conn slotName = do+  escapedName <- escapeIdentifier conn slotName+  case escapedName of+    Nothing -> throwIO $ ReplicantException $ "Invalid slot name: " ++ show slotName+    Just escaped ->+      pure $+      B.intercalate+      ""+      [ "CREATE_REPLICATION_SLOT"+      , escaped+      , "LOGICAL wal2json"+      ]++-- | Create a replication slot using synchronous query execution.+--+-- May throw an exception if the command fails.+createReplicationSlotSync :: Connection -> ByteString -> IO ReplicationSlotInfo+createReplicationSlotSync conn slotName = do+  createReplicationSlotQuery <- createReplicationSlotCommand conn slotName+  result <- exec conn createReplicationSlotQuery+  case result of+    Just r -> do+      resultStatus <- resultStatus r+      case resultStatus of+        TuplesOk -> do+          sName           <- getvalue' r (toRow 0) (toColumn 0)+          consistentPoint <- getvalue' r (toRow 0) (toColumn 1)+          outputPlugin    <- getvalue' r (toRow 0) (toColumn 3)+          case (sName, consistentPoint, outputPlugin) of+            (Just s, Just c, Just op) ->+              case fromByteString c of+                Left _ -> throwIO $ ReplicantException "createReplicationSlotSync: invalid LSN detected"+                Right lsn -> pure $ ReplicationSlotInfo s op Logical Active lsn+            _ -> do+              err <- maybe "createReplicationSlotSync: unknown error" id <$> errorMessage conn+              throwIO $ ReplicantException (B8.unpack err)+        _ -> do+          err <- maybe "createReplicationSlotSync: unknown error" id <$> errorMessage conn+          throwIO $ ReplicantException (B8.unpack err)+    _ -> do+      err <- maybe "createReplicationSlotSync: unknown error" id <$> errorMessage conn+      throwIO $ ReplicantException (B8.unpack err)++getReplicationSlotInfoCommand :: Connection -> ByteString -> IO ByteString+getReplicationSlotInfoCommand conn slotName = do+  escapedName <- escapeStringConn conn slotName+  case escapedName of+    Nothing -> throwIO $ ReplicantException $ "Invalid slot name: " ++ show slotName+    Just escaped ->+      pure $+      B.intercalate+      ""+      [ "select slot_name, plugin, slot_type, active, restart_lsn from pg_replication_slots where slot_name = '"+      , escaped+      , "';"+      ]++-- | Get information about an existing replication slot.  Returns+-- @Nothing@ when the requested slot cannot be found.+--+-- May throw an exception if the command query fails.+getReplicationSlotSync :: Connection -> ByteString -> IO (Maybe ReplicationSlotInfo)+getReplicationSlotSync conn slotName = do+  replicationSlotInfoQuery <- getReplicationSlotInfoCommand conn slotName+  result <- exec conn replicationSlotInfoQuery+  case result of+    Just r -> do+      resultStatus <- resultStatus r+      case resultStatus of+        TuplesOk -> do+          nRows <- ntuples r+          if nRows == 0+            then pure Nothing+            else do+            slotName    <- getvalue' r (toRow 0) (toColumn 0)+            slotPlugin  <- getvalue' r (toRow 0) (toColumn 1)+            slotType    <- getvalue' r (toRow 0) (toColumn 2)+            slotActive  <- getvalue' r (toRow 0) (toColumn 3)+            slotRestart <- getvalue' r (toRow 0) (toColumn 4)+            case (slotName, slotPlugin, slotType, slotActive, slotRestart) of+              (Just n, Just p, Just t, Just a, Just restart) -> do+                case fromByteString restart of+                  Left _ -> pure Nothing -- TODO: this shouldn't happen...+                  Right lsn -> pure $ Just $ ReplicationSlotInfo n p (parseSlotType t) (parseSlotActive a) lsn+              _ ->  pure Nothing+        _ -> pure Nothing+    _ -> do+      err <- maybe "getReplicationSlotSync: unknown error" id <$> errorMessage conn+      throwIO $ ReplicantException (B8.unpack err)++-- | Create replication slot or retrieve an existing slot.+--+-- Can throw exceptions from @getReplicationSlotSync@ or+-- @createReplicationSlotSync@.+setupReplicationSlot :: Connection -> ByteString -> IO ReplicationSlotInfo+setupReplicationSlot conn slotName = do+  maybeSlot <- getReplicationSlotSync conn slotName+  case maybeSlot of+    Just slot -> pure $ slot+    Nothing   -> createReplicationSlotSync conn slotName
+ src/Database/PostgreSQL/Replicant/Serialize.hs view
@@ -0,0 +1,10 @@+module Database.PostgreSQL.Replicant.Serialize where++import Data.ByteString (ByteString)+import Data.Serialize++-- | Consume the rest of the @Get a@ input as a ByteString+consumeByteStringToEnd :: Get ByteString+consumeByteStringToEnd = do+  numRemaining <- remaining+  getByteString numRemaining
+ src/Database/PostgreSQL/Replicant/State.hs view
@@ -0,0 +1,44 @@+{-|+Module      : Database.PostgreSQL.Replicant.State+Description : Internal replication stream state+Copyright   : (c) James King, 2020, 2021+License     : BSD3+Maintainer  : james@agentultra.com+Stability   : experimental+Portability : POSIX++This module has the types and functions for maintaining the client+stream state.++After initiating a replication stream the wal_sender process on the+server may require clients to periodically send progress updates.  The+wal_sender process uses those updates to maintain its internal view of+the clients' state.++This enables the server to report on things like replication lag and+enables the client to disconnect and restart the stream where it left+off.+-}+module Database.PostgreSQL.Replicant.State where++import Control.Concurrent+import Database.PostgreSQL.Replicant.Types.Lsn++data WalProgress+  = WalProgress+  { walProgressReceived :: LSN+  , walProgressFlushed  :: LSN+  , walProgressApplied  :: LSN+  }+  deriving (Eq, Show)++newtype WalProgressState = WalProgressState (MVar WalProgress)++updateWalProgress :: WalProgressState -> LSN -> IO ()+updateWalProgress (WalProgressState state) lsn = do+  walProgress <- takeMVar state+  putMVar state+    $ walProgress { walProgressReceived = lsn+                  , walProgressFlushed  = lsn+                  , walProgressApplied  = lsn+                  }
+ src/Database/PostgreSQL/Replicant/Types/Lsn.hs view
@@ -0,0 +1,104 @@+{-|+Module      : Database.PostgreSQL.Replicant.Types.Lsn+Description : Types and parsers for LSNs+Copyright   : (c) James King, 2020, 2021+License     : BSD3+Maintainer  : james@agentultra.com+Stability   : experimental+Portability : POSIX++/Log Sequence Number/ or LSN is a pointer to a place inside of a WAL+log file.  It contains the file name and an offset in bytes encoded in+two parts.++LSNs can be serialized into 64-bit big-endian numbers in the binary+protocol but are also represented textually in query results and other+places.++This module follows a similar convention to many containers libraries+and should probably be imported qualified to avoid name clashes if+needed.++See: https://www.postgresql.org/docs/10/datatype-pg-lsn.html+-}+module Database.PostgreSQL.Replicant.Types.Lsn where++import Data.Aeson+import Data.Attoparsec.ByteString.Char8+import Data.Bits+import Data.Bits.Extras+import Data.ByteString (ByteString ())+import qualified Data.ByteString.Lazy as BL+import qualified Data.ByteString.Lazy.Builder as Builder+import Data.ByteString.Lazy.Builder.ASCII (word32Hex)+import Data.Serialize+import Data.Word+import qualified Data.Text.Encoding as T+import GHC.Int++data LSN = LSN+  { filepart :: !Int32 -- ^ Filepart+  , offset :: !Int32 -- ^ Offset+  }+  deriving (Show, Eq)++instance Ord LSN where+  compare (LSN l0 r0) (LSN l1 r1) =+    compare l0 l1 <> compare r0 r1++instance Serialize LSN where+  put = putInt64be . toInt64+  get = fromInt64 <$> getInt64be++instance ToJSON LSN where+  toJSON = String . T.decodeUtf8 . toByteString++instance FromJSON LSN where+  parseJSON = withText "LSN" $ \txt ->+    case fromByteString . T.encodeUtf8 $ txt of+      Left err  -> fail err+      Right lsn -> pure lsn++-- | Convert an LSN to a 64-bit integer+toInt64 :: LSN -> Int64+toInt64 (LSN filePart offSet) =+  let r = w64 filePart `shiftL` 32+  in fromIntegral $ r .|. fromIntegral offSet++-- | Convert a 64-bit integer to an LSN+fromInt64 :: Int64 -> LSN+fromInt64 x =+  let mask = w64 $ maxBound @Word32+      offSet = fromIntegral . w32 $ mask .&. fromIntegral x+      filePart = fromIntegral $ x `shiftR` 32+  in LSN filePart offSet++lsnParser :: Parser LSN+lsnParser = LSN <$> (hexadecimal <* char '/') <*> hexadecimal++fromByteString :: ByteString -> Either String LSN+fromByteString = parseOnly lsnParser++-- | Note that as of bytestring ~0.10.12.0 we don't have upper-case+-- hex encoders but the patch to add them has been merged and when+-- available we should switch to them+toByteString :: LSN -> ByteString+toByteString (LSN filepart off) = BL.toStrict+  $ Builder.toLazyByteString+  ( word32Hex (fromIntegral filepart)+    <> Builder.char7 '/'+    <> word32Hex (fromIntegral off)+  )++-- | Add a number of bytes to an LSN+add :: LSN -> Int64 -> LSN+add lsn bytes = fromInt64 . (+ bytes) . toInt64 $ lsn++-- | Subtract a number of bytes from an LSN+sub :: LSN -> Int64 -> LSN+sub lsn bytes = fromInt64 . flip (-) bytes . toInt64 $ lsn++-- | Subtract two LSN's to calculate the difference of bytes between+-- them.+subLsn :: LSN -> LSN -> Int64+subLsn lsn1 lsn2 = toInt64 lsn1 - toInt64 lsn2
+ src/Database/PostgreSQL/Replicant/Util.hs view
@@ -0,0 +1,12 @@+module Database.PostgreSQL.Replicant.Util where++import Control.Exception+import GHC.Int++mkInt64 :: Int -> Int64+mkInt64 k = fromIntegral k * 1000000++maybeThrow :: Exception e => e -> Maybe a -> IO a+maybeThrow exc = \case+  Nothing -> throwIO exc+  Just x  -> pure x
+ test/Spec.hs view
@@ -0,0 +1,120 @@+import Test.Hspec++import Data.Serialize+import Data.ByteString (ByteString)+import qualified Data.ByteString as B+import qualified Data.ByteString.Char8 as B8+import Data.Word+import GHC.Int++import Database.PostgreSQL.Replicant.Message+import Database.PostgreSQL.Replicant.Types.Lsn+import qualified Database.PostgreSQL.Replicant.Queue as Q++examplePrimaryKeepAliveMessage :: ByteString+examplePrimaryKeepAliveMessage+  = B.concat+    [ (B.pack [0x6B])+    , (encode @LSN (fromInt64 123))+    , (encode @LSN (fromInt64 346))+    , (encode @Word8 1)+    ]++main :: IO ()+main = hspec $ do+  context "Message" $ do+    describe "PrimaryKeepAlive" $ do+      it "should decode a valid primary keep alive message" $ do+        (decode $ examplePrimaryKeepAliveMessage)+          `shouldBe`+          (Right $ PrimaryKeepAlive 123 346 ShouldRespond)++    describe "StandbyStatusUpdate" $ do+      it "should encode a valid standby status update message" $ do+        (encode $ StandbyStatusUpdate+         (fromInt64 213)+         (fromInt64 232)+         (fromInt64 234)+         454+         DoNotRespond)+          `shouldBe`+          B.concat+          [ B.pack [0x72]+          , encode @LSN (fromInt64 213)+          , encode @LSN (fromInt64 232)+          , encode @LSN (fromInt64 234)+          , encode @Int64 454+          , encode @Word8 0+          ]++    describe "XLogData" $ do+      it "should encode/decode a valid xLogData message" $ do+        let msg = XLogData (fromInt64 123) (fromInt64 234) 345 (B8.pack "hello")+        (decode . encode $ msg)+          `shouldBe`+          Right msg++    describe "HotStandbyFeedback" $ do+      it "should encode/decode a valid HotStandbyFeedback message" $ do+        let msg = HotStandbyFeedback 123 234 456+        (decode . encode $ msg)+          `shouldBe`+          Right msg++    describe "WalCopyData" $ do+      it "should encode/decode an XLogData message" $ do+        let msg = XLogDataM (XLogData (fromInt64 123) (fromInt64 234) 345 (B8.pack "hello"))+        (decode . encode $ msg)+          `shouldBe`+          Right msg++      it "should encode/decode a PrimaryKeepAlive message" $ do+        let msg = KeepAliveM (PrimaryKeepAlive 123 346 ShouldRespond)+        (decode . encode $ msg)+          `shouldBe`+          Right msg++  context "Types" $ do+    describe "LSN" $ do+      context "Serializable" $ do+        it "should be serializable" $ do+          let lsn = LSN 2 23+          (decode . encode $ lsn) `shouldBe` Right lsn++        it "should be equivalent to fromByteString/toByteString" $ do+          let (Right lsn) = fromByteString "16/3002D50"+          (toByteString <$> (decode . encode @LSN $ lsn)) `shouldBe` Right "16/3002d50"++  describe "FifoQueue" $ do+    it "should dequeue Nothing if the queue is empty" $ do+      queue <- Q.empty @Int+      res <- Q.dequeue queue+      res `shouldBe` Nothing++    it "should respect first-in, first-out" $ do+      queue <- Q.empty+      Q.enqueue queue 1+      Q.enqueue queue 2+      Q.enqueue queue 3+      res <- Q.dequeue queue+      res `shouldBe` Just 1++    context "null" $ do+      it "should return True if the queue is empty" $ do+        queue <- Q.empty+        res <- Q.null queue+        res `shouldBe` True++      it "should return False if the queue has at least one element" $ do+        queue <- Q.empty+        Q.enqueue queue 1+        res <- Q.null queue+        res `shouldBe` False++    context "enqueueRight" $ do+      it "should enqueue an item at the head of the queue" $ do+        queue <- Q.empty+        Q.enqueue queue 1+        Q.enqueueRight queue 2+        res <- Q.dequeue queue+        res `shouldBe` Just 2