packages feed

valiant-0.1.0.0: integration/PoolSpec.hs

module PoolSpec (spec) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (async, mapConcurrently, wait)
import Control.Concurrent.MVar
import Control.Concurrent.STM (TVar, newTVarIO, readTVarIO, modifyTVar', atomically)
import Control.Exception (SomeException, try)
import Control.Monad (replicateM_)
import Data.IORef
import NoThunks.Class (NoThunks, ThunkInfo, noThunks)
import PgWire.Pool (pActive, pClosed, pConnMeta, pEffectiveSize, pIdle, pTotalCreated, pTotalDestroyed, pTotalTimeouts)
import Valiant
import TestSupport
import Test.Hspec

spec :: Spec
spec = do
  describe "withResource" $ do
    it "acquires and releases a connection" $ do
      withTestPool $ \pool -> do
        result <- withResource pool $ \conn -> do
          (rows, _) <- simpleQuery conn "SELECT 42"
          pure (length rows)
        result `shouldBe` 1

    it "handles concurrent access" $ do
      withTestPool $ \pool -> do
        results <- mapConcurrently (\_ -> withResource pool $ \conn -> do
          (rows, _) <- simpleQuery conn "SELECT 1"
          pure (length rows)) [1 :: Int .. 20]
        all (== 1) results `shouldBe` True

    it "reuses connections" $ do
      withTestPool $ \pool -> do
        pid1 <- withResource pool $ \conn -> do
          (rows, _) <- simpleQuery conn "SELECT pg_backend_pid()"
          pure rows
        pid2 <- withResource pool $ \conn -> do
          (rows, _) <- simpleQuery conn "SELECT pg_backend_pid()"
          pure rows
        pid1 `shouldBe` pid2

  describe "concurrent acquire does not exceed poolSize" $ do
    it "never creates more connections than poolSize" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 3
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      -- Track the maximum concurrent active connections
      maxActive <- newTVarIO (0 :: Int)
      active <- newTVarIO (0 :: Int)
      _ <- mapConcurrently (\_ -> withResource pool $ \conn -> do
        atomically $ modifyTVar' active (+ 1)
        cur <- readTVarIO active
        atomically $ modifyTVar' maxActive (\m -> max m cur)
        _ <- simpleQuery conn "SELECT pg_sleep(0.05)"
        atomically $ modifyTVar' active (subtract 1)
        pure ()
        ) [1 :: Int .. 10]
      closePool pool
      maxA <- readTVarIO maxActive
      maxA `shouldSatisfy` (<= 3)

  describe "shutdown wakes blocked waiters" $ do
    it "blocked waiters get PoolClosed" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 1
            , poolAcquireTimeout = 10
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      -- Hold the only connection
      barrier <- newEmptyMVar
      done <- newEmptyMVar
      _ <- async $ withResource pool $ \_ -> do
        putMVar barrier ()
        -- Wait until test says to continue
        takeMVar done
      -- Wait for the connection to be acquired
      takeMVar barrier
      -- Try to acquire in another thread — should block
      waiterResult <- async $ try @SomeException $ withResource pool $ \_ -> pure ()
      -- Give waiter time to block
      threadDelay 50000
      -- Close pool — should wake the waiter
      closePool pool
      putMVar done ()
      result <- wait waiterResult
      case result of
        Left e -> show e `shouldContain` "PoolClosed"
        Right () -> expectationFailure "Expected PoolClosed but got success"

  describe "poolStats" $ do
    it "returns sane values" $ do
      withTestPool $ \pool -> do
        -- Initial state: all idle after first warm-up
        _ <- withResource pool $ \conn -> simpleQuery conn "SELECT 1"
        stats <- poolStats pool
        psIdle stats `shouldSatisfy` (>= 1)
        psInUse stats `shouldBe` 0
        psWaiters stats `shouldBe` 0
        psTotalCreated stats `shouldSatisfy` (>= 1)

    it "tracks in-use connections" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 4
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      barrier <- newEmptyMVar
      done <- newEmptyMVar
      _ <- async $ withResource pool $ \_ -> do
        putMVar barrier ()
        takeMVar done
      takeMVar barrier
      stats <- poolStats pool
      psInUse stats `shouldBe` 1
      putMVar done ()
      threadDelay 50000
      stats2 <- poolStats pool
      psInUse stats2 `shouldBe` 0
      closePool pool

  describe "resize" $ do
    it "shrinks idle connections" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 4
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      -- Create 3 idle connections
      pids <- mapConcurrently (\_ -> withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SELECT pg_backend_pid()"
        pure rows) [1 :: Int .. 3]
      length pids `shouldBe` 3
      stats1 <- poolStats pool
      psIdle stats1 `shouldSatisfy` (>= 2)
      -- Resize down to 1
      resize pool 1
      threadDelay 50000
      stats2 <- poolStats pool
      psIdle stats2 `shouldSatisfy` (<= 1)
      psMaxSize stats2 `shouldBe` 1
      closePool pool

    it "allows growth after resize up" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 1
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      resize pool 3
      stats <- poolStats pool
      psMaxSize stats `shouldBe` 3
      -- Should be able to create multiple concurrent connections now
      results <- mapConcurrently (\_ -> withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SELECT 1"
        pure (length rows)) [1 :: Int .. 3]
      all (== 1) results `shouldBe` True
      closePool pool

  describe "recycling methods" $ do
    it "RecycleFast reuses connections without query" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 2
            , poolRecyclingMethod = RecycleFast
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      pid1 <- withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SELECT pg_backend_pid()"
        pure rows
      pid2 <- withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SELECT pg_backend_pid()"
        pure rows
      pid1 `shouldBe` pid2
      closePool pool

    it "RecycleVerified reuses recent connections without query" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 2
            , poolRecyclingMethod = RecycleVerified
            , poolHealthCheckAge = 60
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      pid1 <- withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SELECT pg_backend_pid()"
        pure rows
      -- Immediately reacquire — should skip health check (used < 60s ago)
      pid2 <- withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SELECT pg_backend_pid()"
        pure rows
      pid1 `shouldBe` pid2
      closePool pool

    it "RecycleClean resets session state" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 2
            , poolRecyclingMethod = RecycleClean
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      -- Set a session variable
      withResource pool $ \conn -> do
        _ <- simpleQuery conn "SET application_name TO 'test_app'"
        pure ()
      -- Reacquire — RecycleClean runs DISCARD ALL which resets session state
      appName <- withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SHOW application_name"
        pure rows
      -- After DISCARD ALL, application_name should be reset to default
      -- (empty string or the connConfig app name, not 'test_app')
      case appName of
        [[Just name]] -> name `shouldNotBe` "test_app"
        _ -> expectationFailure "Expected one row"
      closePool pool

  describe "lifecycle hooks" $ do
    it "fires hooks in correct order" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 2
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      events <- newIORef ([] :: [String])
      setPostCreateHook pool $ \_ -> modifyIORef' events ("create" :)
      setOnAcquireHook pool $ \_ -> modifyIORef' events ("acquire" :)
      setPreReleaseHook pool $ \_ -> modifyIORef' events ("release" :)
      -- First acquire: create + acquire
      _ <- withResource pool $ \conn -> simpleQuery conn "SELECT 1"
      -- Second acquire (reuse): acquire only
      _ <- withResource pool $ \conn -> simpleQuery conn "SELECT 1"
      evts <- reverse <$> readIORef events
      -- First use: create, acquire, release
      -- Second use: acquire, release
      evts `shouldBe` ["create", "acquire", "release", "acquire", "release"]
      closePool pool

    it "hook failure doesn't break the pool" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 2
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      setOnAcquireHook pool $ \_ -> error "hook failure"
      -- Should still work despite the hook throwing
      result <- withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SELECT 42"
        pure (length rows)
      result `shouldBe` 1
      closePool pool

  describe "retain" $ do
    it "filters idle connections" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 4
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      -- Create 3 idle connections and get their PIDs
      pids <- mapConcurrently (\_ -> withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SELECT pg_backend_pid()"
        pure rows) [1 :: Int .. 3]
      length pids `shouldBe` 3
      stats1 <- poolStats pool
      let idleBefore = psIdle stats1
      idleBefore `shouldSatisfy` (>= 2)
      -- Keep only the first connection (drop all others by returning False)
      callCount <- newIORef (0 :: Int)
      retain pool $ \_ -> do
        n <- readIORef callCount
        modifyIORef' callCount (+ 1)
        pure (n == 0) -- keep first, drop rest
      stats2 <- poolStats pool
      psIdle stats2 `shouldSatisfy` (<= 1)
      closePool pool

  describe "QueueMode" $ do
    it "QueueLIFO reuses the most recently released connection" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 4
            , poolQueueMode = QueueLIFO
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      -- Create 2 connections, then release them in order
      pid1 <- withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SELECT pg_backend_pid()"
        pure rows
      pid2 <- withResource pool $ \conn -> do
        (rows, _) <- simpleQuery conn "SELECT pg_backend_pid()"
        -- this should be the same as pid1 (LIFO: reuses most recent)
        pure rows
      -- In LIFO mode, we should get the same connection back
      pid1 `shouldBe` pid2
      closePool pool

    it "QueueFIFO rotates through connections" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 4
            , poolQueueMode = QueueFIFO
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      -- Create 2 idle connections first
      barrier <- newEmptyMVar
      done1 <- newEmptyMVar
      done2 <- newEmptyMVar
      _ <- async $ withResource pool $ \_ -> do
        putMVar barrier ()
        takeMVar done1
      takeMVar barrier
      _ <- async $ withResource pool $ \_ -> do
        putMVar barrier ()
        takeMVar done2
      takeMVar barrier
      -- Now release both
      putMVar done1 ()
      putMVar done2 ()
      threadDelay 50000
      -- With 2 idle, FIFO should give oldest first
      stats <- poolStats pool
      psIdle stats `shouldSatisfy` (>= 2)
      -- Acquire twice and verify we get different PIDs
      pid1 <- withResource pool $ \conn -> do
        ([[Just p]], _) <- simpleQuery conn "SELECT pg_backend_pid()"
        pure p
      pid2 <- withResource pool $ \conn -> do
        ([[Just p]], _) <- simpleQuery conn "SELECT pg_backend_pid()"
        pure p
      -- FIFO should rotate, not reuse the same connection
      -- (Note: the first might equal the second if there's only one idle)
      -- At minimum, verify both acquisitions work
      pid1 `shouldNotBe` ""
      pid2 `shouldNotBe` ""
      closePool pool

  describe "pool timeout" $ do
    it "times out when pool is exhausted" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 1
            , poolAcquireTimeout = 0.5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      barrier <- newEmptyMVar
      done <- newEmptyMVar
      -- Hold the only connection
      _ <- async $ withResource pool $ \_ -> do
        putMVar barrier ()
        takeMVar done
      takeMVar barrier
      -- Try to acquire — should timeout
      result <- try @SomeException $ withResource pool $ \_ -> pure ()
      putMVar done ()
      closePool pool
      case result of
        Left e -> show e `shouldContain` "PoolTimeout"
        Right () -> expectationFailure "Expected PoolTimeout"

    it "timeout increments counter" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 1
            , poolAcquireTimeout = 0.2
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      barrier <- newEmptyMVar
      done <- newEmptyMVar
      _ <- async $ withResource pool $ \_ -> do
        putMVar barrier ()
        takeMVar done
      takeMVar barrier
      _ <- try @SomeException $ withResource pool $ \_ -> pure ()
      putMVar done ()
      threadDelay 50000
      stats <- poolStats pool
      psTotalTimeouts stats `shouldBe` 1
      closePool pool

  describe "no-thunks invariant under churn" $ do
    -- Regression guard. Catches future bugs of the form
    --   writeTVar (pTotalCreated pool) (oldVal + 1)
    -- where the stored value is a thunk rather than WHNF. Existing
    -- code uses modifyTVar' / strict-evaluated values everywhere, so
    -- this should remain green.
    it "pool bookkeeping TVars stay thunk-free after churn" $ do
      url <- requireDatabaseUrl
      let cfg = defaultPoolConfig
            { poolConnString = url
            , poolSize = 4
            , poolAcquireTimeout = 5
            , poolMaxLifeJitter = 0
            }
      pool <- newPool cfg
      -- Serial churn: forces counter increments and idle/active updates.
      replicateM_ 200 $ withResource pool $ \conn -> do
        _ <- simpleQuery conn "SELECT 1"
        pure ()
      -- Concurrent churn: stresses contention paths.
      _ <- mapConcurrently
        (\_ -> withResource pool $ \conn -> simpleQuery conn "SELECT 1")
        [1 :: Int .. 50]
      thunks <- concat <$> sequence
        [ checkTVar "pIdle"            (pIdle pool)
        , checkTVar "pActive"          (pActive pool)
        , checkTVar "pClosed"          (pClosed pool)
        , checkTVar "pEffectiveSize"   (pEffectiveSize pool)
        , checkTVar "pTotalCreated"    (pTotalCreated pool)
        , checkTVar "pTotalDestroyed"  (pTotalDestroyed pool)
        , checkTVar "pTotalTimeouts"   (pTotalTimeouts pool)
        , checkTVar "pConnMeta"        (pConnMeta pool)
        ]
      closePool pool
      case thunks of
        [] -> pure ()
        leaks -> expectationFailure $ "Found thunks in pool state: " <> show leaks

checkTVar :: NoThunks a => String -> TVar a -> IO [(String, ThunkInfo)]
checkTVar name tvar = do
  v <- readTVarIO tvar
  result <- noThunks [name] v
  pure $ maybe [] (\info -> [(name, info)]) result