packages feed

shomei-postgres-0.3.0.0: src/Shomei/Persistence/Pool/Postgres.hs

-- | A finite, cancellation-safe pool over public Hasql connections.
-- Every slot has one owner under masking; query/network work is restored to the caller's
-- masking state, and interruption disposes the connection before returning its capacity.
module Shomei.Persistence.Pool.Postgres
  ( Pool,
    UsageError (..),
    acquirePool,
    use,
    release,
  )
where

import Control.Concurrent.STM
import Control.Exception (SomeException, finally, mask, throwIO, try)
import Control.Monad (forM_)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (DiffTime)
import Hasql.Connection qualified as Connection
import Hasql.Connection.Settings qualified as Settings
import Hasql.Errors (ConnectionError, SessionError)
import Hasql.Session (Session)
import Hasql.Session qualified as Session
import System.Timeout (timeout)

-- Keep the failure vocabulary stable while owning connection lifecycle explicitly.
data UsageError
  = ConnectionUsageError ConnectionError
  | SessionUsageError SessionError
  | AcquisitionTimeoutUsageError
  deriving stock (Eq, Show)

data Entry = Entry !Connection.Connection !Integer

data Pool = Pool
  { idle :: !(TQueue Entry),
    capacity :: !(TVar Int),
    generation :: !(TVar Integer),
    acquisitionMicros :: !Int,
    connectionSettings :: !Settings.Settings,
    initializeSession :: !(Session ())
  }

data Borrowed = Existing !Entry | Vacant !Integer

-- | Acquire lazily. The timeout bounds waiting for capacity; transaction deadlines also
-- cover connection establishment. Server settings validate positive sizes/timeouts.
acquirePool :: Int -> DiffTime -> Int -> Text -> IO Pool
acquirePool size acquisitionTimeout statementTimeoutMs connStr = do
  idle <- newTQueueIO
  capacity <- newTVarIO (max 1 size)
  generation <- newTVarIO 0
  let milliseconds = max 0 statementTimeoutMs
      setting = Text.pack (show milliseconds)
      setup = "SET statement_timeout = " <> setting <> "; SET idle_in_transaction_session_timeout = " <> setting
      micros = max 1 (min (toInteger (maxBound :: Int)) (floor (toRational acquisitionTimeout * 1000000)))
  pure
    Pool
      { idle,
        capacity,
        generation,
        acquisitionMicros = fromInteger micros,
        connectionSettings = Settings.connectionString connStr,
        initializeSession = Session.script setup
      }

-- | Failures discard the connection conservatively, including a closed backend reported as
-- a decoder error. Successful sessions alone return a connection for reuse.
use :: Pool -> Session a -> IO (Either UsageError a)
use pool session = mask \restore -> do
  let returnSlot = atomically (modifyTVar' pool.capacity (+ 1))
      dispose connection = Connection.release connection `finally` returnSlot
      returnEntry entry@(Entry connection epoch) = do
        reusable <- atomically do
          current <- readTVar pool.generation
          if current == epoch then writeTQueue pool.idle entry >> pure True else pure False
        if reusable then pure () else dispose connection
      execute entry@(Entry connection _) = do
        outcome <- try @SomeException (restore (Connection.use connection session))
        case outcome of
          Left interrupted -> dispose connection >> throwIO interrupted
          Right (Left err) -> dispose connection >> pure (Left (SessionUsageError err))
          Right (Right value) -> returnEntry entry >> pure (Right value)
      establish epoch = do
        acquired <- try @SomeException (restore (Connection.acquire pool.connectionSettings))
        case acquired of
          Left interrupted -> returnSlot >> throwIO interrupted
          Right (Left err) -> returnSlot >> pure (Left (ConnectionUsageError err))
          Right (Right connection) -> do
            initialized <- try @SomeException (restore (Connection.use connection pool.initializeSession))
            case initialized of
              Left interrupted -> dispose connection >> throwIO interrupted
              Right (Left err) -> dispose connection >> pure (Left (SessionUsageError err))
              Right (Right ()) -> execute (Entry connection epoch)
  -- Remain masked across the STM ownership transfer. Blocking STM is interruptible and
  -- rolls back before interruption, so cancellation cannot consume an unowned slot.
  borrowed <-
    timeout
      pool.acquisitionMicros
      ( atomically do
          available <- tryReadTQueue pool.idle
          case available of
            Just entry -> pure (Existing entry)
            Nothing -> do
              free <- readTVar pool.capacity
              check (free > 0)
              modifyTVar' pool.capacity (subtract 1)
              Vacant <$> readTVar pool.generation
      )
  case borrowed of
    Nothing -> pure (Left AcquisitionTimeoutUsageError)
    Just (Existing entry) -> execute entry
    Just (Vacant epoch) -> establish epoch

-- | Flush idle connections and retire in-flight connections on return. The same pool stays
-- usable; old and new generations share capacity, preserving the global connection ceiling.
release :: Pool -> IO ()
release pool = mask \_ -> do
  entries <- atomically do
    modifyTVar' pool.generation (+ 1)
    flushTQueue pool.idle
  failures <- traverse (try @SomeException . close) entries
  forM_ [err | Left err <- failures] throwIO
  where
    close (Entry connection _) = Connection.release connection `finally` atomically (modifyTVar' pool.capacity (+ 1))