packages feed

looper-0.3.0.2: src/Looper.hs

{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Looper
  ( LooperDef (..),
    milliseconds,
    seconds,
    minutes,
    hours,
    LooperSettings (..),
    parseLooperSettings,
    mkLooperDef,
    withLoopers,
    runLoopers,
    withLoopersIgnoreOverrun,
    runLoopersIgnoreOverrun,
    withLoopersRaw,
    runLoopersRaw,
    runLooperDef,
    waitNominalDiffTime,
  )
where

import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NE
import Data.Maybe
import Data.Text (Text)
import Data.Time
import Data.Void
import GHC.Generics (Generic)
import OptEnvConf
import UnliftIO
import UnliftIO.Concurrent

-- | A looper definition
data LooperDef m = LooperDef
  { -- | The name of the looper, can be useful for logging
    looperDefName :: Text,
    -- | Whether this looper is enabled
    looperDefEnabled :: Bool,
    -- | The time between the start of each run
    looperDefPeriod :: NominalDiffTime,
    -- | The time before the first run
    looperDefPhase :: NominalDiffTime,
    -- | The function to run
    looperDefFunc :: m ()
  }
  deriving (Generic)

-- | Construct a 'NominalDiffTime' from a number of milliseconds
--
-- Note that scheduling can easily get in the way of accuracy at this
-- level of granularity.
milliseconds :: Double -> NominalDiffTime
milliseconds = seconds . (/ 60)

-- | Construct a 'NominalDiffTime' from a number of seconds
seconds :: Double -> NominalDiffTime
seconds = realToFrac

-- | Construct a 'NominalDiffTime' from a number of minutes
minutes :: Double -> NominalDiffTime
minutes = seconds . (* 60)

-- | Construct a 'NominalDiffTime' from a number of hours
hours :: Double -> NominalDiffTime
hours = minutes . (* 60)

-- | Settings that you might want to pass into a looper using 'mkLooperDef'
data LooperSettings = LooperSettings
  { looperSetEnabled :: Bool,
    looperSetPhase :: NominalDiffTime,
    looperSetPeriod :: NominalDiffTime
  }
  deriving (Show, Eq, Generic)

parseLooperSettings ::
  String ->
  NominalDiffTime ->
  NominalDiffTime ->
  Parser LooperSettings
parseLooperSettings looperName defaultPhase defaultPeriod = do
  looperSetEnabled <-
    subConfig (toConfigCase looperName) $
      subEnv (toEnvCase looperName <> "_") $
        enableDisableSwitch
          [ help $ unwords ["enable the", looperName, "looper"],
            option,
            long looperName,
            env "ENABLE",
            conf "enable",
            value True
          ]
  (looperSetPhase, looperSetPeriod) <- subAll looperName $ do
    ph <-
      setting
        [ help $ unwords ["phase of the", looperName, "looper in seconds"],
          reader (fromInteger <$> auto),
          option,
          name "phase",
          metavar "SECONDS",
          value defaultPhase
        ]
    pe <-
      setting
        [ help $ unwords ["period of the", looperName, "looper in seconds"],
          reader (fromInteger <$> auto),
          name "period",
          metavar "SECONDS",
          value defaultPeriod
        ]
    pure (ph, pe)
  pure LooperSettings {..}

mkLooperDef ::
  -- | Name
  Text ->
  LooperSettings ->
  -- | The function to loop
  m () ->
  LooperDef m
mkLooperDef n LooperSettings {..} func =
  LooperDef
    { looperDefName = n,
      looperDefEnabled = looperSetEnabled,
      looperDefPeriod = looperSetPeriod,
      looperDefPhase = looperSetPhase,
      looperDefFunc = func
    }

-- | Like 'runLoopers' but runs the loopers as background jobs.
withLoopers :: (MonadUnliftIO m) => [LooperDef m] -> m a -> m a
withLoopers = withLoopersIgnoreOverrun looperDefFunc

-- | Simply run loopers
--
-- > runLoopers = runLoopersIgnoreOverrun looperDefFunc
--
-- see 'runLoopersIgnoreOverrun'
--
-- Note that this function will loop forever, you need to wrap it using 'async' yourself.
runLoopers :: (MonadUnliftIO m) => [LooperDef m] -> m ()
runLoopers = runLoopersIgnoreOverrun looperDefFunc

-- | Like 'runLoopersIgnoreOverrun' but runs the loopers as background jobs.
withLoopersIgnoreOverrun ::
  (MonadUnliftIO n) =>
  -- | Custom runner
  (LooperDef m -> n ()) ->
  -- | Loopers
  [LooperDef m] ->
  n a ->
  n a
withLoopersIgnoreOverrun = withLoopersRaw (const $ pure ())

-- | Run loopers with a custom runner, ignoring any overruns
--
-- > runLoopersIgnoreOverrun = runLoopersRaw (pure ())
--
-- see 'runLoopersRaw'
--
-- Note that this function will loop forever, you need to wrap it using 'async' yourself.
runLoopersIgnoreOverrun ::
  (MonadUnliftIO n) =>
  -- | Custom runner
  (LooperDef m -> n ()) ->
  -- | Loopers
  [LooperDef m] ->
  n ()
runLoopersIgnoreOverrun = runLoopersRaw (const $ pure ())

-- | Like 'runLoopersRaw' but runs the loopers as background jobs.
withLoopersRaw ::
  forall m n a.
  (MonadUnliftIO n) =>
  -- | Overrun handler
  (LooperDef m -> n ()) ->
  -- | Runner
  (LooperDef m -> n ()) ->
  -- | Loopers
  [LooperDef m] ->
  --
  n a ->
  n a
withLoopersRaw onOverrun runLooper defs func =
  case NE.nonEmpty (mapMaybe (runLooperDef onOverrun runLooper) defs) of
    Nothing -> func
    Just looperThreads -> do
      stopOrResult <- race (mapConcurrently id looperThreads) func
      case stopOrResult :: Either (NonEmpty Void) a of
        Left _ -> error "uninhabited."
        Right result -> pure result

-- | Run loopers, with a custom runner and overrun handler
--
-- * The overrun handler is run when the looper function takes longer than its period.
--   You can use this to log a warning, for example.
--
-- * The runner function is used to run the looper function
--   You can use 'looperDefFunc' @ :: LooperDef m -> m ()@ to run a 'LooperDef', and you
--   can wrap this function in some custom logic before you pass it into 'runLoopersRaw'
--   In this manner you can add logging or metrics, for example.
--
-- Note that this function will loop forever, you need to wrap it using 'async' yourself.
runLoopersRaw ::
  (MonadUnliftIO n) =>
  -- | Overrun handler
  (LooperDef m -> n ()) ->
  -- | Runner
  (LooperDef m -> n ()) ->
  -- | Loopers
  [LooperDef m] ->
  -- Returns unit because this finishes immediately if there are no loopers
  n ()
runLoopersRaw onOverrun runLooper defs =
  mapConcurrently_ id $ mapMaybe (runLooperDef onOverrun runLooper) defs

runLooperDef ::
  (MonadUnliftIO n) =>
  -- | Overrun handler
  (LooperDef m -> n ()) ->
  -- | Runner
  (LooperDef m -> n ()) ->
  -- | Loopers
  LooperDef m ->
  Maybe (n void)
runLooperDef onOverrun runLooper ld@LooperDef {..} =
  if looperDefEnabled
    then Just $ do
      waitNominalDiffTime looperDefPhase
      let loop = do
            start <- liftIO getCurrentTime
            runLooper ld
            end <- liftIO getCurrentTime
            let elapsed = diffUTCTime end start
            let nextWait = looperDefPeriod - elapsed
            if nextWait < 0
              then onOverrun ld
              else waitNominalDiffTime nextWait
            loop
      loop
    else Nothing

-- | Wait for a given 'NominalDiffTime'
--
-- This takes care of the conversion to microseconds to pass to 'threadDelay' for you.
--
-- > waitNominalDiffTime ndt = liftIO $ threadDelay $ round (toRational ndt * (1000 * 1000))
waitNominalDiffTime :: (MonadIO m) => NominalDiffTime -> m ()
waitNominalDiffTime ndt = liftIO $ threadDelay $ round (toRational ndt * 1_000_000)