cachix-1.12.1: src/Cachix/Daemon/PushManager.hs
module Cachix.Daemon.PushManager
( newPushManagerEnv,
runPushManager,
drainPushManager,
closePushManager,
-- * Push strategy
newPushStrategy,
-- * Push job
PushJob (..),
newPushJob,
addPushJob,
addPushJobFromRequest,
lookupPushJob,
withPushJob,
resolvePushJob,
pendingJobCount,
-- * Query
filterPushJobs,
getFailedPushJobs,
failPushJob,
failPendingJobs,
-- * Store paths
queueStorePaths,
removeStorePath,
queuedStorePathCount,
-- * Tasks
handleTask,
-- * Push events
pushStarted,
pushFinished,
pushStorePathAttempt,
pushStorePathProgress,
pushStorePathDone,
pushStorePathFailed,
-- * Batch processor
startBatchProcessor,
stopBatchProcessor,
-- * Helpers
atomicallyWithTimeout,
)
where
import Cachix.Client.CNix (formatStorePathError, logStorePathWarning, resolveStorePaths)
import Cachix.Client.Command.Push hiding (pushStrategy)
import Cachix.Client.OptionsParser as Client.OptionsParser
( PushOptions (..),
)
import Cachix.Client.Push as Client.Push
import Cachix.Client.Retry (retryAll)
import Cachix.Daemon.NarinfoQuery qualified as NarinfoQuery
import Cachix.Daemon.Protocol qualified as Protocol
import Cachix.Daemon.PushManager.PushJob qualified as PushJob
import Cachix.Daemon.ShutdownLatch qualified as ShutdownLatch
import Cachix.Daemon.TaskQueue
import Cachix.Daemon.Types.Log (Logger)
import Cachix.Daemon.Types.PushEvent (PushEvent (..), PushEventMessage (..), newPushRetryStatus)
import Cachix.Daemon.Types.PushManager
import Cachix.Types.BinaryCache qualified as BinaryCache
import Conduit qualified as C
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.MVar qualified as MVar
import Control.Concurrent.STM.TVar
import Control.Monad.Catch qualified as E
import Control.Monad.IO.Unlift (MonadUnliftIO)
import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)
import Control.Retry (RetryStatus, rsIterNumber)
import Data.ByteString qualified as BS
import Data.HashMap.Strict qualified as HashMap
import Data.IORef
import Data.Sequence qualified as Seq
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Time (UTCTime, diffUTCTime, getCurrentTime, secondsToNominalDiffTime)
import GHC.Clock (getMonotonicTimeNSec)
import Hercules.CNix (StorePath)
import Hercules.CNix.Store (Store, parseStorePath, storePathToPath)
import Katip qualified
import Protolude hiding (toS)
import Protolude.Conv (toS)
import Servant.Auth ()
import Servant.Auth.Client
import Servant.Conduit ()
import UnliftIO.QSem qualified as QSem
newPushManagerEnv :: (MonadIO m) => PushOptions -> NarinfoQuery.NarinfoQueryOptions -> PushParams PushManager () -> OnPushEvent -> Logger -> m PushManagerEnv
newPushManagerEnv pushOptions batchOptions pmPushParams onPushEvent pmLogger = liftIO $ do
pmPushJobs <- newTVarIO mempty
pmPendingJobCount <- newTVarIO 0
pmStorePathIndex <- newTVarIO mempty
pmJobLock <- MVar.newMVar ()
pmTaskQueue <- atomically newTaskQueue
pmTaskSemaphore <- QSem.newQSem (numJobs pushOptions)
pmLastEventTimestamp <- newTVarIO =<< getCurrentTime
let pmProgressEmitIntervalNs = 200 * 1000 * 1000
let pmOnPushEvent id pushEvent = updateTimestampTVar pmLastEventTimestamp >> onPushEvent id pushEvent
-- Create query manager with callback that queues ProcessQueryResponse tasks
let batchCallback requestId response = do
atomically $ writeTask pmTaskQueue $ HandleMissingPathsResponse requestId response
pmNarinfoQueryManager <- NarinfoQuery.new batchOptions batchCallback
pmShutdownLatch <- ShutdownLatch.newShutdownLatch
return $ PushManagerEnv {..}
runPushManager :: (MonadIO m) => PushManagerEnv -> PushManager a -> m a
runPushManager env f = liftIO $ unPushManager f `runReaderT` env
-- | Set the shutdown latch (rejecting new jobs), then wait for all in-flight jobs to complete with an idle timeout.
--
-- Returns True if all jobs completed, False if no progress was observed within the timeout window.
drainPushManager :: TimeoutOptions -> PushManagerEnv -> IO Bool
drainPushManager timeoutOptions PushManagerEnv {..} = do
ShutdownLatch.initiateShutdown () pmShutdownLatch
-- Reset the last event timestamp to start off the countdown from here
updateTimestampTVar pmLastEventTimestamp
atomicallyWithTimeout timeoutOptions pmLastEventTimestamp $ do
pendingJobs <- readTVar pmPendingJobCount
check (pendingJobs <= 0)
-- | Close the task queue so workers see it as closed and exit.
closePushManager :: PushManagerEnv -> IO ()
closePushManager PushManagerEnv {..} =
atomically $ closeTaskQueue pmTaskQueue
-- | Start the batch processor for narinfo queries
startBatchProcessor :: (MonadUnliftIO m, Katip.KatipContext m) => PushManagerEnv -> m ()
startBatchProcessor env@PushManagerEnv {pmNarinfoQueryManager} = do
NarinfoQuery.start pmNarinfoQueryManager $ \paths ->
runPushManager env (processBatchedNarinfo paths)
where
-- Process a batch of store paths for narinfo queries
processBatchedNarinfo :: [StorePath] -> PushManager ([StorePath], [StorePath])
processBatchedNarinfo storePaths = do
pushParams <- asks pmPushParams
queryNarInfoBulk pushParams storePaths
-- | Stop the batch processor
stopBatchProcessor :: (MonadIO m) => PushManagerEnv -> m ()
stopBatchProcessor PushManagerEnv {pmNarinfoQueryManager} = do
NarinfoQuery.stop pmNarinfoQueryManager
-- Manage push jobs
newPushJob :: (MonadIO m) => Protocol.PushRequest -> m PushJob
newPushJob = PushJob.new
addPushJob :: PushJob -> PushManager Bool
addPushJob pushJob = do
PushManagerEnv {..} <- ask
let pushId = PushJob.pushId pushJob
Katip.logLocM Katip.DebugS $ Katip.ls $ "Queued push job " <> (show pushId :: Text)
let queueJob = do
shuttingDown <- ShutdownLatch.isShuttingDownSTM pmShutdownLatch
if shuttingDown
then return False
else do
modifyTVar' pmPushJobs $ HashMap.insert pushId pushJob
incrementTVar pmPendingJobCount
res <- tryWriteTask pmTaskQueue $ QueryMissingPaths pushId
case res of
Just True -> return True
_ -> return False
didQueue <- liftIO $ atomically queueJob
unless didQueue $
Katip.logLocM Katip.WarningS "Failed to queue push job. Queue likely full."
return didQueue
addPushJobFromRequest :: Protocol.PushRequest -> PushManager (Maybe Protocol.PushRequestId)
addPushJobFromRequest pushRequest = do
pushJob <- newPushJob pushRequest
success <- addPushJob pushJob
return $ if success then Just (PushJob.pushId pushJob) else Nothing
removePushJob :: Protocol.PushRequestId -> PushManager ()
removePushJob pushId = do
PushManagerEnv {..} <- ask
liftIO $ atomically $ do
mpushJob <- HashMap.lookup pushId <$> readTVar pmPushJobs
for_ mpushJob $ \pushJob -> do
-- Decrement the job count if this job had not been processed yet
unless (PushJob.isProcessed pushJob) (decrementTVar pmPendingJobCount)
modifyTVar' pmPushJobs (HashMap.delete pushId)
lookupPushJob :: Protocol.PushRequestId -> PushManager (Maybe PushJob)
lookupPushJob pushId = do
pushJobs <- asks pmPushJobs
liftIO $ HashMap.lookup pushId <$> readTVarIO pushJobs
filterPushJobs :: (PushJob -> Bool) -> PushManager [PushJob]
filterPushJobs f = do
pushJobs <- asks pmPushJobs
liftIO $ filter f . HashMap.elems <$> readTVarIO pushJobs
getFailedPushJobs :: PushManager [PushJob]
getFailedPushJobs = filterPushJobs PushJob.isFailed
withPushJob :: Protocol.PushRequestId -> (PushJob -> PushManager ()) -> PushManager ()
withPushJob pushId f =
maybe handleMissingPushJob f =<< lookupPushJob pushId
where
handleMissingPushJob =
Katip.logLocM Katip.ErrorS $ Katip.ls $ "Push job " <> (show pushId :: Text) <> " not found"
-- | Apply an update to many push jobs atomically. Jobs that are already in a
-- terminal state are left untouched. After the update, a job transitions to
-- a terminal state if either the update made it processed directly or it
-- leaves 'pushQueue' empty. Jobs that reach a terminal state are removed from
-- the store path index so that in-flight uploads stop reporting to them.
-- Returns the jobs that were updated; use 'PushJob.isProcessed' to pick out
-- the ones that finished in this call.
--
-- Mark, completion check, index cleanup, and pending-counter decrement happen
-- in one STM transaction so concurrent workers cannot race past the
-- empty-queue check.
applyPushJobUpdates ::
(Foldable t) =>
t Protocol.PushRequestId ->
(UTCTime -> PushJob -> PushJob) ->
PushManager [PushJob]
applyPushJobUpdates pushIds update = do
PushManagerEnv {pmPushJobs, pmPendingJobCount, pmStorePathIndex} <- ask
ts <- liftIO getCurrentTime
liftIO $ atomically $ do
jobs <- readTVar pmPushJobs
let (jobs', updated) = foldl' (step ts) (jobs, []) pushIds
finished = filter PushJob.isProcessed updated
writeTVar pmPushJobs jobs'
modifyTVar' pmPendingJobCount (subtract (length finished))
unless (null finished) $ do
let finishedIds = Set.fromList (map PushJob.pushId finished)
modifyTVar' pmStorePathIndex $ HashMap.map (Seq.filter (`Set.notMember` finishedIds))
pure updated
where
step ts (!jobs, acc) pushId =
case HashMap.lookup pushId jobs of
Just job
| not (PushJob.isProcessed job) ->
let job' = transitionIfDone ts (update ts job)
in (HashMap.insert pushId job' jobs, job' : acc)
_ -> (jobs, acc)
transitionIfDone ts job
| PushJob.isProcessed job = job
| Set.null (PushJob.pushQueue job) =
if PushJob.hasFailedPaths job
then PushJob.fail ts job
else PushJob.complete ts job
| otherwise = job
failPushJob :: Protocol.PushRequestId -> Text -> PushManager ()
failPushJob pushId reason = void $ failPushJobs [pushId] reason
-- | Mark every non-terminal job as failed and emit failure events for their
-- remaining paths.
failPendingJobs :: Text -> PushManager [PushJob]
failPendingJobs reason = do
pmPushJobs <- asks pmPushJobs
allIds <- HashMap.keys <$> liftIO (readTVarIO pmPushJobs)
failPushJobs allIds reason
-- | Mark the given jobs as failed, emitting a 'PushStorePathFailed' event for
-- each path that will no longer be pushed, followed by 'PushFinished' so that
-- subscribers stop waiting. The jobs are dropped from the store path index at
-- the same time, so uploads that are still in flight do not emit any further
-- events for them. Failed jobs stay in 'pmPushJobs' so a later
-- 'getFailedPushJobs' call sees them and the daemon exits with the right
-- code; the natural path removes jobs via 'pushFinished'.
failPushJobs :: (Foldable t) => t Protocol.PushRequestId -> Text -> PushManager [PushJob]
failPushJobs pushIds reason = withJobLock $ do
failedJobs <- applyPushJobUpdates pushIds PushJob.fail
ts <- liftIO getCurrentTime
sendPushEvent <- asks pmOnPushEvent
for_ failedJobs $ \job -> do
let pid = PushJob.pushId job
for_ (unpushedPaths job) $ \path ->
sendStorePathEventAt ts [pid] (PushStorePathFailed path reason)
liftIO $ sendPushEvent pid (PushEvent ts pid PushFinished)
pure failedJobs
where
-- A job that fails before closure resolution has an empty queue; fall
-- back to the requested paths so subscribers still see the failure.
unpushedPaths job
| isNothing (PushJob.startedAt job) = Set.fromList $ Protocol.storePaths (pushRequest job)
| otherwise = PushJob.pushQueue job
pendingJobCount :: PushManager Int
pendingJobCount = do
pmPendingJobCount <- asks pmPendingJobCount
liftIO $ readTVarIO pmPendingJobCount
-- Manage store paths
queueStorePaths :: Protocol.PushRequestId -> [FilePath] -> PushManager ()
queueStorePaths pushId storePaths = do
PushManagerEnv {..} <- ask
let addToQueue storePath = do
isDuplicate <- HashMap.member storePath <$> readTVar pmStorePathIndex
unless isDuplicate $
writeTask pmTaskQueue (PushPath storePath)
modifyTVar' pmStorePathIndex $ HashMap.insertWith (<>) storePath (Seq.singleton pushId)
transactionally $ map addToQueue storePaths
removeStorePath :: FilePath -> PushManager ()
removeStorePath storePath = do
storePathIndex <- asks pmStorePathIndex
liftIO $ atomically $ do
modifyTVar' storePathIndex $ HashMap.delete storePath
lookupStorePathIndex :: FilePath -> PushManager (Seq.Seq Protocol.PushRequestId)
lookupStorePathIndex storePath = do
storePathIndex <- asks pmStorePathIndex
references <- liftIO $ readTVarIO storePathIndex
return $ fromMaybe Seq.empty (HashMap.lookup storePath references)
queuedStorePathCount :: PushManager Integer
queuedStorePathCount = do
pmPushJobs <- asks pmPushJobs
jobs <- liftIO $ readTVarIO pmPushJobs
pure $ foldl' countQueuedPaths 0 (HashMap.elems jobs)
where
countQueuedPaths acc job = acc + fromIntegral (Set.size $ pushQueue job)
resolvePushJob :: Protocol.PushRequestId -> PushJob.ResolvedClosure FilePath -> PushManager ()
resolvePushJob pushId closure = withJobLock $ do
Katip.logLocM Katip.DebugS $ Katip.ls $ showClosureStats closure
updatedJobs <- applyPushJobUpdates [pushId] (PushJob.populateQueue closure)
-- A job that already reached a terminal state (e.g. failed during shutdown)
-- must not start pushing, or it would emit events after 'PushFinished'.
when (null updatedJobs) $
Katip.logLocM Katip.DebugS $
Katip.ls $
"Push job " <> (show pushId :: Text) <> " is no longer active, ignoring resolved closure"
for_ updatedJobs $ \pushJob -> do
pushStarted pushJob
let skippedPaths = Set.difference (PushJob.rcAllPaths closure) (PushJob.rcMissingPaths closure)
ts <- liftIO getCurrentTime
forM_ skippedPaths $ \path ->
sendStorePathEventAt ts [pushId] (PushStorePathSkipped path)
queueStorePaths pushId $ Set.toList (PushJob.rcMissingPaths closure)
when (PushJob.isProcessed pushJob) $
pushFinished pushJob
where
showClosureStats :: PushJob.ResolvedClosure FilePath -> Text
showClosureStats PushJob.ResolvedClosure {..} =
let skippedPaths = Set.difference rcAllPaths rcMissingPaths
queuedCount = length rcMissingPaths
skippedCount = length skippedPaths
totalCount = queuedCount + skippedCount
in T.intercalate
"\n"
[ "Resolved push job " <> show pushId,
"Total paths: " <> show totalCount,
"Queued paths: " <> show queuedCount,
"Skipped paths: " <> show skippedCount
]
handleTask :: Task -> PushManager ()
handleTask task = do
pushParams <- asks pmPushParams
case task of
QueryMissingPaths pushId ->
runQueryMissingPathsTask pushParams pushId
HandleMissingPathsResponse pushId batchResponse ->
runHandleMissingPathsResponseTask pushParams pushId batchResponse
PushPath filePath ->
runPushPathTask pushParams filePath
runQueryMissingPathsTask :: PushParams PushManager () -> Protocol.PushRequestId -> PushManager ()
runQueryMissingPathsTask pushParams pushId =
resolveClosure `withException` failJob
where
failJob :: SomeException -> PushManager ()
failJob err = do
failPushJob pushId $ "Failed to resolve closure: " <> toS (displayException err)
Katip.katipAddContext (Katip.sl "error" (displayException err)) $
Katip.logLocM Katip.ErrorS $
Katip.ls $
"Failed to resolve closure for push job " <> (show pushId :: Text)
resolveClosure = do
Katip.logLocM Katip.DebugS $ Katip.ls $ "Resolving closure for push job " <> (show pushId :: Text)
withPushJob pushId $ \pushJob -> do
let sps = Protocol.storePaths (pushRequest pushJob)
store = pushParamsStore pushParams
-- Resolve paths and track which ones are invalid
(errors, validPaths) <- liftIO $ resolveStorePaths store sps
-- Log warnings for invalid paths
liftIO $ for_ errors $ uncurry logStorePathWarning
-- Emit PushStorePathInvalid events for invalid paths
ts <- liftIO getCurrentTime
forM_ errors $ \(path, err) ->
sendStorePathEventForActiveJobsAt ts [pushId] (PushStorePathInvalid path (formatStorePathError err))
paths <- computeClosure store validPaths
-- Use async batch manager for narinfo queries (non-blocking)
batchManager <- asks pmNarinfoQueryManager
NarinfoQuery.submitRequest batchManager pushId paths
runHandleMissingPathsResponseTask :: PushParams PushManager () -> Protocol.PushRequestId -> NarinfoQuery.NarinfoResponse -> PushManager ()
runHandleMissingPathsResponseTask pushParams pushId batchResponse =
processQueryResponse `withException` failJob
where
failJob :: SomeException -> PushManager ()
failJob err = do
failPushJob pushId $ "Failed to query missing paths: " <> toS (displayException err)
Katip.katipAddContext (Katip.sl "error" (displayException err)) $
Katip.logLocM Katip.ErrorS $
Katip.ls $
"Failed to process batch response for push job " <> (show pushId :: Text)
processQueryResponse = do
Katip.logLocM Katip.DebugS $ Katip.ls $ "Processing batch response for push job " <> (show pushId :: Text)
let allStorePaths = Set.toList $ NarinfoQuery.nrAllPaths batchResponse
missingStorePaths = Set.toList $ NarinfoQuery.nrMissingPaths batchResponse
store = pushParamsStore pushParams
storePathsToPush <- pushOnClosureAttempt pushParams allStorePaths missingStorePaths
resolvedClosure <- do
allPaths <- liftIO $ mapM (storeToFilePath store) allStorePaths
pathsToPush <- liftIO $ mapM (storeToFilePath store) storePathsToPush
return $
PushJob.ResolvedClosure
{ rcAllPaths = Set.fromList allPaths,
rcMissingPaths = Set.fromList pathsToPush
}
resolvePushJob pushId resolvedClosure
runPushPathTask :: PushParams PushManager () -> FilePath -> PushManager ()
runPushPathTask pushParams filePath = do
pushStorePath `withException` failStorePath
where
failStorePath =
pushStorePathFailed filePath . toS . displayException
pushStorePath = do
qs <- asks pmTaskSemaphore
E.bracket_ (QSem.waitQSem qs) (QSem.signalQSem qs) $ do
Katip.logLocM Katip.DebugS $ Katip.ls $ "Pushing store path " <> filePath
let store = pushParamsStore pushParams
storePath <- liftIO $ parseStorePath store (toS filePath)
retryAll $ uploadStorePath pushParams storePath
newPushStrategy ::
Store ->
Maybe Token ->
PushOptions ->
Text ->
BinaryCache.CompressionMethod ->
(StorePath -> PushStrategy PushManager ())
newPushStrategy store authToken opts cacheName compressionMethod storePath =
let onAlreadyPresent = do
sp <- liftIO $ storePathToPath store storePath
Katip.logFM Katip.InfoS $ Katip.ls $ "Skipping " <> (toS sp :: Text)
-- TODO: needs another event type here
pushStorePathDone (toS sp)
onError err = do
let errText = toS (displayException err)
sp <- liftIO $ storePathToPath store storePath
Katip.katipAddContext (Katip.sl "error" errText) $
Katip.logFM Katip.InfoS (Katip.ls $ "Failed to push " <> (toS sp :: Text))
pushStorePathFailed (toS sp) errText
onAttempt retryStatus size = do
sp <- liftIO $ storePathToPath store storePath
let retryContext =
if rsIterNumber retryStatus > 0
then Katip.katipAddContext (Katip.sl "retry" (rsIterNumber retryStatus))
else identity
retryContext $
Katip.logFM Katip.InfoS $
Katip.ls $
"Pushing " <> (toS sp :: Text)
pushStorePathAttempt (toS sp) size retryStatus
onUncompressedNARStream _ size = do
sp <- liftIO $ storePathToPath store storePath
progressEmitIntervalNs <- asks pmProgressEmitIntervalNs
lastEmitNsRef <- liftIO $ newIORef =<< getMonotonicTimeNSec
currentBytesRef <- liftIO $ newIORef (0 :: Int64)
lastEmittedBytesRef <- liftIO $ newIORef (0 :: Int64)
C.awaitForever $ \chunk -> do
let newBytes = fromIntegral (BS.length chunk)
currentBytes <- liftIO $ atomicModifyIORef' currentBytesRef (\b -> (b + newBytes, b + newBytes))
lastEmitNs <- liftIO $ readIORef lastEmitNsRef
nowNs <- liftIO getMonotonicTimeNSec
when (nowNs - lastEmitNs >= progressEmitIntervalNs || currentBytes == size) $ do
liftIO $ writeIORef lastEmitNsRef nowNs
lastEmitted <- liftIO $ readIORef lastEmittedBytesRef
let emitBytes = currentBytes - lastEmitted
liftIO $ writeIORef lastEmittedBytesRef currentBytes
when (emitBytes > 0) $
lift $
lift $
pushStorePathProgress (toS sp) currentBytes emitBytes
C.yield chunk
onDone = do
sp <- liftIO $ storePathToPath store storePath
Katip.logFM Katip.InfoS $ Katip.ls $ "Pushed " <> (toS sp :: Text)
pushStorePathDone (toS sp)
in PushStrategy
{ onAlreadyPresent = onAlreadyPresent,
on401 = liftIO . handleCacheResponse cacheName authToken,
onError = onError,
onAttempt = onAttempt,
onUncompressedNARStream = onUncompressedNARStream,
onDone = onDone,
Client.Push.compressionMethod = compressionMethod,
Client.Push.compressionLevel = Client.OptionsParser.compressionLevel opts,
Client.Push.chunkSize = Client.OptionsParser.chunkSize opts,
Client.Push.numConcurrentChunks = Client.OptionsParser.numConcurrentChunks opts,
Client.Push.omitDeriver = Client.OptionsParser.omitDeriver opts
}
-- Push events
pushStarted :: PushJob -> PushManager ()
pushStarted pushJob@PushJob {pushId} = do
case PushJob.startedAt pushJob of
Nothing -> return ()
Just timestamp -> do
sendPushEvent <- asks pmOnPushEvent
liftIO $ do
sendPushEvent pushId $
PushEvent timestamp pushId PushStarted
pushFinished :: PushJob -> PushManager ()
pushFinished pushJob@PushJob {pushId} = void $ runMaybeT $ do
let defaultDuration = 0
pushDuration <- MaybeT $ pure $ Just (fromMaybe defaultDuration $ PushJob.duration pushJob)
defaultCompletedAt <- liftIO getCurrentTime
completedAt <- MaybeT $ pure $ Just (fromMaybe defaultCompletedAt $ PushJob.completedAt pushJob)
Katip.logLocM Katip.InfoS $
Katip.ls $
T.intercalate
" "
[ "Push job",
show pushId :: Text,
"finished in",
show pushDuration
]
sendPushEvent <- asks pmOnPushEvent
liftIO $ do
sendPushEvent pushId $
PushEvent completedAt pushId PushFinished
lift $ removePushJob pushId
sendStorePathEvent :: (Foldable f) => f Protocol.PushRequestId -> PushEventMessage -> PushManager ()
sendStorePathEvent pushIds msg = do
timestamp <- liftIO getCurrentTime
sendStorePathEventAt timestamp pushIds msg
sendStorePathEventAt :: (Foldable f) => UTCTime -> f Protocol.PushRequestId -> PushEventMessage -> PushManager ()
sendStorePathEventAt timestamp pushIds msg = do
sendPushEvent <- asks pmOnPushEvent
liftIO $ forM_ pushIds $ \pushId ->
sendPushEvent pushId (PushEvent timestamp pushId msg)
pushStorePathAttempt :: FilePath -> Int64 -> RetryStatus -> PushManager ()
pushStorePathAttempt storePath size retryStatus = withJobLock $ do
let pushRetryStatus = newPushRetryStatus retryStatus
pushIds <- lookupStorePathIndex storePath
sendStorePathEvent pushIds (PushStorePathAttempt storePath size pushRetryStatus)
pushStorePathProgress :: FilePath -> Int64 -> Int64 -> PushManager ()
pushStorePathProgress storePath currentBytes newBytes = withJobLock $ do
pushIds <- lookupStorePathIndex storePath
sendStorePathEvent pushIds (PushStorePathProgress storePath currentBytes newBytes)
pushStorePathDone :: FilePath -> PushManager ()
pushStorePathDone storePath =
finishStorePath storePath PushJob.markStorePathPushed (PushStorePathDone storePath)
pushStorePathFailed :: FilePath -> Text -> PushManager ()
pushStorePathFailed storePath errMsg =
finishStorePath storePath PushJob.markStorePathFailed (PushStorePathFailed storePath errMsg)
-- | Record the outcome of a store path push on every job that is still
-- waiting for it, then emit the event to those jobs only. Jobs that already
-- reached a terminal state (e.g. failed during shutdown) receive nothing, so
-- 'PushFinished' stays the last event a subscriber sees.
finishStorePath :: FilePath -> (FilePath -> PushJob -> PushJob) -> PushEventMessage -> PushManager ()
finishStorePath storePath markStorePath msg = withJobLock $ do
pushIds <- lookupStorePathIndex storePath
updatedJobs <- applyPushJobUpdates pushIds (\_ -> markStorePath storePath)
sendStorePathEvent (map PushJob.pushId updatedJobs) msg
for_ (filter PushJob.isProcessed updatedJobs) pushFinished
removeStorePath storePath
-- Helpers
-- | Run a job operation without interleaving its state changes and events with
-- another job operation. STM keeps the internal state consistent; this lock
-- also keeps the externally visible event stream consistent with that state.
withJobLock :: PushManager a -> PushManager a
withJobLock action = do
env@PushManagerEnv {pmJobLock} <- ask
liftIO $ MVar.withMVar pmJobLock $ \_ -> runPushManager env action
sendStorePathEventForActiveJobsAt :: (Foldable f) => UTCTime -> f Protocol.PushRequestId -> PushEventMessage -> PushManager ()
sendStorePathEventForActiveJobsAt timestamp pushIds msg = withJobLock $ do
pushJobs <- asks pmPushJobs >>= liftIO . readTVarIO
let isActive pushId = maybe False (not . PushJob.isProcessed) $ HashMap.lookup pushId pushJobs
sendStorePathEventAt timestamp (filter isActive $ toList pushIds) msg
storeToFilePath :: (MonadIO m) => Store -> StorePath -> m FilePath
storeToFilePath store storePath = do
fp <- liftIO $ storePathToPath store storePath
pure $ toS fp
withException :: (E.MonadCatch m) => m a -> (SomeException -> m a) -> m a
withException action handler = action `E.catchAll` (\e -> handler e >> E.throwM e)
-- STM helpers
transactionally :: (Foldable t, MonadIO m) => t (STM ()) -> m ()
transactionally = liftIO . atomically . sequence_
updateTimestampTVar :: (MonadIO m) => TVar UTCTime -> m ()
updateTimestampTVar tvar = liftIO $ do
now <- getCurrentTime
atomically $ writeTVar tvar now
incrementTVar :: TVar Int -> STM ()
incrementTVar tvar = modifyTVar' tvar (+ 1)
decrementTVar :: TVar Int -> STM ()
decrementTVar tvar = modifyTVar' tvar (subtract 1)
-- | Run a transaction with a timeout.
-- Returns True if the transaction completed, False if timed out.
atomicallyWithTimeout ::
TimeoutOptions ->
-- | A TVar timestamp to compare against
TVar UTCTime ->
-- | The transaction to run
STM () ->
IO Bool
atomicallyWithTimeout TimeoutOptions {..} timeVar transaction = do
timeoutVar <- newTVarIO False
Async.withAsync (updateShutdownTimeout timeoutVar) $ \_ ->
waitForGracefulShutdown timeoutVar
where
waitForGracefulShutdown timeout =
atomically $
(transaction >> return True)
`orElse` (checkShutdownTimeout timeout >> return False)
updateShutdownTimeout timeoutVar =
forever $ do
now <- getCurrentTime
atomically $ do
timestamp <- readTVar timeVar
let isTimeout =
secondsToNominalDiffTime (realToFrac toTimeout) <= now `diffUTCTime` timestamp
writeTVar timeoutVar isTimeout
threadDelay $ ceiling (toPollingInterval * 1000.0 * 1000.0)
checkShutdownTimeout timeout = check =<< readTVar timeout