packages feed

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

-- | The @Database@ effect: a thin @effectful@ wrapper over a @hasql@ connection pool.
-- Interpreters run a 'Session' (or a 'Transaction') and surface a @Left UsageError@ for
-- the caller to translate with 'postgresUnavailable'.
module Shomei.Persistence.Database.Postgres
  ( Database (..),
    runSession,
    runTransaction,
    postgresUnavailable,
    uniqueViolation,
    postgresWriteError,
    runDatabasePool,
    runDatabasePoolWith,
    TransactionRetryPolicy (..),
    defaultTransactionRetryPolicy,
    validateTransactionRetryPolicy,
    retryableTransactionError,
  )
where

import Control.Concurrent (threadDelay)
import Data.Text (Text)
import Data.Text qualified as Text
import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, liftIO, (:>))
import Effectful.Dispatch.Dynamic (interpret_, send)
import GHC.Clock (getMonotonicTimeNSec)
import Hasql.Errors qualified as Hasql
import Hasql.Session (Session)
import Shomei.Error (AuthDependency (PostgreSQL), AuthError (DependencyUnavailable))
import Shomei.Persistence.Pool.Postgres (Pool, UsageError)
import Shomei.Persistence.Pool.Postgres qualified as Pool
import Shomei.Persistence.Transaction.Postgres (Transaction)
import Shomei.Persistence.Transaction.Postgres qualified as Bounded
import System.Timeout (timeout)

data Database :: Effect where
  RunSession :: Session a -> Database m (Either UsageError a)
  RunTransaction :: Transaction a -> Database m (Either UsageError a)

type instance DispatchOf Database = Dynamic

runSession :: (Database :> es) => Session a -> Eff es (Either UsageError a)
runSession = send . RunSession

runTransaction :: (Database :> es) => Transaction a -> Eff es (Either UsageError a)
runTransaction = send . RunTransaction

-- | Collapse all Hasql execution details to the one typed dependency failure.
-- Driver messages and SQL must never cross the persistence boundary.
postgresUnavailable :: UsageError -> AuthError
postgresUnavailable _ = DependencyUnavailable PostgreSQL

-- | The constraint name of a PostgreSQL unique violation (SQLSTATE 23505), when the server
-- supplied one in its primary message. Other failures deliberately remain opaque.
uniqueViolation :: UsageError -> Maybe Text
uniqueViolation = \case
  Pool.SessionUsageError
    ( Hasql.StatementSessionError
        _
        _
        _
        _
        _
        (Hasql.ServerStatementError (Hasql.ServerError "23505" message _ _ _))
      ) -> constraintName message
  _ -> Nothing

-- | Preserve a domain conflict for recognized unique indexes; otherwise keep the existing
-- fail-closed dependency error and never expose SQL or driver text.
postgresWriteError :: (Text -> Maybe AuthError) -> UsageError -> AuthError
postgresWriteError classify err =
  maybe (postgresUnavailable err) id (uniqueViolation err >>= classify)

constraintName :: Text -> Maybe Text
constraintName message =
  case Text.breakOn marker message of
    (_, rest)
      | Text.null rest -> Nothing
      | otherwise ->
          case Text.breakOn "\"" (Text.drop (Text.length marker) rest) of
            (name, closing)
              | not (Text.null name) && not (Text.null closing) -> Just name
            _ -> Nothing
  where
    marker = "unique constraint \""

-- | Validated at startup; an invalid direct policy refuses work without touching the pool.
-- The deadline covers checkout, every attempt, and backoff. No retry follows an ambiguous
-- connection/commit failure. Transaction bodies are Hasql database statements only.
data TransactionRetryPolicy = TransactionRetryPolicy
  { maxAttempts :: !Int,
    deadlineMs :: !Int
  }
  deriving stock (Eq, Show)

defaultTransactionRetryPolicy :: TransactionRetryPolicy
defaultTransactionRetryPolicy = TransactionRetryPolicy 3 5000

validateTransactionRetryPolicy :: TransactionRetryPolicy -> Either Text ()
validateTransactionRetryPolicy policy
  | policy.maxAttempts < 1 = Left "transaction max attempts must be positive"
  | policy.deadlineMs < 1 || policy.deadlineMs > min 2147483647 (maxBound `div` 1000) = Left "transaction deadline milliseconds must be positive and representable"
  | otherwise = Right ()

retryableTransactionError :: UsageError -> Bool
retryableTransactionError = \case
  Pool.SessionUsageError (Hasql.StatementSessionError _ _ _ _ _ (Hasql.ServerStatementError (Hasql.ServerError code _ _ _ _))) -> retryable code
  Pool.SessionUsageError (Hasql.ScriptSessionError _ (Hasql.ServerError code _ _ _ _)) -> retryable code
  _ -> False
  where
    retryable code = code == "40001" || code == "40P01"

runDatabasePool :: (IOE :> es) => Pool -> Eff (Database : es) a -> Eff es a
runDatabasePool = runDatabasePoolWith defaultTransactionRetryPolicy

runDatabasePoolWith :: (IOE :> es) => TransactionRetryPolicy -> Pool -> Eff (Database : es) a -> Eff es a
runDatabasePoolWith policy pool = interpret_ \case
  RunSession sess -> liftIO (Pool.use pool sess)
  RunTransaction transaction -> liftIO do
    case validateTransactionRetryPolicy policy of
      Left _ -> pure (Left Pool.AcquisitionTimeoutUsageError)
      Right () -> do
        started <- getMonotonicTimeNSec
        let deadline = toInteger started + toInteger policy.deadlineMs * 1000000
            attempt remainingAttempts = do
              now <- getMonotonicTimeNSec
              let remainingMicros = (deadline - toInteger now) `div` 1000
              if remainingMicros <= 0
                then pure (Left Pool.AcquisitionTimeoutUsageError)
                else do
                  -- Hasql cancels and rolls back an interrupted session before returning its
                  -- connection. Keep the operation unmasked so the deadline can interrupt
                  -- query I/O. A timeout is never retried.
                  result <- timeout (fromInteger remainingMicros) (Pool.use pool (Bounded.transactionSession (max 1 (fromInteger (remainingMicros `div` 1000))) transaction))
                  case result of
                    Nothing -> pure (Left Pool.AcquisitionTimeoutUsageError)
                    Just (Left err) | remainingAttempts > 1 && retryableTransactionError err -> do
                      after <- getMonotonicTimeNSec
                      let pause = min 10000 ((deadline - toInteger after) `div` 1000)
                      if pause <= 0
                        then pure (Left err)
                        else threadDelay (fromInteger pause) >> attempt (remainingAttempts - 1)
                    Just outcome -> pure outcome
        attempt policy.maxAttempts