hasql-pool 1.5.0.1 → 1.5.1.0
raw patch · 14 files changed
+723/−102 lines, 14 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
+ Hasql.Pool.Config: FIFO :: ReuseOrder
+ Hasql.Pool.Config: LIFO :: ReuseOrder
+ Hasql.Pool.Config: data ReuseOrder
+ Hasql.Pool.Config: reuseOrder :: ReuseOrder -> Setting
+ Hasql.Pool.Config.Defaults: reuseOrder :: ReuseOrder
Files
- CHANGELOG.md +25/−0
- hasql-pool.cabal +3/−1
- src/integration-tests/Helpers/Scripts.hs +61/−0
- src/integration-tests/Helpers/Sessions.hs +8/−0
- src/integration-tests/Specs/Config/IdlenessTimeoutSpec.hs +18/−0
- src/integration-tests/Specs/InterruptionSpec.hs +163/−0
- src/integration-tests/Specs/UsageError/AcquisitionTimeoutSpec.hs +13/−1
- src/integration-tests/Specs/UseSpec.hs +68/−0
- src/library/Hasql/Pool.hs +319/−98
- src/library/Hasql/Pool/Config.hs +3/−0
- src/library/Hasql/Pool/Config/Config.hs +5/−2
- src/library/Hasql/Pool/Config/Defaults.hs +6/−0
- src/library/Hasql/Pool/Config/ReuseOrder.hs +17/−0
- src/library/Hasql/Pool/Config/Setting.hs +14/−0
CHANGELOG.md view
@@ -1,3 +1,28 @@+# v1.5.1.0++## Fixes++- Fix pool capacity leak when `use` gets interrupted during connection+ establishment, connection refresh, or an observation (#60)+- Fix pool state corruption from throwing observation handlers; handlers+ are now contained (see the updated `observationHandler` docs)+- Fix background reaper dying permanently from a failed connection+ release or observation+- Fix `release` leaking flushed entries on failure+- Fix waiters waiting forever on a garbage-collected pool++## Added++- `reuseOrder` setting (#59): LIFO reuse lets the pool shrink to the+ connections the load needs and keeps caches warm; FIFO stays the default+- One background timer for acquisition timeouts instead of one+ uncancelable timer per waiting request (#58)++## Changed++- Connections are handed to waiting requests in arrival order; a request+ no longer takes a connection while others wait (#58)+ # v1.5.0.1 - Allow newer pqi.
hasql-pool.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: hasql-pool-version: 1.5.0.1+version: 1.5.1.0 category: Hasql, Database, PostgreSQL synopsis: Pool of connections for Hasql homepage: https://github.com/nikita-volkov/hasql-pool@@ -78,6 +78,7 @@ other-modules: Hasql.Pool.Config.Config+ Hasql.Pool.Config.ReuseOrder Hasql.Pool.Config.Setting Hasql.Pool.Prelude Hasql.Pool.SessionErrorDestructors@@ -107,6 +108,7 @@ Specs.Config.InitSessionSpec Specs.Helpers.Sessions.CountConnectionsSpec Specs.Helpers.Sessions.GetSettingSpec+ Specs.InterruptionSpec Specs.ReleaseSpec Specs.SpecHook Specs.UsageError.AcquisitionTimeoutSpec
src/integration-tests/Helpers/Scripts.hs view
@@ -3,6 +3,7 @@ import Hasql.Connection.Settings qualified as Connection.Settings import Hasql.Pool qualified as Pool import Hasql.Pool.Config qualified as Config+import Hasql.Pool.Observation (Observation) import Hasql.Session qualified as Session import Pqi qualified import Prelude@@ -44,6 +45,36 @@ appName <- generateName "hasql-pool-test-" onTaggedPool poolSize acqTimeout maxLifetime maxIdletime appName scopeParams (cont appName) +onTaggedPoolWithObservationHandler :: Int -> DiffTime -> DiffTime -> DiffTime -> (Observation -> IO ()) -> Text -> ScopeParams -> (Pool.Pool -> IO ()) -> IO ()+onTaggedPoolWithObservationHandler poolSize acqTimeout maxLifetime maxIdletime observationHandler appName (adapter, host, port) =+ bracket+ ( Pool.acquire+ adapter+ ( Config.settings+ [ Config.size poolSize,+ Config.acquisitionTimeout acqTimeout,+ Config.agingTimeout maxLifetime,+ Config.idlenessTimeout maxIdletime,+ Config.observationHandler observationHandler,+ Config.staticConnectionSettings+ ( mconcat+ [ Connection.Settings.hostAndPort host (fromIntegral port),+ Connection.Settings.user "postgres",+ Connection.Settings.password "",+ Connection.Settings.dbname "postgres",+ Connection.Settings.applicationName appName+ ]+ )+ ]+ )+ )+ Pool.release++onAutotaggedPoolWithObservationHandler :: Int -> DiffTime -> DiffTime -> DiffTime -> (Observation -> IO ()) -> ScopeParams -> (Text -> Pool.Pool -> IO ()) -> IO ()+onAutotaggedPoolWithObservationHandler poolSize acqTimeout maxLifetime maxIdletime observationHandler scopeParams cont = do+ appName <- generateName "hasql-pool-test-"+ onTaggedPoolWithObservationHandler poolSize acqTimeout maxLifetime maxIdletime observationHandler appName scopeParams (cont appName)+ onTaggedPoolWithInitSession :: Int -> DiffTime -> DiffTime -> DiffTime -> Session.Session () -> Text -> ScopeParams -> (Pool.Pool -> IO ()) -> IO () onTaggedPoolWithInitSession poolSize acqTimeout maxLifetime maxIdletime initSession appName (adapter, host, port) = bracket@@ -73,6 +104,36 @@ onAutotaggedPoolWithInitSession poolSize acqTimeout maxLifetime maxIdletime initSession (adapter, host, port) cont = do appName <- generateName "hasql-pool-test-" onTaggedPoolWithInitSession poolSize acqTimeout maxLifetime maxIdletime initSession appName (adapter, host, port) (cont appName)++onTaggedPoolWithReuseOrder :: Int -> DiffTime -> DiffTime -> DiffTime -> Config.ReuseOrder -> Text -> ScopeParams -> (Pool.Pool -> IO ()) -> IO ()+onTaggedPoolWithReuseOrder poolSize acqTimeout maxLifetime maxIdletime reuseOrder appName (adapter, host, port) =+ bracket+ ( Pool.acquire+ adapter+ ( Config.settings+ [ Config.size poolSize,+ Config.acquisitionTimeout acqTimeout,+ Config.agingTimeout maxLifetime,+ Config.idlenessTimeout maxIdletime,+ Config.reuseOrder reuseOrder,+ Config.staticConnectionSettings+ ( mconcat+ [ Connection.Settings.hostAndPort host (fromIntegral port),+ Connection.Settings.user "postgres",+ Connection.Settings.password "",+ Connection.Settings.dbname "postgres",+ Connection.Settings.applicationName appName+ ]+ )+ ]+ )+ )+ Pool.release++onAutotaggedPoolWithReuseOrder :: Int -> DiffTime -> DiffTime -> DiffTime -> Config.ReuseOrder -> ScopeParams -> (Text -> Pool.Pool -> IO ()) -> IO ()+onAutotaggedPoolWithReuseOrder poolSize acqTimeout maxLifetime maxIdletime reuseOrder scopeParams cont = do+ appName <- generateName "hasql-pool-test-"+ onTaggedPoolWithReuseOrder poolSize acqTimeout maxLifetime maxIdletime reuseOrder appName scopeParams (cont appName) onDefaultTaggedPool :: ScopeParams -> (Text -> Pool.Pool -> IO ()) -> IO () onDefaultTaggedPool =
src/integration-tests/Helpers/Sessions.hs view
@@ -6,6 +6,7 @@ getSetting, countConnections, sleep,+ backendPid, ) where @@ -71,3 +72,10 @@ where statement = Statement.preparable "SELECT pg_sleep($1)" encoder Decoders.noResult encoder = Encoders.param (Encoders.nonNullable Encoders.float8)++backendPid :: Session.Session Int32+backendPid =+ Session.statement () statement+ where+ statement = Statement.preparable "SELECT pg_backend_pid()" Encoders.noParams decoder+ decoder = Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.int4))
src/integration-tests/Specs/Config/IdlenessTimeoutSpec.hs view
@@ -1,6 +1,7 @@ module Specs.Config.IdlenessTimeoutSpec where import Hasql.Pool+import Hasql.Pool.Config qualified as Config import Helpers.Scripts qualified as Scripts import Helpers.Sessions qualified as Sessions import Prelude@@ -42,3 +43,20 @@ threadDelay 1_500_000 -- 1.5s res3 <- use pool1 $ Sessions.countConnections appName2 res3 `shouldBe` Right 0++ it "Times out the connections that aren't needed with LIFO" \scopeParams -> do+ -- 0.5s connection idle time+ Scripts.onAutotaggedPoolWithReuseOrder 2 10 1_800 0.5 Config.LIFO scopeParams \appName pool -> do+ held <- newEmptyMVar+ done <- newEmptyMVar+ -- Establish two connections.+ _ <- forkIO (void (use pool (liftIO (putMVar held () *> takeMVar done))))+ takeMVar held+ use pool Sessions.selectOne `shouldReturn` Right 1+ putMVar done ()+ -- Keep using the pool one pool request at a time, which needs only one+ -- connection, for longer than the idle time and the reaper's 1s tick.+ forM_ [1 :: Int .. 15] \_ -> do+ use pool Sessions.selectOne `shouldReturn` Right 1+ threadDelay 100_000 -- 0.1s+ use pool (Sessions.countConnections appName) `shouldReturn` Right 1
+ src/integration-tests/Specs/InterruptionSpec.hs view
@@ -0,0 +1,163 @@+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"
src/integration-tests/Specs/UsageError/AcquisitionTimeoutSpec.hs view
@@ -1,6 +1,6 @@ module Specs.UsageError.AcquisitionTimeoutSpec where -import Control.Concurrent.Async (race)+import Control.Concurrent.Async (forConcurrently, race) import Hasql.Pool import Helpers.Scripts qualified as Scripts import Helpers.Sessions qualified as Sessions@@ -31,3 +31,15 @@ res `shouldBe` Right (Left AcquisitionTimeoutUsageError) -- 0.5s diffUTCTime t1 t0 `shouldSatisfy` (< 0.5)++ it "Gets produced for every waiting pool request, and the pool remains usable" \scopeParams ->+ -- 100ms timeout+ Scripts.onAutotaggedPool 1 0.1 1_800 1_800 scopeParams \_ pool -> do+ held <- newEmptyMVar+ done <- newEmptyMVar+ _ <- forkIO (void (use pool (liftIO (putMVar held () *> takeMVar done))))+ takeMVar held+ results <- forConcurrently [1 .. 3 :: Int] \_ -> use pool Sessions.selectOne+ results `shouldBe` replicate 3 (Left AcquisitionTimeoutUsageError)+ putMVar done ()+ use pool Sessions.selectOne `shouldReturn` Right 1
src/integration-tests/Specs/UseSpec.hs view
@@ -6,6 +6,7 @@ import Hasql.Encoders qualified as Encoders import Hasql.Errors qualified as Errors import Hasql.Pool+import Hasql.Pool.Config qualified as Config import Hasql.Session qualified as Session import Hasql.Statement qualified as Statement import Helpers.Scripts qualified as Scripts@@ -89,6 +90,52 @@ res <- use pool Sessions.selectOne res `shouldSatisfy` isRight + it "Gives a freed connection to a pool request waiting for it rather than to a later one" \scopeParams ->+ Scripts.onAutotaggedPool 1 10 1_800 1_800 scopeParams \_ pool -> do+ order <- newIORef []+ held <- newEmptyMVar+ done <- newEmptyMVar+ finished <- newEmptyMVar+ let record name = use pool (liftIO (atomicModifyIORef' order \names -> (names <> [name], ())))+ -- Requests a connection again as soon as it returns one, before the+ -- waiting pool request gets to run.+ _ <- forkIO do+ _ <- use pool (liftIO (putMVar held () *> takeMVar done))+ _ <- record "later"+ putMVar finished ()+ takeMVar held+ _ <- forkIO (record "waiting" *> putMVar finished ())+ -- Give the waiting pool request time to start waiting.+ threadDelay 100_000+ putMVar done ()+ replicateM_ 2 (takeMVar finished)+ readIORef order `shouldReturn` ["waiting", "later"]++ it "Loses no capacity when a pool request waiting for a connection is interrupted" \scopeParams ->+ Scripts.onAutotaggedPool 1 10 1_800 1_800 scopeParams \_ pool -> do+ held <- newEmptyMVar+ done <- newEmptyMVar+ _ <- forkIO (void (use pool (liftIO (putMVar held () *> takeMVar done))))+ takeMVar held+ waiting <- forkIO (void (use pool Sessions.selectOne))+ -- Give the pool request time to start waiting.+ threadDelay 100_000+ killThread waiting+ putMVar done ()+ use pool Sessions.selectOne `shouldReturn` Right 1++ it "Reuses the most recently returned connection with LIFO" \scopeParams ->+ Scripts.onAutotaggedPoolWithReuseOrder 2 10 1_800 1_800 Config.LIFO scopeParams \_ pool -> do+ (first, second) <- returnTwoConnections pool+ first `shouldNotBe` second+ use pool Sessions.backendPid `shouldReturn` Right second++ it "Reuses the least recently returned connection by default" \scopeParams ->+ Scripts.onAutotaggedPool 2 10 1_800 1_800 scopeParams \_ pool -> do+ (first, second) <- returnTwoConnections pool+ first `shouldNotBe` second+ use pool Sessions.backendPid `shouldReturn` Right first+ it "Cached type errors cause eviction of connection" \scopeParams -> do typeName <- Text.replace "-" "_" <$> Scripts.generateName "cached_type_" Scripts.onAutotaggedPool 1 10 1_800 1_800 scopeParams \_ pool -> do@@ -131,3 +178,24 @@ driverError = Session.onLibpqConnection \connection -> pure (Left (Errors.DriverSessionError "synthetic driver error"), connection)++-- | Establish two connections, return the first before the second, and give+-- their backend PIDs.+returnTwoConnections :: Pool -> IO (Int32, Int32)+returnTwoConnections pool = do+ held <- newEmptyMVar+ done <- newEmptyMVar+ returned <- newEmptyMVar+ _ <- forkIO do+ res <- use pool (Sessions.backendPid <* liftIO (putMVar held () *> takeMVar done))+ putMVar returned res+ takeMVar held+ res <- use pool do+ second <- Sessions.backendPid+ first <- liftIO do+ putMVar done ()+ takeMVar returned+ pure (first, second)+ case res of+ Right (Right first, second) -> pure (first, second)+ _ -> fail ("Unexpected result: " <> show res)
src/library/Hasql/Pool.hs view
@@ -16,6 +16,7 @@ import Hasql.Connection.Settings qualified as Connection.Settings import Hasql.Errors qualified as Errors import Hasql.Pool.Config.Config qualified as Config+import Hasql.Pool.Config.ReuseOrder (ReuseOrder (..)) import Hasql.Pool.Observation import Hasql.Pool.Prelude import Hasql.Pool.SessionErrorDestructors qualified as ErrorsDestruction@@ -38,6 +39,33 @@ entryIsIdle maxIdletime now Entry {..} = now > entryUseTimeNSec + maxIdletime +-- | A pool request waiting for a connection.+data Waiter = Waiter+ { -- | When the pool request times out, in nanoseconds of the monotonic clock.+ waiterDeadline :: Word64,+ -- | What the pool request gets.+ waiterGiven :: TMVar Given,+ -- | Whether the pool request stopped waiting, so that it gets passed over.+ waiterGone :: TVar Bool+ }++-- | What a waiting pool request gets: an idle connection, or the slot of a+-- closed one to establish a connection in, with the reuse flag at the time,+-- or a timeout.+data Given+ = Given (TVar Bool) (Maybe Entry)+ | TimedOut++-- | What a pool request holds after the enrollment transaction commits and+-- has to give back on any failure.+data Holding+ = -- | An entry dequeued from the idle connections.+ EntryHolding (TVar Bool) Entry+ | -- | A capacity unit taken from the pool.+ SlotHolding (TVar Bool)+ | -- | A spot in the waiting queue.+ WaitingHolding Waiter+ -- | Pool of connections to DB. data Pool = Pool { -- | Pool size.@@ -46,22 +74,28 @@ poolAdapter :: Pqi.Adapter, -- | Connection settings. poolFetchConnectionSettings :: IO Connection.Settings.Settings,- -- | Acquisition timeout, in microseconds.- poolAcquisitionTimeout :: Int,+ -- | Acquisition timeout, in nanoseconds.+ poolAcquisitionTimeout :: Word64, -- | Maximal connection lifetime, in nanoseconds. poolMaxLifetime :: Word64, -- | Maximal connection idle time, in nanoseconds. poolMaxIdletime :: Word64,- -- | Avail connections.+ -- | Avail connections, in the order they get reused. poolConnectionQueue :: TQueue Entry,+ -- | Which end of the queue returned connections go to.+ poolReuseOrder :: ReuseOrder, -- | Remaining capacity. -- The pool size limits the sum of poolCapacity, the length -- of poolConnectionQueue and the number of in-flight -- connections. poolCapacity :: TVar Int,+ -- | Pool requests waiting for a connection, the longest waiting first.+ -- Freed connections and slots go to them before anyone else,+ -- so that only the pool request given one wakes up.+ poolWaiting :: TQueue Waiter, -- | Whether to return a connection to the pool. poolReuseVar :: TVar (TVar Bool),- -- | To stop the manager thread via garbage collection.+ -- | To stop the manager and expirer threads via garbage collection. poolReaperRef :: IORef (), -- | Action for reporting the observations. poolObserver :: Observation -> IO (),@@ -69,6 +103,17 @@ poolInitSession :: Session.Session () } +-- | Contain the failures of the user-supplied observation handler. Synchronous+-- exceptions from it are discarded, asynchronous ones are honored, so that+-- neither affects the pool's state or the session result, and the pool's+-- threads stay killable.+containObserver :: (Observation -> IO ()) -> Observation -> IO ()+containObserver observer observation =+ catch @SomeException (observer observation) \exc ->+ case fromException exc of+ Just SomeAsyncException {} -> throwIO exc+ Nothing -> return ()+ -- | Create a connection-pool. -- -- No connections actually get established by this function. It is delegated@@ -82,7 +127,9 @@ connectionQueue <- newTQueueIO capVar <- newTVarIO (Config.size config) reuseVar <- newTVarIO =<< newTVarIO True+ waiting <- newTQueueIO reaperRef <- newIORef ()+ let observer = containObserver (Config.observationHandler config) managerTid <- forkIOWithUnmask $ \unmask -> unmask $ forever $ do threadDelay 1000000@@ -93,23 +140,22 @@ (idleEntries, liveEntries) = partition (entryIsIdle maxIdletimeNanos now) unagedEntries traverse_ (writeTQueue connectionQueue) liveEntries return $ do- forM_ agedEntries $ \entry -> do- Connection.release (entryConnection entry)- atomically $ modifyTVar' capVar succ- (Config.observationHandler config) (ConnectionObservation (entryId entry) (TerminatedConnectionStatus AgingConnectionTerminationReason))- forM_ idleEntries $ \entry -> do- Connection.release (entryConnection entry)- atomically $ modifyTVar' capVar succ- (Config.observationHandler config) (ConnectionObservation (entryId entry) (TerminatedConnectionStatus IdlenessConnectionTerminationReason))+ forM_ agedEntries $ reap observer reuseVar waiting capVar AgingConnectionTerminationReason+ forM_ idleEntries $ reap observer reuseVar waiting capVar IdlenessConnectionTerminationReason + expirerTid <- forkIOWithUnmask $ \unmask -> unmask $ expire waiting+ void . mkWeakIORef reaperRef $ do- -- When the pool goes out of scope, stop the manager.+ -- When the pool goes out of scope, resolve the pool requests still+ -- waiting, and stop the manager and the expirer.+ atomically $ drainWaiting waiting killThread managerTid+ killThread expirerTid - return $ Pool (Config.size config) adapter (Config.connectionSettingsProvider config) acqTimeoutMicros agingTimeoutNanos maxIdletimeNanos connectionQueue capVar reuseVar reaperRef (Config.observationHandler config) (Config.initSession config)+ return $ Pool (Config.size config) adapter (Config.connectionSettingsProvider config) acqTimeoutNanos agingTimeoutNanos maxIdletimeNanos connectionQueue (Config.reuseOrder config) capVar waiting reuseVar reaperRef observer (Config.initSession config) where- acqTimeoutMicros =- div (fromIntegral (diffTimeToPicoseconds (Config.acquisitionTimeout config))) 1_000_000+ acqTimeoutNanos =+ div (fromIntegral (diffTimeToPicoseconds (Config.acquisitionTimeout config))) 1_000 agingTimeoutNanos = div (fromIntegral (diffTimeToPicoseconds (Config.agingTimeout config))) 1_000 maxIdletimeNanos =@@ -130,10 +176,19 @@ newReuse <- newTVar True writeTVar poolReuseVar newReuse entries <- flushTQueue poolConnectionQueue- return $ forM_ entries $ \entry -> do- Connection.release (entryConnection entry)- atomically $ modifyTVar' poolCapacity succ- poolObserver (ConnectionObservation (entryId entry) (TerminatedConnectionStatus ReleaseConnectionTerminationReason))+ return $ forM_ entries $ \entry ->+ mask \restore ->+ catch @SomeException+ ( do+ restore $ Connection.release (entryConnection entry)+ restore $ poolObserver (ConnectionObservation (entryId entry) (TerminatedConnectionStatus ReleaseConnectionTerminationReason))+ atomically $ freeSlot poolReuseVar poolWaiting poolCapacity+ )+ \exc -> do+ atomically $ freeSlot poolReuseVar poolWaiting poolCapacity+ case fromException exc of+ Just SomeAsyncException {} -> throwIO exc+ Nothing -> return () -- | Use a connection from the pool to run a session and return the connection -- to the pool, when finished.@@ -144,113 +199,279 @@ -- time one is needed. The error still gets returned from this function. -- -- __Warning:__ Due to the mechanism mentioned above you should avoid intercepting this error type from within sessions.-use :: Pool -> Session.Session a -> IO (Either UsageError a)+--+-- When no connection is available, the pool requests wait for one in the+-- order they came, and a later pool request doesn't take a connection while+-- others wait.+use :: forall a. Pool -> Session.Session a -> IO (Either UsageError a) use Pool {..} sess = do- timeout <- do- delay <- registerDelay poolAcquisitionTimeout- return $ readTVar delay- join . atomically $ do- reuseVar <- readTVar poolReuseVar- asum- [ readTQueue poolConnectionQueue <&> onConn reuseVar,- do- capVal <- readTVar poolCapacity- if capVal > 0- then do- writeTVar poolCapacity $! pred capVal- return $ onNewConn reuseVar- else retry,- do- timedOut <- timeout- if timedOut- then return . return . Left $ AcquisitionTimeoutUsageError- else retry- ]+ now <- getMonotonicTimeNSec+ mask \unmask -> do+ holding <- atomically $ do+ reuseVar <- readTVar poolReuseVar+ waited <- someoneWaiting poolWaiting+ -- Behind the pool requests already waiting, which get what's freed first.+ if waited+ then enqueue now <&> WaitingHolding+ else+ asum+ [ readTQueue poolConnectionQueue <&> EntryHolding reuseVar,+ do+ capVal <- readTVar poolCapacity+ if capVal > 0+ then do+ writeTVar poolCapacity $! pred capVal+ return $ SlotHolding reuseVar+ else retry,+ enqueue now <&> WaitingHolding+ ]+ case holding of+ EntryHolding reuseVar entry -> onEntry unmask reuseVar entry+ SlotHolding reuseVar -> onSlot unmask reuseVar+ WaitingHolding waiter -> do+ given <- atomically (takeTMVar (waiterGiven waiter)) `onException` abandon waiter+ case given of+ TimedOut -> return $ Left AcquisitionTimeoutUsageError+ Given reuseVar Nothing -> onSlot unmask reuseVar+ Given reuseVar (Just entry) -> onEntry unmask reuseVar entry where- onNewConn reuseVar = do- settings <- poolFetchConnectionSettings+ enqueue :: Word64 -> STM Waiter+ enqueue now = do+ waiter <- Waiter (now + poolAcquisitionTimeout) <$> newEmptyTMVar <*> newTVar False+ writeTQueue poolWaiting waiter+ return waiter++ abandon :: Waiter -> IO ()+ abandon waiter = do+ given <- atomically $ do+ writeTVar (waiterGone waiter) True+ tryTakeTMVar (waiterGiven waiter)+ forM_ given \case+ Given _ Nothing -> atomically returnSlot+ Given reuseVar (Just entry) -> returnConn reuseVar entry+ TimedOut -> return ()++ returnSlot :: STM ()+ returnSlot = freeSlot poolReuseVar poolWaiting poolCapacity++ -- \| Holding one entry dequeued from the idle connections. If it is still+ -- good, run the session on it; if it expired, replace it with a fresh+ -- connection on the same slot. All the way, the holding is given back on+ -- interruption.+ onEntry :: (forall x. IO x -> IO x) -> TVar Bool -> Entry -> IO (Either UsageError a)+ onEntry unmask reuseVar entry = do now <- getMonotonicTimeNSec+ if entryIsAged poolMaxLifetime now entry+ then renewEntry unmask reuseVar entry AgingConnectionTerminationReason+ else+ if entryIsIdle poolMaxIdletime now entry+ then renewEntry unmask reuseVar entry IdlenessConnectionTerminationReason+ else onLiveConn unmask reuseVar entry {entryUseTimeNSec = now}++ -- \| Holding an expired entry. Release its connection and establish a+ -- fresh one on the same slot. On interruption the slot gets given back:+ -- the released connection cannot return to the pool.+ renewEntry :: (forall x. IO x -> IO x) -> TVar Bool -> Entry -> ConnectionTerminationReason -> IO (Either UsageError a)+ renewEntry unmask reuseVar entry reason = do+ outcome <-+ onException+ ( do+ Connection.release (entryConnection entry)+ unmask $ poolObserver (ConnectionObservation (entryId entry) (TerminatedConnectionStatus reason))+ establish unmask+ )+ (atomically returnSlot)+ case outcome of+ Left err -> return $ Left err+ Right newEntry -> onLiveConn unmask reuseVar newEntry++ -- \| Holding one capacity unit. Establish a connection on it and run the+ -- session. On interruption the slot gets given back.+ onSlot :: (forall x. IO x -> IO x) -> TVar Bool -> IO (Either UsageError a)+ onSlot unmask reuseVar = do+ outcome <- onException (establish unmask) (atomically returnSlot)+ case outcome of+ Left err -> return $ Left err+ Right entry -> onLiveConn unmask reuseVar entry++ -- Run with one capacity unit held: establish the connection and run the+ -- init session on it. On any failure the slot gets given back and the+ -- error gets returned. On interruption the slot gets given back and the+ -- exception gets rethrown.+ establish :: (forall x. IO x -> IO x) -> IO (Either UsageError Entry)+ establish unmask = do+ settings <- unmask poolFetchConnectionSettings+ now <- getMonotonicTimeNSec id <- Uuid.nextRandom- poolObserver (ConnectionObservation id ConnectingConnectionStatus)- Connection.acquire poolAdapter settings >>= \case+ unmask $ poolObserver (ConnectionObservation id ConnectingConnectionStatus)+ unmask (Connection.acquire poolAdapter settings) >>= \case Left connErr -> do let connErrText = case connErr of Errors.NetworkingConnectionError details -> Just details Errors.AuthenticationConnectionError details -> Just details Errors.CompatibilityConnectionError details -> Just details Errors.OtherConnectionError details -> if details == "" then Nothing else Just details- poolObserver (ConnectionObservation id (TerminatedConnectionStatus (NetworkErrorConnectionTerminationReason connErrText)))- atomically $ modifyTVar' poolCapacity succ+ unmask $ poolObserver (ConnectionObservation id (TerminatedConnectionStatus (NetworkErrorConnectionTerminationReason connErrText)))+ atomically returnSlot return $ Left $ ConnectionUsageError connErr- Right connection -> do- Connection.use connection poolInitSession >>= \case+ Right connection ->+ unmask (Connection.use connection poolInitSession) >>= \case Left err -> do Connection.release connection- atomically $ modifyTVar' poolCapacity succ- ErrorsDestruction.reset- ( \details -> do- poolObserver (ConnectionObservation id (TerminatedConnectionStatus (NetworkErrorConnectionTerminationReason (Just details))))- )- (poolObserver (ConnectionObservation id (TerminatedConnectionStatus (InitializationErrorTerminationReason err))))- err+ unmask+ $ ErrorsDestruction.reset+ (\details -> poolObserver (ConnectionObservation id (TerminatedConnectionStatus (NetworkErrorConnectionTerminationReason (Just details)))))+ (poolObserver (ConnectionObservation id (TerminatedConnectionStatus (InitializationErrorTerminationReason err))))+ err+ atomically returnSlot return $ Left $ SessionUsageError err Right () -> do- poolObserver (ConnectionObservation id (ReadyForUseConnectionStatus EstablishedConnectionReadyForUseReason))- onLiveConn reuseVar (Entry connection now now id)-- onConn reuseVar entry = do- now <- getMonotonicTimeNSec- if entryIsAged poolMaxLifetime now entry- then do- Connection.release (entryConnection entry)- poolObserver (ConnectionObservation (entryId entry) (TerminatedConnectionStatus AgingConnectionTerminationReason))- onNewConn reuseVar- else- if entryIsIdle poolMaxIdletime now entry- then do- Connection.release (entryConnection entry)- poolObserver (ConnectionObservation (entryId entry) (TerminatedConnectionStatus IdlenessConnectionTerminationReason))- onNewConn reuseVar- else do- onLiveConn reuseVar entry {entryUseTimeNSec = now}+ unmask $ poolObserver (ConnectionObservation id (ReadyForUseConnectionStatus EstablishedConnectionReadyForUseReason))+ return $ Right (Entry connection now now id) - onLiveConn reuseVar entry = do- poolObserver (ConnectionObservation (entryId entry) InUseConnectionStatus)- sessRes <- try @SomeException (Connection.use (entryConnection entry) sess)+ -- \| Holding one entry ready for use. Run the session on it, return the+ -- entry to the pool, and return the session result. Interrupted, the+ -- session exception gets rethrown after the entry is back in the pool.+ onLiveConn :: (forall x. IO x -> IO x) -> TVar Bool -> Entry -> IO (Either UsageError a)+ onLiveConn unmask reuseVar entry = do+ sessRes <-+ onException+ ( unmask $ do+ poolObserver (ConnectionObservation (entryId entry) InUseConnectionStatus)+ try @SomeException (Connection.use (entryConnection entry) sess)+ )+ (returnConn reuseVar entry) case sessRes of Left exc -> do- returnConn+ returnConn reuseVar entry throwIO exc Right (Left err) -> if ErrorsDestruction.requiresConnectionDiscard err then do Connection.release (entryConnection entry)- atomically $ modifyTVar' poolCapacity succ- poolObserver- ( ConnectionObservation- (entryId entry)- (TerminatedConnectionStatus (NetworkErrorConnectionTerminationReason (ErrorsDestruction.discardDetails err)))- )+ atomically returnSlot+ unmask+ $ poolObserver+ ( ConnectionObservation+ (entryId entry)+ (TerminatedConnectionStatus (NetworkErrorConnectionTerminationReason (ErrorsDestruction.discardDetails err)))+ ) return $ Left $ SessionUsageError err else do- returnConn- poolObserver (ConnectionObservation (entryId entry) (ReadyForUseConnectionStatus (SessionFailedConnectionReadyForUseReason err)))+ returnConn reuseVar entry+ unmask $ poolObserver (ConnectionObservation (entryId entry) (ReadyForUseConnectionStatus (SessionFailedConnectionReadyForUseReason err))) return $ Left $ SessionUsageError err Right (Right res) -> do- returnConn- poolObserver (ConnectionObservation (entryId entry) (ReadyForUseConnectionStatus SessionSucceededConnectionReadyForUseReason))+ returnConn reuseVar entry+ unmask $ poolObserver (ConnectionObservation (entryId entry) (ReadyForUseConnectionStatus SessionSucceededConnectionReadyForUseReason)) return $ Right res- where- returnConn =- join . atomically $ do- reuse <- readTVar reuseVar- if reuse- then writeTQueue poolConnectionQueue entry $> return ()- else return $ do- Connection.release (entryConnection entry)- atomically $ modifyTVar' poolCapacity succ- poolObserver (ConnectionObservation (entryId entry) (TerminatedConnectionStatus ReleaseConnectionTerminationReason))++ -- Back to the pool request waiting longest or to the idle connections,+ -- unless the pool was released meanwhile.+ returnConn reuseVar entry =+ join . atomically $ do+ reuse <- readTVar reuseVar+ if reuse+ then do+ nextWaiter poolWaiting >>= \case+ Just waiter -> do+ currentReuseVar <- readTVar poolReuseVar+ putTMVar (waiterGiven waiter) (Given currentReuseVar (Just entry))+ Nothing ->+ case poolReuseOrder of+ LIFO -> unGetTQueue poolConnectionQueue entry+ FIFO -> writeTQueue poolConnectionQueue entry+ return $ return ()+ else return $ do+ Connection.release (entryConnection entry)+ atomically returnSlot+ poolObserver (ConnectionObservation (entryId entry) (TerminatedConnectionStatus ReleaseConnectionTerminationReason))++-- | Whether a pool request is waiting. The ones that stopped waiting get+-- dropped.+someoneWaiting :: TQueue Waiter -> STM Bool+someoneWaiting waiting =+ tryPeekTQueue waiting >>= \case+ Nothing -> return False+ Just waiter ->+ readTVar (waiterGone waiter) >>= \case+ True -> readTQueue waiting *> someoneWaiting waiting+ False -> return True++-- | Take the pool request waiting longest, passing over the ones that stopped+-- waiting.+nextWaiter :: TQueue Waiter -> STM (Maybe Waiter)+nextWaiter waiting =+ tryReadTQueue waiting >>= \case+ Nothing -> return Nothing+ Just waiter ->+ readTVar (waiterGone waiter) >>= \case+ True -> nextWaiter waiting+ False -> return (Just waiter)++-- | Give the slot of a closed connection to the pool request waiting longest,+-- or back to the pool if no pool request waits.+freeSlot :: TVar (TVar Bool) -> TQueue Waiter -> TVar Int -> STM ()+freeSlot reuseVarVar waiting capVar =+ nextWaiter waiting >>= \case+ Just waiter -> do+ reuseVar <- readTVar reuseVarVar+ putTMVar (waiterGiven waiter) (Given reuseVar Nothing)+ Nothing -> modifyTVar' capVar succ++-- | Time out the pool requests waiting too long. All wait as long, so the pool+-- request waiting longest times out first: one thread sleeps until its+-- deadline, rather than every waiting pool request registering a timer, which+-- GHC can't cancel.+expire :: TQueue Waiter -> IO ()+expire waiting = forever $ do+ deadline <- atomically $ do+ waited <- someoneWaiting waiting+ if waited then waiterDeadline <$> peekTQueue waiting else retry+ now <- getMonotonicTimeNSec+ -- In microseconds, at most an hour at a time.+ when (deadline > now) $ threadDelay (fromIntegral (min 3_600_000_000 (div (deadline - now) 1_000 + 1)))+ atomically . timeOut =<< getMonotonicTimeNSec+ where+ timeOut now =+ tryPeekTQueue waiting >>= \case+ Just waiter | waiterDeadline waiter <= now -> do+ _ <- readTQueue waiting+ gone <- readTVar (waiterGone waiter)+ unless gone $ putTMVar (waiterGiven waiter) TimedOut+ timeOut now+ _ -> return ()++-- | Resolve the pool requests still waiting: with the pool gone, nothing will+-- ever be given to them.+drainWaiting :: TQueue Waiter -> STM ()+drainWaiting waiting =+ tryReadTQueue waiting >>= \case+ Nothing -> return ()+ Just waiter -> do+ gone <- readTVar (waiterGone waiter)+ unless gone $ void $ tryPutTMVar (waiterGiven waiter) TimedOut+ drainWaiting waiting++-- | Terminate one expired connection on the reaper's tick: release it, report+-- the termination, and give its slot back. The slot gets given back even when+-- the release or the observation fails, so no capacity is lost, and a failing+-- entry neither skips the other entries of the tick nor kills the reaper.+reap :: (Observation -> IO ()) -> TVar (TVar Bool) -> TQueue Waiter -> TVar Int -> ConnectionTerminationReason -> Entry -> IO ()+reap observer reuseVar waiting capVar reason entry =+ mask \restore ->+ catch @SomeException+ ( do+ restore $ Connection.release (entryConnection entry)+ restore $ observer (ConnectionObservation (entryId entry) (TerminatedConnectionStatus reason))+ atomically $ freeSlot reuseVar waiting capVar+ )+ \exc -> do+ atomically $ freeSlot reuseVar waiting capVar+ case fromException exc of+ Just SomeAsyncException {} -> throwIO exc+ Nothing -> return () -- | Union over all errors that 'use' can result in. data UsageError
src/library/Hasql/Pool/Config.hs view
@@ -11,10 +11,13 @@ Setting.dynamicConnectionSettings, Setting.observationHandler, Setting.initSession,+ Setting.reuseOrder,+ ReuseOrder.ReuseOrder (..), ) where import Hasql.Pool.Config.Config qualified as Config+import Hasql.Pool.Config.ReuseOrder qualified as ReuseOrder import Hasql.Pool.Config.Setting qualified as Setting import Hasql.Pool.Prelude
src/library/Hasql/Pool/Config/Config.hs view
@@ -2,6 +2,7 @@ import Hasql.Connection.Settings qualified as Connection.Settings import Hasql.Pool.Config.Defaults qualified as Defaults+import Hasql.Pool.Config.ReuseOrder (ReuseOrder) import Hasql.Pool.Observation (Observation) import Hasql.Pool.Prelude import Hasql.Session qualified as Session@@ -14,7 +15,8 @@ idlenessTimeout :: DiffTime, connectionSettingsProvider :: IO Connection.Settings.Settings, observationHandler :: Observation -> IO (),- initSession :: Session.Session ()+ initSession :: Session.Session (),+ reuseOrder :: ReuseOrder } -- | Reasonable defaults, which can be built upon.@@ -27,5 +29,6 @@ idlenessTimeout = Defaults.idlenessTimeout, connectionSettingsProvider = Defaults.dynamicConnectionSettings, observationHandler = Defaults.observationHandler,- initSession = Defaults.initSession+ initSession = Defaults.initSession,+ reuseOrder = Defaults.reuseOrder }
src/library/Hasql/Pool/Config/Defaults.hs view
@@ -1,6 +1,7 @@ module Hasql.Pool.Config.Defaults where import Hasql.Connection.Settings qualified as Connection.Settings+import Hasql.Pool.Config.ReuseOrder (ReuseOrder (..)) import Hasql.Pool.Observation (Observation) import Hasql.Pool.Prelude import Hasql.Session qualified as Session@@ -45,3 +46,8 @@ -- > pure () initSession :: Session.Session () initSession = pure ()++-- |+-- The idle connection returned longest ago first.+reuseOrder :: ReuseOrder+reuseOrder = FIFO
+ src/library/Hasql/Pool/Config/ReuseOrder.hs view
@@ -0,0 +1,17 @@+module Hasql.Pool.Config.ReuseOrder where++import Hasql.Pool.Prelude++-- | Which of the idle connections a pool request gets.+data ReuseOrder+ = -- | Last in, first out: the one returned most recently.+ --+ -- The pool keeps reusing the few connections the load needs, so the rest+ -- time out as idle, and the ones in use keep their caches warm.+ LIFO+ | -- | First in, first out: the one returned longest ago.+ --+ -- Pool requests cycle through all the connections, so steady load keeps+ -- every connection open.+ FIFO+ deriving (Show, Eq)
src/library/Hasql/Pool/Config/Setting.hs view
@@ -3,6 +3,7 @@ import Hasql.Connection.Settings qualified as Connection.Settings import Hasql.Pool.Config.Config (Config) import Hasql.Pool.Config.Config qualified as Config+import Hasql.Pool.Config.ReuseOrder (ReuseOrder (..)) import Hasql.Pool.Observation (Observation) import Hasql.Pool.Prelude import Hasql.Session qualified as Session@@ -74,6 +75,8 @@ -- -- Typically it's used for monitoring the state of the pool via metrics and logging. --+-- An observation handler must not throw. Synchronous exceptions from it are discarded; asynchronous exceptions are honored. Its failure never affects the session result or the pool's state.+-- -- If the provided action is not lightweight, it's recommended to use intermediate bufferring via channels like TBQueue to avoid occupying the pool management thread for too long. -- E.g., if the action is @'atomically' . 'writeTBQueue' yourQueue@, then reading from it and processing can be done on a separate thread. --@@ -95,3 +98,14 @@ initSession :: Session.Session () -> Setting initSession x = Setting (\config -> config {Config.initSession = x})++-- | Which of the idle connections a pool request gets.+--+-- With 'LIFO', the pool shrinks to the connections the load needs: the rest+-- time out as idle (see 'idlenessTimeout'). With 'FIFO', steady load keeps+-- every connection open.+--+-- 'FIFO' by default.+reuseOrder :: ReuseOrder -> Setting+reuseOrder x =+ Setting (\config -> config {Config.reuseOrder = x})