packages feed

pgmq-effectful-0.7.0.0: test/DisconnectSpec.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}

-- | BUG-1: a connection lost while a statement is in flight must classify as
-- transient, whichever hasql constructor it arrives in.
--
-- Drives three real faults against a dedicated PostgreSQL (never the
-- suite-shared one) while a 'readWithPoll' is blocked server-side, records
-- what surfaced, and asserts that 'isTransient' says retry and that the same
-- pool completes a later send.
--
-- All work happens inside one 'withResource' acquisition; the test cases are
-- pure assertions over the recorded observations, because tasty runs cases
-- concurrently and a sequence of faults must not be split across them.
module DisconnectSpec (tests) where

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Exception (SomeException, bracket, try)
import Data.Aeson qualified as Aeson
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Int (Int32, Int64)
import Data.Text qualified as T
import Data.Word (Word32)
import Effectful (Eff, IOE, runEff)
import Effectful.Error.Static (Error, runError)
import EphemeralDb (ephemeralConfig, installPgmqNative)
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.Errors qualified as HasqlErrors
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.Effectful
  ( MessageBody (..),
    Pgmq,
    PgmqRuntimeError (..),
    QueueName,
    ReadWithPollMessage (..),
    SendMessage (..),
    createQueue,
    isAmbiguousReply,
    isTransient,
    parseQueueName,
    readWithPoll,
    runPgmq,
    sendMessage,
  )
import System.Posix.Signals (sigKILL, signalProcess)
import System.Random (randomRIO)
import Test.Tasty (TestTree, testGroup, withResource)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase)

-- | What one fault produced.
data FaultObservation = FaultObservation
  { -- | The error the in-flight poll surfaced; Nothing means it completed.
    obsError :: !(Maybe PgmqRuntimeError),
    -- | Attempts the same pool needed to complete a send afterwards.
    obsRecoveryAttempts :: !Int,
    -- | Errors seen on the way to recovery, in order.
    obsRecoveryErrors :: ![PgmqRuntimeError],
    -- | Whether the send eventually succeeded within the attempt budget.
    obsRecovered :: !Bool
  }

data DisconnectObservations = DisconnectObservations
  { obsTerminate :: !FaultObservation,
    obsKill :: !FaultObservation,
    obsRestart :: !FaultObservation
  }

tests :: TestTree
tests =
  withResource runDisconnectCycle (const (pure ())) $ \getObs ->
    testGroup
      "DisconnectSpec (BUG-1)"
      [ faultCases "backend termination" (obsTerminate <$> getObs),
        faultCases "backend SIGKILL" (obsKill <$> getObs),
        faultCases "immediate restart" (obsRestart <$> getObs),
        testCase "SIGKILL reply is ambiguous" $ do
          obs <- obsKill <$> getObs
          case obsError obs of
            Nothing -> pure ()
            Just err -> assertBool ("expected an ambiguous reply, got " <> show err) (isAmbiguousReply err)
      ]

faultCases :: String -> IO FaultObservation -> TestTree
faultCases label getObs =
  testGroup
    label
    [ testCase "surfaces an error" $ do
        obs <- getObs
        case obsError obs of
          Nothing -> assertFailure "the in-flight poll completed; the fault did not land"
          Just _ -> pure (),
      testCase "error is a disconnect shape" $ do
        obs <- getObs
        case obsError obs of
          Nothing -> pure ()
          Just err -> assertBool ("unexpected shape: " <> show err) (isDisconnectShape err),
      testCase "error is transient" $ do
        obs <- getObs
        case obsError obs of
          Nothing -> pure ()
          Just err -> assertBool ("expected transient, got " <> show err) (isTransient err),
      testCase "ambiguous replies are transient" $ do
        obs <- getObs
        case obsError obs of
          Nothing -> pure ()
          Just err ->
            assertBool
              ("ambiguous but not transient: " <> show err)
              (not (isAmbiguousReply err) || isTransient err),
      testCase "same pool recovers" $ do
        obs <- getObs
        assertBool
          ( "pool did not recover within "
              <> show (obsRecoveryAttempts obs)
              <> " attempts; errors seen: "
              <> show (obsRecoveryErrors obs)
          )
          (obsRecovered obs)
    ]

-- | The values a lost connection is known to arrive in (see design note 017).
isDisconnectShape :: PgmqRuntimeError -> Bool
isDisconnectShape = \case
  PgmqSessionError (HasqlErrors.ConnectionSessionError _) -> True
  PgmqSessionError (HasqlErrors.StatementSessionError _ _ _ _ _ statementError) ->
    case statementError of
      HasqlErrors.ServerStatementError (HasqlErrors.ServerError code _ _ _ _) ->
        code `elem` ["", "57P01", "57P02"]
      HasqlErrors.UnexpectedRowCountStatementError 1 1 1 -> True
      _ -> False
  _ -> False

-- The fault cycle -------------------------------------------------------------

-- | Start a dedicated PostgreSQL, run the three faults in order against one
-- size-1 pool, and record what each produced. Every resource is released
-- before this returns.
runDisconnectCycle :: IO DisconnectObservations
runDisconnectCycle = do
  db0 <- startOrFail
  ref <- newIORef db0
  bracket (pure ref) (\r -> readIORef r >>= Pg.stop) $ \dbRef -> do
    installPgmqNative (Pg.connectionSettings db0)
    -- The poll queue stays empty so every poll blocks server-side; recovery
    -- sends go to a separate queue so they never satisfy a later poll.
    pollQueue <- genQueueName "disconnect_poll_"
    sendQueue <- genQueueName "disconnect_send_"
    bracket (acquirePool db0) Pool.release $ \pool -> do
      runOrFail pool (createQueue pollQueue >> createQueue sendQueue)

      terminate <- faultRound "backend termination" dbRef pool pollQueue sendQueue $ \pid -> do
        db <- readIORef dbRef
        bracket (acquirePool db) Pool.release $ \control ->
          () <$ session control (Session.statement pid terminateBackend)

      kill <- faultRound "backend SIGKILL" dbRef pool pollQueue sendQueue $ \pid ->
        signalProcess sigKILL (fromIntegral pid)

      restart <- faultRound "immediate restart" dbRef pool pollQueue sendQueue $ \_pid -> do
        db <- readIORef dbRef
        db' <- crashAndRecover db
        writeIORef dbRef db'

      pure DisconnectObservations {obsTerminate = terminate, obsKill = kill, obsRestart = restart}

-- | Read the pool connection's backend pid, block it in 'readWithPoll', inject
-- the fault, collect what the poll surfaced, and then measure how many sends
-- the same pool needs to succeed again.
faultRound ::
  String ->
  IORef Pg.Database ->
  Pool.Pool ->
  QueueName ->
  QueueName ->
  (Int32 -> IO ()) ->
  IO FaultObservation
faultRound label dbRef pool pollQueue sendQueue inject = do
  -- ephemeral-pg runs PostgreSQL with fsync and synchronous_commit off, so a
  -- crash would otherwise discard the queues still sitting in the WAL buffers.
  session pool (Session.script "checkpoint")
  pid <- session pool (Session.statement () backendPid)
  outcomeVar <- newEmptyMVar
  _ <- forkIO $ do
    result <- try @SomeException (runPoll pool pollQueue)
    putMVar outcomeVar result
  db <- readIORef dbRef
  awaitPolling db pid 200
  inject pid
  outcome <- takeMVar outcomeVar
  err <- case outcome of
    Left exc -> assertFailure (label <> ": poll thread threw " <> show exc)
    Right (Left runtimeErr) -> pure (Just runtimeErr)
    Right (Right ()) -> pure Nothing
  (attempts, errors, recovered) <- recover pool sendQueue 100
  pure
    FaultObservation
      { obsError = err,
        obsRecoveryAttempts = attempts,
        obsRecoveryErrors = errors,
        obsRecovered = recovered
      }

runPoll :: Pool.Pool -> QueueName -> IO (Either PgmqRuntimeError ())
runPoll pool queue =
  runPgmqIO pool . (() <$) . readWithPoll $
    ReadWithPollMessage
      { queueName = queue,
        delay = 30,
        batchSize = Just 1,
        maxPollSeconds = 15,
        pollIntervalMs = 100,
        conditional = Nothing
      }

-- | Wait until the server shows the given backend busy in @read_with_poll@.
-- The attacked pool's only connection is busy, so this opens its own
-- short-lived control pool each time (the SIGKILL round restarts every
-- backend, so nothing is shared across rounds).
awaitPolling :: Pg.Database -> Int32 -> Int -> IO ()
awaitPolling db pid budget =
  bracket (acquirePool db) Pool.release $ \control -> go control budget
  where
    go _ 0 = assertFailure ("backend " <> show pid <> " never showed read_with_poll as active")
    go control n = do
      count <- Pool.use control (Session.statement pid activePollCount)
      case count of
        Right 1 -> pure ()
        _ -> threadDelay 50_000 >> go control (n - 1)

-- | Send one message through the attacked pool, retrying while the error is
-- transient, with a 100 ms pause, up to the attempt budget. Returns the
-- attempts used, every error seen in order, and whether a send succeeded. A
-- permanent error stops the loop.
recover :: Pool.Pool -> QueueName -> Int -> IO (Int, [PgmqRuntimeError], Bool)
recover pool queue budget = go 1 []
  where
    go attempt seen = do
      result <-
        runPgmqIO pool $
          () <$ sendMessage SendMessage {queueName = queue, messageBody = MessageBody (Aeson.String "probe"), delay = Nothing}
      case result of
        Right () -> pure (attempt, reverse seen, True)
        Left err
          | attempt >= budget || not (isTransient err) -> pure (attempt, reverse (err : seen), False)
          | otherwise -> threadDelay 100_000 >> go (attempt + 1) (err : seen)

-- | Stop PostgreSQL with SIGQUIT and start it again on the same data directory
-- and port. Retries once, then fails loudly.
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
  config <- ephemeralConfig
  result <- Pg.startCached config Pg.defaultCacheConfig
  case result of
    Left err -> assertFailure $ "could not start a dedicated PostgreSQL: " <> show err
    Right db -> pure db

-- | Size 1 is essential: it guarantees the backend pid read before the fault
-- belongs to the connection the poll uses.
acquirePool :: Pg.Database -> IO Pool.Pool
acquirePool db =
  Pool.acquire $
    PoolConfig.settings
      [ PoolConfig.size 1,
        PoolConfig.staticConnectionSettings (Pg.connectionSettings db)
      ]

runPgmqIO :: Pool.Pool -> Eff '[Pgmq, Error PgmqRuntimeError, IOE] a -> IO (Either PgmqRuntimeError a)
runPgmqIO pool action = either (Left . snd) Right <$> runEff (runError @PgmqRuntimeError (runPgmq pool action))

runOrFail :: Pool.Pool -> Eff '[Pgmq, Error PgmqRuntimeError, IOE] a -> IO a
runOrFail pool action =
  runPgmqIO pool action >>= either (\err -> assertFailure ("pgmq operation failed: " <> show err)) pure

session :: Pool.Pool -> Session a -> IO a
session pool s = do
  result <- Pool.use pool s
  case result of
    Left err -> assertFailure $ "Session failed: " <> show err
    Right a -> pure a

genQueueName :: T.Text -> IO QueueName
genQueueName prefix = do
  suffix <- randomRIO (10000 :: Word32, 99999)
  case parseQueueName (prefix <> T.pack (show suffix)) of
    Left err -> error $ "Failed to generate queue name: " <> show err
    Right qn -> pure qn

backendPid :: Statement () Int32
backendPid = preparable "select pg_backend_pid()" E.noParams (D.singleRow (D.column (D.nonNullable D.int4)))

terminateBackend :: Statement Int32 Bool
terminateBackend = preparable "select pg_terminate_backend($1)" (E.param (E.nonNullable E.int4)) (D.singleRow (D.column (D.nonNullable D.bool)))

activePollCount :: Statement Int32 Int64
activePollCount =
  preparable
    "select count(*)::int8 from pg_stat_activity where pid = $1 and state = 'active' and query like '%read_with_poll%'"
    (E.param (E.nonNullable E.int4))
    (D.singleRow (D.column (D.nonNullable D.int8)))