packages feed

glue-core-0.7.0: src/Glue/Shutdown.hs

{-# LANGUAGE FlexibleContexts    #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Module supporting graceful service shutdown, rejecting new requests with either
-- a fallback value or t'ServiceShuttingDownException', waiting for in-flight requests
-- to drain within a configurable grace period, and cancelling any remaining calls if
-- the grace period expires.
module Glue.Shutdown
  ( ShutdownOptions (..)
  , ServiceShuttingDownException (..)
  , ShutdownHandle (..)
  , defaultShutdownOptions
  , gracefulShutdown
  ) where

import           Glue.Types
import           UnliftIO           (Exception, MonadIO, MonadUnliftIO)
import           UnliftIO.Async     (race)
import           UnliftIO.Exception (finally, throwIO)
import           UnliftIO.STM       (atomically, modifyTVar', newTVarIO,
                                     readTVar, retrySTM, writeTVar)
import           UnliftIO.Timeout   (timeout)

-- | Options for configuring graceful service shutdown.
data ShutdownOptions b = ShutdownOptions
  { shutdownFallback      :: Maybe b
  -- ^ Optional default value returned for rejected requests after shutdown is initiated.
  -- If 'Nothing', t'ServiceShuttingDownException' is thrown instead.
  , shutdownGracePeriodMs :: Int
  -- ^ Maximum time in milliseconds to wait for existing in-flight requests to complete before cancelling them.
  , shutdownDescription   :: String
  -- ^ Description attached to t'ServiceShuttingDownException'.
  }
  deriving (Eq, Show)

-- | Default shutdown options: throws t'ServiceShuttingDownException' on rejected calls and waits up to 5 seconds for in-flight calls.
defaultShutdownOptions :: ShutdownOptions b
defaultShutdownOptions =
  ShutdownOptions
    { shutdownFallback      = Nothing
    , shutdownGracePeriodMs = 5000
    , shutdownDescription   = "Service is shutting down."
    }

-- | Exception thrown when a service is shutting down and no 'shutdownFallback' is configured.
newtype ServiceShuttingDownException = ServiceShuttingDownException String
  deriving (Eq, Show)

instance Exception ServiceShuttingDownException

-- | Handle used to control and inspect a service wrapped with 'gracefulShutdown'.
data ShutdownHandle m = ShutdownHandle
  { initiateShutdown :: m Bool
  -- ^ Initiates shutdown: immediately rejects new requests, waits up to 'shutdownGracePeriodMs'
  -- for in-flight requests to finish, and cancels any remaining calls if the grace period expires.
  -- Returns 'True' if all in-flight calls drained cleanly within the grace period, or 'False' if forced cancellation occurred.
  , isShuttingDown   :: m Bool
  -- ^ Returns 'True' if shutdown has been initiated.
  , inFlightCount    :: m Int
  -- ^ Returns the current number of in-flight requests executing the service.
  }

-- | Wraps a service with graceful shutdown support.
gracefulShutdown
  :: forall m n a b.
     (MonadUnliftIO m, MonadIO n)
  => ShutdownOptions b
  -- ^ Options configuring fallback behaviour and grace period.
  -> BasicService m a b
  -- ^ Service to wrap.
  -> n (BasicService m a b, ShutdownHandle m)
gracefulShutdown options service = do
  shuttingDownVar <- newTVarIO False
  forceCancelVar  <- newTVarIO False
  inFlightVar     <- newTVarIO (0 :: Int)

  let rejectRequest :: m b
      rejectRequest =
        case shutdownFallback options of
          Just fallback -> pure fallback
          Nothing       -> throwIO $ ServiceShuttingDownException (shutdownDescription options)

      waitForForceCancel :: m ()
      waitForForceCancel = atomically $ do
        cancelled <- readTVar forceCancelVar
        if cancelled then pure () else retrySTM

      waitDrained :: m ()
      waitDrained = atomically $ do
        active <- readTVar inFlightVar
        if active <= 0 then pure () else retrySTM

      wrappedService :: BasicService m a b
      wrappedService request = do
        accepted <- atomically $ do
          shuttingDown <- readTVar shuttingDownVar
          if shuttingDown
            then pure False
            else do
              modifyTVar' inFlightVar (+ 1)
              pure True
        if not accepted
          then rejectRequest
          else do
            res <-
              race waitForForceCancel (service request)
                `finally` atomically (modifyTVar' inFlightVar (subtract 1))
            case res of
              Right val -> pure val
              Left ()   -> rejectRequest

      handle =
        ShutdownHandle
          { initiateShutdown = do
              atomically $ writeTVar shuttingDownVar True
              let graceUs = max 0 (shutdownGracePeriodMs options) * 1000
              drained <- timeout graceUs waitDrained
              case drained of
                Just () -> pure True
                Nothing -> do
                  atomically $ writeTVar forceCancelVar True
                  waitDrained
                  pure False
          , isShuttingDown = atomically $ readTVar shuttingDownVar
          , inFlightCount  = atomically $ readTVar inFlightVar
          }

  pure (wrappedService, handle)