cachix-1.9.0: src/Cachix/Daemon/NarinfoQuery.hs
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE NamedFieldPuns #-}
module Cachix.Daemon.NarinfoQuery
( -- * Types
NarinfoQueryManager,
NarinfoRequest (..),
NarinfoResponse (..),
NarinfoQueryOptions (..),
defaultNarinfoQueryOptions,
-- * Operations
new,
submitRequest,
start,
stop,
cleanupStaleEntries,
-- * Cache operations (for testing)
lookupCache,
nqmCache,
)
where
import Cachix.Daemon.TTLCache (TTLCache)
import Cachix.Daemon.TTLCache qualified as TTLCache
import Control.Concurrent.STM
import Control.Monad.Extra (partitionM)
import Control.Monad.IO.Unlift (MonadUnliftIO)
import Data.Sequence qualified as Seq
import Data.Set qualified as Set
import Data.Time (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
import Hercules.CNix.Store (StorePath, getStorePathHash)
import Katip qualified
import Protolude
import UnliftIO.Async qualified as Async
-- | Type alias for the positive narinfo cache.
-- Keys are store path hashes.
type NarinfoCache = TTLCache ByteString
-- | Configuration for the narinfo query manager
data NarinfoQueryOptions = NarinfoQueryOptions
{ -- | Maximum number of paths to accumulate before processing a query
nqoMaxBatchSize :: !Int,
-- | Maximum time to wait before processing a query (in seconds)
-- Use 0 for immediate processing
nqoMaxWaitTime :: !NominalDiffTime,
-- | TTL for cache entries (in seconds)
-- Use 0 to disable caching
nqoCacheTTL :: !NominalDiffTime,
-- | Maximum number of entries in the cache
-- Use 0 for unlimited
nqoMaxCacheSize :: !Int
}
deriving stock (Eq, Show)
-- | Default configuration with reasonable values
defaultNarinfoQueryOptions :: NarinfoQueryOptions
defaultNarinfoQueryOptions =
NarinfoQueryOptions
{ nqoMaxBatchSize = 100,
nqoMaxWaitTime = 0.5, -- 500ms
nqoCacheTTL = 300.0, -- 5 minutes
nqoMaxCacheSize = 0 -- Unlimited
}
-- | A request to check narinfo for store paths
data NarinfoRequest requestId = NarinfoRequest
{ -- | Unique identifier for this request
qrRequestId :: !requestId,
-- | Store paths to check
qrStorePaths :: ![StorePath],
-- | Cached existing paths
qrCachedPaths :: ![StorePath]
}
-- | Response to a narinfo query request
data NarinfoResponse = NarinfoResponse
{ -- | All paths in the dependency closure
nrAllPaths :: !(Set StorePath),
-- | Paths missing from the cache
nrMissingPaths :: !(Set StorePath)
}
deriving stock (Eq, Show)
-- | Internal state of the query manager
data QueryState requestId = QueryState
{ -- | Accumulated requests waiting to be processed
qsPendingRequests :: !(Seq (NarinfoRequest requestId)),
-- | All unique paths from pending requests
qsAccumulatedPaths :: !(Set StorePath),
-- | Time when the first request in this query group was added
qsQueryStartTime :: !(Maybe UTCTime),
-- | Current timeout TVar (Nothing if no timeout active)
qsTimeoutVar :: !(Maybe (TVar Bool)),
-- | Whether the processor should continue running
qsRunning :: !Bool
}
-- | Manager for narinfo queries
data NarinfoQueryManager requestId = NarinfoQueryManager
{ -- | Configuration
nqmConfig :: !NarinfoQueryOptions,
-- | Internal state
nqmState :: !(TVar (QueryState requestId)),
-- | Handle to the processor thread
nqmProcessorThread :: !(MVar (Async ())),
-- | Callback to handle query responses
nqmCallback :: !(requestId -> NarinfoResponse -> IO ()),
-- | Cache for path existence information
nqmCache :: !(TVar NarinfoCache),
-- | Cached current time for TTL calculations (updated periodically)
nqmCachedTime :: !(TVar UTCTime),
-- | Handle to the time refresh thread
nqmTimeRefreshThread :: !(MVar (Async ()))
}
-- | Create a new narinfo query manager
new :: (MonadIO m) => NarinfoQueryOptions -> (requestId -> NarinfoResponse -> IO ()) -> m (NarinfoQueryManager requestId)
new nqmConfig nqmCallback = liftIO $ do
nqmState <- newTVarIO initialState
nqmProcessorThread <- newEmptyMVar
nqmCache <- newTVarIO TTLCache.empty
nqmCachedTime <- newTVarIO =<< getCurrentTime
nqmTimeRefreshThread <- newEmptyMVar
return NarinfoQueryManager {..}
where
initialState =
QueryState
{ qsPendingRequests = Seq.empty,
qsAccumulatedPaths = Set.empty,
qsQueryStartTime = Nothing,
qsTimeoutVar = Nothing,
qsRunning = True
}
-- Cache operations
-- | Check if a path exists in the cache and is not stale
lookupCache :: (MonadIO m) => NarinfoQueryManager requestId -> StorePath -> m Bool
lookupCache NarinfoQueryManager {nqmCache, nqmCachedTime} path = liftIO $ do
pathHash <- getStorePathHash path
now <- readTVarIO nqmCachedTime
cache <- readTVarIO nqmCache
return $ TTLCache.lookup now pathHash cache
-- | Update cache with new entries (only existing paths)
updateCache :: (MonadIO m) => NarinfoQueryManager requestId -> Set StorePath -> m ()
updateCache NarinfoQueryManager {nqmCache, nqmConfig, nqmCachedTime} paths = liftIO $ do
-- Convert paths to hashes
pathHashes <- mapM getStorePathHash (Set.toList paths)
now <- readTVarIO nqmCachedTime
let ttl = nqoCacheTTL nqmConfig
when (ttl > 0) $ do
let expiresAt = addUTCTime ttl now
atomically $ modifyTVar' nqmCache $ \cache ->
let cacheWithNewEntries = foldr (`TTLCache.insert` expiresAt) cache pathHashes
maxSize = nqoMaxCacheSize nqmConfig
currentSize = TTLCache.size cacheWithNewEntries
-- Lazy cleanup: only clean up if cache is getting large (20% over limit)
cleanupThreshold = if maxSize > 0 then maxSize + (maxSize `div` 5) else maxBound
in if currentSize > cleanupThreshold
then
let cleanedCache = TTLCache.cleanupExpired now cacheWithNewEntries
finalCache =
if maxSize > 0 && TTLCache.size cleanedCache > maxSize
then TTLCache.pruneToSize maxSize cleanedCache
else cleanedCache
in finalCache
else cacheWithNewEntries
-- | Remove stale entries from cache
cleanupStaleEntries :: (MonadIO m) => NarinfoQueryManager requestId -> m ()
cleanupStaleEntries NarinfoQueryManager {nqmCache, nqmCachedTime} = liftIO $ do
now <- readTVarIO nqmCachedTime
atomically $ modifyTVar' nqmCache $ TTLCache.cleanupExpired now
-- | Submit a request to the query manager
--
-- The store paths will be queued for processing.
-- The closure will _not_ be computed.
submitRequest ::
(MonadIO m) =>
NarinfoQueryManager requestId ->
requestId ->
[StorePath] ->
m ()
submitRequest manager@NarinfoQueryManager {nqmConfig, nqmState, nqmCachedTime} requestId storePaths = liftIO $ do
-- Check cache for each path in a single pass
(cachedExistingPaths, pathsToQuery') <- partitionM (lookupCache manager) storePaths
-- Perform the state update atomically and determine if timeout is needed
needsTimeout <- atomically $ do
now <- readTVar nqmCachedTime
batchState <- readTVar nqmState
let isFirstRequest = Seq.null (qsPendingRequests batchState)
needsNewTimeout = isFirstRequest && isNothing (qsTimeoutVar batchState) && nqoMaxWaitTime nqmConfig > 0
updatedPaths = qsAccumulatedPaths batchState <> Set.fromList pathsToQuery'
newRequest = NarinfoRequest requestId storePaths cachedExistingPaths
newStartTime = case qsQueryStartTime batchState of
Nothing -> Just now
justTime -> justTime
writeTVar nqmState $
batchState
{ qsPendingRequests = qsPendingRequests batchState Seq.|> newRequest,
qsAccumulatedPaths = updatedPaths,
qsQueryStartTime = newStartTime
}
return needsNewTimeout
-- Set up timeout outside of STM if needed
when needsTimeout $ do
timeoutVar <- startBatchTimeout (nqoMaxWaitTime nqmConfig)
atomically $ do
modifyTVar' nqmState $ \bs -> bs {qsTimeoutVar = Just timeoutVar}
-- | Start the query processor thread
start ::
(MonadUnliftIO m, Katip.KatipContext m) =>
NarinfoQueryManager requestId ->
-- | Function to process a batch of paths
([StorePath] -> m ([StorePath], [StorePath])) ->
m ()
start manager@NarinfoQueryManager {nqmProcessorThread, nqmTimeRefreshThread} processBatch = do
-- Start time refresh thread
timeThread <- Async.async $ runTimeRefreshThread manager
liftIO $ putMVar nqmTimeRefreshThread timeThread
-- Start processor thread
thread <- Async.async $ runQueryProcessorThread manager processBatch
liftIO $ putMVar nqmProcessorThread thread
-- | Stop the query processor
stop :: (MonadIO m) => NarinfoQueryManager requestId -> m ()
stop NarinfoQueryManager {nqmState, nqmProcessorThread, nqmTimeRefreshThread} = liftIO $ do
-- Signal shutdown
atomically $ modifyTVar' nqmState $ \batchState -> batchState {qsRunning = False}
-- Wait for processor thread to finish
thread <- tryTakeMVar nqmProcessorThread
for_ thread Async.wait
-- Cancel the time refresh thread
timeThread <- tryTakeMVar nqmTimeRefreshThread
for_ timeThread Async.cancel
-- | Start a timeout for the current batch
startBatchTimeout :: NominalDiffTime -> IO (TVar Bool)
startBatchTimeout delay = do
let delayMicros = ceiling (delay * 1000000) -- Convert to microseconds
registerDelay delayMicros
-- | Check if the current query has timed out
isQueryTimedOut :: QueryState requestId -> STM Bool
isQueryTimedOut queryState =
case qsTimeoutVar queryState of
Nothing -> return False
Just timeoutVar -> readTVar timeoutVar
-- | Data representing a batch ready for processing
data ReadyBatch requestId = ReadyBatch
{ rbRequests :: !(Seq (NarinfoRequest requestId)),
rbAllPaths :: ![StorePath],
rbBatchStartTime :: !(Maybe UTCTime)
}
-- | Wait for a batch to be ready for processing or shutdown
-- Returns Nothing if shutdown requested, Just batch if ready to process
waitForBatchOrShutdown ::
NarinfoQueryOptions ->
TVar (QueryState requestId) ->
STM (Maybe (ReadyBatch requestId))
waitForBatchOrShutdown config stateVar = do
queryState <- readTVar stateVar
-- Check for shutdown first
if not (qsRunning queryState)
then return Nothing
else do
-- Check if we have any pending requests
if Seq.null (qsPendingRequests queryState)
then retry -- No work, wait for requests
else do
-- We have requests, check if batch is ready
let pathCount = Set.size (qsAccumulatedPaths queryState)
sizeReady = pathCount >= nqoMaxBatchSize config
-- If timeout is 0, process immediately
immediateMode = nqoMaxWaitTime config <= 0
-- Check timeout condition
timeoutReady <- isQueryTimedOut queryState
if sizeReady || timeoutReady || immediateMode
then do
-- Batch is ready, extract data and clear state
let requests = qsPendingRequests queryState
allPaths = Set.toList (qsAccumulatedPaths queryState)
startTime = qsQueryStartTime queryState
-- Clear the batch state
writeTVar stateVar $
queryState
{ qsPendingRequests = Seq.empty,
qsAccumulatedPaths = Set.empty,
qsQueryStartTime = Nothing,
qsTimeoutVar = Nothing
}
return $
Just
ReadyBatch
{ rbRequests = requests,
rbAllPaths = allPaths,
rbBatchStartTime = startTime
}
else retry -- Not ready yet, wait for timeout or more requests
-- | Time refresh thread that updates cached time every few seconds
runTimeRefreshThread :: (MonadUnliftIO m) => NarinfoQueryManager requestId -> m ()
runTimeRefreshThread NarinfoQueryManager {nqmCachedTime} = do
loop
where
loop = do
-- Update cached time every second
liftIO $ threadDelay 1_000_000
now <- liftIO getCurrentTime
liftIO $ atomically $ writeTVar nqmCachedTime now
loop
-- | Main query processor loop
runQueryProcessorThread ::
(MonadUnliftIO m, Katip.KatipContext m) =>
NarinfoQueryManager requestId ->
([StorePath] -> m ([StorePath], [StorePath])) ->
m ()
runQueryProcessorThread manager@NarinfoQueryManager {nqmConfig, nqmState} processBatch = do
loop
where
loop = do
-- Wait for a batch to be ready or shutdown
maybeReady <- liftIO $ atomically $ waitForBatchOrShutdown nqmConfig nqmState
case maybeReady of
Nothing -> return () -- Shutdown requested
Just readyBatch -> do
-- Process the ready batch
processReadyBatch manager processBatch readyBatch
loop
-- | Process a ready batch
processReadyBatch ::
(MonadUnliftIO m, Katip.KatipContext m) =>
NarinfoQueryManager requestId ->
([StorePath] -> m ([StorePath], [StorePath])) ->
ReadyBatch requestId ->
m ()
processReadyBatch manager@NarinfoQueryManager {nqmCallback} processBatch ReadyBatch {rbRequests, rbAllPaths, rbBatchStartTime} = do
-- Process the batch if we have paths to query
(allPathsInClosure, missingPaths) <-
if null rbAllPaths
then return ([], [])
else do
processingStartTime <- liftIO getCurrentTime
-- Log batch statistics
let requestCount = Seq.length rbRequests
pathCount = length rbAllPaths
waitTime = case rbBatchStartTime of
Nothing -> 0
Just startTime -> processingStartTime `diffUTCTime` startTime
Katip.logFM Katip.DebugS "Processing narinfo batch"
& Katip.katipAddContext
( Katip.sl "requests" requestCount
<> Katip.sl "paths" pathCount
<> Katip.sl "wait_time_s" (show waitTime :: Text)
)
-- Query narinfo
(allPathsInClosure, missingPaths) <- processBatch rbAllPaths
processingEndTime <- liftIO getCurrentTime
let processingTime = processingEndTime `diffUTCTime` processingStartTime
closureSize = length allPathsInClosure
missingCount = length missingPaths
Katip.logFM Katip.DebugS "Batch completed"
& Katip.katipAddContext
( Katip.sl "processing_time_s" (show processingTime :: Text)
<> Katip.sl "total_paths" closureSize
<> Katip.sl "missing_paths" missingCount
)
return (allPathsInClosure, missingPaths)
-- Build lookup tables including cached results
let allPathsSet = Set.fromList allPathsInClosure
missingPathsSet = Set.fromList missingPaths
-- Update cache with results (only cache positive lookups)
let existingPathsSet = allPathsSet `Set.difference` missingPathsSet
unless (Set.null existingPathsSet) $ do
Katip.logFM Katip.DebugS "Updating narinfo cache with existing paths"
& Katip.katipAddContext (Katip.sl "count" (Set.size existingPathsSet))
updateCache manager existingPathsSet
-- Respond to each request using the manager's callback
liftIO $ forM_ rbRequests $ \NarinfoRequest {qrRequestId, qrStorePaths} -> do
-- Find the missing paths for this requests from the batch
let qrStorePathsSet = Set.fromList qrStorePaths
missingPathsForRequest = qrStorePathsSet `Set.intersection` missingPathsSet
response = NarinfoResponse qrStorePathsSet missingPathsForRequest
-- Call the manager's callback with request ID and response
nqmCallback qrRequestId response