cachix 1.11.0 → 1.11.1
raw patch · 18 files changed
+447/−235 lines, 18 filesdep +http-media
Dependencies added: http-media
Files
- CHANGELOG.md +16/−0
- cachix.cabal +2/−1
- src/Cachix/Client/Command/Cache.hs +2/−3
- src/Cachix/Client/Command/Config.hs +3/−5
- src/Cachix/Client/Command/Doctor.hs +4/−4
- src/Cachix/Client/Command/Pin.hs +3/−5
- src/Cachix/Client/Command/Push.hs +2/−2
- src/Cachix/Client/Command/Watch.hs +9/−2
- src/Cachix/Client/Push.hs +7/−8
- src/Cachix/Client/Push/S3.hs +2/−2
- src/Cachix/Client/Retry.hs +190/−94
- src/Cachix/Daemon.hs +93/−41
- src/Cachix/Daemon/Listen.hs +1/−2
- src/Cachix/Daemon/Log.hs +13/−1
- src/Cachix/Daemon/Push.hs +7/−4
- src/Cachix/Daemon/PushManager.hs +81/−57
- src/Cachix/Daemon/Types/Daemon.hs +5/−4
- src/Cachix/Daemon/Worker.hs +7/−0
CHANGELOG.md view
@@ -4,6 +4,22 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).+## [1.11.1] - 2026-04-29++### Added++- client: support reading the retry_after field in "problem details" response bodies++### Changed++- client: increase the default number of retries for HTTP requests+- client: always check for the Retry-After header in error responses, not just for certain status codes++### Fixed++- client: add missing retries to backend endpoints to help handle intermittent network issues+- daemon: remove risk of race conditions when checking for job completion+- daemon: fail any pending jobs on shutdown timeout ## [1.11.0] - 2026-03-20
cachix.cabal view
@@ -1,6 +1,6 @@ cabal-version: 2.2 name: cachix-version: 1.11.0+version: 1.11.1 synopsis: Command-line client for Nix binary cache hosting https://cachix.org @@ -161,6 +161,7 @@ , http-client , http-client-tls , http-conduit+ , http-media , http-types , immortal , inline-c-cpp
src/Cachix/Client/Command/Cache.hs view
@@ -8,18 +8,17 @@ import Cachix.Client.Exception (CachixException (..)) import Cachix.Client.InstallationMode qualified as InstallationMode import Cachix.Client.NixVersion (assertNixVersion)-import Cachix.Client.Retry (retryHttp)+import Cachix.Client.Retry (retryClientM) import Cachix.Client.Servant import Protolude hiding (toS) import Servant.Auth.Client-import Servant.Client.Streaming use :: Env -> Text -> InstallationMode.UseOptions -> IO () use env name useOptions = do optionalAuthToken <- Config.getAuthTokenMaybe (config env) let token = fromMaybe (Token "") optionalAuthToken -- 1. get cache public key- res <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token name+ res <- retryClientM (clientenv env) $ API.getCache cachixClient token name case res of Left err -> Push.handleCacheResponse name optionalAuthToken err Right binaryCache -> do
src/Cachix/Client/Command/Config.hs view
@@ -7,7 +7,7 @@ import Cachix.API.Error import Cachix.Client.Config qualified as Config import Cachix.Client.Env (Env (..))-import Cachix.Client.Retry (retryHttp)+import Cachix.Client.Retry (retryClientM) import Cachix.Client.Secrets ( SigningKey (SigningKey), exportSigningKey,@@ -23,7 +23,6 @@ import Protolude.Conv import Servant.API (NoContent (..)) import Servant.Auth.Client-import Servant.Client.Streaming -- TODO: check that token actually authenticates! authtoken :: Env -> Maybe Text -> IO ()@@ -42,9 +41,8 @@ bcc = Config.BinaryCacheConfig name signingKey -- we first validate if key can be added to the binary cache (_ :: NoContent) <-- escalate <=< retryHttp $- (`runClientM` clientenv env) $- API.createKey cachixClient authToken name signingKeyCreate+ escalate+ =<< retryClientM (clientenv env) (API.createKey cachixClient authToken name signingKeyCreate) -- if key was successfully added, write it to the config -- TODO: warn if binary cache with the same key already exists let cfg = config env & Config.setBinaryCaches [bcc]
src/Cachix/Client/Command/Doctor.hs view
@@ -4,7 +4,7 @@ import Cachix.Client.Config qualified as Config import Cachix.Client.Env (Env (..)) import Cachix.Client.OptionsParser (DaemonOptions (..), DoctorOptions (..))-import Cachix.Client.Retry (retryHttp)+import Cachix.Client.Retry (retryClientM) import Cachix.Client.Servant (cachixClient, isErr) import Cachix.Daemon.Listen (getSocketPath) import Cachix.Daemon.Protocol qualified as Protocol@@ -21,7 +21,7 @@ import Protolude hiding (toS) import Protolude.Conv import Servant.Auth.Client-import Servant.Client.Streaming (ClientError, runClientM)+import Servant.Client.Streaming (ClientError) import System.Directory (doesFileExist) import System.Environment (lookupEnv) import System.Timeout (timeout)@@ -220,7 +220,7 @@ let hasSigningKey = any (\c -> Config.name c == cacheName && not (T.null (Config.secretKey c))) configuredCaches -- Try to get cache info (validates auth and connectivity)- cacheRes <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token cacheName+ cacheRes <- retryClientM (clientenv env) $ API.getCache cachixClient token cacheName case cacheRes of Right bc ->@@ -343,7 +343,7 @@ } Just storeHash -> do -- Use narinfoBulk to check if the path exists- result <- retryHttp $ (`runClientM` clientenv env) $ API.narinfoBulk cachixClient token cacheName [storeHash]+ result <- retryClientM (clientenv env) $ API.narinfoBulk cachixClient token cacheName [storeHash] case result of Right missingHashes -> let status = if storeHash `elem` missingHashes then NotInCache else InCache
src/Cachix/Client/Command/Pin.hs view
@@ -7,14 +7,13 @@ import Cachix.Client.Env (Env (..)) import Cachix.Client.Exception (CachixException (..)) import Cachix.Client.OptionsParser (PinOptions (..))-import Cachix.Client.Retry (retryHttp)+import Cachix.Client.Retry (retryClientM) import Cachix.Client.Servant import Cachix.Types.PinCreate qualified as PinCreate import Data.Text qualified as T import Hercules.CNix.Store (storePathToPath, withStore) import Protolude hiding (toS) import Protolude.Conv-import Servant.Client.Streaming import System.Directory (doesFileExist) pin :: Env -> PinOptions -> IO ()@@ -36,9 +35,8 @@ keep = pinKeep pinOpts } void $- escalate <=< retryHttp $- (`runClientM` clientenv env) $- API.createPin cachixClient authToken (pinCacheName pinOpts) pinCreate+ escalate+ =<< retryClientM (clientenv env) (API.createPin cachixClient authToken (pinCacheName pinOpts) pinCreate) where validateArtifact :: Text -> Text -> IO () validateArtifact storePath artifact = do
src/Cachix/Client/Command/Push.hs view
@@ -19,7 +19,7 @@ import Cachix.Client.HumanSize (humanSize) import Cachix.Client.OptionsParser as Options (PushOptions (..)) import Cachix.Client.Push as Push-import Cachix.Client.Retry (retryHttp)+import Cachix.Client.Retry (retryClientM) import Cachix.Client.Secrets import Cachix.Client.Servant import Cachix.Types.BinaryCache (BinaryCacheName)@@ -137,7 +137,7 @@ compressionMethodBackend <- case pushSecret of PushSigningKey {} -> pure Nothing PushToken token -> do- res <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token name+ res <- retryClientM (clientenv env) $ API.getCache cachixClient token name case res of Left err -> handleCacheResponse name authToken err Right binaryCache -> pure (Just $ BinaryCache.preferredCompressionMethod binaryCache)
src/Cachix/Client/Command/Watch.hs view
@@ -7,6 +7,7 @@ where import Cachix.Client.Command.Push+import Cachix.Client.Config qualified as Config import Cachix.Client.Env (Env (..)) import Cachix.Client.InstallationMode qualified as InstallationMode import Cachix.Client.OptionsParser@@ -19,6 +20,7 @@ import Cachix.Client.Push import Cachix.Client.WatchStore qualified as WatchStore import Cachix.Daemon qualified as Daemon+import Cachix.Daemon.Log qualified as Daemon.Log import Cachix.Daemon.NarinfoQuery (NarinfoQueryOptions) import Cachix.Daemon.PostBuildHook qualified as Daemon.PostBuildHook import Cachix.Daemon.Progress qualified as Daemon.Progress@@ -87,9 +89,14 @@ daemonKeepAliveInterval = defaultKeepAliveInterval, daemonKeepAliveTimeout = defaultKeepAliveTimeout }- daemon <- Daemon.new env store daemonOptions (Just logHandle) pushOpts cacheName+ let logLevel =+ if Config.verbose (cachixoptions env)+ then Daemon.Log.Debug+ else Daemon.Log.Info+ logger <- Daemon.Log.new Daemon.Log.namespace (Just logHandle) logLevel+ exitCode <- Daemon.Log.withLogger logger $ \registeredLogger -> do+ daemon <- Daemon.new env store daemonOptions registeredLogger pushOpts cacheName - exitCode <- bracket (startDaemonThread daemon) (shutdownDaemonThread daemon logHandle) $ \_ -> do processEnv <- getEnvironment let newProcessEnv = Daemon.PostBuildHook.modifyEnv (Daemon.PostBuildHook.envVar hookEnv) processEnv
src/Cachix/Client/Push.hs view
@@ -49,7 +49,7 @@ import Cachix.Client.CNix (formatStorePathWarning, validateStorePath) import Cachix.Client.Exception (CachixException (..)) import Cachix.Client.Push.S3 qualified as Push.S3-import Cachix.Client.Retry (retryAll, retryHttp)+import Cachix.Client.Retry (retryAll, retryClientM, retryHttp) import Cachix.Client.Secrets import Cachix.Client.Servant import Cachix.Types.BinaryCache qualified as BinaryCache@@ -188,13 +188,12 @@ narinfoExists pushParams storeHash = do let cacheName = pushParamsName pushParams authToken = getCacheAuthToken (pushParamsSecret pushParams)- retryHttp $- (`runClientM` pushParamsClientEnv pushParams) $- API.narinfoHead- cachixClient- authToken- cacheName- (NarInfoHash.NarInfoHash (decodeUtf8With lenientDecode storeHash))+ retryClientM (pushParamsClientEnv pushParams) $+ API.narinfoHead+ cachixClient+ authToken+ cacheName+ (NarInfoHash.NarInfoHash (decodeUtf8With lenientDecode storeHash)) getCacheAuthToken :: PushSecret -> Token getCacheAuthToken (PushToken token) = token
src/Cachix/Client/Push/S3.hs view
@@ -12,7 +12,7 @@ import Cachix.API qualified as API import Cachix.API.Error-import Cachix.Client.Retry (retryHttp)+import Cachix.Client.Retry (retryClientM, retryHttp) import Cachix.Client.Servant (cachixClient) import Cachix.Types.BinaryCache import Cachix.Types.MultipartUpload qualified as Multipart@@ -93,7 +93,7 @@ createMultipartUpload :: ConduitT ByteString Void m (Either ClientError Multipart.CreateMultipartUploadResponse) createMultipartUpload = do let createNarRequest = API.createNar cachixClient authToken cacheName (Just (compressionMethod options))- liftIO $ retryHttp $ runClientM createNarRequest env+ liftIO $ retryClientM env createNarRequest uploadPart :: UUID -> Text -> (Int, ByteString) -> m (Maybe Multipart.CompletedPart) uploadPart narId uploadId (partNumber, !part) = do
src/Cachix/Client/Retry.hs view
@@ -1,19 +1,25 @@ module Cachix.Client.Retry- ( retryAll,- retryAllWithPolicy,+ ( -- * Whole-operation retry+ retryAll,++ -- * HTTP retry retryHttp,- retryHttpWith,- endlessRetryPolicy,+ retryClientM,++ -- * Policies endlessConstantRetryPolicy, ) where -import Cachix.Client.Exception (CachixException (..))+import Cachix.Client.Exception (CachixException) import Control.Concurrent.Async qualified as Async import Control.Exception.Safe (Handler (..)) import Control.Monad.Catch (MonadCatch, MonadMask, handleJust, throwM) import Control.Retry+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy qualified as LBS import Data.List (lookup)+import Data.Text qualified as T import Data.Time ( UTCTime, defaultTimeLocale,@@ -25,31 +31,48 @@ ) import GHC.Read (Read (readPrec)) import Network.HTTP.Client qualified as HTTP-import Network.HTTP.Types.Header (hRetryAfter)+import Network.HTTP.Media qualified as Media+import Network.HTTP.Types.Header (Header, hContentType, hRetryAfter) import Network.HTTP.Types.Status qualified as HTTP import Protolude hiding (Handler (..), handleJust)-import Servant.Client (ClientError (..))+import Servant.Client (ClientEnv, ClientError (..)) import Servant.Client qualified as Servant+import Servant.Client.Streaming (ClientM, runClientM) import Text.ParserCombinators.ReadPrec qualified as ReadPrec (lift) -defaultRetryPolicy :: RetryPolicy-defaultRetryPolicy =- exponentialBackoff (1000 * 1000) <> limitRetries 5+-- Policies +-- | Per-HTTP-request policy. Wall-time bounded so the user-visible delay is+-- predictable regardless of how many attempts fit. Jittered exponential+-- backoff to avoid thundering-herd on shared origins (CDN incidents).+httpRetryPolicy :: (MonadIO m) => RetryPolicyM m+httpRetryPolicy =+ limitRetriesByCumulativeDelay (secondsToMicros 90) $+ capDelay (secondsToMicros 15) $+ fullJitterBackoff (secondsToMicros 1)++-- | Whole-operation policy used by 'retryAll'. Small attempt count with a+-- short delay, since each attempt re-runs the entire operation (e.g. a full+-- NAR re-upload). Compounds with 'httpRetryPolicy' on HTTP errors, so+-- keep this tight to bound worst-case wall time per store path.+retryAllPolicy :: RetryPolicy+retryAllPolicy =+ constantDelay (secondsToMicros 5) <> limitRetries 3++-- | Constant 1s delay, no attempt cap. For callers that own their own+-- termination condition (e.g. websocket reconnect loops). endlessConstantRetryPolicy :: RetryPolicy endlessConstantRetryPolicy =- constantDelay (1000 * 1000)+ constantDelay (secondsToMicros 1) -endlessRetryPolicy :: RetryPolicy-endlessRetryPolicy =- exponentialBackoff (1000 * 1000)+-- Whole-operation retry +-- | Retry an entire operation on any synchronous exception, except for+-- 'ExitCode' and 'CachixException' (which are fatal). Use for operations+-- that re-run the full unit of work, like a NAR upload. retryAll :: (MonadIO m, MonadMask m) => (RetryStatus -> m a) -> m a-retryAll = retryAllWithPolicy defaultRetryPolicy--retryAllWithPolicy :: (MonadIO m, MonadMask m) => RetryPolicyM m -> (RetryStatus -> m a) -> m a-retryAllWithPolicy policy f =- recovering policy handlers $+retryAll f =+ recovering retryAllPolicy handlers $ rethrowLinkedThreadExceptions . f where handlers = skipAsyncExceptions ++ [exitCodeHandler, cachixExceptionsHandler, allHandler]@@ -63,83 +86,43 @@ -- Retry everything else allHandler _ = Handler $ \(_ :: SomeException) -> return True --- | Unwrap 'Async.ExceptionInLinkedThread' exceptions and rethrow the inner exception.-rethrowLinkedThreadExceptions :: (MonadCatch m) => m a -> m a-rethrowLinkedThreadExceptions =- handleJust unwrapLinkedThreadException throwM--unwrapLinkedThreadException :: SomeException -> Maybe SomeException-unwrapLinkedThreadException e- | Just (Async.ExceptionInLinkedThread _ e') <- fromException e = Just e'- | otherwise = Nothing--retryHttp :: (MonadIO m, MonadMask m) => m a -> m a-retryHttp = retryHttpWith defaultRetryPolicy+-- HTTP retry --- | Retry policy for HTTP requests.+-- | Retry an HTTP operation that signals failure by throwing+-- 'HTTP.HttpException' or Servant 'ClientError'. Honors @Retry-After@ headers+-- and Cloudflare problem-details @retry_after@ bodies. ----- Retries a subset of HTTP exceptions and overrides the delay with the Retry-After header if present.-retryHttpWith :: forall m a. (MonadIO m, MonadMask m) => RetryPolicyM m -> m a -> m a-retryHttpWith policy = recoveringDynamic policy handlers . const+-- For Servant calls that return @Either ClientError a@ (e.g. 'runClientM'),+-- use 'retryClientM' instead — 'retryHttp' only triggers on thrown errors.+retryHttp :: (MonadIO m, MonadMask m) => m a -> m a+retryHttp = recoveringDynamic httpRetryPolicy handlers . const where- handlers :: [RetryStatus -> Handler m RetryAction] handlers =- skipAsyncExceptions' ++ [retryHttpExceptions, retryClientExceptions]-- skipAsyncExceptions' = map (fmap toRetryAction .) skipAsyncExceptions-- retryHttpExceptions _ = Handler httpExceptionToRetryHandler- retryClientExceptions _ = Handler clientExceptionToRetryHandler-- httpExceptionToRetryHandler :: HTTP.HttpException -> m RetryAction- httpExceptionToRetryHandler (HTTP.HttpExceptionRequest _ (HTTP.StatusCodeException response _))- | statusMayHaveRetryHeader (HTTP.responseStatus response) = overrideDelayWithRetryAfter response- httpExceptionToRetryHandler ex = return . toRetryAction . shouldRetryHttpException $ ex-- clientExceptionToRetryHandler :: ClientError -> m RetryAction- clientExceptionToRetryHandler (FailureResponse _req res)- | shouldRetryHttpStatusCode (Servant.responseStatusCode res) =- return ConsultPolicy- clientExceptionToRetryHandler (ConnectionError ex) =- case fromException ex of- Just httpException -> httpExceptionToRetryHandler httpException- Nothing -> return DontRetry- clientExceptionToRetryHandler _ = return DontRetry--data RetryAfter- = RetryAfterDate UTCTime- | RetryAfterSeconds Int- deriving (Eq, Show)--instance Read RetryAfter where- readPrec = parseSeconds <|> parseWebDate- where- parseSeconds = RetryAfterSeconds <$> readPrec- parseWebDate = ReadPrec.lift $ RetryAfterDate <$> readPTime True defaultTimeLocale rfc822DateFormat+ map (fmap toRetryAction .) skipAsyncExceptions+ ++ [ \_ -> Handler httpExceptionToRetryAction,+ \_ -> Handler clientErrorToRetryAction+ ] -overrideDelayWithRetryAfter :: (MonadIO m) => HTTP.Response a -> m RetryAction-overrideDelayWithRetryAfter response =- case lookupRetryAfter response of- Nothing ->- return ConsultPolicy- Just (RetryAfterSeconds seconds) ->- return $ ConsultPolicyOverrideDelay (seconds * 1000 * 1000)- Just (RetryAfterDate date) -> do- seconds <- secondsFromNow date- return $- if seconds > 0- then ConsultPolicyOverrideDelay (seconds * 1000 * 1000)- else ConsultPolicy+-- | Run a Servant client action with HTTP retries.+--+-- 'runClientM' returns 'Either ClientError a' instead of throwing, so wrapping+-- it with 'retryHttp' would never trigger the retry handlers. This inspects+-- the return value directly, sharing the same retry decision logic as+-- 'retryHttp'.+retryClientM :: (MonadIO m, NFData a) => ClientEnv -> ClientM a -> m (Either ClientError a)+retryClientM env req =+ retryingDynamic httpRetryPolicy checkResult $ const $ liftIO (runClientM req env) where- secondsFromNow date = do- now <- liftIO getCurrentTime- return $ ceiling $ nominalDiffTimeToSeconds (date `diffUTCTime` now)-- lookupRetryAfter = readMaybe . decodeUtf8 <=< lookup hRetryAfter . HTTP.responseHeaders+ checkResult _ (Right _) = pure DontRetry+ checkResult _ (Left err) = clientErrorToRetryAction err --- | Determine whether the HTTP exception is worth retrying.+-- Retry decisions ----- Temporary connection or network transfer errors are good candidates.+-- Decide whether a given failure is worth retrying, and at what delay. Shared+-- between 'retryHttp' (exception path) and 'retryClientM' (Either path).++-- | Whether an HTTP exception is worth retrying. Temporary connection or+-- network transfer errors are good candidates. shouldRetryHttpException :: HTTP.HttpException -> Bool shouldRetryHttpException (HTTP.InvalidUrlException _ _) = False shouldRetryHttpException (HTTP.HttpExceptionRequest _ reason) =@@ -162,11 +145,124 @@ HTTP.HttpZlibException _ -> True _ -> False --- | Determine whether the HTTP status code is worth retrying.+-- | Whether an HTTP status code is worth retrying. 429 and any 5xx. shouldRetryHttpStatusCode :: HTTP.Status -> Bool-shouldRetryHttpStatusCode code | code == HTTP.tooManyRequests429 = True-shouldRetryHttpStatusCode code | HTTP.statusIsServerError code = True-shouldRetryHttpStatusCode _ = False+shouldRetryHttpStatusCode code+ | code == HTTP.tooManyRequests429 = True+ | HTTP.statusIsServerError code = True+ | otherwise = False -statusMayHaveRetryHeader :: HTTP.Status -> Bool-statusMayHaveRetryHeader = flip elem [HTTP.tooManyRequests429, HTTP.serviceUnavailable503]+-- | Convert an 'HTTP.HttpException' to a 'RetryAction', honoring any 'Retry-After' hints.+httpExceptionToRetryAction :: (MonadIO m) => HTTP.HttpException -> m RetryAction+httpExceptionToRetryAction ex@(HTTP.HttpExceptionRequest _ (HTTP.StatusCodeException response body))+ | shouldRetryHttpException ex =+ let headers = HTTP.responseHeaders response+ in overrideDelayWithRetryAfter $+ lookupRetryAfter headers+ <|> lookupRetryAfterBody headers (LBS.fromStrict body)+httpExceptionToRetryAction ex = pure $ toRetryAction $ shouldRetryHttpException ex++-- | Convert a 'ClientError' to a 'RetryAction', honoring any 'Retry-After' hints.+clientErrorToRetryAction :: (MonadIO m) => ClientError -> m RetryAction+clientErrorToRetryAction (FailureResponse _req res)+ | shouldRetryHttpStatusCode (Servant.responseStatusCode res) =+ let headers = toList (Servant.responseHeaders res)+ in overrideDelayWithRetryAfter $+ lookupRetryAfter headers+ <|> lookupRetryAfterBody headers (Servant.responseBody res)+clientErrorToRetryAction (ConnectionError ex) =+ case fromException ex of+ Just httpException -> httpExceptionToRetryAction httpException+ Nothing -> pure DontRetry+clientErrorToRetryAction _ = pure DontRetry++-- Retry-After+--+-- Parse and honor server hints about when to retry,+data RetryAfter+ = RetryAfterDate UTCTime+ | RetryAfterSeconds Int+ deriving (Eq, Show)++instance Read RetryAfter where+ readPrec = parseSeconds <|> parseWebDate+ where+ parseSeconds = RetryAfterSeconds <$> readPrec+ parseWebDate = ReadPrec.lift $ RetryAfterDate <$> readPTime True defaultTimeLocale rfc822DateFormat++-- | Honor a parsed 'RetryAfter', clamped to 'maxRetryAfterMicros'. Negative+-- or past-dated values fall back to the policy's own delay.+overrideDelayWithRetryAfter :: (MonadIO m) => Maybe RetryAfter -> m RetryAction+overrideDelayWithRetryAfter Nothing = pure ConsultPolicy+overrideDelayWithRetryAfter (Just (RetryAfterSeconds s))+ | s <= 0 = pure ConsultPolicy+ | otherwise = pure $ ConsultPolicyOverrideDelay (clampMicros (secondsToMicros s))+overrideDelayWithRetryAfter (Just (RetryAfterDate date)) = do+ s <- secondsFromNow date+ pure $+ if s > 0+ then ConsultPolicyOverrideDelay (clampMicros (secondsToMicros s))+ else ConsultPolicy+ where+ secondsFromNow d = do+ now <- liftIO getCurrentTime+ pure $ ceiling $ nominalDiffTimeToSeconds (d `diffUTCTime` now)++-- | Bound a delay (in microseconds) by 'maxRetryAfterMicros'.+clampMicros :: Int -> Int+clampMicros micros+ | micros < 0 = maxRetryAfterMicros+ | otherwise = min micros maxRetryAfterMicros++lookupRetryAfter :: [Header] -> Maybe RetryAfter+lookupRetryAfter = readMaybe . T.unpack . decodeUtf8With lenientDecode <=< lookup hRetryAfter++-- | Parse @retry_after@ (in seconds) from an @application/problem+json@ body.+--+-- @retry_after@ is not part of RFC 9457 (which only standardizes @type@,+-- @status@, @title@, @detail@, @instance@), but Cloudflare returns it as an+-- extension member on origin 5xx error pages instead of the canonical+-- 'Retry-After' HTTP header. Honoring it lets us back off the right amount+-- during transient origin outages.+lookupRetryAfterBody :: [Header] -> LBS.ByteString -> Maybe RetryAfter+lookupRetryAfterBody headers body = do+ ct <- lookup hContentType headers+ -- Some origins serve problem-details bodies with the bare @application/json@+ -- content type, so accept that too.+ _ <- Media.matchContent acceptableContentTypes ct+ pd <- Aeson.decode' body+ RetryAfterSeconds <$> problemRetryAfter pd+ where+ acceptableContentTypes =+ ["application" Media.// "problem+json", "application" Media.// "json"]++newtype ProblemDetails = ProblemDetails {problemRetryAfter :: Maybe Int}++instance Aeson.FromJSON ProblemDetails where+ parseJSON = Aeson.withObject "ProblemDetails" $ \o ->+ ProblemDetails <$> o Aeson..:? "retry_after"++-- Time++-- | Convert seconds to microseconds (the unit 'threadDelay' and the @retry@+-- package use for delays).+secondsToMicros :: Int -> Int+secondsToMicros = (* 1_000_000)++-- | Cap on a server-supplied 'Retry-After' override. Bounds the per-attempt+-- sleep so a hostile or buggy origin can't park the client for a long time.+maxRetryAfterMicros :: Int+maxRetryAfterMicros = secondsToMicros 120++-- Linked-thread exception unwrapping++-- | Unwrap 'Async.ExceptionInLinkedThread' and rethrow the inner exception so+-- retry handlers can match on the actual cause.+rethrowLinkedThreadExceptions :: (MonadCatch m) => m a -> m a+rethrowLinkedThreadExceptions =+ handleJust unwrapLinkedThreadException throwM++unwrapLinkedThreadException :: SomeException -> Maybe SomeException+unwrapLinkedThreadException e+ | Just (Async.ExceptionInLinkedThread _ e') <- fromException e = Just e'+ | otherwise = Nothing
src/Cachix/Daemon.hs view
@@ -19,7 +19,7 @@ import Cachix.Client.OptionsParser (DaemonOptions, PushOptions, daemonNarinfoQueryOptions) import Cachix.Client.OptionsParser qualified as Options import Cachix.Client.Push-import Cachix.Client.Retry (retryHttp)+import Cachix.Client.Retry (retryClientM) import Cachix.Client.Servant (cachixClient) import Cachix.Daemon.EventLoop qualified as EventLoop import Cachix.Daemon.Listen as Listen@@ -36,7 +36,7 @@ import Cachix.Types.BinaryCache (BinaryCacheName) import Cachix.Types.BinaryCache qualified as BinaryCache import Control.Concurrent.STM.TMChan-import Control.Exception.Safe (catchAny)+import Control.Exception.Safe (catchAny, tryAny) import Data.IORef (IORef, atomicModifyIORef', newIORef) import Data.Text qualified as T import Hercules.CNix.Store (Store, withStore)@@ -45,16 +45,19 @@ import Network.Socket qualified as Socket import Network.Socket.ByteString qualified as Socket.BS import Protolude hiding (bracket)-import Servant.Client.Streaming (runClientM) import System.Environment (lookupEnv) import System.IO.Error (isResourceVanishedError) import System.Posix.Process (getProcessID) import System.Posix.Signals qualified as Signal+import System.Timeout (timeout) import UnliftIO (MonadUnliftIO, withRunInIO) import UnliftIO.Async qualified as Async import UnliftIO.Exception (bracket) -- | Configure a new daemon. Use 'run' to start it.+--+-- The caller must provide a logger with scribes already registered (e.g. by+-- wrapping the call in 'Log.withLogger'). new :: -- | The Cachix environment. Env ->@@ -62,20 +65,16 @@ Store -> -- | Daemon-specific options. DaemonOptions ->- -- | An optional handle to output logs to.- Maybe Handle ->+ -- | A logger with registered scribes.+ Log.Logger -> -- | Push options, like compression settings and number of jobs. PushOptions -> -- | The name of the binary cache to push to. BinaryCacheName -> -- | The configured daemon environment. IO DaemonEnv-new daemonEnv nixStore daemonOptions daemonLogHandle daemonPushOptions daemonCacheName = do- let daemonLogLevel =- if Config.verbose (Env.cachixoptions daemonEnv)- then Debug- else Info- daemonLogger <- Log.new "cachix.daemon" daemonLogHandle daemonLogLevel+new daemonEnv nixStore daemonOptions daemonLogger daemonPushOptions daemonCacheName = do+ Log.logMsg daemonLogger Katip.InfoS "Starting Cachix Daemon" daemonEventLoop <- EventLoop.new daemonPid <- getProcessID@@ -89,7 +88,7 @@ daemonPushSecret <- Command.Push.getPushSecretRequired (config daemonEnv) daemonCacheName let authToken = getAuthTokenFromPushSecret daemonPushSecret- daemonBinaryCache <- Push.getBinaryCache daemonEnv authToken daemonCacheName+ daemonBinaryCache <- Push.getBinaryCache daemonLogger daemonEnv authToken daemonCacheName daemonSubscriptionManagerThread <- newEmptyMVar daemonSubscriptionManager <- Subscription.newSubscriptionManager@@ -111,15 +110,20 @@ start :: Env -> DaemonOptions -> PushOptions -> BinaryCacheName -> IO () start daemonEnv daemonOptions daemonPushOptions daemonCacheName = withStore $ \store -> do- daemon <- new daemonEnv store daemonOptions Nothing daemonPushOptions daemonCacheName- void $ runDaemon daemon installSignalHandlers- result <- run daemon+ let logLevel =+ if Config.verbose (Env.cachixoptions daemonEnv)+ then Debug+ else Info+ logger <- Log.new Log.namespace Nothing logLevel+ result <- Log.withLogger logger $ \registeredLogger -> do+ daemon <- new daemonEnv store daemonOptions registeredLogger daemonPushOptions daemonCacheName+ void $ runDaemon daemon installSignalHandlers+ run daemon exitWith (toExitCode result) -- | Run a daemon from a given configuration. run :: DaemonEnv -> IO (Either DaemonError ()) run daemon = fmap join <$> runDaemon daemon $ do- Katip.logFM Katip.InfoS "Starting Cachix Daemon" DaemonEnv {..} <- ask printConfiguration@@ -185,7 +189,7 @@ authResult <- case maybeToken of Nothing -> pure $ Left "No auth token configured" Just token -> do- res <- liftIO $ retryHttp $ (`runClientM` clientenv daemonEnv) $ API.getCache cachixClient token daemonCacheName+ res <- liftIO $ retryClientM (clientenv daemonEnv) $ API.getCache cachixClient token daemonCacheName pure $ case res of Right _ -> Right () Left err -> Left (show err)@@ -330,20 +334,18 @@ DaemonEnv {..} <- ask -- 1. Drain: set latch (reject new jobs), wait for in-flight jobs to complete- drainWithLogging daemonPushManager-- -- 2. Stop the batch processor (safe: all jobs completed the full pipeline)- liftIO $ PushManager.stopBatchProcessor daemonPushManager-- -- 3. Close the task queue- liftIO $ PushManager.closePushManager daemonPushManager-- -- 4. Stop worker threads (workers see closed queue and exit)- withTakeMVar daemonWorkerThreads Worker.stopWorkers+ drained <- drainWithLogging daemonPushManager - -- 5. Close all event subscriptions- withTakeMVar daemonSubscriptionManagerThread (shutdownSubscriptions daemonSubscriptionManager)+ -- Fail any remaining jobs that didn't finish after the drain timeout+ unless drained $ do+ stuck <-+ PushManager.runPushManager daemonPushManager $+ PushManager.failPendingJobs "Daemon stopped before this path finished uploading"+ Katip.logFM Katip.WarningS $+ Katip.logStr+ ("Failed " <> show (length stuck) <> " stuck jobs after drain timeout." :: Text) + -- 2. Compute the exit result now so clients see the real exit code, even if worker cleanup below blocks on a stuck upload. failedJobs <- PushManager.runPushManager daemonPushManager PushManager.getFailedPushJobs let pushResult =@@ -351,9 +353,22 @@ then Right () else Left DaemonPushFailure - -- 6. Gracefully close open connections to clients+ -- 3. Stop the batch processor and close the task queue so workers exit after their current task.+ liftIO $ PushManager.stopBatchProcessor daemonPushManager+ liftIO $ PushManager.closePushManager daemonPushManager++ -- 4. Disconnect clients with a goodbye message that includes the push result Async.mapConcurrently_ (sayGoodbye daemonClients pushResult) =<< SocketStore.toList daemonClients + -- 5. Stop worker threads. If drain succeeded, workers are idle and exit+ -- immediately on the closed queue. If drain timed out, workers are stuck+ -- mid-upload; interrupt them so the daemon process can exit promptly.+ withTakeMVar daemonWorkerThreads $+ if drained then Worker.stopWorkers else Worker.abortWorkers++ -- 6. Close all event subscriptions+ withTakeMVar daemonSubscriptionManagerThread (shutdownSubscriptions daemonSubscriptionManager)+ return pushResult where drainWithLogging daemonPushManager = do@@ -373,6 +388,7 @@ if drained then Katip.logFM Katip.DebugS "Push manager drained." else Katip.logFM Katip.WarningS "Push manager drain timed out. Some jobs may not have completed."+ pure drained shutdownSubscriptions daemonSubscriptionManager subscriptionManagerThread = do Katip.logFM Katip.DebugS "Shutting down event manager..."@@ -382,19 +398,55 @@ sayGoodbye socketStore exitResult socket = do let clientSock = SocketStore.socket socket- let clientThread = SocketStore.handlerThread socket- let clientSocketId = SocketStore.socketId socket+ clientThread = SocketStore.handlerThread socket+ clientSocketId = SocketStore.socketId socket++ -- Stop the handler first so we have exclusive read access to the socket+ -- below. sendAll uses an independent sendLock, so the bye message still+ -- goes out cleanly. Async.cancel clientThread - -- Wave goodbye to the client that requested the shutdown- liftIO $ Listen.serverBye clientSocketId socketStore exitResult- liftIO $ Socket.shutdown clientSock Socket.ShutdownBoth `catchAny` (\_ -> return ())- -- Wait for the other end to disconnect- ebs <- liftIO $ try $ Socket.BS.recv clientSock 4096- case ebs of- Left err | isResourceVanishedError err -> Katip.logFM Katip.DebugS "Client did not disconnect cleanly."- Left err -> Katip.logFM Katip.DebugS $ Katip.ls $ "Client socket threw an error: " <> displayException err- Right _ -> Katip.logFM Katip.DebugS "Client disconnected."+ sendResult <-+ liftIO $+ tryAny $+ Listen.serverBye clientSocketId socketStore exitResult+ case sendResult of+ Left err ->+ Katip.logFM Katip.WarningS $+ Katip.ls $+ "Failed to send DaemonExit: " <> displayException err+ Right () -> pure ()++ -- Half-close the write side. Pending bytes (the bye message) flush to+ -- the client; the client reads them, then sees EOF.+ liftIO $ safeShutdown clientSock Socket.ShutdownSend++ -- Bounded wait for the client to read the bye message and close its end.+ mDisconnect <-+ liftIO $+ timeout disconnectTimeoutUs $+ try $+ Socket.BS.recv clientSock 4096+ case mDisconnect of+ Nothing ->+ Katip.logFM Katip.DebugS "Client did not disconnect within timeout."+ Just (Left err)+ | isResourceVanishedError err ->+ Katip.logFM Katip.DebugS "Client did not disconnect cleanly."+ Just (Left err) ->+ Katip.logFM Katip.DebugS $+ Katip.ls $+ "Client socket threw an error: " <> displayException err+ Just (Right _) ->+ Katip.logFM Katip.DebugS "Client disconnected."++ liftIO $ safeShutdown clientSock Socket.ShutdownBoth++ safeShutdown sock mode =+ Socket.shutdown sock mode `catchAny` \_ -> pure ()++ disconnectTimeoutUs :: Int+ disconnectTimeoutUs = 5_000_000 withTakeMVar :: (MonadUnliftIO m) => MVar a -> (a -> m ()) -> m () withTakeMVar mvar f = do
src/Cachix/Daemon/Listen.hs view
@@ -23,7 +23,6 @@ ) import Cachix.Daemon.Types.EventLoop (EventLoop) import Cachix.Daemon.Types.SocketStore (SocketId, SocketStore)-import Control.Exception.Safe (catchAny) import Control.Monad.Catch qualified as E import Data.Aeson qualified as Aeson import Data.ByteString qualified as BS@@ -117,7 +116,7 @@ serverBye :: SocketId -> SocketStore -> Either DaemonError () -> IO () serverBye socketId socketStore exitResult =- SocketStore.sendAll socketId (Protocol.newMessage (DaemonExit exitStatus)) socketStore `catchAny` (\_ -> return ())+ SocketStore.sendAll socketId (Protocol.newMessage (DaemonExit exitStatus)) socketStore where exitStatus = DaemonExitStatus {exitCode, exitMessage} exitCode = toExitCodeInt exitResult
src/Cachix/Daemon/Log.hs view
@@ -1,6 +1,8 @@ module Cachix.Daemon.Log ( new, withLogger,+ namespace,+ logMsg, getKatipNamespace, getKatipContext, getKatipLogEnv,@@ -22,12 +24,22 @@ import Katip.Scribes.Handle (brackets, colorBySeverity, getKeys) import Protolude +-- | The default namespace for the Cachix daemon logger.+namespace :: Katip.Namespace+namespace = "cachix.daemon"+ new :: (MonadIO m) => Katip.Namespace -> Maybe Handle -> LogLevel -> m Logger new logLabel logHandle logLevel = do logKLogEnv <- liftIO $ Katip.initLogEnv logLabel ""- let logKNamespace = mempty+ let logKNamespace = logLabel let logKContext = mempty return $ Logger {..}++-- | Log a single message using a 'Logger' from outside the 'Katip' monad.+logMsg :: (MonadIO m) => Logger -> Katip.Severity -> Katip.LogStr -> m ()+logMsg logger sev msg =+ Katip.runKatipT (logKLogEnv logger) $+ Katip.logMsg (logKNamespace logger) sev msg withLogger :: (MonadIO m, E.MonadMask m) => Logger -> (Logger -> m a) -> m a withLogger logger@(Logger {..}) f = do
src/Cachix/Daemon/Push.hs view
@@ -12,14 +12,16 @@ ( PushOptions (..), ) import Cachix.Client.Push as Client.Push-import Cachix.Client.Retry (retryHttp)+import Cachix.Client.Retry (retryClientM) import Cachix.Client.Servant+import Cachix.Daemon.Log qualified as Log import Cachix.Daemon.PushManager qualified as PushManager import Cachix.Daemon.Types (PushManager) import Cachix.Types.BinaryCache (BinaryCache, BinaryCacheName) import Cachix.Types.BinaryCache qualified as BinaryCache import Data.Set qualified as Set import Hercules.CNix.Store (Store)+import Katip qualified import Protolude hiding (toS) import Servant.Auth () import Servant.Auth.Client@@ -51,12 +53,13 @@ pushParamsStore = store } -getBinaryCache :: Env -> Maybe Token -> BinaryCacheName -> IO BinaryCache-getBinaryCache env authToken name = do+getBinaryCache :: Log.Logger -> Env -> Maybe Token -> BinaryCacheName -> IO BinaryCache+getBinaryCache logger env authToken name = do+ Log.logMsg logger Katip.DebugS $ "Fetching cache info for " <> Katip.ls name -- Self-signed caches might not have a token, which is why this code is so weird. -- In practice, public self-signed caches don't need one and private ones always need a token. let token = fromMaybe (Token "") authToken- res <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token name+ res <- retryClientM (clientenv env) $ API.getCache cachixClient token name case res of Left err -> handleCacheResponse name authToken err Right binaryCache -> pure binaryCache
src/Cachix/Daemon/PushManager.hs view
@@ -20,6 +20,7 @@ -- * Query filterPushJobs, getFailedPushJobs,+ failPendingJobs, -- * Store paths queueStorePaths,@@ -109,12 +110,14 @@ 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 a timeout.--- Returns True if all jobs completed, False if timed out.+-- | 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)@@ -208,31 +211,70 @@ handleMissingPushJob = Katip.logLocM Katip.ErrorS $ Katip.ls $ "Push job " <> (show pushId :: Text) <> " not found" -modifyPushJob :: Protocol.PushRequestId -> (PushJob -> PushJob) -> PushManager (Maybe PushJob)-modifyPushJob pushId f = do- pushJobs <- asks pmPushJobs- liftIO $ atomically $ modifyPushJobSTM pushJobs pushId f--modifyPushJobSTM :: PushJobStore -> Protocol.PushRequestId -> (PushJob -> PushJob) -> STM (Maybe PushJob)-modifyPushJobSTM pushJobs pushId f =- stateTVar pushJobs $ \jobs -> do- let pj = HashMap.adjust f pushId jobs- (HashMap.lookup pushId pj, pj)+-- | Apply an update to many push jobs atomically. After the update, a job+-- transitions to a terminal state if either the update made it processed+-- directly or it leaves 'pushQueue' empty. Returns the jobs that transitioned+-- to a terminal state in this call.+--+-- Mark, completion check, 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} <- ask+ ts <- liftIO getCurrentTime+ liftIO $ atomically $ do+ jobs <- readTVar pmPushJobs+ let (jobs', finished) = foldl' (step ts) (jobs, []) pushIds+ writeTVar pmPushJobs jobs'+ modifyTVar' pmPendingJobCount (subtract (length finished))+ pure finished+ 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)+ jobs' = HashMap.insert pushId job' jobs+ in if PushJob.isProcessed job'+ then (jobs', job' : acc)+ else (jobs', acc)+ _ -> (jobs, acc) -modifyPushJobs :: (Foldable f) => f Protocol.PushRequestId -> (PushJob -> PushJob) -> PushManager ()-modifyPushJobs pushIds f = do- pushJobs <- asks pmPushJobs- liftIO $ atomically $ modifyTVar' pushJobs $ \pushJobs' ->- foldl' (flip (HashMap.adjust f)) pushJobs' pushIds+ 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 -> PushManager ()-failPushJob pushId = do- PushManagerEnv {..} <- ask- timestamp <- liftIO getCurrentTime- liftIO $ atomically $ do- _ <- modifyPushJobSTM pmPushJobs pushId $ PushJob.fail timestamp- decrementTVar pmPendingJobCount+failPushJob pushId = void $ applyPushJobUpdates [pushId] PushJob.fail +-- | Mark every non-terminal job as failed and emit failure events for any+-- paths still in their queues. 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'.+failPendingJobs :: Text -> PushManager [PushJob]+failPendingJobs reason = do+ pmPushJobs <- asks pmPushJobs+ allIds <- HashMap.keys <$> liftIO (readTVarIO pmPushJobs)+ failed <- applyPushJobUpdates allIds PushJob.fail++ ts <- liftIO getCurrentTime+ sendPushEvent <- asks pmOnPushEvent+ for_ failed $ \job -> do+ let pid = PushJob.pushId job+ for_ (PushJob.pushQueue job) $ \path ->+ sendStorePathEventAt ts [pid] (PushStorePathFailed path reason)+ liftIO $ sendPushEvent pid (PushEvent ts pid PushFinished)++ pure failed+ pendingJobCount :: PushManager Int pendingJobCount = do pmPendingJobCount <- asks pmPendingJobCount@@ -265,21 +307,6 @@ references <- liftIO $ readTVarIO storePathIndex return $ fromMaybe Seq.empty (HashMap.lookup storePath references) -checkPushJobCompleted :: Protocol.PushRequestId -> PushManager ()-checkPushJobCompleted pushId = do- PushManagerEnv {..} <- ask- mpushJob <- lookupPushJob pushId- for_ mpushJob $ \pushJob ->- when (Set.null $ pushQueue pushJob) $ do- timestamp <- liftIO getCurrentTime- liftIO $ atomically $ do- _ <- modifyPushJobSTM pmPushJobs pushId $ \pushJob' ->- if PushJob.hasFailedPaths pushJob'- then PushJob.fail timestamp pushJob'- else PushJob.complete timestamp pushJob'- decrementTVar pmPendingJobCount- pushFinished pushJob- queuedStorePathCount :: PushManager Integer queuedStorePathCount = do pmPushJobs <- asks pmPushJobs@@ -292,19 +319,17 @@ resolvePushJob pushId closure = do Katip.logLocM Katip.DebugS $ Katip.ls $ showClosureStats closure - timestamp <- liftIO getCurrentTime- _ <- modifyPushJob pushId $ PushJob.populateQueue closure timestamp+ finishedJobs <- applyPushJobUpdates [pushId] (PushJob.populateQueue closure) withPushJob pushId $ \pushJob -> do pushStarted pushJob- -- Emit PushStorePathSkipped events for paths already present in the cache let skippedPaths = Set.difference (PushJob.rcAllPaths closure) (PushJob.rcMissingPaths closure)+ ts <- liftIO getCurrentTime forM_ skippedPaths $ \path ->- sendStorePathEvent [pushId] (PushStorePathSkipped path)- -- Create STM action for each path and then run everything atomically+ sendStorePathEventAt ts [pushId] (PushStorePathSkipped path) queueStorePaths pushId $ Set.toList (PushJob.rcMissingPaths closure)- -- Check if the job is already completed, i.e. all paths have been skipped.- checkPushJobCompleted pushId++ for_ finishedJobs pushFinished where showClosureStats :: PushJob.ResolvedClosure FilePath -> Text showClosureStats PushJob.ResolvedClosure {..} =@@ -358,8 +383,9 @@ liftIO $ for_ errors $ uncurry logStorePathWarning -- Emit PushStorePathInvalid events for invalid paths+ ts <- liftIO getCurrentTime forM_ errors $ \(path, err) ->- sendStorePathEvent [pushId] (PushStorePathInvalid path (formatStorePathError err))+ sendStorePathEventAt ts [pushId] (PushStorePathInvalid path (formatStorePathError err)) paths <- computeClosure store validPaths @@ -532,6 +558,10 @@ 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)@@ -550,23 +580,17 @@ pushStorePathDone :: FilePath -> PushManager () pushStorePathDone storePath = do pushIds <- lookupStorePathIndex storePath- modifyPushJobs pushIds (PushJob.markStorePathPushed storePath)-+ finishedJobs <- applyPushJobUpdates pushIds (\_ -> PushJob.markStorePathPushed storePath) sendStorePathEvent pushIds (PushStorePathDone storePath)-- mapM_ checkPushJobCompleted pushIds-+ for_ finishedJobs pushFinished removeStorePath storePath pushStorePathFailed :: FilePath -> Text -> PushManager () pushStorePathFailed storePath errMsg = do pushIds <- lookupStorePathIndex storePath- modifyPushJobs pushIds (PushJob.markStorePathFailed storePath)-+ finishedJobs <- applyPushJobUpdates pushIds (\_ -> PushJob.markStorePathFailed storePath) sendStorePathEvent pushIds (PushStorePathFailed storePath errMsg)-- mapM_ checkPushJobCompleted pushIds-+ for_ finishedJobs pushFinished removeStorePath storePath -- Helpers
src/Cachix/Daemon/Types/Daemon.hs view
@@ -105,11 +105,12 @@ localKatipNamespace f (Daemon m) = Daemon (local (\s -> s {daemonLogger = Log.localKatipNamespace f (daemonLogger s)}) m) -- | Run a pre-configured daemon.+--+-- The caller is responsible for setting up logging (e.g. via 'Log.withLogger')+-- before calling this function. The 'daemonLogger' in the environment must+-- already have its scribes registered. runDaemon :: DaemonEnv -> Daemon a -> IO (Either DaemonError a)-runDaemon env f = tryDaemon $ do- Log.withLogger (daemonLogger env) $ \logger -> do- let pushManagerEnv = (daemonPushManager env) {pmLogger = logger}- unDaemon f `runReaderT` env {daemonLogger = logger, daemonPushManager = pushManagerEnv}+runDaemon env f = tryDaemon $ unDaemon f `runReaderT` env where tryDaemon :: IO a -> IO (Either DaemonError a) tryDaemon a =
src/Cachix/Daemon/Worker.hs view
@@ -1,6 +1,7 @@ module Cachix.Daemon.Worker ( startWorkers, stopWorkers,+ abortWorkers, startWorker, stopWorker, Immortal.Thread,@@ -43,6 +44,12 @@ stopWorker worker = liftIO $ do Immortal.mortalize worker Immortal.wait worker++-- | Stop workers by interrupting their current task with an async exception.+abortWorkers :: (Katip.KatipContext m) => [Immortal.Thread] -> m ()+abortWorkers workers = do+ Katip.logFM Katip.WarningS "Aborting workers."+ liftIO $ Async.mapConcurrently_ Immortal.stop workers logWorkerException :: (Exception e, Katip.KatipContext m) => Either e () -> m () logWorkerException (Left err) =