packages feed

resource-pool 0.5.0.1 → 0.5.1.0

raw patch · 7 files changed

+192/−59 lines, 7 filesPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

API changes (from Hackage documentation)

- Data.Pool.Internal: [reaperRef] :: Pool a -> !IORef ()
+ Data.Pool.Internal: WakeupSem :: TVar Bool -> WakeupSem
+ Data.Pool.Internal: [wakeupSem] :: LocalPool a -> !WakeupSem
+ Data.Pool.Internal: newWakeupSem :: IO WakeupSem
+ Data.Pool.Internal: newtype WakeupSem
+ Data.Pool.Internal: wakeupSignal :: WakeupSem -> STM ()
+ Data.Pool.Internal: wakeupWait :: WakeupSem -> STM ()
- Data.Pool.Internal: LocalPool :: !Int -> !TVar (Stripe a) -> !IORef () -> LocalPool a
+ Data.Pool.Internal: LocalPool :: !Int -> !TVar (Stripe a) -> !WakeupSem -> !IORef () -> LocalPool a
- Data.Pool.Internal: Pool :: !PoolConfig a -> !SmallArray (LocalPool a) -> !IORef () -> Pool a
+ Data.Pool.Internal: Pool :: !PoolConfig a -> !SmallArray (LocalPool a) -> Pool a
- Data.Pool.Internal: restoreSize :: TVar (Stripe a) -> IO ()
+ Data.Pool.Internal: restoreSize :: LocalPool a -> IO ()
- Data.Pool.Internal: signal :: Stripe a -> Maybe a -> STM (Stripe a)
+ Data.Pool.Internal: signal :: LocalPool a -> Stripe a -> Maybe a -> STM (Stripe a)
- Data.Pool.Internal: waitForResource :: TVar (Stripe a) -> TMVar (Maybe a) -> IO (Maybe a)
+ Data.Pool.Internal: waitForResource :: LocalPool a -> TMVar (Maybe a) -> IO (Maybe a)

Files

CHANGELOG.md view
@@ -1,3 +1,7 @@+# resource-pool-0.5.1.0 (2026-08-15)+* Spawn a collector thread per stripe and make them wake up when appropriate+  instead of polling every second.+ # resource-pool-0.5.0.1 (2026-07-08) * Fix a bug where a thread waiting for a resource would get stuck in the queue   indefinitely if resource creation failed in another thread.
README.md view
@@ -7,3 +7,38 @@  A high-performance striped resource pooling implementation for Haskell based on [QSem](https://hackage.haskell.org/package/base/docs/Control-Concurrent-QSem.html).++## Advice for library authors++If your library creates a pool on behalf of its users, don't expose your own,+restricted set of pool parameters (size, TTL, ...) and construct the+`PoolConfig` internally. Such a config inevitably lags behind features of this+library (stripe count, labels, whatever comes next) and users can't take+advantage of them without waiting for you to mirror each one.++Instead, take a function of type `IO a -> (a -> IO ()) -> PoolConfig a` as a+parameter. Your library supplies the resource creation and destruction actions:++```haskell+createConnectionPool+  :: ConnectionSettings+  -> (IO Connection -> (Connection -> IO ()) -> PoolConfig Connection)+  -> IO (Pool Connection)+createConnectionPool settings mkPoolConfig =+  newPool $ mkPoolConfig connect disconnect+  where+    connect :: IO Connection+    connect = ...++    disconnect :: Connection -> IO ()+    disconnect = ...+```++while users retain full control over the rest of the pool configuration:++```haskell+pool <- createConnectionPool settings $ \create free ->+  setPoolLabel "db"+    . setNumStripes (Just 1)+    $ defaultPoolConfig create free 60 10+```
resource-pool.cabal view
@@ -1,7 +1,7 @@ cabal-version:       3.0 build-type:          Simple name:                resource-pool-version:             0.5.0.1+version:             0.5.1.0 license:             BSD-3-Clause license-file:        LICENSE category:            Data, Database, Network
src/Data/Pool.hs view
@@ -73,10 +73,10 @@         q <- newEmptyTMVar         writeTVar (stripeVar lp) $! stripe {queueR = Queue q (queueR stripe)}         pure-          $ waitForResource (stripeVar lp) q >>= \case+          $ waitForResource lp q >>= \case             Just a -> pure (a, lp)             Nothing -> do-              a <- createResource (poolConfig pool) `onException` restoreSize (stripeVar lp)+              a <- createResource (poolConfig pool) `onException` restoreSize lp               pure (a, lp)       else takeAvailableResource pool lp stripe @@ -131,7 +131,7 @@   [] -> do     writeTVar (stripeVar lp) $! stripe {available = available stripe - 1}     pure $ do-      a <- createResource (poolConfig pool) `onException` restoreSize (stripeVar lp)+      a <- createResource (poolConfig pool) `onException` restoreSize lp       pure (a, lp)   Entry a _ : as -> do     writeTVar (stripeVar lp)
src/Data/Pool/Internal.hs view
@@ -11,6 +11,7 @@ import Control.Exception import Control.Monad import Data.Either+import Data.Function import Data.Hashable (hash) import Data.IORef import Data.List qualified as L@@ -23,13 +24,13 @@ data Pool a = Pool   { poolConfig :: !(PoolConfig a)   , localPools :: !(SmallArray (LocalPool a))-  , reaperRef :: !(IORef ())   }  -- | A single, local pool. data LocalPool a = LocalPool   { stripeId :: !Int   , stripeVar :: !(TVar (Stripe a))+  , wakeupSem :: !WakeupSem   , cleanerRef :: !(IORef ())   } @@ -39,6 +40,7 @@ data Stripe a = Stripe   { available :: !Int   , cache :: ![Entry a]+  -- ^ Ordered by 'lastUsed', newest first (required by collector threads).   , queue :: !(Queue a)   , queueR :: !(Queue a)   }@@ -79,9 +81,6 @@   -> Double   -- ^ The number of seconds for which an unused resource is kept around. The   -- smallest acceptable value is @0.5@.-  ---  -- /Note:/ the elapsed time before destroying a resource may be a little-  -- longer than requested, as the collector thread wakes at 1-second intervals.   -> Int   -- ^ The maximum number of resources to keep open __across all stripes__. The   -- smallest acceptable value is @1@ per stripe.@@ -129,9 +128,10 @@ -- pool is garbage collected, it's recommended to manually call -- 'destroyAllResources' when you're done with the pool so that the resources -- are freed up as soon as possible.-newPool :: PoolConfig a -> IO (Pool a)+newPool :: forall a. PoolConfig a -> IO (Pool a) newPool pc = do-  when (poolCacheTTL pc < 0.5) $ do+  -- Arranged so that NaN is also rejected as it breaks the collector thread.+  unless (poolCacheTTL pc >= 0.5) $ do     error "poolCacheTTL must be at least 0.5"   when (poolMaxResources pc < 1) $ do     error "poolMaxResources must be at least 1"@@ -151,30 +151,36 @@           , queue = Empty           , queueR = Empty           }-    -- When the local pool goes out of scope, free its resources.-    void . mkWeakIORef ref $ cleanStripe (const True) (freeResource pc) stripe+    sem <- newWakeupSem+    mask_ $ do+      -- The collector must not reference 'ref', otherwise the finalizer below+      -- would never run.+      collectorId <- forkIOWithUnmask $ \unmask -> unmask $ do+        tid <- myThreadId+        labelThread tid+          $ "resource-pool: collector #"+            ++ show n+            ++ " ("+            ++ T.unpack (pcLabel pc)+            ++ ")"+        collector sem stripe+      void . mkWeakIORef ref $ do+        -- When the local pool goes out of scope, stop its collector and free+        -- its resources.+        killThread collectorId+        cleanStripe (const True) (freeResource pc) stripe     pure       LocalPool         { stripeId = n         , stripeVar = stripe+        , wakeupSem = sem         , cleanerRef = ref         }-  mask_ $ do-    ref <- newIORef ()-    collectorA <- forkIOWithUnmask $ \unmask -> unmask $ do-      tid <- myThreadId-      labelThread tid $ "resource-pool: collector (" ++ T.unpack (pcLabel pc) ++ ")"-      collector pools-    void . mkWeakIORef ref $ do-      -- When the pool goes out of scope, stop the collector. Resources existing-      -- in stripes will be taken care by their cleaners.-      killThread collectorA-    pure-      Pool-        { poolConfig = pc-        , localPools = pools-        , reaperRef = ref-        }+  pure+    Pool+      { poolConfig = pc+      , localPools = pools+      }   where     stripeResources :: Int -> [(Int, Int)]     stripeResources numStripes =@@ -186,12 +192,37 @@           0 -> acc           rest -> r + 1 : addRest rs (rest - 1) -    -- Collect stale resources from the pool once per second.-    collector pools = forever $ do-      threadDelay 1000000+    collector :: WakeupSem -> TVar (Stripe a) -> IO r+    collector sem stripe = forever $ do+      atomically $ wakeupWait sem+      -- The wakeup signal means that a resource was just put into the empty+      -- cache, so neither it nor any resource cached after it can expire+      -- earlier than TTL from now.+      --+      -- Waiting a full TTL before looking at the cache also caps signal-driven+      -- wakeups at one per TTL when resources are rapidly taken from and put+      -- back into an almost-empty cache.+      waitUntil . (+ poolCacheTTL pc) =<< getMonotonicTime+      fix $ \loop ->+        (cache <$> readTVarIO stripe) >>= \case+          [] -> pure ()+          entries -> do+            -- Nothing can expire before the last entry.+            waitUntil $ lastUsed (L.last entries) + poolCacheTTL pc+            now <- getMonotonicTime+            let isStale e = now - lastUsed e > poolCacheTTL pc+            cleanStripe isStale (freeResource pc) stripe+            loop++    waitUntil :: Double -> IO ()+    waitUntil deadline = do       now <- getMonotonicTime-      let isStale e = now - lastUsed e > poolCacheTTL pc-      mapM_ (cleanStripe isStale (freeResource pc) . stripeVar) pools+      let micros = (deadline - now) * 1000000+      when (micros > 0) $ do+        threadDelay+          $ if micros >= fromIntegral (maxBound :: Int)+            then maxBound+            else ceiling micros  -- | Destroy a resource. --@@ -201,7 +232,7 @@ destroyResource pool lp a = mask_ $ do   atomically $ do     stripe <- readTVar (stripeVar lp)-    newStripe <- signal stripe Nothing+    newStripe <- signal lp stripe Nothing     writeTVar (stripeVar lp) $! newStripe   freeResource (poolConfig pool) a @@ -209,7 +240,7 @@ putResource :: LocalPool a -> a -> IO () putResource lp a = atomically $ do   stripe <- readTVar (stripeVar lp)-  newStripe <- signal stripe (Just a)+  newStripe <- signal lp stripe (Just a)   writeTVar (stripeVar lp) $! newStripe  -- | Destroy all resources in all stripes in the pool.@@ -233,6 +264,24 @@ ---------------------------------------- -- Helpers +-- | Binary semaphore for signaling a collector thread to wake up.+newtype WakeupSem = WakeupSem (TVar Bool)++newWakeupSem :: IO WakeupSem+newWakeupSem = WakeupSem <$> newTVarIO False++wakeupSignal :: WakeupSem -> STM ()+wakeupSignal (WakeupSem var) = writeTVar var True++wakeupWait :: WakeupSem -> STM ()+wakeupWait (WakeupSem var) = do+  signaled <- readTVar var+  if signaled+    then writeTVar var False+    else retry++----------------------------------------+ -- | Get a local pool. getLocalPool :: SmallArray (LocalPool a) -> IO (LocalPool a) getLocalPool pools = do@@ -267,34 +316,34 @@     stripes = sizeofSmallArray pools  -- | Wait for the resource to be put into a given 'TMVar'.-waitForResource :: TVar (Stripe a) -> TMVar (Maybe a) -> IO (Maybe a)-waitForResource mstripe q = atomically (takeTMVar q) `onException` cleanup+waitForResource :: LocalPool a -> TMVar (Maybe a) -> IO (Maybe a)+waitForResource lp q = atomically (takeTMVar q) `onException` cleanup   where     cleanup = atomically $ do-      stripe <- readTVar mstripe+      stripe <- readTVar (stripeVar lp)       newStripe <-         tryTakeTMVar q >>= \case           Just ma -> do             -- Between entering the exception handler and taking ownership of             -- the stripe we got the resource we wanted. We don't need it             -- anymore though, so pass it to someone else.-            signal stripe ma+            signal lp stripe ma           Nothing -> do             -- If we're still waiting, fill up the TMVar with an undefined value             -- so that 'signal' can discard our TMVar from the queue.             putTMVar q $ error "unreachable"             pure stripe-      writeTVar mstripe $! newStripe+      writeTVar (stripeVar lp) $! newStripe  -- | If an exception is received while a resource is being created, restore the -- original size of the stripe.-restoreSize :: TVar (Stripe a) -> IO ()-restoreSize mstripe = atomically $ do-  stripe <- readTVar mstripe+restoreSize :: LocalPool a -> IO ()+restoreSize lp = atomically $ do+  stripe <- readTVar (stripeVar lp)   -- Signal needs to be called so that if there are threads waiting for a   -- resource, one of them wakes up and attempts the creation itself.-  newStripe <- signal stripe Nothing-  writeTVar mstripe $! newStripe+  newStripe <- signal lp stripe Nothing+  writeTVar (stripeVar lp) $! newStripe  -- | Free resource entries in the stripes that fulfil a given condition. cleanStripe@@ -312,14 +361,15 @@     -- the number of resources taken from the pool.     writeTVar mstripe $! stripe {cache = fresh}     pure $ map entry stale-  -- We need to ignore exceptions in the 'free' function, otherwise if an-  -- exception is thrown half-way, we leak the rest of the resources. Also,-  -- asynchronous exceptions need to be hard masked here we need to run 'free'-  -- for all resources.-  uninterruptibleMask $ \release -> do-    rs <- forM stale $ try @SomeException . release . free-    -- If any async exception arrived in between, propagate it.-    rethrowFirstAsyncException $ lefts rs+  -- We need to catch all exceptions in the 'free' function, otherwise if an+  -- exception was thrown half-way through the traversal of 'stale', we would+  -- leak the rest of the resources.+  --+  -- The loop outside of a call to 'free' is not interruptible, so asynchronous+  -- exceptions can only be delivered inside 'free'. If such a situation arises,+  -- propagate the first one we got.+  rs <- forM stale $ try @SomeException . free+  rethrowFirstAsyncException $ lefts rs   where     rethrowFirstAsyncException = \case       [] -> pure ()@@ -327,14 +377,18 @@         | Just SomeAsyncException {} <- fromException e -> throwIO e         | otherwise -> rethrowFirstAsyncException es -signal :: forall a. Stripe a -> Maybe a -> STM (Stripe a)-signal stripe ma =+signal :: forall a. LocalPool a -> Stripe a -> Maybe a -> STM (Stripe a)+signal lp stripe ma =+  -- When cache changes from empty to non-empty, the collector needs to be+  -- signaled via wakeupSem.   if available stripe == 0     then loop (queue stripe) (queueR stripe)     else do       newCache <- case ma of         Just a -> do           now <- unsafeIOToSTM getMonotonicTime+          when (null $ cache stripe) $ do+            wakeupSignal $ wakeupSem lp           pure $ Entry a now : cache stripe         Nothing -> pure $ cache stripe       pure@@ -348,6 +402,7 @@       newCache <- case ma of         Just a -> do           now <- unsafeIOToSTM getMonotonicTime+          wakeupSignal $ wakeupSem lp           pure [Entry a now]         Nothing -> pure []       pure
src/Data/Pool/Introspection.hs view
@@ -72,7 +72,7 @@         q <- newEmptyTMVar         writeTVar (stripeVar lp) $! stripe {queueR = Queue q (queueR stripe)}         pure-          $ waitForResource (stripeVar lp) q >>= \case+          $ waitForResource lp q >>= \case             Just a -> do               t2 <- getMonotonicTime               let res =@@ -88,7 +88,7 @@               pure (res, lp)             Nothing -> do               t2 <- getMonotonicTime-              a <- createResource (poolConfig pool) `onException` restoreSize (stripeVar lp)+              a <- createResource (poolConfig pool) `onException` restoreSize lp               t3 <- getMonotonicTime               let res =                     Resource@@ -141,7 +141,7 @@     writeTVar (stripeVar lp) $! stripe {available = newAvailable}     pure $ do       t2 <- getMonotonicTime-      a <- createResource (poolConfig pool) `onException` restoreSize (stripeVar lp)+      a <- createResource (poolConfig pool) `onException` restoreSize lp       t3 <- getMonotonicTime       let res =             Resource
test/Main.hs view
@@ -39,6 +39,9 @@     "config validation"     [ testCase "rejects too small poolCacheTTL" $ do         expectError . newPool $ poolConfig_ 0.4 1+    , testCase "rejects a NaN poolCacheTTL" $ do+        -- A NaN would send the collector into a busy loop.+        expectError . newPool $ poolConfig_ (0 / 0) 1     , testCase "rejects non-positive poolMaxResources" $ do         expectError . newPool $ poolConfig_ 100 0     , testCase "rejects non-positive number of stripes" $ do@@ -131,9 +134,45 @@               0.5               5         _ <- withResource pool pure-        -- The collector thread wakes up every second.+        -- The collector should free the resource promptly after its TTL+        -- expires.         waitUntil "the resource is collected" $ (== 1) <$> readIORef freedC         readIORef createdC >>= assertEqual "created resources" 1+    , testCase "the collector runs again after the cache is refilled" $ do+        freedC <- newIORef (0 :: Int)+        pool <-+          newPool+            $ defaultPoolConfig+              (pure ())+              (\_ -> atomicModifyIORef' freedC $ \n -> (n + 1, ()))+              0.5+              5+        _ <- withResource pool pure+        waitUntil "the first resource is collected" $ (== 1) <$> readIORef freedC+        -- The collector went back to sleep on an empty cache; putting a new+        -- resource into it needs to wake it up again.+        _ <- withResource pool pure+        waitUntil "the second resource is collected" $ (== 2) <$> readIORef freedC+    , testCase "entries not yet stale in a collection round are collected later" $ do+        freedC <- newIORef (0 :: Int)+        pool <-+          newPool+            $ defaultPoolConfig+              (pure ())+              (\_ -> atomicModifyIORef' freedC $ \n -> (n + 1, ()))+              0.5+              5+        (r1, lp1) <- takeResource pool+        (r2, lp2) <- takeResource pool+        putResource lp1 r1+        -- Put the second resource back only after a while, so that when the+        -- collector wakes up to free the first one, the second one is not yet+        -- stale and has to be freed in a later collection round, even though+        -- the pool sees no further activity.+        threadDelay 300000+        putResource lp2 r2+        waitUntil "the first resource is collected" $ (>= 1) <$> readIORef freedC+        waitUntil "the second resource is collected" $ (== 2) <$> readIORef freedC     ]  ----------------------------------------