packages feed

cachix-1.11.0: test/Daemon/NarinfoQuerySpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Daemon.NarinfoQuerySpec where

import Cachix.Daemon.NarinfoQuery (NarinfoQueryManager, NarinfoQueryOptions (..), NarinfoResponse, defaultNarinfoQueryOptions)
import Cachix.Daemon.NarinfoQuery qualified as NarinfoQuery
import Control.Concurrent.STM
import Data.Set qualified as Set
import Data.Time (UTCTime, getCurrentTime)
import Hercules.CNix qualified as CNix
import Hercules.CNix.Store (Store, StorePath, withStoreFromURI)
import Katip qualified
import Protolude
import Test.Hspec
import UnliftIO.Async qualified as Async
import UnliftIO.Timeout (timeout)

-- Create a mock StorePath for testing
mockStorePath :: Store -> Int -> IO StorePath
mockStorePath store i = do
  let pathText = "/nix/store/" <> show i <> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-mock"
  CNix.parseStorePath store (encodeUtf8 pathText)

-- Test data structure to track batch processor calls
data BatchCall = BatchCall
  { bcPaths :: [StorePath],
    bcTimestamp :: UTCTime
  }
  deriving (Show, Eq)

-- Test data structure to track callback calls
data CallbackCall requestId = CallbackCall
  { ccRequestId :: requestId,
    ccResponse :: NarinfoResponse,
    ccTimestamp :: UTCTime
  }
  deriving (Show, Eq)

-- Mock batch processor that records calls and returns controlled results
createMockBatchProcessor ::
  TVar [BatchCall] -> -- Record of all batch calls
  TVar [(Set StorePath, Set StorePath)] -> -- Queue of (existing, missing) responses
  [StorePath] ->
  IO ([StorePath], [StorePath])
createMockBatchProcessor callsVar responsesVar inputPaths = do
  now <- getCurrentTime
  let call = BatchCall inputPaths now

  atomically $ modifyTVar' callsVar (call :)

  responses <- readTVarIO responsesVar
  case responses of
    [] -> return ([], inputPaths) -- Default: all missing
    ((existing, missing) : rest) -> do
      atomically $ writeTVar responsesVar rest
      let existingList = filter (`Set.member` existing) inputPaths
          missingList = filter (`Set.member` missing) inputPaths
      -- Return all paths in closure (existing paths) and missing paths
      return (existingList, missingList)

-- Mock callback that records all calls
createMockCallback ::
  TVar [CallbackCall requestId] ->
  requestId ->
  NarinfoResponse ->
  IO ()
createMockCallback callsVar requestId response = do
  now <- getCurrentTime
  let call = CallbackCall requestId response now
  atomically $ modifyTVar' callsVar (call :)

-- Test context to pass around
data TestContext requestId = TestContext
  { tcManager :: NarinfoQueryManager requestId,
    tcBatchCalls :: TVar [BatchCall],
    tcCallbackCalls :: TVar [CallbackCall requestId],
    tcResponsesQueue :: TVar [(Set StorePath, Set StorePath)]
  }

-- Helper to start batch processor asynchronously with its own Katip context
startQueryProcessorAsync :: NarinfoQueryManager requestId -> ([StorePath] -> IO ([StorePath], [StorePath])) -> IO ()
startQueryProcessorAsync manager batchProcessor =
  void $ Async.async $ do
    handleScribe <- Katip.mkHandleScribe Katip.ColorIfTerminal stderr (Katip.permitItem Katip.InfoS) Katip.V0
    let makeLogEnv = Katip.registerScribe "stderr" handleScribe Katip.defaultScribeSettings =<< Katip.initLogEnv "test" "test"
    bracket makeLogEnv Katip.closeScribes $ \le ->
      Katip.runKatipContextT le () mempty $
        NarinfoQuery.start manager (liftIO . batchProcessor)

-- Test setup helper that encapsulates common initialization
withTestManager ::
  NarinfoQueryOptions ->
  (TestContext Int -> IO a) ->
  IO a
withTestManager config action = do
  batchCalls <- newTVarIO []
  responsesQueue <- newTVarIO []
  callbackCalls <- newTVarIO []

  let callback = createMockCallback callbackCalls
      batchProcessor = createMockBatchProcessor batchCalls responsesQueue

  manager <- NarinfoQuery.new config callback
  startQueryProcessorAsync manager batchProcessor

  let testContext =
        TestContext
          { tcManager = manager,
            tcBatchCalls = batchCalls,
            tcCallbackCalls = callbackCalls,
            tcResponsesQueue = responsesQueue
          }

  finally (action testContext) (NarinfoQuery.stop manager)

-- | Wait for an STM condition to be satisfied, with a timeout.
-- Fails with an error if the timeout expires before the condition is met.
waitForSTM :: Int -> STM Bool -> IO ()
waitForSTM timeoutMicros condition = do
  result <- timeout timeoutMicros $ atomically $ do
    satisfied <- condition
    unless satisfied retry
  when (isNothing result) $
    expectationFailure "Timeout waiting for STM condition"

-- | Wait for at least @n@ callbacks to be recorded, with a 5s timeout.
waitForCallbacks :: TVar [CallbackCall requestId] -> Int -> IO ()
waitForCallbacks callsVar n =
  waitForSTM 5_000_000 $ do
    cbs <- readTVar callsVar
    return $ length cbs >= n

spec :: Spec
spec = do
  -- Initialize the CNix library
  runIO CNix.init

  describe "Batching" $ do
    it "triggers batch when size threshold is reached" $ withStoreFromURI "dummy://" $ \store -> do
      path1 <- mockStorePath store 1
      path2 <- mockStorePath store 2
      path3 <- mockStorePath store 3
      let config = defaultNarinfoQueryOptions {nqoMaxBatchSize = 2, nqoMaxWaitTime = 10} -- Large timeout, small batch
      withTestManager config $ \TestContext {..} -> do
        atomically $ writeTVar tcResponsesQueue [(Set.fromList [path1, path2, path3], Set.empty)]

        -- Submit both requests - second one reaches size threshold (2 paths total)
        NarinfoQuery.submitRequest tcManager (1 :: Int) [path1]
        NarinfoQuery.submitRequest tcManager (2 :: Int) [path2]

        -- Wait for the batch to be processed and both callbacks to arrive
        waitForCallbacks tcCallbackCalls 2

        -- Verify exactly one batch with both paths
        calls <- readTVarIO tcBatchCalls
        length calls `shouldBe` 1
        let batchPaths = case head calls of
              Just (BatchCall paths _) -> paths
              Nothing -> panic "Expected batch call"
        Set.fromList batchPaths `shouldBe` Set.fromList [path1, path2]

    it "triggers batch when timeout is reached" $ withStoreFromURI "dummy://" $ \store -> do
      path1 <- mockStorePath store 1
      let config = defaultNarinfoQueryOptions {nqoMaxBatchSize = 100, nqoMaxWaitTime = 0.05} -- Small timeout, large batch
      withTestManager config $ \TestContext {..} -> do
        atomically $ writeTVar tcResponsesQueue [(Set.fromList [path1], Set.empty)]

        -- Submit request that won't reach size threshold
        NarinfoQuery.submitRequest tcManager (1 :: Int) [path1]

        -- Wait for timeout-triggered batch and callback
        waitForCallbacks tcCallbackCalls 1

        calls <- readTVarIO tcBatchCalls
        length calls `shouldBe` 1 -- Batch triggered by timeout
    it "processes immediately when timeout is zero" $ withStoreFromURI "dummy://" $ \store -> do
      path1 <- mockStorePath store 1
      let config = defaultNarinfoQueryOptions {nqoMaxBatchSize = 100, nqoMaxWaitTime = 0} -- Immediate mode
      withTestManager config $ \TestContext {..} -> do
        atomically $ writeTVar tcResponsesQueue [(Set.fromList [path1], Set.empty)]

        NarinfoQuery.submitRequest tcManager (1 :: Int) [path1]

        waitForCallbacks tcCallbackCalls 1

        calls <- readTVarIO tcBatchCalls
        length calls `shouldBe` 1 -- Processed immediately
    it "only caches existing paths, not missing ones" $ withStoreFromURI "dummy://" $ \store -> do
      path1 <- mockStorePath store 1
      path2 <- mockStorePath store 2
      path3 <- mockStorePath store 3
      let config = defaultNarinfoQueryOptions {nqoMaxWaitTime = 0}
      withTestManager config $ \TestContext {..} -> do
        let existingPaths = Set.fromList [path1, path2]
            missingPaths = Set.fromList [path3]
        atomically $ writeTVar tcResponsesQueue [(existingPaths, missingPaths)]

        -- Submit all three paths
        NarinfoQuery.submitRequest tcManager (1 :: Int) [path1, path2, path3]

        waitForCallbacks tcCallbackCalls 1

        -- Check what's in cache - only existing paths should be cached
        cached1 <- NarinfoQuery.lookupCache tcManager path1
        cached2 <- NarinfoQuery.lookupCache tcManager path2
        cached3 <- NarinfoQuery.lookupCache tcManager path3

        cached1 `shouldBe` True -- Existing path cached
        cached2 `shouldBe` True -- Existing path cached
        cached3 `shouldBe` False -- Missing path not cached
    it "bypasses batch processor for cached paths" $ withStoreFromURI "dummy://" $ \store -> do
      path1 <- mockStorePath store 1
      path2 <- mockStorePath store 2
      let config = defaultNarinfoQueryOptions {nqoMaxWaitTime = 0}
      withTestManager config $ \TestContext {..} -> do
        atomically $ writeTVar tcResponsesQueue [(Set.fromList [path1], Set.empty), (Set.fromList [path2], Set.empty)]

        -- First request - path1 will be cached
        NarinfoQuery.submitRequest tcManager (1 :: Int) [path1]

        waitForCallbacks tcCallbackCalls 1

        -- Second request - path1 from cache, path2 goes to batch
        NarinfoQuery.submitRequest tcManager (2 :: Int) [path1, path2]

        waitForCallbacks tcCallbackCalls 2

        -- Should have 2 batch calls (one for each unique uncached path)
        calls <- readTVarIO tcBatchCalls
        length calls `shouldBe` 2

        -- Second batch should only contain path2
        let secondBatchPaths = case head calls of
              Just (BatchCall paths _) -> paths
              Nothing -> panic "Expected batch call"
        secondBatchPaths `shouldBe` [path2]

        -- Second response should contain both paths (path1 from cache + path2 from batch)
        callbacks <- readTVarIO tcCallbackCalls
        let secondResponse = case head callbacks of
              Just (CallbackCall _ response _) -> response
              Nothing -> panic "Expected callback call"
        NarinfoQuery.nrAllPaths secondResponse `shouldBe` Set.fromList [path1, path2]

    it "distributes correct paths to each request" $ withStoreFromURI "dummy://" $ \store -> do
      path1 <- mockStorePath store 1
      path2 <- mockStorePath store 2
      path3 <- mockStorePath store 3
      path4 <- mockStorePath store 4
      path5 <- mockStorePath store 5
      path6 <- mockStorePath store 6
      -- Use batch size = 5 (exact unique paths count) so batch triggers on request 2
      -- Request 1 adds 3 paths, request 2 adds 2 more unique (5 total), triggering batch
      let config = defaultNarinfoQueryOptions {nqoMaxBatchSize = 5, nqoMaxWaitTime = 10}
      withTestManager config $ \TestContext {..} -> do
        -- Setup: path1,3,5 exist; path2,4,6 missing
        let existingPaths = Set.fromList [path1, path3, path5]
            missingPaths = Set.fromList [path2, path4, path6]
        atomically $ writeTVar tcResponsesQueue [(existingPaths, missingPaths)]

        -- Request 1: paths 1,2,3 (3 unique paths, below threshold)
        NarinfoQuery.submitRequest tcManager (1 :: Int) [path1, path2, path3]
        -- Request 2: paths 3,4,5 (path 3 overlaps, adds 2 new → 5 total, triggers batch)
        NarinfoQuery.submitRequest tcManager (2 :: Int) [path3, path4, path5]

        -- Wait until both callbacks are received (deterministic, no timing dependency)
        waitForCallbacks tcCallbackCalls 2

        callbacks <- readTVarIO tcCallbackCalls

        -- Find responses by request ID
        let findResponse rid = find (\(CallbackCall r _ _) -> r == rid) callbacks
        Just (CallbackCall _ response1 _) <- return $ findResponse 1
        Just (CallbackCall _ response2 _) <- return $ findResponse 2

        -- Request 1 should get: existing=[1,3], missing=[2]
        NarinfoQuery.nrAllPaths response1 `shouldBe` Set.fromList [path1, path2, path3]
        NarinfoQuery.nrMissingPaths response1 `shouldBe` Set.fromList [path2]

        -- Request 2 should get: existing=[3,5], missing=[4]
        NarinfoQuery.nrAllPaths response2 `shouldBe` Set.fromList [path3, path4, path5]
        NarinfoQuery.nrMissingPaths response2 `shouldBe` Set.fromList [path4]

    it "deduplicates paths across requests in same batch" $ withStoreFromURI "dummy://" $ \store -> do
      path1 <- mockStorePath store 1
      path2 <- mockStorePath store 2
      let config = defaultNarinfoQueryOptions {nqoMaxBatchSize = 3, nqoMaxWaitTime = 0.1}
      withTestManager config $ \TestContext {..} -> do
        atomically $ writeTVar tcResponsesQueue [(Set.fromList [path1, path2], Set.empty)]

        -- Submit overlapping requests that will be batched together
        NarinfoQuery.submitRequest tcManager (1 :: Int) [path1, path2] -- paths 1,2
        NarinfoQuery.submitRequest tcManager (2 :: Int) [path2, path1] -- paths 2,1 (same, different order)
        waitForCallbacks tcCallbackCalls 2

        calls <- readTVarIO tcBatchCalls
        length calls `shouldBe` 1

        -- Batch should contain deduplicated paths
        let batchPaths = case head calls of
              Just (BatchCall paths _) -> paths
              Nothing -> panic "Expected batch call"
        Set.fromList batchPaths `shouldBe` Set.fromList [path1, path2]
        length batchPaths `shouldBe` 2 -- No duplicates