hasql-mapping-0.1.1.0: src/library/Hasql/Mapping/IsTransaction.hs
-- | An explicit export list is required here (unlike this package's other modules) purely
-- mechanically: a module with no export list only exports entities it defines, not ones it
-- merely imports, so 'IsolationLevel' and 'Mode' would otherwise stay invisible to any instance
-- that imports only this module.
module Hasql.Mapping.IsTransaction
( IsTransaction (..),
IsolationLevel (..),
Mode (..),
toSessionWithUnboundedRetries,
toSessionWithoutRetries,
)
where
import qualified Hasql.Session as Session
import Hasql.Transaction (Transaction)
import Hasql.Transaction.Sessions (IsolationLevel (..), Mode (..))
import qualified Hasql.Transaction.Sessions as Sessions
-- |
-- Evidence that a data-structure determines an atomic, retryable database transaction.
--
-- 'isolation' and 'mode' are properties of the transaction, not of the call site: whether an
-- operation needs 'Serializable' is a fact about what it does, and a caller reaching for
-- 'toSessionWithUnboundedRetries' or 'toSessionWithoutRetries' cannot override or forget them.
--
-- The defaults are the conservative ones ('Serializable' and 'Write'), so the safe case is free
-- and every relaxation is explicit and reviewable in the instance. The opposite defaults would
-- make an under-isolated transaction invisible.
--
-- A composite transaction declares the join of its components by hand, using 'Sessions.IsolationLevel'
-- and 'Sessions.Mode'\'s 'Semigroup' instances:
--
-- > instance IsTransaction Composite where
-- > isolation = isolation \@Part1 <> isolation \@Part2
-- > mode = mode \@Part1 <> mode \@Part2
--
-- The two identities are deliberately opposite, because a reader who learns one will guess the
-- other wrong:
--
-- * @mempty@ is @minBound@ ('ReadCommitted' and 'Read'), so that a component with no opinion
-- never downgrades a component that has one.
-- * An /omitted/ class method defaults to 'Serializable' and 'Write', so that an author who never
-- considered the question gets the safe answer.
--
-- Both are conservative, by opposite rules. The join is declared explicitly rather than derived,
-- so changing which components make up a composite requires updating these declarations as well.
--
-- ==== __Example of such a module__
--
-- > module MusicCatalogueDb.Transactions.InsertAlbumWithTracks where
-- >
-- > import Hasql.Mapping.IsTransaction
-- > import qualified Hasql.Transaction as Transaction
-- > import qualified MusicCatalogueDb.Statements.InsertAlbum as InsertAlbum
-- > import qualified MusicCatalogueDb.Statements.InsertTrack as InsertTrack
-- > import Prelude
-- >
-- > data InsertAlbumWithTracks = InsertAlbumWithTracks
-- > { album :: InsertAlbum.InsertAlbum,
-- > tracks :: [InsertTrack.InsertTrack]
-- > }
-- >
-- > type InsertAlbumWithTracksResult = InsertAlbum.InsertAlbumResult
-- >
-- > instance IsTransaction InsertAlbumWithTracks where
-- > type Result InsertAlbumWithTracks = InsertAlbumWithTracksResult
-- > isolation = ReadCommitted -- inserts only fresh rows, so no anomaly exposure
-- > transaction params = do
-- > albumId <- Transaction.statement params.album InsertAlbum.statement
-- > for_ params.tracks \track ->
-- > Transaction.statement track InsertTrack.statement
-- > pure albumId
class IsTransaction a where
type Result a
-- |
-- Defaults to 'Serializable', the conservative choice.
isolation :: IsolationLevel
isolation = Serializable
-- |
-- Defaults to 'Write', the conservative choice.
mode :: Mode
mode = Write
transaction :: a -> Transaction (Result a)
-- |
-- Runs the transaction with its declared 'isolation' and 'mode', retrying it indefinitely on
-- serialization failures and deadlocks.
--
-- @hasql-transaction@'s retry is a @fix@ loop with no backoff and no cap: a 'Serializable'
-- transaction under sustained contention can spin indefinitely, holding a connection and never
-- surfacing an error. Prefer 'toSessionWithoutRetries' with your own bounded retry loop unless
-- that is an acceptable risk for the operation.
toSessionWithUnboundedRetries :: forall a. (IsTransaction a) => a -> Session.Session (Result a)
toSessionWithUnboundedRetries a =
Sessions.transaction (isolation @a) (mode @a) (transaction a)
-- |
-- Runs the transaction with its declared 'isolation' and 'mode', without retrying it on failure.
toSessionWithoutRetries :: forall a. (IsTransaction a) => a -> Session.Session (Result a)
toSessionWithoutRetries a =
Sessions.transactionNoRetry (isolation @a) (mode @a) (transaction a)