pg-migrate-1.1.0.0: src/Database/PostgreSQL/Migrate/Runner.hs
module Database.PostgreSQL.Migrate.Runner
( runMigrationPlan,
runMigrationPlanWith,
withRunLifecycle,
resumeNonTransactionalMigration,
invokeEvent,
)
where
import Control.Exception
( AsyncException,
SomeException,
displayException,
fromException,
mask,
throwIO,
try,
)
import Data.Int (Int32, Int64)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Data.Time (NominalDiffTime, UTCTime, getCurrentTime)
import Data.Version (showVersion)
import Data.Word (Word64)
import Database.PostgreSQL.Migrate.Ledger
import Database.PostgreSQL.Migrate.Ledger.Migrations
import Database.PostgreSQL.Migrate.Ledger.Sql
import Database.PostgreSQL.Migrate.Ledger.Types
import Database.PostgreSQL.Migrate.Plan (planDescription)
import Database.PostgreSQL.Migrate.Runner.Connection
import Database.PostgreSQL.Migrate.Runner.Lock
import Database.PostgreSQL.Migrate.Runner.Types
import Database.PostgreSQL.Migrate.Types
import GHC.Clock (getMonotonicTimeNSec)
import Hasql.Connection qualified as Connection
import Hasql.Connection.Settings qualified as Settings
import Hasql.Decoders qualified as Decoders
import Hasql.Encoders qualified as Encoders
import Hasql.Errors qualified as Errors
import Hasql.Session (Session)
import Hasql.Session qualified as Session
import Hasql.Statement qualified as Statement
import Hasql.Transaction (Transaction)
import Hasql.Transaction qualified as Transaction
import Hasql.Transaction.Sessions qualified as Transaction.Sessions
import Paths_pg_migrate qualified as Package
import PgMigrate.Prelude
data PlannedMigration = PlannedMigration
{ plannedId :: !MigrationId,
plannedPosition :: !Int32,
plannedMigration :: !Migration
}
-- | Run a plan with a freshly acquired connection from concrete settings.
runMigrationPlan ::
RunOptions ->
Settings.Settings ->
MigrationPlan ->
IO (Either MigrationError MigrationReport)
runMigrationPlan options settings =
runMigrationPlanWith options (connectionProviderFromSettings settings)
-- | Run a plan under the lock and timeout lifecycle using a provider.
runMigrationPlanWith ::
RunOptions ->
ConnectionProvider ->
MigrationPlan ->
IO (Either MigrationError MigrationReport)
runMigrationPlanWith options ConnectionProvider {useDedicatedConnection} plan = do
(result, observedCleanupIssues) <-
withRunLifecycle
options
(ConnectionProvider useDedicatedConnection)
(\connection -> runLocked options connection plan)
pure $ case attachCleanup observedCleanupIssues result of
Left migrationError -> Left migrationError
Right report -> Right report {cleanupIssues = observedCleanupIssues}
withRunLifecycle ::
RunOptions ->
ConnectionProvider ->
(Connection.Connection -> IO (Either MigrationError value)) ->
IO (Either MigrationError value, [CleanupIssue])
withRunLifecycle options ConnectionProvider {useDedicatedConnection} action =
case validateOptions options of
Left optionsError -> pure (Left optionsError, [])
Right () -> do
acquired <- useDedicatedConnection (\connection -> runOnConnection options connection action)
pure $ case acquired of
Left connectionError -> (Left (ConnectionAcquisitionFailed connectionError), [])
Right result -> result
runOnConnection ::
RunOptions ->
Connection.Connection ->
(Connection.Connection -> IO (Either MigrationError value)) ->
IO (Either MigrationError value, [CleanupIssue])
runOnConnection options connection action = do
serverVersion <- checkServerVersion connection
case serverVersion of
Left migrationError -> pure (Left migrationError, [])
Right _ ->
withStatementTimeoutResource options connection $ do
waitEvent <- invokeEvent options (LockWaitStarted (runLockWait options))
case waitEvent of
Left eventError -> pure (Left eventError, [])
Right () ->
withAdvisoryLockResource options connection $ \lockDuration -> do
acquiredEvent <- invokeEvent options (LockAcquired lockDuration)
case acquiredEvent of
Left eventError -> pure (Left eventError)
Right () -> action connection
withStatementTimeoutResource ::
RunOptions ->
Connection.Connection ->
IO (Either MigrationError value, [CleanupIssue]) ->
IO (Either MigrationError value, [CleanupIssue])
withStatementTimeoutResource options connection action =
mask $ \restore -> do
applied <- applyStatementTimeout connection (runStatementTimeout options)
case applied of
Left migrationError -> pure (Left migrationError, [])
Right previous -> do
captured <- try @SomeException (restore action)
cleanup <- restoreStatementTimeout connection previous
finishResource captured [cleanup]
withAdvisoryLockResource ::
RunOptions ->
Connection.Connection ->
(NominalDiffTime -> IO (Either MigrationError value)) ->
IO (Either MigrationError value, [CleanupIssue])
withAdvisoryLockResource options connection action =
mask $ \restore -> do
acquired <-
restore
( acquireAdvisoryLock
connection
(ledgerLockKey (runLedgerConfig options))
(runLockWait options)
)
case acquired of
Left migrationError -> pure (Left migrationError, [])
Right lockDuration -> do
captured <- try @SomeException (restore ((,[]) <$> action lockDuration))
cleanup <-
releaseAdvisoryLock connection (ledgerLockKey (runLedgerConfig options))
finishResource captured [cleanup]
finishResource ::
Either SomeException (Either MigrationError value, [CleanupIssue]) ->
[Either CleanupIssue ()] ->
IO (Either MigrationError value, [CleanupIssue])
finishResource captured cleanupResults = do
let (observedCleanupIssues, _) = partitionEithers cleanupResults
case captured of
Left exception
| isAsyncException exception -> throwIO exception
| otherwise -> pure (Left (MigrationActionFailed exception), observedCleanupIssues)
Right (result, existingCleanupIssues) ->
pure (result, existingCleanupIssues <> observedCleanupIssues)
attachCleanup ::
[CleanupIssue] ->
Either MigrationError value ->
Either MigrationError value
attachCleanup cleanupIssues result =
case NonEmpty.nonEmpty cleanupIssues of
Nothing -> result
Just issues -> case result of
Left primary -> Left (CleanupFailed primary issues)
Right value -> Right value
runLocked ::
RunOptions ->
Connection.Connection ->
MigrationPlan ->
IO (Either MigrationError MigrationReport)
runLocked options connection plan = do
initialized <-
runSession
connection
(initializeOrUpgradeLedger (runLedgerConfig options) libraryRunnerVersion)
case initialized of
Left migrationError -> pure (Left migrationError)
Right (Left ledgerError) -> pure (Left (LedgerInitializationFailed ledgerError))
Right (Right ()) -> do
loaded <- runSession connection (loadLedger (runLedgerConfig options))
case loaded of
Left migrationError -> pure (Left migrationError)
Right snapshot -> runVerified options connection plan snapshot
runVerified ::
RunOptions ->
Connection.Connection ->
MigrationPlan ->
LedgerSnapshot ->
IO (Either MigrationError MigrationReport)
runVerified options connection plan snapshot = do
let verification =
comparePlanWithLedger
(runUnknownMigrationsPolicy options)
(planDescription plan)
(storedMigrations snapshot)
case verification of
VerificationReport {issues = _ : _} ->
pure (Left (PlanVerificationFailed verification))
VerificationReport {appliedMigrations, pendingMigrations} -> do
let planned = flattenPlan plan
plannedById = Map.fromList ((\migration -> (plannedId migration, migration)) <$> planned)
pending = mapMaybe (`Map.lookup` plannedById) pendingMigrations
validatedEvent <-
invokeEvent
options
PlanValidated
{ alreadyAppliedCount = length appliedMigrations,
pendingCount = length pending
}
case validatedEvent of
Left eventError -> pure (Left eventError)
Right () -> executeVerified options connection planned appliedMigrations pending
executeVerified ::
RunOptions ->
Connection.Connection ->
[PlannedMigration] ->
[MigrationId] ->
[PlannedMigration] ->
IO (Either MigrationError MigrationReport)
executeVerified options connection planned appliedIds pending = do
reportStartedAt <- getCurrentTime
reportStartedMonotonic <- getMonotonicTimeNSec
executed <- executePending Map.empty pending
case executed of
Left migrationError -> pure (Left migrationError)
Right executedById -> do
reportFinishedAt <- getCurrentTime
reportDuration <- elapsedSince reportStartedMonotonic
completedEvent <- invokeEvent options (MigrationPlanCompleted reportDuration)
case completedEvent of
Left eventError -> pure (Left eventError)
Right () ->
case NonEmpty.nonEmpty (resultsInPlanOrder executedById) of
Nothing -> error "migration plan unexpectedly produced no results"
Just results ->
pure
( Right
MigrationReport
{ startedAt = reportStartedAt,
finishedAt = reportFinishedAt,
results,
cleanupIssues = []
}
)
where
alreadyApplied =
Map.fromList
[ (identifier, MigrationResult identifier AlreadyApplied Nothing)
| identifier <- appliedIds
]
resultsInPlanOrder executedById =
mapMaybe
(\migration -> Map.lookup (plannedId migration) (Map.union executedById alreadyApplied))
planned
executePending accumulated [] = pure (Right accumulated)
executePending accumulated (migration : rest) = do
executed <- executePlannedMigration options connection migration
case executed of
Left migrationError -> pure (Left migrationError)
Right result ->
executePending (Map.insert (plannedId migration) result accumulated) rest
executePlannedMigration ::
RunOptions ->
Connection.Connection ->
PlannedMigration ->
IO (Either MigrationError MigrationResult)
executePlannedMigration options connection planned =
case migrationModeOf (plannedMigration planned) of
Transactional -> executeTransactional options connection planned
NonTransactional -> executeNonTransactional options connection planned
executeTransactional ::
RunOptions ->
Connection.Connection ->
PlannedMigration ->
IO (Either MigrationError MigrationResult)
executeTransactional options connection planned = do
startedEvent <- invokeEvent options (MigrationStarted identifier)
case startedEvent of
Left eventError -> pure (Left eventError)
Right () -> do
startedAt <- getCurrentTime
startedMonotonic <- getMonotonicTimeNSec
case transactionalAction (runLedgerConfig options) planned startedAt of
Left migrationError -> finishFailure startedMonotonic migrationError
Right transaction -> do
attempted <- try @SomeException (Connection.use connection (runTransaction transaction))
case attempted of
Left exception
| isAsyncException exception -> throwIO exception
| otherwise -> finishFailure startedMonotonic (MigrationActionFailed exception)
Right (Left sessionError) ->
finishFailure startedMonotonic (DatabaseSessionFailed sessionError)
Right (Right ()) -> do
stored <-
runSession
connection
( Session.statement
identifier
(storedMigrationExistsStatement (runLedgerConfig options))
)
case stored of
Left migrationError -> finishFailure startedMonotonic migrationError
Right True -> finishSuccess startedMonotonic
Right False -> finishFailure startedMonotonic (TransactionCondemned identifier)
where
identifier = plannedId planned
finishSuccess started = do
duration <- elapsedSince started
completed <- invokeEvent options (MigrationCompleted identifier duration)
pure $ case completed of
Left eventError -> Left eventError
Right () -> Right (MigrationResult identifier AppliedNow (Just duration))
finishFailure started primary = do
duration <- elapsedSince started
observed <-
invokeEventWithPrimary
options
primary
(MigrationFailureObserved identifier duration)
pure $ case observed of
Left eventError -> Left eventError
Right () -> Left primary
transactionalAction ::
LedgerConfig ->
PlannedMigration ->
UTCTime ->
Either MigrationError (Transaction ())
transactionalAction config PlannedMigration {plannedId, plannedPosition, plannedMigration} startedAt = do
action <- case migrationActionOf plannedMigration of
SqlAction bytes -> Right (Transaction.sql bytes)
TransactionAction transaction -> Right transaction
SessionAction _ -> Left (InvalidMigrationAction plannedId)
pure $ do
action
Transaction.statement
AppliedLedgerRow
{ appliedMigrationId = plannedId,
appliedPosition = plannedPosition,
appliedChecksum = migrationChecksumOf plannedMigration,
appliedKind = migrationKindOf plannedMigration,
appliedTransactionMode = migrationModeOf plannedMigration,
appliedStartedAt = startedAt,
appliedRunnerVersion = libraryRunnerVersion
}
(insertAppliedMigrationStatement config)
executeNonTransactional ::
RunOptions ->
Connection.Connection ->
PlannedMigration ->
IO (Either MigrationError MigrationResult)
executeNonTransactional options connection planned = do
startedEvent <- invokeEvent options (MigrationStarted identifier)
case startedEvent of
Left eventError -> pure (Left eventError)
Right () -> do
startedAt <- getCurrentTime
startedMonotonic <- getMonotonicTimeNSec
let ledgerRow = appliedLedgerRow planned startedAt
inserted <-
runSession
connection
( runTransaction
( Transaction.statement
ledgerRow
(insertRunningMigrationStatement (runLedgerConfig options))
)
)
case inserted of
Left migrationError -> finishFailureEvent startedMonotonic migrationError
Right () ->
executeNonTransactionalAfterRunning
options
connection
planned
ledgerRow
startedMonotonic
where
identifier = plannedId planned
finishFailureEvent started primary = do
duration <- elapsedSince started
observed <-
invokeEventWithPrimary
options
primary
(MigrationFailureObserved identifier duration)
pure $ case observed of
Left eventError -> Left eventError
Right () -> Left primary
resumeNonTransactionalMigration ::
RunOptions ->
Connection.Connection ->
MigrationId ->
Int32 ->
Migration ->
IO (Either MigrationError MigrationResult)
resumeNonTransactionalMigration options connection identifier position migration = do
startedAt <- getCurrentTime
startedMonotonic <- getMonotonicTimeNSec
let planned = PlannedMigration identifier position migration
ledgerRow = appliedLedgerRow planned startedAt
executeNonTransactionalAfterRunning options connection planned ledgerRow startedMonotonic
executeNonTransactionalAfterRunning ::
RunOptions ->
Connection.Connection ->
PlannedMigration ->
AppliedLedgerRow ->
Word64 ->
IO (Either MigrationError MigrationResult)
executeNonTransactionalAfterRunning options connection planned ledgerRow startedMonotonic = do
action <- nonTransactionalAction connection planned
case action of
Left migrationError ->
recordObservedFailure migrationError (renderMigrationError migrationError)
Right runAction -> do
attempted <- try @SomeException runAction
case attempted of
Left exception
| isAsyncException exception -> throwIO exception
| otherwise ->
let primary = MigrationActionFailed exception
in recordObservedFailure
primary
(Text.pack (displayException exception))
Right (Left sessionError) ->
let primary = NonTransactionalMigrationFailed identifier sessionError
in recordObservedFailure primary (Errors.toDetailedText sessionError)
Right (Right ()) -> do
transitioned <- markRunningApplied options connection ledgerRow
case transitioned of
Left migrationError -> pure (Left migrationError)
Right () -> finishSuccess
where
identifier = plannedId planned
finishSuccess = do
duration <- elapsedSince startedMonotonic
completed <- invokeEvent options (MigrationCompleted identifier duration)
pure $ case completed of
Left eventError -> Left eventError
Right () -> Right (MigrationResult identifier AppliedNow (Just duration))
finishFailureEvent primary = do
duration <- elapsedSince startedMonotonic
observed <-
invokeEventWithPrimary
options
primary
(MigrationFailureObserved identifier duration)
pure $ case observed of
Left eventError -> Left eventError
Right () -> Left primary
recordObservedFailure primary diagnostic = do
recorded <- markRunningFailed options connection ledgerRow diagnostic
case recorded of
Left recordingError ->
pure
( Left
(NonTransactionalFailureRecordingFailed identifier primary recordingError)
)
Right () -> finishFailureEvent primary
nonTransactionalAction ::
Connection.Connection ->
PlannedMigration ->
IO
( Either
MigrationError
(IO (Either Errors.SessionError ()))
)
nonTransactionalAction connection PlannedMigration {plannedId, plannedMigration} =
pure $ case migrationActionOf plannedMigration of
SqlAction bytes ->
Right
( Connection.use
connection
( Session.statement
()
( Statement.unpreparable
(Text.Encoding.decodeUtf8 bytes)
Encoders.noParams
Decoders.noResult
)
)
)
SessionAction session -> Right (Connection.use connection session)
TransactionAction _ -> Left (InvalidMigrationAction plannedId)
markRunningApplied ::
RunOptions ->
Connection.Connection ->
AppliedLedgerRow ->
IO (Either MigrationError ())
markRunningApplied options connection ledgerRow = do
transitioned <-
runSession
connection
( runTransaction
( Transaction.statement
ledgerRow
(markRunningMigrationAppliedStatement (runLedgerConfig options))
)
)
pure $ transitioned >>= expectOneTransition ledgerRow Applied
markRunningFailed ::
RunOptions ->
Connection.Connection ->
AppliedLedgerRow ->
Text ->
IO (Either MigrationError ())
markRunningFailed options connection ledgerRow diagnostic = do
transitioned <-
runSession
connection
( runTransaction
( Transaction.statement
FailedLedgerRow
{ failedLedgerIdentity = ledgerRow,
failedLedgerError = diagnostic
}
(markRunningMigrationFailedStatement (runLedgerConfig options))
)
)
pure $ transitioned >>= expectOneTransition ledgerRow Failed
expectOneTransition ::
AppliedLedgerRow ->
MigrationStatus ->
Int64 ->
Either MigrationError ()
expectOneTransition ledgerRow targetStatus affected
| affected == 1 = Right ()
| otherwise =
Left
( LedgerTransitionDidNotMatch
(appliedMigrationId ledgerRow)
Running
targetStatus
)
appliedLedgerRow :: PlannedMigration -> UTCTime -> AppliedLedgerRow
appliedLedgerRow PlannedMigration {plannedId, plannedPosition, plannedMigration} startedAt =
AppliedLedgerRow
{ appliedMigrationId = plannedId,
appliedPosition = plannedPosition,
appliedChecksum = migrationChecksumOf plannedMigration,
appliedKind = migrationKindOf plannedMigration,
appliedTransactionMode = migrationModeOf plannedMigration,
appliedStartedAt = startedAt,
appliedRunnerVersion = libraryRunnerVersion
}
renderMigrationError :: MigrationError -> Text
renderMigrationError = Text.pack . show
runTransaction :: Transaction value -> Session value
runTransaction =
Transaction.Sessions.transactionNoRetry
Transaction.Sessions.ReadCommitted
Transaction.Sessions.Write
flattenPlan :: MigrationPlan -> [PlannedMigration]
flattenPlan plan =
concatMap flattenComponent (toList (planComponentsOf plan))
where
flattenComponent component =
zipWith
( \position migration ->
PlannedMigration
(MigrationId (componentNameOf component) (migrationNameOf migration))
position
migration
)
[1 ..]
(toList (componentMigrationsOf component))
runSession ::
Connection.Connection ->
Session value ->
IO (Either MigrationError value)
runSession connection session = do
result <- Connection.use connection session
pure (first DatabaseSessionFailed result)
invokeEvent :: RunOptions -> MigrationEvent -> IO (Either MigrationError ())
invokeEvent options = invokeEventWithMaybePrimary options Nothing
invokeEventWithPrimary ::
RunOptions ->
MigrationError ->
MigrationEvent ->
IO (Either MigrationError ())
invokeEventWithPrimary options primary =
invokeEventWithMaybePrimary options (Just primary)
invokeEventWithMaybePrimary ::
RunOptions ->
Maybe MigrationError ->
MigrationEvent ->
IO (Either MigrationError ())
invokeEventWithMaybePrimary options primary event = do
attempted <- try @SomeException (runEventHandler options event)
case attempted of
Left exception
| isAsyncException exception -> throwIO exception
| otherwise -> pure (Left (EventHandlerFailed primary exception))
Right () -> pure (Right ())
validateOptions :: RunOptions -> Either MigrationError ()
validateOptions options = do
case runLockWait options of
WaitFor timeout | timeout < 0 -> Left (InvalidLockWait timeout)
_ -> Right ()
case runStatementTimeout options of
Just timeout | timeout <= 0 -> Left (InvalidStatementTimeout timeout)
_ -> Right ()
elapsedSince :: Word64 -> IO NominalDiffTime
elapsedSince started = do
finished <- getMonotonicTimeNSec
pure (realToFrac (fromIntegral (finished - started) / (1000000000 :: Double)))
isAsyncException :: SomeException -> Bool
isAsyncException exception = isJust (fromException exception :: Maybe AsyncException)
libraryRunnerVersion :: Text
libraryRunnerVersion = Text.pack (showVersion Package.version)