packages feed

cachix-1.10.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 = do
  started <- newEmptyMVar
  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 $ do
        liftIO $ putMVar started ()
        NarinfoQuery.start manager (liftIO . batchProcessor)
  -- Wait for the processor to start before returning
  takeMVar started

-- 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"

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 first request (1 path) - should not trigger
        NarinfoQuery.submitRequest tcManager (1 :: Int) [path1]
        threadDelay 10000

        calls1 <- readTVarIO tcBatchCalls
        length calls1 `shouldBe` 0 -- No batch yet

        -- Submit second request (1 more unique path) - should trigger batch (2 paths total)
        NarinfoQuery.submitRequest tcManager (2 :: Int) [path2]
        threadDelay 20000 -- Wait for batch processing
        calls2 <- readTVarIO tcBatchCalls
        length calls2 `shouldBe` 1 -- One batch triggered
        let batchPaths = case head calls2 of
              Just (BatchCall paths _) -> paths
              Nothing -> panic "Expected batch call"
        Set.fromList batchPaths `shouldBe` Set.fromList [path1, path2]

        -- Verify both requests got responses
        callbacks <- readTVarIO tcCallbackCalls
        length callbacks `shouldBe` 2

    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 to trigger
        threadDelay 60000 -- 60ms > 50ms timeout
        calls <- readTVarIO tcBatchCalls
        length calls `shouldBe` 1 -- Batch triggered by timeout
        callbacks <- readTVarIO tcCallbackCalls
        length callbacks `shouldBe` 1 -- Request got response
    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]
        threadDelay 20000 -- Short wait
        calls <- readTVarIO tcBatchCalls
        length calls `shouldBe` 1 -- Processed immediately
        callbacks <- readTVarIO tcCallbackCalls
        length callbacks `shouldBe` 1

    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]
        threadDelay 20000

        -- 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]
        threadDelay 20000

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

        -- 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]

        -- Both requests should have gotten responses
        callbacks <- readTVarIO tcCallbackCalls
        length callbacks `shouldBe` 2

        -- Second response should contain both paths (path1 from cache + path2 from batch)
        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)
        waitForSTM 5_000_000 $ do
          cbs <- readTVar tcCallbackCalls
          return $ length cbs >= 2

        callbacks <- readTVarIO tcCallbackCalls
        length callbacks `shouldBe` 2

        -- 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)
        threadDelay 120000 -- Wait 120ms, longer than 100ms timeout
        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

        -- Both requests should still get responses
        callbacks <- readTVarIO tcCallbackCalls
        length callbacks `shouldBe` 2