packages feed

hpgsql-0.2.0.0: src/Hpgsql/Transaction.hs

module Hpgsql.Transaction (withTransaction, withTransactionMode, begin, beginMode, commit, rollback, transactionStatus, IsolationLevel (..), ReadWriteMode (..), TransactionStatus (..)) where

import qualified Control.Concurrent.STM as STM
import Control.Exception.Safe (Exception (..), bracketWithError, throw, tryJust)
import Control.Monad (unless)
import Hpgsql.Internal (execute_, fullTransactionStatus, transactionStatus)
import Hpgsql.InternalTypes (HPgConnection (..), InternalConnectionState (..), IrrecoverableHpgsqlError)
import Hpgsql.Query (Query)
import Hpgsql.TransactionStatusInternal (TransactionStatus (..))

-- The types and constructors here have matching names to postgresql-simple where
-- I thought sameness would be convenient. The implementation is of course our
-- own, and I did choose to keep some things different out of my preferences.

data IsolationLevel
  = DefaultIsolationLevel
  | ReadUncommitted
  | ReadCommitted
  | RepeatableRead
  | Serializable
  deriving (Show, Eq, Ord, Enum, Bounded)

data ReadWriteMode
  = DefaultReadWriteMode
  | ReadWrite
  | ReadOnly
  deriving (Show, Eq, Ord, Enum, Bounded)

begin :: HPgConnection -> IO ()
begin conn = beginMode conn DefaultIsolationLevel DefaultReadWriteMode

beginMode :: HPgConnection -> IsolationLevel -> ReadWriteMode -> IO ()
beginMode conn il rw = do
  let readWrite = case rw of
        ReadWrite -> Just "READ WRITE"
        ReadOnly -> Just "READ ONLY"
        DefaultReadWriteMode -> Nothing
      isolLvl = case il of
        DefaultIsolationLevel -> Nothing
        Serializable -> Just "ISOLATION LEVEL SERIALIZABLE"
        RepeatableRead -> Just "ISOLATION LEVEL REPEATABLE READ"
        ReadCommitted -> Just "ISOLATION LEVEL READ COMMITTED"
        ReadUncommitted -> Just "ISOLATION LEVEL READ UNCOMMITTED"
  execute_ conn $ "BEGIN " <> withComma readWrite isolLvl
  where
    withComma :: Maybe Query -> Maybe Query -> Query
    withComma mv1 mv2 = case (mv1, mv2) of
      (Just v1, Just v2) -> v1 <> "," <> v2
      (Just v1, Nothing) -> v1
      (Nothing, Just v2) -> v2
      (Nothing, Nothing) -> ""

commit :: HPgConnection -> IO ()
commit conn = execute_ conn "COMMIT"

rollback :: HPgConnection -> IO ()
rollback conn = execute_ conn "ROLLBACK"

-- | Runs the supplied function inside a transaction with the database's
-- default isolation level and read-write mode, i.e. `BEGIN`s a transaction,
-- runs the supplied function and then `COMMIT`s if there are no exceptions.
-- In case the supplied function throws an exception, this runs `ROLLBACK`.
-- In case an asynchronous exception is thrown, Hpgsql ensures a `ROLLBACK`
-- will be issued before your next query on the same connection.
withTransaction :: HPgConnection -> IO a -> IO a
withTransaction conn = withTransactionMode conn DefaultIsolationLevel DefaultReadWriteMode

-- | Runs the supplied function inside a transaction with the supplied
-- isolation level and read-write mode, i.e. `BEGIN`s a transaction,
-- runs the supplied function and then `COMMIT`s if there are no exceptions.
-- In case the supplied function throws an exception, this runs `ROLLBACK`.
-- In case an asynchronous exception is thrown, Hpgsql ensures a `ROLLBACK`
-- will be issued before your next query on this connection.
withTransactionMode :: HPgConnection -> IsolationLevel -> ReadWriteMode -> IO a -> IO a
withTransactionMode conn il rw f = bracketWithError (beginMode conn il rw) cleanup $ \() -> do
  res <- tryJust notIrrecoverableError f
  case res of
    Left ex -> do
      -- In case of a synchronous exception, we rollback synchronously.
      -- If this is interrupted:
      -- - Before ROLLBACK is sent, `cleanup` will enqueue a "ROLLBACK".
      -- - After ROLLBACK is sent but before it finishes, a ROLLBACK will still be enqueued
      --   (check `cleanup`), which means:
      --      - If ROLLBACK has already completed by the time the new ROLLBACK is meant to be sent,
      --        the new ROLLBACK will produce a "WARNING: there is no transaction in progress". This
      --        isn't great, but is mostly harmless.
      --      - If ROLLBACK is cancelled by the future ROLLBACK, all is good as well.
      -- - After ROLLBACK is sent and completed, `cleanup` won't enqueue a new ROLLBACK, and all is well.
      --
      -- We rollback here, not in `cleanup`, because that runs with async exceptions masked
      rollback conn >> throw ex -- Reminder that this can be interrupted before/after "rollback" is sent
    Right v -> do
      -- If this is interrupted:
      -- - Before COMMIT is sent, it's equivalent to failing in the middle of the user-supplied action
      -- - After COMMIT is sent but before it's finished, a ROLLBACK will still be enqueued
      --   (check `cleanup`), which means:
      --      - If COMMIT has already completed by the time ROLLBACK is meant to be sent, ROLLBACK will
      --        produce a "WARNING: there is no transaction in progress". This isn't great, but
      --        is mostly harmless.
      --      - If COMMIT is cancelled by the future ROLLBACK, all is good as well.
      -- - After COMMIT is sent and completed, this is just interruption right after a successful
      --   operation, and `cleanup` won't enqueue a ROLLBACK.
      commit conn
      pure v
  where
    notIrrecoverableError e = case fromException e of
      Just (_ :: IrrecoverableHpgsqlError) -> Nothing
      _ -> Just e
    cleanup mEx () = case mEx of
      Nothing -> pure ()
      Just (fromException -> (Just (_ :: IrrecoverableHpgsqlError))) -> pure () -- Do nothing if an internal error was thrown, just let it propagate
      Just _ex -> do
        -- print _ex
        -- Mark ROLLBACK to be sent before next command
        STM.atomically $ do
          (_, txnStatus) <- fullTransactionStatus conn.internalConnectionState
          -- The rollback in case of a synchronous exception may have run, so we
          -- don't need another one in that case.
          unless (txnStatus == TransIdle) $ do
            st <- STM.readTVar conn.internalConnectionState
            STM.writeTVar conn.internalConnectionState st {mustIssueRollbackBeforeNextCommand = True}