packages feed

cachix-1.12.1: test/Daemon/PushManagerSpec.hs

module Daemon.PushManagerSpec where

import Cachix.Client.Env qualified as Env
import Cachix.Client.OptionsParser (defaultPushOptions)
import Cachix.Client.Push (PushSecret (PushToken))
import Cachix.Daemon.Log qualified as Log
import Cachix.Daemon.NarinfoQuery (defaultNarinfoQueryOptions)
import Cachix.Daemon.Protocol qualified as Protocol
import Cachix.Daemon.Push qualified as Daemon.Push
import Cachix.Daemon.PushManager
import Cachix.Daemon.PushManager.PushJob qualified as PushJob
import Cachix.Daemon.Types.PushEvent (PushEvent (..), PushEventMessage (..))
import Cachix.Daemon.Types.PushManager
import Cachix.Types.BinaryCache qualified as BinaryCache
import Cachix.Types.Permission (Permission (Write))
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.MVar qualified as MVar
import Control.Concurrent.STM.TVar
import Control.Monad (fail)
import Control.Retry (defaultRetryStatus)
import Data.Set qualified as Set
import Data.Time (diffUTCTime, getCurrentTime)
import Hercules.CNix qualified as CNix
import Protolude
import Servant.Auth.Client (Token (Token))
import System.IO.Temp (withSystemTempDirectory)
import System.Timeout qualified as Timeout
import Test.Hspec

instance MonadFail PushManager where
  fail msg = liftIO (expectationFailure msg) >> mzero

spec :: Spec
spec = do
  describe "push job" $ do
    it "starts in the queued state" $ do
      let request = Protocol.PushRequest {Protocol.storePaths = ["foo", "bar"], Protocol.subscribeToUpdates = False}
      pushJob <- PushJob.new request
      PushJob.status pushJob `shouldBe` Queued

    it "can be resolved" $ do
      let request = Protocol.PushRequest {Protocol.storePaths = ["foo", "bar"], Protocol.subscribeToUpdates = False}
          pathSet = Set.fromList ["foo", "bar"]
          closure = PushJob.ResolvedClosure pathSet pathSet
      initPushJob <- PushJob.new request
      timestamp <- getCurrentTime
      let pushJob = PushJob.populateQueue closure timestamp initPushJob
      PushJob.status pushJob `shouldBe` Running
      PushJob.queue pushJob `shouldBe` pathSet
      PushJob.result pushJob `shouldBe` mempty

    it "marks paths as pushed" $
      do
        let request = Protocol.PushRequest {Protocol.storePaths = ["foo", "bar"], Protocol.subscribeToUpdates = False}
            pathSet = Set.fromList ["foo", "bar"]
            closure = PushJob.ResolvedClosure pathSet pathSet
        timestamp <- getCurrentTime

        initPushJob <- PushJob.new request

        let pushJob =
              initPushJob
                & PushJob.populateQueue closure timestamp
                & PushJob.markStorePathPushed "foo"
        PushJob.status pushJob `shouldBe` Running
        PushJob.queue pushJob `shouldBe` Set.fromList ["bar"]
        PushJob.result pushJob
          `shouldBe` PushJob.PushResult
            { PushJob.prFailedPaths = mempty,
              PushJob.prPushedPaths = Set.fromList ["foo"],
              PushJob.prSkippedPaths = mempty
            }

    it "marks paths as failed" $
      do
        let request = Protocol.PushRequest {Protocol.storePaths = ["foo", "bar"], Protocol.subscribeToUpdates = False}
            pathSet = Set.fromList ["foo", "bar"]
            closure = PushJob.ResolvedClosure pathSet pathSet

        timestamp <- getCurrentTime
        initPushJob <- PushJob.new request
        let pushJob =
              initPushJob
                & PushJob.populateQueue closure timestamp
                & PushJob.markStorePathFailed "foo"
        PushJob.status pushJob `shouldBe` Running
        PushJob.queue pushJob `shouldBe` Set.fromList ["bar"]
        PushJob.result pushJob
          `shouldBe` PushJob.PushResult
            { PushJob.prFailedPaths = Set.fromList ["foo"],
              PushJob.prPushedPaths = mempty,
              PushJob.prSkippedPaths = mempty
            }

    it "unmark paths as failed after successful retry" $
      do
        let request = Protocol.PushRequest {Protocol.storePaths = ["foo", "bar"], Protocol.subscribeToUpdates = False}
            pathSet = Set.fromList ["foo", "bar"]
            closure = PushJob.ResolvedClosure pathSet pathSet

        timestamp <- getCurrentTime
        initPushJob <- PushJob.new request
        let pushJob =
              initPushJob
                & PushJob.populateQueue closure timestamp
                & PushJob.markStorePathFailed "foo"
                & PushJob.markStorePathPushed "foo"
        PushJob.status pushJob `shouldBe` Running
        PushJob.result pushJob
          `shouldBe` PushJob.PushResult
            { PushJob.prFailedPaths = mempty,
              PushJob.prPushedPaths = Set.fromList ["foo"],
              PushJob.prSkippedPaths = mempty
            }

  describe "push manager" $ do
    it "queues push jobs " $ inPushManager $ do
      let request = Protocol.PushRequest {Protocol.storePaths = ["foo", "bar"], Protocol.subscribeToUpdates = False}
      Just pushId <- addPushJobFromRequest request
      Just pushJob <- lookupPushJob pushId
      liftIO $ do
        PushJob.pushId pushJob `shouldBe` pushId
        PushJob.pushRequest pushJob `shouldBe` request

    it "manages the lifecycle of a push job" $ inPushManager $ do
      let paths = ["bar", "foo"]

      let pushRequest = Protocol.PushRequest {Protocol.storePaths = paths, Protocol.subscribeToUpdates = False}
      Just pushId <- addPushJobFromRequest pushRequest

      let pathSet = Set.fromList paths
          closure = PushJob.ResolvedClosure pathSet pathSet
      resolvePushJob pushId closure

      withPushJob pushId $ \pushJob -> liftIO $ do
        PushJob.status pushJob `shouldBe` Running
        PushJob.startedAt pushJob `shouldSatisfy` isJust

      forM_ paths $ \path -> do
        pushStorePathAttempt path 1 defaultRetryStatus
        pushStorePathDone path

      withPushJob pushId $ \pushJob -> liftIO $ do
        PushJob.status pushJob `shouldBe` Completed
        PushJob.completedAt pushJob `shouldSatisfy` isJust
        PushJob.result pushJob
          `shouldBe` PushResult
            { prFailedPaths = mempty,
              prPushedPaths = Set.fromList paths,
              prSkippedPaths = mempty
            }

    describe "failing jobs" $ do
      it "notifies subscribers when a job fails before closure resolution" $ do
        events <- newTVarIO []
        withPushManagerOnEvent (recordEvents events) $ \pm -> do
          runPushManager pm $ do
            let request = Protocol.PushRequest {Protocol.storePaths = ["foo", "bar"], Protocol.subscribeToUpdates = True}
            Just pushId <- addPushJobFromRequest request
            failPushJob pushId "closure resolution failed"

            withPushJob pushId $ \pushJob ->
              liftIO $ PushJob.status pushJob `shouldBe` Failed

          messages <- map eventMessage . reverse <$> readTVarIO events
          messages
            `shouldBe` [ PushStorePathFailed "bar" "closure resolution failed",
                         PushStorePathFailed "foo" "closure resolution failed",
                         PushFinished
                       ]

      it "does not emit further events for failed jobs on shutdown" $ do
        events <- newTVarIO []
        withPushManagerOnEvent (recordEvents events) $ \pm -> do
          runPushManager pm $ do
            let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = True}
            Just pushId <- addPushJobFromRequest request
            failPushJob pushId "closure resolution failed"

          eventsAfterFailure <- readTVarIO events
          failed <- runPushManager pm $ failPendingJobs "daemon stopped"
          length failed `shouldBe` 0
          eventsAfterShutdown <- readTVarIO events
          eventsAfterShutdown `shouldBe` eventsAfterFailure

      it "only reports paths that were not pushed when failing a resolved job" $ do
        events <- newTVarIO []
        withPushManagerOnEvent (recordEvents events) $ \pm -> do
          runPushManager pm $ do
            let paths = ["bar", "foo"]
                pathSet = Set.fromList paths
            let request = Protocol.PushRequest {Protocol.storePaths = paths, Protocol.subscribeToUpdates = True}
            Just pushId <- addPushJobFromRequest request
            resolvePushJob pushId (PushJob.ResolvedClosure pathSet pathSet)
            pushStorePathDone "bar"
            void $ failPendingJobs "daemon stopped"

          messages <- map eventMessage . reverse <$> readTVarIO events
          messages
            `shouldBe` [ PushStarted,
                         PushStorePathDone "bar",
                         PushStorePathFailed "foo" "daemon stopped",
                         PushFinished
                       ]

      it "does not emit events for a failed job when its in-flight paths finish" $ do
        events <- newTVarIO []
        withPushManagerOnEvent (recordEvents events) $ \pm -> do
          runPushManager pm $ do
            let paths = ["bar", "foo"]
                pathSet = Set.fromList paths
            let request = Protocol.PushRequest {Protocol.storePaths = paths, Protocol.subscribeToUpdates = True}
            Just pushId <- addPushJobFromRequest request
            resolvePushJob pushId (PushJob.ResolvedClosure pathSet pathSet)
            failPushJob pushId "daemon stopped"

          eventsAfterFailure <- readTVarIO events
          lastMay (map eventMessage (reverse eventsAfterFailure)) `shouldBe` Just PushFinished

          -- The uploads that were in flight when the job failed report back.
          runPushManager pm $ do
            pushStorePathAttempt "foo" 1 defaultRetryStatus
            pushStorePathProgress "foo" 1 1
            pushStorePathDone "foo"
            pushStorePathFailed "bar" "upload failed"

          eventsAfterUploads <- readTVarIO events
          eventsAfterUploads `shouldBe` eventsAfterFailure

      it "ignores closure resolution for a job that already failed" $ do
        events <- newTVarIO []
        withPushManagerOnEvent (recordEvents events) $ \pm -> do
          pushId <- runPushManager pm $ do
            let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = True}
            Just pushId <- addPushJobFromRequest request
            failPushJob pushId "daemon stopped"
            pure pushId

          eventsAfterFailure <- readTVarIO events
          lastMay (map eventMessage (reverse eventsAfterFailure)) `shouldBe` Just PushFinished

          runPushManager pm $ do
            let pathSet = Set.fromList ["foo"]
            resolvePushJob pushId (PushJob.ResolvedClosure pathSet pathSet)
            pushStorePathDone "foo"

            withPushJob pushId $ \pushJob ->
              liftIO $ PushJob.status pushJob `shouldBe` Failed
            count <- pendingJobCount
            liftIO $ count `shouldBe` 0

          eventsAfterResolve <- readTVarIO events
          eventsAfterResolve `shouldBe` eventsAfterFailure

      it "serializes closure resolution with failure" $ do
        events <- newTVarIO []
        startedEvent <- MVar.newEmptyMVar
        releaseStartedEvent <- MVar.newEmptyMVar
        let onEvent pushId event = do
              recordEvents events pushId event
              when (eventMessage event == PushStarted) $ do
                MVar.putMVar startedEvent ()
                MVar.takeMVar releaseStartedEvent

        withPushManagerOnEvent onEvent $ \pm -> do
          pushId <- runPushManager pm $ do
            let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = True}
            Just pushId <- addPushJobFromRequest request
            pure pushId

          let pathSet = Set.singleton "foo"
              resolve = runPushManager pm $ resolvePushJob pushId (PushJob.ResolvedClosure pathSet pathSet)
              failJob = runPushManager pm $ failPushJob pushId "daemon stopped"
          Async.withAsync resolve $ \resolveThread -> do
            MVar.takeMVar startedEvent
            failureInvoked <- MVar.newEmptyMVar
            Async.withAsync (MVar.putMVar failureInvoked () >> failJob) $ \failureThread -> do
              MVar.takeMVar failureInvoked
              assertStillRunning failureThread
              MVar.putMVar releaseStartedEvent ()
              Async.wait resolveThread
              Async.wait failureThread

          runPushManager pm $ do
            pushStorePathAttempt "foo" 1 defaultRetryStatus
            pushStorePathProgress "foo" 1 1
            pushStorePathDone "foo"

          messages <- map eventMessage . reverse <$> readTVarIO events
          messages
            `shouldBe` [ PushStarted,
                         PushStorePathFailed "foo" "daemon stopped",
                         PushFinished
                       ]

      it "serializes in-flight progress events with failure" $ do
        events <- newTVarIO []
        progressEvent <- MVar.newEmptyMVar
        releaseProgressEvent <- MVar.newEmptyMVar
        let onEvent pushId event = do
              recordEvents events pushId event
              when (isProgressEvent event) $ do
                MVar.putMVar progressEvent ()
                MVar.takeMVar releaseProgressEvent

        withPushManagerOnEvent onEvent $ \pm -> do
          pushId <- runPushManager pm $ do
            let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = True}
                pathSet = Set.singleton "foo"
            Just pushId <- addPushJobFromRequest request
            resolvePushJob pushId (PushJob.ResolvedClosure pathSet pathSet)
            pure pushId

          let failJob = runPushManager pm $ failPushJob pushId "daemon stopped"
          Async.withAsync (runPushManager pm $ pushStorePathProgress "foo" 1 1) $ \progressThread -> do
            MVar.takeMVar progressEvent
            failureInvoked <- MVar.newEmptyMVar
            Async.withAsync (MVar.putMVar failureInvoked () >> failJob) $ \failureThread -> do
              MVar.takeMVar failureInvoked
              assertStillRunning failureThread
              MVar.putMVar releaseProgressEvent ()
              Async.wait progressThread
              Async.wait failureThread

          runPushManager pm $ do
            pushStorePathAttempt "foo" 1 defaultRetryStatus
            pushStorePathDone "foo"

          messages <- map eventMessage . reverse <$> readTVarIO events
          messages
            `shouldBe` [ PushStarted,
                         PushStorePathProgress "foo" 1 1,
                         PushStorePathFailed "foo" "daemon stopped",
                         PushFinished
                       ]

      it "keeps shared paths active for jobs that have not failed" $ do
        events <- newTVarIO []
        withPushManagerOnEvent (recordEvents events) $ \pm -> do
          (failedId, completedId) <- runPushManager pm $ do
            let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = True}
                pathSet = Set.singleton "foo"
                closure = PushJob.ResolvedClosure pathSet pathSet
            Just failedId <- addPushJobFromRequest request
            Just completedId <- addPushJobFromRequest request
            resolvePushJob failedId closure
            resolvePushJob completedId closure
            failPushJob failedId "daemon stopped"
            pushStorePathDone "foo"
            pure (failedId, completedId)

          recordedEvents <- reverse <$> readTVarIO events
          messagesFor failedId recordedEvents
            `shouldBe` [ PushStarted,
                         PushStorePathFailed "foo" "daemon stopped",
                         PushFinished
                       ]
          messagesFor completedId recordedEvents
            `shouldBe` [ PushStarted,
                         PushStorePathDone "foo",
                         PushFinished
                       ]

          failedJob <- runPushManager pm $ lookupPushJob failedId
          PushJob.status <$> failedJob `shouldBe` Just Failed
          completedJob <- runPushManager pm $ lookupPushJob completedId
          completedJob `shouldBe` Nothing
          runPushManager pm pendingJobCount `shouldReturn` 0

    describe "graceful shutdown" $ do
      it "shuts down with no jobs" $
        withPushManager $ \pm -> do
          _ <- drainPushManager timeoutOptions pm
          closePushManager pm

      it "shuts down after jobs complete" $ withPushManager $ \pm -> do
        let paths = ["foo"]
        let longTimeoutOptions = TimeoutOptions {toTimeout = 1.0, toPollingInterval = 0.1}

        Just _ <- runPushManager pm $ do
          let request = Protocol.PushRequest {Protocol.storePaths = paths, Protocol.subscribeToUpdates = False}
          pushId <- addPushJobFromRequest request
          let pathSet = Set.fromList paths
              closure = PushJob.ResolvedClosure pathSet pathSet
          for_ pushId $ \pid -> resolvePushJob pid closure
          return pushId

        startTime <- getCurrentTime
        Async.concurrently_ (drainPushManager longTimeoutOptions pm) $
          runPushManager pm $
            for_ paths pushStorePathDone
        endTime <- getCurrentTime

        let elapsed = diffUTCTime endTime startTime
        elapsed `shouldSatisfy` (< 0.5)
        closePushManager pm

      it "shuts down on job stall" $
        withPushManager $ \pm -> do
          _ <- runPushManager pm $ do
            let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = False}
            addPushJobFromRequest request

          _ <- drainPushManager timeoutOptions pm
          closePushManager pm

  describe "STM" $
    describe "timeout" $ do
      it "times out a transaction after n seconds" $ do
        timestamp <- newTVarIO =<< getCurrentTime
        result <- atomicallyWithTimeout timeoutOptions timestamp retry
        result `shouldBe` False

withPushManager :: (PushManagerEnv -> IO a) -> IO a
withPushManager = withPushManagerOnEvent mempty

withPushManagerOnEvent :: OnPushEvent -> (PushManagerEnv -> IO a) -> IO a
withPushManagerOnEvent onPushEvent f = do
  CNix.init
  withTempStore $ \store -> do
    logger <- liftIO $ Log.new "daemon" Nothing Log.Debug
    cachixOptions <- Env.defaultCachixOptions
    clientEnv <- Env.createClientEnv cachixOptions
    let binaryCache = newBinaryCache "test"
        pushSecret = PushToken (Token "test")
        pushOptions = defaultPushOptions
        batchOptions = defaultNarinfoQueryOptions
        pushParams = Daemon.Push.newPushParams store clientEnv binaryCache pushSecret pushOptions
    newPushManagerEnv pushOptions batchOptions pushParams onPushEvent logger >>= f

recordEvents :: TVar [PushEvent] -> OnPushEvent
recordEvents events _ event = atomically $ modifyTVar' events (event :)

assertStillRunning :: Async.Async () -> IO ()
assertStillRunning thread = do
  result <- Timeout.timeout 100000 $ Async.wait thread
  result `shouldBe` Nothing

isProgressEvent :: PushEvent -> Bool
isProgressEvent PushEvent {eventMessage = PushStorePathProgress {}} = True
isProgressEvent _ = False

messagesFor :: Protocol.PushRequestId -> [PushEvent] -> [PushEventMessage]
messagesFor pushId = map eventMessage . filter ((== pushId) . eventPushId)

inPushManager :: PushManager a -> IO a
inPushManager f = withPushManager (`runPushManager` f)

withTempStore :: (CNix.Store -> IO a) -> IO a
withTempStore f =
  withSystemTempDirectory "cnix-test-store" $ \d ->
    CNix.withStoreFromURI (toS d) f

newBinaryCache :: BinaryCache.BinaryCacheName -> BinaryCache.BinaryCache
newBinaryCache name =
  BinaryCache.BinaryCache
    { BinaryCache.name = name,
      BinaryCache.uri = "https://" <> name <> ".cachix.org",
      BinaryCache.isPublic = True,
      BinaryCache.publicSigningKeys = [],
      BinaryCache.githubUsername = "",
      BinaryCache.permission = Write,
      BinaryCache.preferredCompressionMethod = BinaryCache.ZSTD
    }

timeoutOptions :: TimeoutOptions
timeoutOptions = TimeoutOptions {toTimeout = 0.2, toPollingInterval = 0.1}