packages feed

hasql-pool-1.5.1.0: src/integration-tests/Specs/InterruptionSpec.hs

module Specs.InterruptionSpec where

import Control.Exception (SomeException, try)
import Hasql.Pool
import Hasql.Pool qualified as Pool
import Hasql.Pool.Config qualified as Config
import Hasql.Pool.Observation
import Helpers.Scripts qualified as Scripts
import Helpers.Sessions qualified as Sessions
import Prelude
import Test.Hspec

-- | Regression tests for the pool capacity getting lost when a pool request
-- gets interrupted anywhere between the enrollment and the session
-- (https://github.com/nikita-volkov/hasql-pool/issues/60), and for the pool
-- surviving failing observation handlers.
spec :: SpecWith Scripts.ScopeParams
spec = do
  it "Restores the pool capacity when the pool request is interrupted while observing the connection attempt" \scopeParams ->
    onGatedPool connectingObs 1 10 1_800 1_800 scopeParams \gate pool -> do
      interruptAt gate (\pool -> void (use pool Sessions.selectOne)) pool
      use pool Sessions.selectOne `shouldReturn` Right 1

  it "Restores the pool capacity when the pool request is interrupted while observing the idle-eviction" \scopeParams ->
    onGatedPool idleEvictionObs 1 10 1_800 0.3 scopeParams \gate pool -> do
      -- Establish a connection and let it go idle.
      _ <- use pool Sessions.selectOne
      threadDelay 600_000 -- 0.6s
      interruptAt gate (\pool -> void (use pool Sessions.selectOne)) pool
      use pool Sessions.selectOne `shouldReturn` Right 1

  it "Preserves the dequeued connection when the pool request is interrupted while observing its use" \scopeParams ->
    onGatedPool inUseObs 1 10 1_800 1_800 scopeParams \gate pool -> do
      -- Establish a connection and return it to the pool.
      _ <- use pool Sessions.selectOne
      interruptAt gate (\pool -> void (use pool Sessions.selectOne)) pool
      use pool Sessions.selectOne `shouldReturn` Right 1

  it "Succeeds the session when the observation handler throws synchronously during the connection attempt" \scopeParams ->
    Scripts.onAutotaggedPoolWithObservationHandler 1 10 1_800 1_800 (throwAt connectingObs) scopeParams \_ pool -> do
      use pool Sessions.selectOne `shouldReturn` Right 1

  it "Succeeds the session when the observation handler throws synchronously during the idle-eviction" \scopeParams ->
    Scripts.onAutotaggedPoolWithObservationHandler 1 10 1_800 0.3 (throwAt idleEvictionObs) scopeParams \_ pool -> do
      _ <- use pool Sessions.selectOne
      threadDelay 600_000 -- 0.6s
      use pool Sessions.selectOne `shouldReturn` Right 1

  it "Succeeds the session when the observation handler throws synchronously while observing the use" \scopeParams ->
    Scripts.onAutotaggedPoolWithObservationHandler 1 10 1_800 1_800 (throwAt inUseObs) scopeParams \_ pool -> do
      use pool Sessions.selectOne `shouldReturn` Right 1

  it "The reaper survives a throwing observation handler and keeps reaping on later ticks" \scopeParams ->
    Scripts.onAutotaggedPool 1 10 1_800 1_800 scopeParams \_ counterPool ->
      Scripts.onAutotaggedPoolWithObservationHandler 1 10 1_800 0.3 (throwAt idleEvictionObs) scopeParams \appName pool -> do
        -- Establish a connection and let it go idle past the reaper's tick.
        _ <- use pool Sessions.selectOne
        threadDelay 1_600_000 -- 1.6s
        -- The connection got reaped and the reaper is still alive.
        use counterPool (Sessions.countConnections appName) `shouldReturn` Right 0
        use pool Sessions.selectOne `shouldReturn` Right 1
        -- It keeps reaping on the later ticks.
        threadDelay 1_600_000 -- 1.6s
        use counterPool (Sessions.countConnections appName) `shouldReturn` Right 0

  it "The pool remains usable after the release with a throwing observation handler" \scopeParams ->
    Scripts.onAutotaggedPoolWithObservationHandler 1 10 1_800 1_800 (throwAt releaseObs) scopeParams \_ pool -> do
      _ <- use pool Sessions.selectOne
      release pool
      use pool Sessions.selectOne `shouldReturn` Right 1

  it "Resolves the pool requests waiting on a garbage-collected pool" \scopeParams -> do
    (adapter, host, port) <- pure scopeParams
    result <- newEmptyMVar
    -- Inside this block the pool is only reachable from the waiting pool
    -- request. Once collected, the finalizer resolves it, and if it is not
    -- collectible, the acquisition timeout does. Either way it does not wait
    -- forever.
    do
      pool <-
        Pool.acquire
          adapter
          ( Config.settings
              [ -- No capacity: every pool request waits.
                Config.size 0,
                Config.acquisitionTimeout 0.5
              ]
          )
      _ <- forkIO (putMVar result =<< use pool Sessions.selectOne)
      threadDelay 100_000 -- 0.1s, to let the pool request start waiting
      pure ()
    performGC
    performGC
    res <- takeMVar result
    res `shouldBe` Left AcquisitionTimeoutUsageError

-- | Runs the action in a pool request interrupted at the gated observation.
-- The pool request is expected to die with the interruption.
interruptAt :: Gate -> (Pool.Pool -> IO ()) -> Pool.Pool -> IO ()
interruptAt gate action pool = do
  armGate gate
  done <- newEmptyMVar
  thread <- forkIO do
    _ <- try @SomeException (action pool)
    putMVar done ()
  takeMVar (gateEntered gate)
  killThread thread
  disarmGate gate
  takeMVar done

-- | Runs the continuation in a pool with a gated observation handler.
onGatedPool :: (Observation -> Bool) -> Int -> DiffTime -> DiffTime -> DiffTime -> Scripts.ScopeParams -> (Gate -> Pool.Pool -> IO ()) -> IO ()
onGatedPool matches poolSize acqTimeout maxLifetime maxIdletime scopeParams cont = do
  gate <- newGate matches
  Scripts.onAutotaggedPoolWithObservationHandler poolSize acqTimeout maxLifetime maxIdletime (gateHandler gate) scopeParams \appName pool -> cont gate pool

-- | A gate for stopping a pool request at a chosen observation and letting the
-- test interrupt it there. While the gate is armed, the pool request stops at
-- the first matching observation, waiting to be released or interrupted.
data Gate = Gate
  { gateMatches :: Observation -> Bool,
    gateEntered :: MVar (),
    gateProceed :: MVar (),
    gateArmed :: IORef Bool
  }

newGate :: (Observation -> Bool) -> IO Gate
newGate matches = Gate matches <$> newEmptyMVar <*> newEmptyMVar <*> newIORef False

armGate, disarmGate :: Gate -> IO ()
armGate gate = writeIORef (gateArmed gate) True
disarmGate gate = writeIORef (gateArmed gate) False

-- | The observation handler stopping at the gate.
gateHandler :: Gate -> Observation -> IO ()
gateHandler gate observation =
  when (gateMatches gate observation) do
    armed <- readIORef (gateArmed gate)
    when armed do
      putMVar (gateEntered gate) ()
      takeMVar (gateProceed gate)

-- | The observations the tests stop or throw at.
connectingObs, idleEvictionObs, inUseObs, releaseObs :: Observation -> Bool
connectingObs = \case
  ConnectionObservation _ ConnectingConnectionStatus -> True
  _ -> False
idleEvictionObs = \case
  ConnectionObservation _ (TerminatedConnectionStatus IdlenessConnectionTerminationReason) -> True
  _ -> False
inUseObs = \case
  ConnectionObservation _ InUseConnectionStatus -> True
  _ -> False
releaseObs = \case
  ConnectionObservation _ (TerminatedConnectionStatus ReleaseConnectionTerminationReason) -> True
  _ -> False

-- | An observation handler that throws synchronously at the matching
-- observations, and passes all the other observations through.
throwAt :: (Observation -> Bool) -> Observation -> IO ()
throwAt matches observation =
  when (matches observation) do
    error "Synchronous exception from the observation handler"