shibuya-pgmq-adapter-0.11.0.0: test/Shibuya/Adapter/Pgmq/InternalSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Shibuya.Adapter.Pgmq.InternalSpec (spec) where
import Data.Aeson (Value (..), object, (.=))
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString qualified as BS
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Int (Int32)
import Data.Time (NominalDiffTime, UTCTime (..), fromGregorian)
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, liftIO, runEff)
import Effectful.Dispatch.Dynamic (interpret)
import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)
import Hasql.Errors qualified as HasqlErrors
import Pgmq.Effectful (Pgmq, PgmqRuntimeError (..))
import Pgmq.Effectful.Effect qualified as PgmqEffect
import Pgmq.Hasql.Statements.Types (ReadGrouped (..), ReadMessage (..), ReadWithPollMessage (..))
import Pgmq.Types (Message (..), MessageBody (..), MessageId (..), parseQueueName)
import Shibuya.Adapter.Pgmq.Config
( PgmqAdapterConfig (..),
PollRetryConfig (..),
PollingConfig (..),
defaultPollRetryConfig,
defaultPollingConfig,
)
import Shibuya.Adapter.Pgmq.Internal
( finalizeAutoDeadLetter,
mergeDlqHeaders,
mkReadGrouped,
mkReadMessage,
mkReadWithPoll,
nominalToSeconds,
pgmqChunks,
)
import Streamly.Data.Stream qualified as Stream
import Test.Hspec
spec :: Spec
spec = do
nominalToSecondsSpec
mkReadMessageSpec
mkReadWithPollSpec
mkReadGroupedSpec
pollRetrySpec
autoDeadLetterHookSpec
mergeDlqHeadersSpec
-- | Tests for nominalToSeconds
nominalToSecondsSpec :: Spec
nominalToSecondsSpec = describe "nominalToSeconds" $ do
it "converts whole seconds" $ do
nominalToSeconds 5 `shouldBe` 5
it "rounds up fractional seconds" $ do
nominalToSeconds 5.1 `shouldBe` 6
it "rounds up small fractions" $ do
nominalToSeconds 5.001 `shouldBe` 6
it "handles zero" $ do
nominalToSeconds 0 `shouldBe` 0
it "handles negative (rounds toward positive infinity)" $ do
nominalToSeconds (-5.1) `shouldBe` (-5)
describe "clamping" $ do
it "exact 30 seconds passes through" $ do
nominalToSeconds 30 `shouldBe` 30
it "rounds up subsecond" $ do
nominalToSeconds 0.1 `shouldBe` 1
it "clamps a 100-year delay to maxBound :: Int32" $ do
let hundredYears = 100 * 365 * 24 * 60 * 60 :: NominalDiffTime
nominalToSeconds hundredYears `shouldBe` (maxBound :: Int32)
it "clamps a very large negative delay to minBound :: Int32" $ do
let hugelyNegative = negate (100 * 365 * 24 * 60 * 60) :: NominalDiffTime
nominalToSeconds hugelyNegative `shouldBe` (minBound :: Int32)
-- | Tests for mkReadMessage
mkReadMessageSpec :: Spec
mkReadMessageSpec = describe "mkReadMessage" $ do
let queueName = case parseQueueName "test_queue" of
Right q -> q
Left e -> error $ "Unexpected: " <> show e
config =
PgmqAdapterConfig
{ queueName = queueName,
visibilityTimeout = 60,
batchSize = 10,
polling = defaultPollingConfig,
pollRetry = defaultPollRetryConfig,
ackRetry = defaultPollRetryConfig,
deadLetterConfig = Nothing,
haltVisibilityTimeout = Nothing,
maxRetries = 3,
fifoConfig = Nothing,
prefetchConfig = Nothing
}
ReadMessage
{ queueName = queryQueueName,
delay = queryDelay,
batchSize = queryBatchSize,
conditional = queryConditional
} = mkReadMessage config
it "sets queueName from config" $ do
queryQueueName `shouldBe` queueName
it "sets delay to visibilityTimeout" $ do
queryDelay `shouldBe` 60
it "sets batchSize from config" $ do
queryBatchSize `shouldBe` Just 10
it "sets conditional to Nothing" $ do
queryConditional `shouldBe` Nothing
-- | Tests for mkReadWithPoll
mkReadWithPollSpec :: Spec
mkReadWithPollSpec = describe "mkReadWithPoll" $ do
let queueName = case parseQueueName "test_queue" of
Right q -> q
Left e -> error $ "Unexpected: " <> show e
config =
PgmqAdapterConfig
{ queueName = queueName,
visibilityTimeout = 60,
batchSize = 10,
polling = defaultPollingConfig,
pollRetry = defaultPollRetryConfig,
ackRetry = defaultPollRetryConfig,
deadLetterConfig = Nothing,
haltVisibilityTimeout = Nothing,
maxRetries = 3,
fifoConfig = Nothing,
prefetchConfig = Nothing
}
ReadWithPollMessage
{ queueName = queryQueueName,
delay = queryDelay,
batchSize = queryBatchSize,
maxPollSeconds = queryMaxPoll,
pollIntervalMs = queryPollInterval,
conditional = queryConditional
} = mkReadWithPoll config 5 100
it "sets queueName from config" $ do
queryQueueName `shouldBe` queueName
it "sets delay to visibilityTimeout" $ do
queryDelay `shouldBe` 60
it "sets batchSize from config" $ do
queryBatchSize `shouldBe` Just 10
it "sets maxPollSeconds from parameter" $ do
queryMaxPoll `shouldBe` 5
it "sets pollIntervalMs from parameter" $ do
queryPollInterval `shouldBe` 100
it "sets conditional to Nothing" $ do
queryConditional `shouldBe` Nothing
-- | Tests for mkReadGrouped
mkReadGroupedSpec :: Spec
mkReadGroupedSpec = describe "mkReadGrouped" $ do
let queueName = case parseQueueName "test_queue" of
Right q -> q
Left e -> error $ "Unexpected: " <> show e
config =
PgmqAdapterConfig
{ queueName = queueName,
visibilityTimeout = 60,
batchSize = 20,
polling = defaultPollingConfig,
pollRetry = defaultPollRetryConfig,
ackRetry = defaultPollRetryConfig,
deadLetterConfig = Nothing,
haltVisibilityTimeout = Nothing,
maxRetries = 3,
fifoConfig = Nothing,
prefetchConfig = Nothing
}
ReadGrouped
{ queueName = queryQueueName,
visibilityTimeout = queryVt,
qty = queryQty
} = mkReadGrouped config
it "sets queueName from config" $ do
queryQueueName `shouldBe` queueName
it "sets visibilityTimeout from config" $ do
queryVt `shouldBe` 60
it "sets qty to batchSize" $ do
queryQty `shouldBe` 20
pollRetrySpec :: Spec
pollRetrySpec = describe "pgmqChunks poll retry" $ do
it "retries transient poll errors and returns the successful batch" $ do
calls <- newIORef (0 :: Int)
let cfg = retryTestConfig 5
result <-
runStubPgmq calls (transientThenSuccess 2) $
Stream.toList (Stream.take 1 (pgmqChunks cfg))
result `shouldBe` Right [Vector.singleton testMessage]
readIORef calls `shouldReturn` 3
it "does not retry permanent poll errors" $ do
calls <- newIORef (0 :: Int)
let cfg = retryTestConfig 5
result <-
runStubPgmq calls (const (Left permanentError)) $
Stream.toList (Stream.take 1 (pgmqChunks cfg))
result `shouldBe` Left permanentError
readIORef calls `shouldReturn` 1
it "stops retrying after maxAttempts is exhausted" $ do
calls <- newIORef (0 :: Int)
let cfg = retryTestConfig 2
result <-
runStubPgmq calls (const (Left transientError)) $
Stream.toList (Stream.take 1 (pgmqChunks cfg))
result `shouldBe` Left transientError
readIORef calls `shouldReturn` 2
autoDeadLetterHookSpec :: Spec
autoDeadLetterHookSpec = describe "finalizeAutoDeadLetter" $ do
it "calls the auto-DLQ hook only after finalize succeeds" $ do
autoCalls <- newIORef (0 :: Int)
failureCalls <- newIORef (0 :: Int)
let action :: Eff '[Error PgmqRuntimeError, IOE] ()
action =
finalizeAutoDeadLetter
testMessage
(\_ -> bump autoCalls)
(\_ _ -> bump failureCalls)
(pure ())
result <- runEff $ runErrorNoCallStack action
result `shouldBe` Right ()
readIORef autoCalls `shouldReturn` 1
readIORef failureCalls `shouldReturn` 0
it "calls the ack-failure hook without reporting auto-DLQ success when finalize fails" $ do
autoCalls <- newIORef (0 :: Int)
failureCalls <- newIORef (0 :: Int)
let action :: Eff '[Error PgmqRuntimeError, IOE] ()
action =
finalizeAutoDeadLetter
testMessage
(\_ -> bump autoCalls)
(\_ _ -> bump failureCalls)
(throwError permanentError)
result <- runEff $ runErrorNoCallStack action
result `shouldBe` Right ()
readIORef autoCalls `shouldReturn` 0
readIORef failureCalls `shouldReturn` 1
-- | Tests for mergeDlqHeaders, the helper that injects the failing
-- consumer's trace context onto a DLQ message while preserving the
-- original producer's trace under x-shibuya-upstream-* keys.
mergeDlqHeadersSpec :: Spec
mergeDlqHeadersSpec = describe "mergeDlqHeaders" $ do
it "with no consumer headers, forwards original headers verbatim" $ do
let original =
Just $
object
[ "traceparent" .= ("00-producer-trace-id-pid-01" :: String),
"tracestate" .= ("vendor=opaque" :: String),
"custom" .= ("value" :: String)
]
mergeDlqHeaders Nothing original `shouldBe` original
it "with no consumer headers and no original, returns Nothing" $ do
mergeDlqHeaders Nothing Nothing `shouldBe` Nothing
it "consumer's traceparent overrides original's, original moves under x-shibuya-upstream-traceparent" $ do
let original =
Just $
object
[ "traceparent" .= ("00-producer-trace-id-pid-01" :: String),
"tracestate" .= ("vendor=opaque" :: String),
"custom" .= ("preserved" :: String)
]
consumerHdrs =
Just
[ ("traceparent", "00-consumer-trace-id-cid-01"),
("tracestate", "consumer=ok")
]
case mergeDlqHeaders consumerHdrs original of
Just (Object obj) -> do
KeyMap.lookup "traceparent" obj
`shouldBe` Just (String "00-consumer-trace-id-cid-01")
KeyMap.lookup "tracestate" obj
`shouldBe` Just (String "consumer=ok")
KeyMap.lookup "x-shibuya-upstream-traceparent" obj
`shouldBe` Just (String "00-producer-trace-id-pid-01")
KeyMap.lookup "x-shibuya-upstream-tracestate" obj
`shouldBe` Just (String "vendor=opaque")
KeyMap.lookup "custom" obj
`shouldBe` Just (String "preserved")
other -> expectationFailure $ "expected merged Object, got " <> show other
it "consumer's headers carry through when original headers are absent" $ do
let consumerHdrs =
Just
[ ("traceparent", "00-consumer-trace-id-cid-01"),
("tracestate", "consumer=ok")
]
case mergeDlqHeaders consumerHdrs Nothing of
Just (Object obj) -> do
KeyMap.lookup "traceparent" obj
`shouldBe` Just (String "00-consumer-trace-id-cid-01")
KeyMap.lookup "tracestate" obj
`shouldBe` Just (String "consumer=ok")
KeyMap.lookup "x-shibuya-upstream-traceparent" obj `shouldBe` Nothing
KeyMap.lookup "x-shibuya-upstream-tracestate" obj `shouldBe` Nothing
other -> expectationFailure $ "expected merged Object, got " <> show other
it "original's tracestate is stashed even when consumer has no tracestate" $ do
let original =
Just $
object
[ "traceparent" .= ("00-producer-trace-id-pid-01" :: String),
"tracestate" .= ("vendor=opaque" :: String)
]
consumerHdrs = Just [("traceparent", "00-consumer-trace-id-cid-01")]
case mergeDlqHeaders consumerHdrs original of
Just (Object obj) -> do
KeyMap.lookup "traceparent" obj
`shouldBe` Just (String "00-consumer-trace-id-cid-01")
KeyMap.lookup "tracestate" obj `shouldBe` Nothing
KeyMap.lookup "x-shibuya-upstream-traceparent" obj
`shouldBe` Just (String "00-producer-trace-id-pid-01")
KeyMap.lookup "x-shibuya-upstream-tracestate" obj
`shouldBe` Just (String "vendor=opaque")
other -> expectationFailure $ "expected merged Object, got " <> show other
it "leniently decodes non-UTF8 trace headers" $ do
let consumerHdrs = Just [(BS.pack [0xff, 0xfe], BS.pack [0xff])]
mergeDlqHeaders consumerHdrs Nothing `shouldSatisfy` \case
Just (Object obj) -> not (KeyMap.null obj)
_ -> False
retryTestConfig :: Int -> PgmqAdapterConfig
retryTestConfig attempts =
let queueName = case parseQueueName "retry_test" of
Right q -> q
Left e -> error $ "Unexpected: " <> show e
in PgmqAdapterConfig
{ queueName = queueName,
visibilityTimeout = 30,
batchSize = 1,
polling = StandardPolling {pollInterval = 0},
pollRetry =
PollRetryConfig
{ maxAttempts = attempts,
initialBackoff = 0,
maxBackoff = 0
},
ackRetry = defaultPollRetryConfig,
deadLetterConfig = Nothing,
haltVisibilityTimeout = Nothing,
maxRetries = 3,
fifoConfig = Nothing,
prefetchConfig = Nothing
}
runStubPgmq ::
IORef Int ->
(Int -> Either PgmqRuntimeError (Vector.Vector Message)) ->
Eff '[Pgmq, Error PgmqRuntimeError, IOE] a ->
IO (Either PgmqRuntimeError a)
runStubPgmq calls respond action =
runEff $
runErrorNoCallStack $
interpret
( \_ -> \case
PgmqEffect.ReadMessage _ -> nextPoll
PgmqEffect.ReadWithPoll _ -> nextPoll
PgmqEffect.ReadGrouped _ -> nextPoll
PgmqEffect.ReadGroupedWithPoll _ -> nextPoll
PgmqEffect.ReadGroupedRoundRobin _ -> nextPoll
PgmqEffect.ReadGroupedRoundRobinWithPoll _ -> nextPoll
_ -> error "unexpected Pgmq operation in retry test"
)
action
where
nextPoll = do
n <- liftIO $ atomicModifyIORef' calls (\c -> let c' = c + 1 in (c', c'))
case respond n of
Left err -> throwError err
Right batch -> pure batch
transientThenSuccess :: Int -> Int -> Either PgmqRuntimeError (Vector.Vector Message)
transientThenSuccess failures n
| n <= failures = Left transientError
| otherwise = Right (Vector.singleton testMessage)
transientError :: PgmqRuntimeError
transientError = PgmqAcquisitionTimeout
permanentError :: PgmqRuntimeError
permanentError =
PgmqConnectionError (HasqlErrors.AuthenticationConnectionError "bad password")
bump :: IORef Int -> IO ()
bump ref = atomicModifyIORef' ref (\n -> (n + 1, ()))
testMessage :: Message
testMessage =
Message
{ messageId = MessageId 1,
visibilityTime = testTime,
enqueuedAt = testTime,
lastReadAt = Nothing,
readCount = 1,
body = MessageBody Null,
headers = Nothing
}
testTime :: UTCTime
testTime = UTCTime (fromGregorian 2026 1 1) 0