pgmq-config-0.5.0.0: test/NotifyCrashSpec.hs
{-# LANGUAGE OverloadedStrings #-}
-- | PGH-6: insert notifications must survive PostgreSQL crash recovery.
--
-- @pgmq.notify_insert_throttle@ is an UNLOGGED table, so PostgreSQL truncates it
-- during crash recovery. The insert trigger only raised @PG_NOTIFY@ when it
-- successfully updated a throttle row, so after a crash the trigger fired, found
-- no row, and silently stopped notifying until an application re-enabled notify.
--
-- This module drives a real crash cycle: it starts its own PostgreSQL instance
-- (never the suite-shared one), enables notify, kills the server with an
-- immediate shutdown (SIGQUIT), restarts it on the same data directory, and then
-- asserts that a post-recovery send still reaches a LISTENing client.
module NotifyCrashSpec (tests) where
import Control.Concurrent (threadDelay)
import Control.Exception (bracket)
import Control.Lens ((^.))
import Control.Monad (unless)
import Data.Aeson qualified as Aeson
import Data.ByteString (ByteString)
import Data.Generics.Labels ()
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Word (Word32)
import Database.PostgreSQL.LibPQ qualified as LibPQ
import Database.PostgreSQL.Migrate
( defaultRunOptions,
migrationPlan,
runMigrationPlan,
)
import EphemeralPg qualified as Pg
-- 'shutdownMode' names a field of both Pg.Config and Pg.Database, so the record
-- update below needs the selector from the module that defines only Database.
import EphemeralPg.Database qualified as PgDb
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Pool qualified as Pool
import Hasql.Pool.Config qualified as PoolConfig
import Hasql.Session (Session)
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Pgmq.Config (ensureQueues, standardQueue, withNotifyInsert)
import Pgmq.Hasql.Sessions qualified as Sessions
import Pgmq.Hasql.Statements.Types qualified as StmtTypes
import Pgmq.Migration qualified as Migration
import Pgmq.Types (MessageBody (..), QueueName, notifyChannelName, parseQueueName, queueNameToText)
import System.Random (randomRIO)
import Test.Tasty (TestTree, testGroup, withResource)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
-- | Everything the crash cycle observed, collected in one pass so the
-- assertions below can report each fact as its own test case.
data CrashObservations = CrashObservations
{ -- | The queue the cycle ran against.
obsQueueName :: !QueueName,
-- | The channel the listener subscribed to.
obsChannel :: !Text,
-- | Queue names present in @pgmq.list_notify_insert_throttles()@ after the crash.
obsThrottlesAfterCrash :: ![Text],
-- | Insert triggers on the queue table after the crash (1 = survived).
obsTriggersAfterCrash :: !Int64,
-- | Messages still in the queue after the crash (the queue table is logged).
obsQueueLengthAfterCrash :: !Int64,
-- | Channel of the notification delivered by the post-crash send, if any.
obsNotifyAfterCrash :: !(Maybe ByteString),
-- | Queue names in the throttle table after a reconcile ran.
obsThrottlesAfterReconcile :: ![Text]
}
tests :: TestTree
tests =
withResource runCrashCycle (const (pure ())) $ \getObs ->
testGroup
"NotifyCrashSpec"
[ testCase "post-crash: throttle row truncated, trigger intact" $ do
obs <- getObs
obsThrottlesAfterCrash obs @?= []
obsTriggersAfterCrash obs @?= 1
obsQueueLengthAfterCrash obs @?= 1,
testCase "post-crash: send delivers a notification" $ do
obs <- getObs
case obsNotifyAfterCrash obs of
Nothing ->
assertFailure $
"expected a notification on "
<> show (obsChannel obs)
<> " within 2s after crash recovery, got none"
Just chan ->
assertBool
( "notification arrived on "
<> show chan
<> " but the contract channel is "
<> show (obsChannel obs)
)
(chan == TE.encodeUtf8 (obsChannel obs)),
testCase "post-crash: a reconcile restores the throttle row" $ do
obs <- getObs
obsThrottlesAfterReconcile obs @?= [queueNameToText (obsQueueName obs)]
]
-- | Start a dedicated PostgreSQL instance, enable notify, crash it, recover it,
-- and record what happened. Every resource is released before this returns.
runCrashCycle :: IO CrashObservations
runCrashCycle = do
qn <- genQueueName
db0 <- startOrFail
ref <- newIORef db0
bracket (pure ref) (\r -> readIORef r >>= Pg.stop) (crashCycle qn)
crashCycle :: QueueName -> IORef Pg.Database -> IO CrashObservations
crashCycle qn ref = do
db0 <- readIORef ref
installPgmq db0
-- Pre-crash: create the queue, enable unthrottled notify, send one message.
-- No listener is opened yet: an immediate shutdown kills every pre-crash
-- connection, so a listener created here could never see the assertion's
-- notification.
bracket (acquirePool db0) Pool.release $ \pool -> do
runSession pool (Sessions.createQueue qn)
runSession pool $
Sessions.enableNotifyInsert
StmtTypes.EnableNotifyInsert
{ StmtTypes.queueName = qn,
StmtTypes.throttleIntervalMs = Just 0
}
runSession pool (sendProbe qn "before-crash")
-- ephemeral-pg runs PostgreSQL with fsync, synchronous_commit and
-- full_page_writes all off, so an immediate shutdown would otherwise discard
-- every commit still sitting in the WAL buffers — including the pgmq schema
-- itself. CHECKPOINT flushes them. It does NOT make the unlogged throttle
-- table crash-safe: recovery still resets unlogged relations to their init
-- fork, which is the behavior under test.
runSession pool (Session.script "checkpoint")
db1 <- crashAndRecover db0
writeIORef ref db1
bracket (acquirePool db1) Pool.release $ \pool -> do
throttlesAfterCrash <- listThrottleNames pool
triggers <- runSession pool (Session.statement (queueTableName qn) insertTriggerCount)
metrics <- runSession pool (Sessions.queueMetrics qn)
let channel = notifyChannelName qn
notified <- withListener db1 channel $ \conn -> do
_ <- runSession pool (sendProbe qn "after-crash")
awaitNotify conn 20
runSession pool (ensureQueues [withNotifyInsert (Just 0) (standardQueue qn)])
throttlesAfterReconcile <- listThrottleNames pool
pure
CrashObservations
{ obsQueueName = qn,
obsChannel = channel,
obsThrottlesAfterCrash = throttlesAfterCrash,
obsTriggersAfterCrash = triggers,
obsQueueLengthAfterCrash = metrics ^. #queueLength,
obsNotifyAfterCrash = LibPQ.notifyRelname <$> notified,
obsThrottlesAfterReconcile = throttlesAfterReconcile
}
-- | Stop PostgreSQL with SIGQUIT and start it again on the same data directory.
-- That is a genuine crash: the next start runs crash recovery, which truncates
-- every UNLOGGED table. Retries once, then fails loudly — the crash cycle is the
-- test, so it must never degrade into a skip.
crashAndRecover :: Pg.Database -> IO Pg.Database
crashAndRecover db = do
let crashing = db {PgDb.shutdownMode = Pg.ShutdownImmediate}
first <- Pg.restart crashing
case first of
Right db' -> pure db'
Left _ -> do
second <- Pg.restart crashing
case second of
Right db' -> pure db'
Left err -> assertFailure $ "could not restart PostgreSQL after crash: " <> show err
-- Database plumbing -----------------------------------------------------------
startOrFail :: IO Pg.Database
startOrFail = do
result <- Pg.startCached Pg.defaultConfig Pg.defaultCacheConfig
case result of
Left err -> assertFailure $ "could not start a dedicated PostgreSQL: " <> show err
Right db -> pure db
-- | Apply the full pgmq migration ledger, exactly as @EphemeralDb@ does.
installPgmq :: Pg.Database -> IO ()
installPgmq db = do
component <- either (error . ("Invalid PGMQ migration component: " <>) . show) pure Migration.pgmqMigrations
plan <- either (error . ("Invalid PGMQ migration plan: " <>) . show) pure (migrationPlan (component :| []))
installResult <- runMigrationPlan defaultRunOptions (Pg.connectionSettings db) plan
case installResult of
Left migrationErr -> error $ "Migration failed: " <> show migrationErr
Right _ -> pure ()
acquirePool :: Pg.Database -> IO Pool.Pool
acquirePool db =
Pool.acquire $
PoolConfig.settings
[ PoolConfig.size 2,
PoolConfig.staticConnectionSettings (Pg.connectionSettings db)
]
runSession :: Pool.Pool -> Session a -> IO a
runSession pool session = do
result <- Pool.use pool session
case result of
Left err -> assertFailure $ "Session failed: " <> show err
Right a -> pure a
listThrottleNames :: Pool.Pool -> IO [Text]
listThrottleNames pool = do
throttles <- runSession pool Sessions.listNotifyInsertThrottles
pure (map (^. #throttleQueueName) throttles)
sendProbe :: QueueName -> Text -> Session ()
sendProbe qn label =
()
<$ Sessions.sendMessage
StmtTypes.SendMessage
{ StmtTypes.queueName = qn,
StmtTypes.messageBody = MessageBody (Aeson.String label),
StmtTypes.delay = Nothing
}
-- | The physical table backing a queue: @q_@ plus the lowercased queue name.
queueTableName :: QueueName -> Text
queueTableName qn = "q_" <> T.toLower (queueNameToText qn)
-- | How many insert-notification triggers exist on the given @pgmq@ table.
insertTriggerCount :: Statement Text Int64
insertTriggerCount = preparable sql encoder decoder
where
sql =
"select count(*)::int8 \
\from pg_trigger t \
\join pg_class c on c.oid = t.tgrelid \
\join pg_namespace n on n.oid = c.relnamespace \
\where n.nspname = 'pgmq' \
\and c.relname = $1 \
\and t.tgname = 'trigger_notify_queue_insert_listeners'"
encoder = E.param (E.nonNullable E.text)
decoder = D.singleRow (D.column (D.nonNullable D.int8))
genQueueName :: IO QueueName
genQueueName = do
suffix <- randomRIO (10000 :: Word32, 99999)
case parseQueueName ("crash_test_" <> T.pack (show suffix)) of
Left err -> error $ "Failed to generate queue name: " <> show err
Right qn -> pure qn
-- LISTEN plumbing -------------------------------------------------------------
-- | Open a raw libpq connection (hasql exposes no notification API), subscribe
-- to @channel@, and run the action. @ephemeral-pg@ hands out connection strings
-- as 'Text' while libpq consumes 'ByteString', so both the conninfo and the
-- command are encoded explicitly.
withListener :: Pg.Database -> Text -> (LibPQ.Connection -> IO a) -> IO a
withListener db channel action =
bracket (LibPQ.connectdb (TE.encodeUtf8 (Pg.connectionString db))) LibPQ.finish $ \conn -> do
connStatus <- LibPQ.status conn
unless (connStatus == LibPQ.ConnectionOk) $ do
err <- LibPQ.errorMessage conn
assertFailure $ "libpq connection failed: " <> show err
-- The channel contains dots, so LISTEN needs the identifier double-quoted.
result <- LibPQ.exec conn (TE.encodeUtf8 ("LISTEN " <> quoteIdentifier channel))
case result of
Nothing -> assertFailure "LISTEN returned no result"
Just res -> do
execStatus <- LibPQ.resultStatus res
unless (execStatus == LibPQ.CommandOk) $
assertFailure ("LISTEN failed with " <> show execStatus)
action conn
quoteIdentifier :: Text -> Text
quoteIdentifier ident = "\"" <> T.replace "\"" "\"\"" ident <> "\""
-- | Poll for a notification, 100 ms per attempt.
awaitNotify :: LibPQ.Connection -> Int -> IO (Maybe LibPQ.Notify)
awaitNotify conn attempts
| attempts <= 0 = pure Nothing
| otherwise = do
_ <- LibPQ.consumeInput conn
pending <- LibPQ.notifies conn
case pending of
Just n -> pure (Just n)
Nothing -> do
threadDelay 100_000
awaitNotify conn (attempts - 1)