pgmq-effectful-0.7.0.0: test/ClassificationSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module ClassificationSpec (tests) where
import Data.HashSet qualified as HashSet
import Data.Text (Text)
import Hasql.Errors qualified as HasqlErrors
import Pgmq.Effectful
( PgmqRuntimeError (..),
isAmbiguousReply,
isTransient,
)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase)
-- | A server-reported statement error with the given SQLSTATE, wrapped the way
-- it actually arrives at 'isTransient': inside 'StatementSessionError'
-- (statement count, index, SQL, params, prepared flag) around
-- 'ServerStatementError' around 'ServerError', whose first field is the
-- five-character SQLSTATE.
serverStatementError :: Text -> PgmqRuntimeError
serverStatementError code =
PgmqSessionError
( HasqlErrors.StatementSessionError
1
0
"select 1"
[]
True
(HasqlErrors.ServerStatementError (HasqlErrors.ServerError code "boom" Nothing Nothing Nothing))
)
-- | A server error reported for a 'HasqlErrors.ScriptSessionError' with the
-- given SQLSTATE.
scriptError :: Text -> PgmqRuntimeError
scriptError code =
PgmqSessionError
( HasqlErrors.ScriptSessionError
"checkpoint"
(HasqlErrors.ServerError code "boom" Nothing Nothing Nothing)
)
-- | The value hasql 1.10 surfaces when libpq's own connection-loss result is
-- decoded: a server error with every field empty.
lostReplyStatementError :: PgmqRuntimeError
lostReplyStatementError =
PgmqSessionError
( HasqlErrors.StatementSessionError
1
0
"select 1"
[]
True
(HasqlErrors.ServerStatementError (HasqlErrors.ServerError "" "" Nothing Nothing Nothing))
)
-- | A row-count statement error with the given bounds and actual count.
rowCountError :: Int -> Int -> Int -> PgmqRuntimeError
rowCountError lo hi actual =
PgmqSessionError
( HasqlErrors.StatementSessionError
1
0
"select 1"
[]
True
(HasqlErrors.UnexpectedRowCountStatementError lo hi actual)
)
-- | Connection-level samples (acquisition and connect failures).
connectionSamples :: [(String, PgmqRuntimeError)]
connectionSamples =
[ ("networking connection error", PgmqConnectionError (HasqlErrors.NetworkingConnectionError "refused")),
("authentication error", PgmqConnectionError (HasqlErrors.AuthenticationConnectionError "bad password")),
("compatibility error", PgmqConnectionError (HasqlErrors.CompatibilityConnectionError "version mismatch")),
("other connection error", PgmqConnectionError (HasqlErrors.OtherConnectionError "libpq says no"))
]
-- | Every sample error this module constructs, for whole-table properties.
allSamples :: [(String, PgmqRuntimeError)]
allSamples =
[("acquisition timeout", PgmqAcquisitionTimeout)]
<> connectionSamples
<> [ ("connection-drop session error", PgmqSessionError (HasqlErrors.ConnectionSessionError "dropped")),
("driver session error", PgmqSessionError (HasqlErrors.DriverSessionError "bug")),
("missing-types session error", PgmqSessionError (HasqlErrors.MissingTypesSessionError HashSet.empty)),
("empty-SQLSTATE statement error", lostReplyStatementError),
("empty-SQLSTATE script error", scriptError ""),
("row count 1 1 0", rowCountError 1 1 0),
("row count 1 1 1", rowCountError 1 1 1),
("row count 1 1 2", rowCountError 1 1 2)
]
<> [("statement " <> show code, serverStatementError code) | (code, _) <- transientStates <> permanentStates]
<> [("script " <> show code, scriptError code) | (code, _) <- transientStates <> permanentStates]
-- | SQLSTATEs that retries exist for: they arrive as server errors inside
-- 'StatementSessionError' and must classify transient.
transientStates :: [(Text, String)]
transientStates =
[ ("40001", "serialization_failure"),
("40P01", "deadlock_detected"),
("55P03", "lock_not_available"),
("57P01", "admin_shutdown"),
("57P02", "crash_shutdown"),
("57P03", "cannot_connect_now"),
("53100", "disk_full"),
("53200", "out_of_memory"),
("53300", "too_many_connections")
]
-- | Genuine statement bugs must stay permanent: retrying cannot fix them.
permanentStates :: [(Text, String)]
permanentStates =
[ ("23505", "unique_violation"),
("42P01", "undefined_table"),
("22P02", "invalid_text_representation")
]
tests :: TestTree
tests =
testGroup
"isTransient classification"
[ testCase "acquisition timeout is transient" $
assertBool "expected transient" (isTransient PgmqAcquisitionTimeout),
testCase "networking connection error is transient" $
assertBool
"expected transient"
( isTransient
(PgmqConnectionError (HasqlErrors.NetworkingConnectionError "refused"))
),
testCase "authentication error is not transient" $
assertBool
"expected permanent"
( not $
isTransient
(PgmqConnectionError (HasqlErrors.AuthenticationConnectionError "bad password"))
),
testCase "compatibility error is not transient" $
assertBool
"expected permanent"
( not $
isTransient
(PgmqConnectionError (HasqlErrors.CompatibilityConnectionError "version mismatch"))
),
testCase "other connection error is treated as transient" $
assertBool
"expected transient"
( isTransient
(PgmqConnectionError (HasqlErrors.OtherConnectionError "libpq says no"))
),
testCase "connection-drop session error is transient" $
assertBool
"expected transient"
( isTransient
(PgmqSessionError (HasqlErrors.ConnectionSessionError "dropped"))
),
testCase "driver session error is not transient" $
assertBool
"expected permanent"
( not $
isTransient
(PgmqSessionError (HasqlErrors.DriverSessionError "bug"))
),
testCase "missing-types session error is not transient" $
assertBool
"expected permanent"
( not $
isTransient
(PgmqSessionError (HasqlErrors.MissingTypesSessionError HashSet.empty))
),
testGroup
"server-reported SQLSTATEs (PGH-10)"
( [ testCase (show code <> " " <> name <> " is transient") $
assertBool "expected transient" (isTransient (serverStatementError code))
| (code, name) <- transientStates
]
<> [ testCase (show code <> " " <> name <> " is permanent") $
assertBool "expected permanent" (not (isTransient (serverStatementError code)))
| (code, name) <- permanentStates
]
),
testCase "row-count decode failure (1 1 0) is not transient" $
assertBool "expected permanent" (not (isTransient (rowCountError 1 1 0))),
testCase "row-count decode failure (1 1 2) is not transient" $
assertBool "expected permanent" (not (isTransient (rowCountError 1 1 2))),
testGroup
"lost replies (BUG-1)"
[ testCase "statement error with empty SQLSTATE is transient" $
assertBool "expected transient" (isTransient lostReplyStatementError),
testCase "script error with empty SQLSTATE is transient" $
assertBool "expected transient" (isTransient (scriptError "")),
testCase "stray-result shape (1 1 1) is transient" $
assertBool "expected transient" (isTransient (rowCountError 1 1 1))
],
testGroup
"script errors follow the SQLSTATE rule"
[ testCase "script 57P01 is transient" $
assertBool "expected transient" (isTransient (scriptError "57P01")),
testCase "script 23505 is permanent" $
assertBool "expected permanent" (not (isTransient (scriptError "23505")))
],
testGroup
"isAmbiguousReply"
( [ testCase "empty-SQLSTATE statement error is ambiguous" $
assertBool "expected ambiguous" (isAmbiguousReply lostReplyStatementError),
testCase "empty-SQLSTATE script error is ambiguous" $
assertBool "expected ambiguous" (isAmbiguousReply (scriptError "")),
testCase "stray-result shape (1 1 1) is ambiguous" $
assertBool "expected ambiguous" (isAmbiguousReply (rowCountError 1 1 1))
]
<> [ testCase (name <> " is not ambiguous") $
assertBool "expected unambiguous" (not (isAmbiguousReply err))
| (name, err) <-
[ ("statement \"57P01\"", serverStatementError "57P01"),
("statement \"23505\"", serverStatementError "23505"),
("script \"57P01\"", scriptError "57P01"),
("row count 1 1 0", rowCountError 1 1 0),
("row count 1 1 2", rowCountError 1 1 2),
("connection-drop session error", PgmqSessionError (HasqlErrors.ConnectionSessionError "dropped")),
("acquisition timeout", PgmqAcquisitionTimeout),
("driver session error", PgmqSessionError (HasqlErrors.DriverSessionError "bug")),
("missing-types session error", PgmqSessionError (HasqlErrors.MissingTypesSessionError HashSet.empty))
]
<> connectionSamples
]
<> [ testCase "every ambiguous reply is transient" $
mapM_
( \(name, err) ->
assertBool
(name <> " is ambiguous but not transient")
(not (isAmbiguousReply err) || isTransient err)
)
allSamples
]
)
]