packages feed

polysemy-hasql-0.0.1.0: lib/Polysemy/Hasql/Interpreter/Database.hs

module Polysemy.Hasql.Interpreter.Database where

import Conc (Lock, interpretAtomic, interpretLockReentrant, lock)
import qualified Database.PostgreSQL.LibPQ as LibPQ
import Exon (exon)
import Hasql.Connection (Connection, withLibPQConnection)
import qualified Log
import Polysemy.Db.Data.DbConfig (DbConfig)
import Polysemy.Db.Data.DbConnectionError (DbConnectionError)
import qualified Polysemy.Db.Data.DbError as DbError
import Polysemy.Db.Data.DbError (DbError)
import qualified Time
import Time (NanoSeconds (NanoSeconds))

import qualified Polysemy.Hasql.Data.ConnectionState as ConnectionState
import Polysemy.Hasql.Data.ConnectionState (ConnectionState (ConnectionState), ConnectionsState (ConnectionsState))
import Polysemy.Hasql.Data.ConnectionTag (ConnectionTag (GlobalTag, SerialTag))
import Polysemy.Hasql.Data.InitDb (InitDb (InitDb), hoistInitDb)
import Polysemy.Hasql.Effect.Database (
  ConnectionSource (Global, Supplied, Unique),
  Database (Release, ResetInit, Retry, Session, Tag, Use, WithInit),
  withDatabaseGlobal,
  )
import qualified Polysemy.Hasql.Effect.DbConnectionPool as DbConnectionPool
import Polysemy.Hasql.Effect.DbConnectionPool (DbConnectionPool)
import Polysemy.Hasql.Interpreter.DbConnectionPool (interpretDbConnectionPool)
import Polysemy.Hasql.Session (runSession)

genTag ::
  Member (AtomicState ConnectionsState) r =>
  Sem r ConnectionTag
genTag =
  SerialTag <$> atomicState' \ ConnectionsState {..} ->
    let new = counter + 1
    in (ConnectionsState {counter = new, ..}, new)

tagForSource ::
  Member (AtomicState ConnectionsState) r =>
  ConnectionSource ->
  Sem r (Either Connection ConnectionTag)
tagForSource = \case
  Global -> pure (Right GlobalTag)
  Unique t -> Right <$> fromMaybeA genTag t
  Supplied _ c -> pure (Left c)

needsInit ::
  Member (AtomicState ConnectionsState) r =>
  InitDb m ->
  Int ->
  Sem r Bool
needsInit (InitDb clientId once _) count =
  atomicView (#clientInits . at clientId) <&> \case
    Just lastConnection ->
      not once && lastConnection < count
    Nothing ->
      True

runInit ::
  Members [AtomicState ConnectionsState, Log, Embed IO] r =>
  InitDb (Sem r) ->
  Int ->
  Connection ->
  Sem r ()
runInit (InitDb clientId _ initDb) count connection = do
  Log.trace [exon|Running init for '##{clientId}'|]
  initDb connection
  atomicModify' (#clientInits . at clientId ?~ count)

acquireConnection ::
  Members [DbConnectionPool !! DbConnectionError, AtomicState ConnectionState, Stop DbError, Lock] r =>
  ConnectionTag ->
  Sem r (Int, Connection)
acquireConnection ctag =
  lock do
    atomicGet >>= \case
      ConnectionState count Nothing tids -> do
        conn <- resumeHoist DbError.Connection (DbConnectionPool.acquire ctag)
        atomicPut (ConnectionState (count + 1) (Just conn) tids)
        pure (count + 1, conn)
      ConnectionState count (Just conn) _ ->
        pure (count, conn)

releaseConnection ::
  Members [DbConnectionPool !! DbConnectionError, AtomicState ConnectionState, Log] r =>
  ConnectionTag ->
  Sem r ()
releaseConnection ctag = do
  atomicModify' (#connection .~ Nothing)
  DbConnectionPool.release ctag !! \ e ->
    Log.error [exon|Releasing connection failed: #{show e}|]

-- | After a computation failed, the Postgres connection needs to be health-checked.
-- If the status is 'LibPQ.ConnectionBad', remove the connection from the state and release it, causing the next call to
-- 'acquireConnection' to request a new one.
--
-- It is conceivable for the connection to be stuck in a startup phase like 'LibPQ.ConnectionSSLStartup', but since
-- hasql only uses a connection that is fully established, it shouldn't happen.
--
-- TODO Can't hurt to investigate this anyway.
releaseBadConnection ::
  Members [DbConnectionPool !! DbConnectionError, AtomicState ConnectionState, Stop DbError, Log, Lock, Embed IO] r =>
  ConnectionTag ->
  Connection ->
  Sem r ()
releaseBadConnection ctag conn =
  tryIOError (withLibPQConnection conn LibPQ.status) >>= \case
    Right LibPQ.ConnectionBad -> do
      Log.debug [exon|Releasing bad connection ##{ctag}|]
      releaseConnection ctag
    Left err ->
      Log.error [exon|Releasing bad connection failed: #{err}|]
    _ ->
      unit

bracketConnection ::
  Member Resource r =>
  Members [DbConnectionPool !! DbConnectionError, AtomicState ConnectionState, Stop DbError, Log, Lock, Embed IO] r =>
  ConnectionTag ->
  (Int -> Connection -> Sem r a) ->
  Sem r a
bracketConnection ctag use =
  resumeHoist DbError.Connection $ DbConnectionPool.use ctag $
  bracketOnError (acquireConnection ctag) onError (raise . uncurry use)
  where
    onError (_, conn) = releaseBadConnection ctag conn

withInit ::
  Members [AtomicState ConnectionsState, Stop DbError, Log, Embed IO] r =>
  Int ->
  Connection ->
  InitDb (Sem r) ->
  Sem r a ->
  Sem r a
withInit count connection initDb main = do
  whenM (needsInit initDb count) do
    runInit initDb count connection
  main

withInitManaged ::
  Members [AtomicState ConnectionState, AtomicState ConnectionsState, DbConnectionPool !! DbConnectionError] r =>
  Members [Stop DbError, Lock, Resource, Log, Embed IO] r =>
  ConnectionTag ->
  InitDb (Sem r) ->
  (Connection -> Sem r a) ->
  Sem r a
withInitManaged ctag initDb use = do
  bracketConnection ctag \ count connection -> do
    Log.trace [exon|Client '##{clientTag}' uses database connection '##{ctag}'|]
    withInit count connection initDb (use connection)
  where
    clientTag = initDb ^. #tag

retrying ::
  Members [DbConnectionPool !! DbConnectionError, Time t d, Stop DbError, Resource, Embed IO] r =>
  (Int, NanoSeconds) ->
  Sem (Stop DbError : r) a ->
  Sem r a
retrying (total, interval) action =
  spin total
  where
    spin 0 = subsume action
    spin count =
      runStop action >>= leftA \case
        DbError.Connection _ -> do
          Time.sleep interval
          spin (count - 1)
        e ->
          stop e

managedConnection ::
  ∀ r m t d a .
  Members [AtomicState ConnectionsState, AtomicState ConnectionState, DbConnectionPool !! DbConnectionError] r =>
  Members [Time t d, Resource, Lock, Log, Embed IO] r =>
  ConnectionTag ->
  InitDb (Sem r) ->
  Maybe (Int, NanoSeconds) ->
  Database m a ->
  Tactical (Database !! DbError) m (Stop DbError : r) a
managedConnection ctag initDb retryMay = \case
  WithInit (InitDb t o new) ma -> do
    s <- getInitialStateT
    newT <- bindT new
    let new' c = void (interpretResumableH (managedConnection ctag def Nothing) (newT (c <$ s)))
    raise . interpretResumableH (managedConnection ctag (InitDb t o new') retryMay) =<< runT ma
  Session ma -> do
    result <- retrying retry $ withInitManaged ctag (hoistInitDb (insertAt @0) initDb) \ connection ->
      runSession connection ma
    pureT result
  Use use ->
    withInitManaged ctag (hoistInitDb (insertAt @0) initDb) \ connection ->
      runTSimple (use connection)
  Release ->
    pureT =<< releaseConnection ctag
  Retry interval count ma -> do
    let r = Just (fromMaybe 0 count, Time.convert interval)
    raise . interpretResumableH (managedConnection ctag (hoistInitDb raise initDb) r) =<< runT ma
  Tag ->
    pureT ctag
  ResetInit ->
    pureT =<< atomicModify' @ConnectionsState (#clientInits .~ mempty)
  where
    retry = fromMaybe (0, NanoSeconds 0) retryMay

unmanagedConnection ::
  ∀ r m a .
  Members [AtomicState ConnectionsState, AtomicState ConnectionState, DbConnectionPool !! DbConnectionError] r =>
  Members [Resource, Lock, Log, Embed IO, Final IO] r =>
  Connection ->
  InitDb (Sem r) ->
  Database m a ->
  Tactical (Database !! DbError) m (Stop DbError : r) a
unmanagedConnection connection initDb = \case
  WithInit (InitDb t o new) ma -> do
    s <- getInitialStateT
    newT <- bindT new
    let new' c = void (interpretResumableH (unmanagedConnection connection def) (newT (c <$ s)))
    raise . interpretResumableH (unmanagedConnection connection (InitDb t o new')) =<< runT ma
  Session ma ->
    withInit 0 connection (hoistInitDb (insertAt @0) initDb) do
      pureT =<< runSession connection ma
  Use use ->
    withInit 0 connection (hoistInitDb (insertAt @0) initDb) do
      runTSimple (use connection)
  Release ->
    unitT
  -- TODO
  Retry _ _ ma ->
    runTSimple ma
  Tag ->
    pureT "unmanaged"
  -- TODO
  ResetInit ->
    unitT

handleDatabase ::
  ∀ r m a t d .
  Members [AtomicState ConnectionsState, AtomicState ConnectionState, DbConnectionPool !! DbConnectionError] r =>
  Members [Time t d, Resource, Lock, Log, Embed IO, Final IO] r =>
  Either Connection ConnectionTag ->
  Database m a ->
  Tactical (Database !! DbError) m (Stop DbError : r) a
handleDatabase = \case
  Right t -> managedConnection t def Nothing
  Left c -> unmanagedConnection c def

type DatabaseScope =
  [
    AtomicState ConnectionState,
    Lock
  ]

databaseScope ::
  Members [DbConnectionPool !! DbConnectionError, AtomicState ConnectionsState, Resource, Mask, Race, Embed IO] r =>
  (Either Connection ConnectionTag -> Sem (DatabaseScope ++ r) a) ->
  ConnectionSource ->
  Sem r a
databaseScope use source =
  interpretLockReentrant $ interpretAtomic def do
    ctag <- tagForSource source
    finally (use ctag) (traverse_ (resume_ . DbConnectionPool.free) ctag)

interpretDatabases ::
  ∀ t d r .
  Members [DbConnectionPool !! DbConnectionError, AtomicState ConnectionsState] r =>
  Members [Time t d, Log, Resource, Mask, Race, Embed IO, Final IO] r =>
  InterpreterFor (Scoped ConnectionSource (Database !! DbError)) r
interpretDatabases =
  interpretResumableScopedWithH @DatabaseScope (flip databaseScope) handleDatabase

interpretDatabase ::
  ∀ t d r .
  Members [DbConnectionPool !! DbConnectionError, Time t d, Resource, Log, Mask, Race, Embed IO, Final IO] r =>
  InterpretersFor [Database !! DbError, Scoped ConnectionSource (Database !! DbError)] r
interpretDatabase =
  interpretAtomic (ConnectionsState 0 mempty) .
  interpretDatabases .
  raiseUnder .
  withDatabaseGlobal

type HasqlStack =
  [
    Database !! DbError,
    Scoped ConnectionSource (Database !! DbError),
    DbConnectionPool !! DbConnectionError
  ]

interpretHasql ::
  Members [Time t d, Log, Mask, Resource, Race, Embed IO, Final IO] r =>
  DbConfig ->
  Maybe Int ->
  Maybe Int ->
  InterpretersFor HasqlStack r
interpretHasql dbConfig maxActive maxAvailable =
  interpretDbConnectionPool dbConfig maxActive maxAvailable .
  interpretDatabase