pgmq-config-0.5.0.0: test/ConfigSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module ConfigSpec
( tests,
)
where
import Control.Lens ((^.))
import Data.Generics.Labels ()
import Data.Int (Int32)
import Data.Text qualified as T
import Data.Time (UTCTime)
import Data.Word (Word32)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Pool qualified as Pool
import Hasql.Session qualified
import Hasql.Statement (Statement, preparable)
import Pgmq.Config
import Pgmq.Hasql.Sessions qualified as Sessions
import Pgmq.Types (QueueName, parseQueueName, parseTopicPattern, queueNameToText, topicPatternToText)
import System.Random (randomRIO)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
tests :: Pool.Pool -> TestTree
tests pool =
testGroup
"Pgmq.Config"
[ testEnsureQueuesCreatesStandard pool,
testEnsureQueuesCreatesUnlogged pool,
testEnsureQueuesIdempotent pool,
testEnsureQueuesIncremental pool,
testEnsureQueuesWithNotify pool,
testEnsureQueuesWithNotifyDefault pool,
testEnsureQueuesWithFifo pool,
testEnsureQueuesSkipsExistingFifo pool,
testEnsureQueuesUpdatesDriftedThrottle pool,
testEnsureQueuesReportsQueueTypeDrift pool,
testEnsureQueuesWithTopicBinding pool,
testEnsureQueuesIsTrulyIdempotent pool,
testEnsureQueuesSilentIdempotent pool,
testEnsureQueuesSilentIncremental pool
]
-- | Helper to run a session and fail on error
runSession :: Pool.Pool -> Hasql.Session.Session a -> IO a
runSession p session = do
result <- Pool.use p session
case result of
Left err -> assertFailure $ "Session failed: " <> show err
Right a -> pure a
-- | Generate a random queue name for test isolation
genQueueName :: IO QueueName
genQueueName = do
suffix <- randomRIO (10000 :: Word32, 99999)
case parseQueueName ("cfg_test_" <> T.pack (show suffix)) of
Left err -> error $ "Failed to generate queue name: " <> show err
Right qn -> pure qn
-- | Clean up a queue (ignore errors)
cleanupQueue :: Pool.Pool -> QueueName -> IO ()
cleanupQueue p qn = do
_ <- Pool.use p (Sessions.dropQueue qn)
pure ()
testEnsureQueuesCreatesStandard :: Pool.Pool -> TestTree
testEnsureQueuesCreatesStandard pool = testCase "creates a standard queue" $ do
qn <- genQueueName
let configs = [standardQueue qn]
actions <- runSession pool (ensureQueuesReport configs)
assertBool "should have CreatedQueue action" $
any isCreatedQueue actions
-- Verify queue exists
queues <- runSession pool Sessions.listQueues
assertBool "queue should be in list" $
any (\q -> (q ^. #name) == qn) queues
cleanupQueue pool qn
testEnsureQueuesCreatesUnlogged :: Pool.Pool -> TestTree
testEnsureQueuesCreatesUnlogged pool = testCase "creates an unlogged queue" $ do
qn <- genQueueName
let configs = [unloggedQueue qn]
actions <- runSession pool (ensureQueuesReport configs)
assertBool "should have CreatedQueue action" $
any isCreatedQueue actions
-- Verify it's unlogged
queues <- runSession pool Sessions.listQueues
let mq = filter (\q -> (q ^. #name) == qn) queues
case mq of
[q] -> (q ^. #isUnlogged) @?= True
_ -> assertFailure "queue not found"
cleanupQueue pool qn
testEnsureQueuesIdempotent :: Pool.Pool -> TestTree
testEnsureQueuesIdempotent pool = testCase "is idempotent (second run skips)" $ do
qn <- genQueueName
let configs = [standardQueue qn]
-- First run: creates
actions1 <- runSession pool (ensureQueuesReport configs)
assertBool "first run should create" $
any isCreatedQueue actions1
-- Second run: skips
actions2 <- runSession pool (ensureQueuesReport configs)
assertBool "second run should skip" $
all isSkipped actions2
cleanupQueue pool qn
testEnsureQueuesIncremental :: Pool.Pool -> TestTree
testEnsureQueuesIncremental pool = testCase "creates only new queues incrementally" $ do
qn1 <- genQueueName
qn2 <- genQueueName
-- First run: create one queue
_ <- runSession pool (ensureQueuesReport [standardQueue qn1])
-- Second run: add a second queue
actions <- runSession pool (ensureQueuesReport [standardQueue qn1, standardQueue qn2])
-- qn1 should be skipped, qn2 should be created
let qn1Actions = filter (actionForQueue qn1) actions
qn2Actions = filter (actionForQueue qn2) actions
assertBool "existing queue should be skipped" $
all isSkipped qn1Actions
assertBool "new queue should be created" $
any isCreatedQueue qn2Actions
cleanupQueue pool qn1
cleanupQueue pool qn2
testEnsureQueuesWithNotify :: Pool.Pool -> TestTree
testEnsureQueuesWithNotify pool = testCase "enables notify insert" $ do
qn <- genQueueName
let configs = [withNotifyInsert (Just 500) (standardQueue qn)]
actions <- runSession pool (ensureQueuesReport configs)
assertBool "should have EnabledNotify action" $
any isEnabledNotify actions
-- Second run should skip notify
actions2 <- runSession pool (ensureQueuesReport configs)
assertBool "second run should skip notify" $
any isSkippedNotify actions2
cleanupQueue pool qn
-- | A queue configured with @withNotifyInsert Nothing@ must reconcile cleanly,
-- twice.
--
-- @Nothing@ is documented as "use the pgmq default (250 ms)". Before the fix
-- the pgmq-hasql statement bound an SQL NULL for the throttle interval, which
-- @pgmq.enable_notify_insert@ inserted into a @NOT NULL@ column — a column
-- DEFAULT does not apply to an explicitly supplied NULL — so the call always
-- raised SQLSTATE 23502. Reconciliation is not one transaction: each statement
-- autocommits, so the queue creation stuck while the notify enable failed,
-- and every subsequent startup failed the same way forever.
testEnsureQueuesWithNotifyDefault :: Pool.Pool -> TestTree
testEnsureQueuesWithNotifyDefault pool = testCase "enables notify insert with the default throttle" $ do
qn <- genQueueName
let configs = [withNotifyInsert Nothing (standardQueue qn)]
actions <- runSession pool (ensureQueuesReport configs)
assertBool "first run should have EnabledNotify action" $
any isEnabledNotify actions
-- Second run must be a clean skip, proving the first run actually recorded
-- a throttle row rather than failing.
actions2 <- runSession pool (ensureQueuesReport configs)
assertBool "second run should skip notify" $
any isSkippedNotify actions2
-- The recorded interval must be the documented 250ms default.
throttles <- runSession pool Sessions.listNotifyInsertThrottles
let mine = filter (\t -> (t ^. #throttleQueueName) == queueNameToText qn) throttles
case mine of
[t] -> (t ^. #throttleIntervalMs) @?= 250
_ -> assertFailure $ "expected exactly one throttle row, got " <> show (length mine)
cleanupQueue pool qn
testEnsureQueuesWithFifo :: Pool.Pool -> TestTree
testEnsureQueuesWithFifo pool = testCase "creates FIFO index" $ do
qn <- genQueueName
let configs = [withFifoIndex (standardQueue qn)]
actions <- runSession pool (ensureQueuesReport configs)
assertBool "should have CreatedFifoIndex action" $
any isFifoIndex actions
cleanupQueue pool qn
-- | The FIFO action must report what actually happened.
--
-- @pgmq.create_fifo_index@ is @CREATE INDEX IF NOT EXISTS@ and tells the caller
-- nothing, so the reconciler used to report 'CreatedFifoIndex' on every run
-- forever and 'SkippedFifoIndex' was unreachable dead code. The reconciler now
-- snapshots @pg_indexes@ first, so the second run skips the call outright.
testEnsureQueuesSkipsExistingFifo :: Pool.Pool -> TestTree
testEnsureQueuesSkipsExistingFifo pool = testCase "second run skips the existing FIFO index" $ do
qn <- genQueueName
let configs = [withFifoIndex (standardQueue qn)]
actions1 <- runSession pool (ensureQueuesReport configs)
assertBool
("first run should create the FIFO index, got " <> show actions1)
(any isFifoIndex actions1)
actions2 <- runSession pool (ensureQueuesReport configs)
assertBool
("second run should skip the FIFO index, got " <> show actions2)
(any isSkippedFifoIndex actions2)
assertBool
("second run must not claim to have created it, got " <> show actions2)
(not (any isFifoIndex actions2))
cleanupQueue pool qn
-- | A declared throttle interval that differs from the stored one is applied.
--
-- This is the reconciler's single mutation of already-existing state. Before
-- the fix the declared value was compared only for presence, so changing it in
-- the config had no effect on the database and the report said 'SkippedNotify'.
testEnsureQueuesUpdatesDriftedThrottle :: Pool.Pool -> TestTree
testEnsureQueuesUpdatesDriftedThrottle pool = testCase "updates a drifted notify throttle" $ do
qn <- genQueueName
_ <- runSession pool (ensureQueuesReport [withNotifyInsert (Just 250) (standardQueue qn)])
-- Redeclare with a different interval: the row must be brought in line.
actions <- runSession pool (ensureQueuesReport [withNotifyInsert (Just 500) (standardQueue qn)])
assertBool
("expected UpdatedNotifyThrottle " <> show qn <> " 250 500, got " <> show actions)
(any (isUpdatedThrottle qn 250 500) actions)
throttleFor pool qn >>= (@?= 500)
-- A third run at the now-current value is a clean skip, so the update
-- converges instead of firing on every startup.
actions3 <- runSession pool (ensureQueuesReport [withNotifyInsert (Just 500) (standardQueue qn)])
assertBool
("third run should skip notify, got " <> show actions3)
(any isSkippedNotify actions3)
cleanupQueue pool qn
-- | A declared queue type contradicting the live queue is reported, not fixed.
--
-- Converting a queue's type means dropping and recreating it, destroying every
-- message it holds, so the reconciler surfaces the contradiction and leaves the
-- decision to an operator. Before the fix it silently reported 'SkippedQueue'.
testEnsureQueuesReportsQueueTypeDrift :: Pool.Pool -> TestTree
testEnsureQueuesReportsQueueTypeDrift pool = testCase "reports queue-type drift without mutating" $ do
qn <- genQueueName
_ <- runSession pool (ensureQueuesReport [standardQueue qn])
actions <- runSession pool (ensureQueuesReport [unloggedQueue qn])
assertBool
("expected DetectedQueueTypeDrift for " <> show qn <> ", got " <> show actions)
(any (isQueueTypeDrift qn ObservedStandard) actions)
assertBool
("drift must replace the plain skip, got " <> show actions)
(not (any isSkippedQueue actions))
-- The queue is untouched: still standard, still there.
queues <- runSession pool Sessions.listQueues
case filter (\q -> (q ^. #name) == qn) queues of
[q] -> do
(q ^. #isUnlogged) @?= False
(q ^. #isPartitioned) @?= False
other -> assertFailure $ "expected exactly one queue row, got " <> show (length other)
cleanupQueue pool qn
-- | The stored throttle interval for a queue.
throttleFor :: Pool.Pool -> QueueName -> IO Int32
throttleFor pool qn = do
throttles <- runSession pool Sessions.listNotifyInsertThrottles
case filter (\t -> (t ^. #throttleQueueName) == queueNameToText qn) throttles of
[t] -> pure (t ^. #throttleIntervalMs)
other -> assertFailure $ "expected exactly one throttle row, got " <> show (length other)
testEnsureQueuesWithTopicBinding :: Pool.Pool -> TestTree
testEnsureQueuesWithTopicBinding pool = testCase "binds topic pattern" $ do
qn <- genQueueName
pat <- case parseTopicPattern "orders.*" of
Left err -> assertFailure ("Failed to parse topic pattern: " <> show err) >> error "unreachable"
Right p -> pure p
let configs = [withTopicBinding pat (standardQueue qn)]
actions <- runSession pool (ensureQueuesReport configs)
assertBool "should have BoundTopic action" $
any isBoundTopic actions
-- Verify binding exists
bindings <- runSession pool Sessions.listTopicBindings
assertBool "binding should exist" $
any
( \b ->
(b ^. #bindingQueueName) == queueNameToText qn
&& topicPatternToText (b ^. #bindingPattern) == "orders.*"
)
bindings
-- Second run should skip
actions2 <- runSession pool (ensureQueuesReport configs)
assertBool "second run should skip topic binding" $
any isSkippedTopicBinding actions2
cleanupQueue pool qn
-- Helpers for checking action types
isCreatedQueue :: ReconcileAction -> Bool
isCreatedQueue (CreatedQueue _ _) = True
isCreatedQueue _ = False
isSkipped :: ReconcileAction -> Bool
isSkipped (SkippedQueue _) = True
isSkipped (SkippedNotify _) = True
isSkipped (SkippedFifoIndex _) = True
isSkipped (SkippedTopicBinding _ _) = True
isSkipped _ = False
isSkippedTopicBinding :: ReconcileAction -> Bool
isSkippedTopicBinding (SkippedTopicBinding _ _) = True
isSkippedTopicBinding _ = False
isEnabledNotify :: ReconcileAction -> Bool
isEnabledNotify (EnabledNotify _ _) = True
isEnabledNotify _ = False
isSkippedNotify :: ReconcileAction -> Bool
isSkippedNotify (SkippedNotify _) = True
isSkippedNotify _ = False
isFifoIndex :: ReconcileAction -> Bool
isFifoIndex (CreatedFifoIndex _) = True
isFifoIndex _ = False
isSkippedFifoIndex :: ReconcileAction -> Bool
isSkippedFifoIndex (SkippedFifoIndex _) = True
isSkippedFifoIndex _ = False
isSkippedQueue :: ReconcileAction -> Bool
isSkippedQueue (SkippedQueue _) = True
isSkippedQueue _ = False
isUpdatedThrottle :: QueueName -> Int32 -> Int32 -> ReconcileAction -> Bool
isUpdatedThrottle qn observed declared (UpdatedNotifyThrottle q o d) =
q == qn && o == observed && d == declared
isUpdatedThrottle _ _ _ _ = False
isQueueTypeDrift :: QueueName -> ObservedQueueType -> ReconcileAction -> Bool
isQueueTypeDrift qn observed (DetectedQueueTypeDrift q _ o) = q == qn && o == observed
isQueueTypeDrift _ _ _ = False
isBoundTopic :: ReconcileAction -> Bool
isBoundTopic (BoundTopic _ _) = True
isBoundTopic _ = False
actionForQueue :: QueueName -> ReconcileAction -> Bool
actionForQueue qn (CreatedQueue q _) = q == qn
actionForQueue qn (SkippedQueue q) = q == qn
actionForQueue qn (EnabledNotify q _) = q == qn
actionForQueue qn (SkippedNotify q) = q == qn
actionForQueue qn (CreatedFifoIndex q) = q == qn
actionForQueue qn (SkippedFifoIndex q) = q == qn
actionForQueue qn (BoundTopic q _) = q == qn
actionForQueue qn (SkippedTopicBinding q _) = q == qn
actionForQueue qn (UpdatedNotifyThrottle q _ _) = q == qn
actionForQueue qn (DetectedQueueTypeDrift q _ _) = q == qn
-- | Silent-variant version of 'testEnsureQueuesIdempotent': call 'ensureQueues'
-- twice with the same standard-queue config and confirm the queue exists exactly
-- once afterwards.
testEnsureQueuesSilentIdempotent :: Pool.Pool -> TestTree
testEnsureQueuesSilentIdempotent pool = testCase "silent ensureQueues is idempotent" $ do
qn <- genQueueName
let configs = [standardQueue qn]
runSession pool (ensureQueues configs)
runSession pool (ensureQueues configs)
queues <- runSession pool Sessions.listQueues
let matching = filter (\q -> (q ^. #name) == qn) queues
length matching @?= 1
cleanupQueue pool qn
-- | Silent-variant version of 'testEnsureQueuesIncremental': after an initial
-- single-queue reconcile, a second reconcile that adds a new queue must leave
-- both queues in place.
testEnsureQueuesSilentIncremental :: Pool.Pool -> TestTree
testEnsureQueuesSilentIncremental pool = testCase "silent ensureQueues adds new queues incrementally" $ do
qn1 <- genQueueName
qn2 <- genQueueName
runSession pool (ensureQueues [standardQueue qn1])
runSession pool (ensureQueues [standardQueue qn1, standardQueue qn2])
queues <- runSession pool Sessions.listQueues
let names = map (^. #name) queues
assertBool "qn1 should exist" (qn1 `elem` names)
assertBool "qn2 should exist" (qn2 `elem` names)
cleanupQueue pool qn1
cleanupQueue pool qn2
-- | Assert that calling the silent 'ensureQueues' a second time for a queue with
-- notify-insert configured does NOT reset 'last_notified_at' to the epoch.
--
-- Under the pre-fix behaviour, 'ensureQueues' unconditionally calls
-- 'pgmq.enable_notify_insert', which first runs 'pgmq.disable_notify_insert'
-- (DELETE FROM pgmq.notify_insert_throttle) and then re-inserts the row with
-- the default 'last_notified_at = to_timestamp(0)'. After the fix, the second
-- call queries existing state and skips the notify mutation entirely, so a
-- timestamp bumped between the two calls is preserved.
testEnsureQueuesIsTrulyIdempotent :: Pool.Pool -> TestTree
testEnsureQueuesIsTrulyIdempotent pool = testCase "ensureQueues is truly idempotent for notify" $ do
qn <- genQueueName
let configs = [withNotifyInsert (Just 500) (standardQueue qn)]
-- First run creates the queue and enables notify. Fresh throttle row has
-- last_notified_at = to_timestamp(0) (the table default).
runSession pool (ensureQueues configs)
-- Bump last_notified_at to a recent timestamp so a reset would be observable.
bumped <- runSession pool (Hasql.Session.statement (queueNameToText qn) bumpLastNotifiedAtStmt)
assertBool "bumped timestamp should be post-epoch" (bumped > epochUtc)
-- Second run must be a no-op for notify (i.e., must not rewrite the row).
runSession pool (ensureQueues configs)
after <- runSession pool (Hasql.Session.statement (queueNameToText qn) pgNotifyLastAt)
assertBool
( "last_notified_at must not be reset by second ensureQueues; was "
<> show after
)
(after > epochUtc)
after @?= bumped
cleanupQueue pool qn
-- | Read 'last_notified_at' for a queue. One-off statement for tests only.
pgNotifyLastAt :: Statement T.Text UTCTime
pgNotifyLastAt = preparable sql encoder decoder
where
sql = "SELECT last_notified_at FROM pgmq.notify_insert_throttle WHERE queue_name = $1"
encoder = E.param (E.nonNullable E.text)
decoder = D.singleRow (D.column (D.nonNullable D.timestamptz))
-- | Bump 'last_notified_at' to clock_timestamp() and return the new value.
-- Used to observe whether a subsequent ensureQueues resets it.
bumpLastNotifiedAtStmt :: Statement T.Text UTCTime
bumpLastNotifiedAtStmt = preparable sql encoder decoder
where
sql =
"UPDATE pgmq.notify_insert_throttle \
\SET last_notified_at = clock_timestamp() \
\WHERE queue_name = $1 RETURNING last_notified_at"
encoder = E.param (E.nonNullable E.text)
decoder = D.singleRow (D.column (D.nonNullable D.timestamptz))
-- | Postgres epoch as a UTCTime, i.e. 'to_timestamp(0)'.
epochUtc :: UTCTime
epochUtc = read "1970-01-01 00:00:00 UTC"