packages feed

job-0.1: lib/Job/Memory.hs

{-# LANGUAGE StrictData #-}
{-# LANGUAGE NoFieldSelectors #-}

module Job.Memory (queue) where

import Control.Concurrent
import Control.Concurrent.Async qualified as As
import Control.Concurrent.STM
import Control.Exception.Safe qualified as Ex
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)
import Control.Monad.Trans.Resource.Extra qualified as R
import Control.Monad.Trans.State.Strict (put, runStateT)
import Data.Acquire qualified as A
import Data.Fixed
import Data.Function
import Data.List qualified as List
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Time qualified as Time
import Data.UUID.V7 (UUID)
import Data.UUID.V7 qualified as UUID7
import GHC.IO.Exception
import GHC.Stack

import Job

--------------------------------------------------------------------------------

keepAliveBeat :: Micro
keepAliveBeat = 4

retryDelay :: Time.NominalDiffTime
retryDelay = 2

data Env job = Env
   { jobs :: TVar (Map Id (Meta, job))
   , queued :: TVar (Set (Meta, Id))
   , worker :: TMVar (UUID, (Id, Meta, job) -> STM ())
   , active :: TVar Bool
   }

acqEnv :: A.Acquire (Env job)
acqEnv = do
   jobs <- R.mkAcquire1 (newTVarIO mempty) \t -> do
      atomically $ writeTVar t mempty
   queued <- R.mkAcquire1 (newTVarIO mempty) \t -> do
      atomically $ writeTVar t mempty
   worker <- R.mkAcquire1 newEmptyTMVarIO \t ->
      atomically $ void $ tryTakeTMVar t
   active <- R.mkAcquire1 (newTVarIO True) \tv ->
      atomically $ writeTVar tv False -- TODO wait until no workers running?
   pure Env{..}

ensureActive :: Env job -> STM ()
ensureActive env =
   readTVar env.active >>= \case
      True -> pure ()
      False -> throwSTM $ resourceVanished "Job.Memory.queue"

connect1 :: forall job. Env job -> Time.UTCTime -> STM (Maybe Id)
connect1 env now =
   readTVar env.active >>= \case
      False -> pure Nothing
      True ->
         nextJob >>= mapM \x@(i, _, _) -> do
            (_, f) <- takeTMVar env.worker
            f x >> pure i
  where
   nextJob :: STM (Maybe (Id, Meta, job))
   nextJob = do
      q0 <- readTVar env.queued
      forM (Set.minView q0) \((m0@Meta{..}, i), q1) -> do
         writeTVar env.queued q1
         let m = Meta{alive = Just now, ..}
         jobs0 <- readTVar env.jobs
         (!jobs1, yj) <- flip runStateT Nothing do
            Map.alterF
               ( mapM \(m1, j) -> do
                  when (m0 /= m1) $ Ex.throwString "m0 /= m1"
                  put (Just j)
                  pure (m, j)
               )
               i
               jobs0
         case yj of
            Just j -> writeTVar env.jobs jobs1 >> pure (i, m, j)
            Nothing -> Ex.throwString "job in queue but not in map"

connectMany :: forall job. Env job -> IO [Id]
connectMany env = fmap ($ []) (go id)
  where
   go :: ([Id] -> [Id]) -> IO ([Id] -> [Id])
   go f = do
      -- We perform each connect1 in a separate transaction
      -- so that we don't hog concurrent access to the Env.
      now <- Time.getCurrentTime
      atomically (connect1 env now) >>= \case
         Just i -> go ((i :) . f)
         Nothing -> pure f

-- | An in-memory 'Queue'.
queue :: forall job. A.Acquire (Queue job)
queue = do
   env <- acqEnv
   void do
      -- This background thread perform 'connectMany' every 60 seconds.
      -- Not really necessary, just in case a timer died for some reason.
      R.mkAcquire1
         ( As.async $ forever do
            threadDelay 60_000_000
            void $ connectMany env
         )
         As.uninterruptibleCancel
   pure
      Queue
         { push = \nice wait job -> do
            let m = Meta{nice, wait, alive = Nothing, try = 0}
            i <- newId
            atomically do
               ensureActive env
               modifyTVar' env.jobs $ Map.insert i (m, job)
               modifyTVar' env.queued $ Set.insert (m, i)
            void $ As.async do
               threadDelayUTCTime wait
               void $ connectMany env
            pure i
         , ---------
           prune = \f ->
            atomically do
               ensureActive env
               jobs0 <- readTVar env.jobs
               let (js, qs, a) =
                     List.foldl'
                        ( \(!js0, !qs0, !al) (i, (m, j)) ->
                           case f i m j of
                              (False, ar) -> (js0, qs0, al <> ar)
                              (True, ar) ->
                                 ( (i, (m, j)) : js0
                                 , maybe ((m, i) : qs0) (const qs0) m.alive
                                 , al <> ar
                                 )
                        )
                        mempty
                        $ List.sortOn (\(i, (m, _)) -> (m, i))
                        $ Map.toList jobs0
               writeTVar env.jobs $! Map.fromList js
               writeTVar env.queued $! Set.fromList qs
               pure a
         , ---------
           pull = runMaybeT do
            liftIO (readTVarIO env.active) >>= \case
               False -> MaybeT $ pure Nothing
               True -> pure ()
            tout :: TVar (Maybe (Maybe (Nice, Time.UTCTime))) <-
               liftIO $ newTVarIO Nothing
            (i :: Id, meta :: Meta, job :: job) <- MaybeT do
               R.mkAcquireType1
                  ( do
                     k0 <- UUID7.genUUID
                     Ex.bracketOnError
                        ( atomically do
                           readTVar env.active >>= \case
                              False -> pure Nothing
                              True -> do
                                 tj <- newEmptyTMVar
                                 -- Blocks until we become next worker
                                 putTMVar env.worker (k0, putTMVar tj)
                                 pure (Just tj)
                        )
                        ( mapM_ \_ -> atomically do
                           tryTakeTMVar env.worker >>= \case
                              Just (k1, f)
                                 | k0 /= k1 ->
                                    putTMVar env.worker (k1, f)
                              _ -> pure ()
                        )
                        ( \case
                           Nothing -> pure Nothing
                           Just tj -> atomically do
                              readTVar env.active >>= \case
                                 False -> pure Nothing
                                 True ->
                                    -- Blocks until we get job input
                                    Just <$> takeTMVar tj
                        )
                  )
                  ( \yx rt -> forM_ yx \(i, m0, job) -> do
                     ym1 <-
                        readTVarIO tout >>= \case
                           -- no 'retry', and no 'finish',
                           -- and release with exception.
                           Nothing
                              | A.ReleaseExceptionWith _ <- rt -> do
                                 now <- Time.getCurrentTime
                                 let alive = Nothing
                                     try = succ m0.try
                                     nice = m0.nice
                                     wait = Time.addUTCTime retryDelay now
                                 pure $ Just Meta{..}
                           -- explicit 'retry'.
                           Just (Just (nice, wait)) -> do
                              let alive = Nothing
                                  try = succ m0.try
                              pure $ Just Meta{..}
                           -- explicit 'finish',
                           -- or release without exception.
                           _ -> pure Nothing
                     case ym1 of
                        Nothing ->
                           atomically $ modifyTVar' env.jobs $ Map.delete i
                        Just !m1 -> do
                           atomically do
                              ensureActive env
                              modifyTVar' env.queued $ Set.insert (m1, i)
                              modifyTVar' env.jobs $
                                 Map.insert i (m1, job)
                           void $ As.async do
                              threadDelayUTCTime m1.wait
                              void $ connectMany env
                  )
            -- While working on this job, send heartbeats
            void $ lift do
               R.mkAcquire1
                  ( As.async $ fix \again -> do
                     eactive <- Ex.tryAny do
                        threadDelayMicro keepAliveBeat
                        now <- Time.getCurrentTime
                        let m1
                              | Meta{..} <- meta =
                                 Meta{alive = Just now, ..}
                        atomically do
                           a <- readTVar env.active
                           when a do
                              modifyTVar' env.jobs $
                                 Map.insert i (m1, job)
                           pure a
                     case eactive of
                        Right False -> pure ()
                        _ -> again
                  )
                  As.uninterruptibleCancel
            pure
               Work
                  { id = i
                  , job
                  , meta
                  , retry = \n w ->
                     atomically $ writeTVar tout $ Just (Just (n, w))
                  , finish = atomically $ writeTVar tout $ Just Nothing
                  }
         }

--------------------------------------------------------------------------------

-- | Like 'threadDelay', but waits until a specified 'Time.UTCTime'.
threadDelayUTCTime :: Time.UTCTime -> IO ()
threadDelayUTCTime wait = do
   t <- registerDelayUTCTime wait
   atomically $ readTVar t >>= check

-- | Like 'registerDelay', but waits until a specified 'Time.UTCTime'.
registerDelayUTCTime :: Time.UTCTime -> IO (TVar Bool)
registerDelayUTCTime wait = do
   start <- Time.getCurrentTime
   if wait <= start
      then newTVarIO True
      else do
         tvtmp <-
            registerDelayMicro $
               ceilingPicoToMicro $
                  Time.nominalDiffTimeToSeconds $
                     Time.diffUTCTime wait start
         tvout <- newTVarIO False
         void $ Ex.mask_ $ As.async do
            atomically $ readTVar tvtmp >>= check
            fix \again -> do
               now <- Time.getCurrentTime
               if wait <= now
                  then atomically $ writeTVar tvout True
                  else do
                     -- Only happens if there were leap seconds involved.
                     -- We just loop innefficiently for a bit.
                     threadDelay 10_000 -- 10 milliseconds
                     again
         pure tvout

ceilingPicoToMicro :: Pico -> Micro
ceilingPicoToMicro (MkFixed p) = MkFixed (ceiling (toRational p / 1_000_000))

-- | Like 'registerDelay', but not limited to @'maxBound' :: 'Int'@.
registerDelayMicro :: Micro -> IO (TVar Bool)
registerDelayMicro us
   | us <= 0 = newTVarIO True
   | otherwise = do
      tv <- liftIO $ newTVarIO False
      void $ Ex.mask_ $ As.async do
         threadDelayMicro us
         atomically $ writeTVar tv True
      pure tv

-- | Like 'threadDelay', but not limited to @'maxBound' :: 'Int'@.
threadDelayMicro :: Micro -> IO ()
threadDelayMicro = fix \again us -> when (us > 0) do
   let d = floor (min us stepMax * 1_000_000)
   threadDelay d
   again (us - stepMax)
  where
   stepMax :: Micro
   stepMax = MkFixed (fromIntegral (maxBound :: Int))

--------------------------------------------------------------------------------

resourceVanished :: (HasCallStack) => String -> IOError
resourceVanished s =
   (userError s)
      { ioe_location = prettyCallStack (popCallStack callStack)
      , ioe_type = ResourceVanished
      }