packages feed

postgresql-resilient-0.1.0.0: src/Database/PostgreSQL/Resilient.hs

{-# LANGUAGE DeriveAnyClass, DerivingVia #-}
{-# LANGUAGE FlexibleContexts, ScopedTypeVariables #-}
{-# LANGUAGE OverloadedStrings, RankNTypes #-}

{-|
Module      : Database.PostgreSQL.Resilient
Description : PostgreSQL single-connection pool with automatic reconnection support, built on top of postgresql-simple.
License     : Apache-2.0
Maintainer  : volpegabriel@gmail.com
Stability   : experimental

The `withResilientConnection` function gives us a `ResilientConnection` from which we can always get a health connection, while automatic reconnection with retries and exponential back-offs are being handled in the background.

@
import           Database.PostgreSQL.Resilient
import qualified Database.PostgreSQL.Simple    as P

withResilientConnection defaultResilientSettings logHandler connectInfo $ \pool ->
  (conn :: P.Connection) <- getConnection pool
  res <- P.query_ conn "SELECT * FROM foo"
  putStrLn $ show res

logHandler :: String -> IO ()
logHandler = putStrLn

connectInfo :: P.ConnectInfo
connectInfo = P.ConnectInfo
  { P.connectHost     = "localhost"
  , P.connectPort     = 5432
  , P.connectUser     = "postgres"
  , P.connectPassword = ""
  , P.connectDatabase = "store"
  }

defaultResilientSettings :: ResilientSettings
defaultResilientSettings = ResilientSettings
  { healthCheckEvery     = 3
  , exponentialBackoffThreshold = 10
  }
@
-}
module Database.PostgreSQL.Resilient
  ( ResilientConnection(..)
  , ResilientSettings(..)
  , Seconds
  , withResilientConnection
  , defaultResilientSettings
  )
where

import           Control.Concurrent             ( forkIO
                                                , killThread
                                                , threadDelay
                                                )
import           Control.Concurrent.MVar
import           Control.Monad                  ( forever )
import           Control.Monad.Catch
import           Data.IORef
import           Data.Functor                   ( void )
import           Data.Maybe                     ( fromJust )
import qualified Database.PostgreSQL.Simple    as P
import           GHC.IO.Exception
import           Prelude                 hiding ( init )

data DBConnectionError = DBConnectionError deriving (Exception, Show)

{- | Single connection pool with built-in reconnection -}
data ResilientConnection m = ResilientConnection
  { getConnection :: m P.Connection -- ^ Get the latest healthy connection.
  }

type LogHandler = String -> IO ()

{- | Represents amount of seconds -}
newtype Seconds = Seconds Int
  deriving (Eq, Num, Ord, Show) via Int

{- | The resilient settings -}
data ResilientSettings = ResilientSettings
  { healthCheckEvery :: Seconds            -- ^ How often to check the connection status.
  , exponentialBackoffThreshold :: Seconds -- ^ After this threshold, stop the exponential back-off.
  } deriving Show

{- | Default resilient settings -}
defaultResilientSettings :: ResilientSettings
defaultResilientSettings =
  ResilientSettings { healthCheckEvery = 3, exponentialBackoffThreshold = 10 }

{- | Sleep for n amount of seconds -}
sleep :: Seconds -> IO ()
sleep (Seconds n) = threadDelay (n * 1000000)

healthCheck :: LogHandler -> P.Connection -> IO ()
healthCheck logger conn = do
  (res :: [P.Only String]) <- P.query_ conn "SELECT version();"
  logger $ show res

{- | Returns a `ResilientConnection` from which you can always acquire the latest connection available.
 -
 - Reconnections with configurable retries and exponential back-offs as well as closing the connection once done using it (guaranteed by `bracket`) are too handled by this function.
 - -}
withResilientConnection
  :: forall a
   . ResilientSettings
  -> LogHandler
  -> P.ConnectInfo
  -> (ResilientConnection IO -> IO a)
  -> IO a
withResilientConnection settings logger info f = do
  ((,) <$> newIORef Nothing <*> newEmptyMVar) >>= \(connRef, signal) ->
    let shutdown = readMVar signal >>= killThread -- ends keep-alive process
        pool     = ResilientConnection (fromJust <$> readIORef connRef)
        ka       = keepAlive (reconnect connRef) pool
        init     = acquire connRef >> ka >>= putMVar signal
    in  bracket (pool <$ init) (release shutdown) f
 where
  acquire ref = do
    logger "Connecting to PostgreSQL"
    conn <- P.connect info
    conn <$ atomicWriteIORef ref (Just conn)

  release shutdown pool = do
    logger "Closing PostgreSQL connection"
    conn <- getConnection pool
    P.close conn
    logger "Shutdown PostgreSQL reconnection process"
    shutdown

  clean conn = do
    logger "Closing no longer valid PostgreSQL connection"
    P.close conn

  reconnect ref n = catch (void $ acquire ref) $ \(e :: SomeException) ->
    logger (retries e) >> sleep n >> reconnect ref n'
   where
    retries e = show e <> "\n >>> Retrying in " <> show n <> " seconds."
    t  = exponentialBackoffThreshold settings
    n' = if n >= t then t else n * 2

  keepAlive rec pool = forkIO $ forever $ do
    sleep $ healthCheckEvery settings
    logger "Checking PostgreSQL connection status"
    conn <- getConnection pool
    catch
      (healthCheck logger conn)
      (\(e :: IOError) ->
        -- OtherError is thrown on every internal libpq error such as connection error
        if ioe_type e == ResourceVanished || ioe_type e == OtherError
          then clean conn >> rec 1
          else logger (show e) >> throwM DBConnectionError
      )