| {-# LANGUAGE TemplateHaskell, ScopedTypeVariables, NamedFieldPuns #-} |
| {-# OPTIONS_GHC -fno-warn-orphans #-} |
| |
| {-| Unittests for the job scheduler. |
| |
| -} |
| |
| {- |
| |
| Copyright (C) 2014 Google Inc. |
| All rights reserved. |
| |
| Redistribution and use in source and binary forms, with or without |
| modification, are permitted provided that the following conditions are |
| met: |
| |
| 1. Redistributions of source code must retain the above copyright notice, |
| this list of conditions and the following disclaimer. |
| |
| 2. Redistributions in binary form must reproduce the above copyright |
| notice, this list of conditions and the following disclaimer in the |
| documentation and/or other materials provided with the distribution. |
| |
| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS |
| IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED |
| TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR |
| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF |
| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING |
| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| -} |
| |
| module Test.Ganeti.JQScheduler (testJQScheduler) where |
| |
| import Control.Applicative |
| import Control.Lens ((&), (.~), _2) |
| import Data.List (inits) |
| import Data.Maybe |
| import qualified Data.Map as Map |
| import Data.Set (Set, difference) |
| import qualified Data.Set as Set |
| import Data.Traversable (traverse) |
| import Text.JSON (JSValue(..)) |
| import Test.HUnit |
| import Test.QuickCheck |
| |
| import Test.Ganeti.JQueue.Objects (genQueuedOpCode, genJobId, justNoTs) |
| import Test.Ganeti.SlotMap (genTestKey, overfullKeys) |
| import Test.Ganeti.TestCommon |
| import Test.Ganeti.TestHelper |
| import Test.Ganeti.Types () |
| |
| import Ganeti.JQScheduler.Filtering |
| import Ganeti.JQScheduler.ReasonRateLimiting |
| import Ganeti.JQScheduler.Types |
| import Ganeti.JQueue.Lens |
| import Ganeti.JQueue.Objects |
| import Ganeti.Objects (FilterRule(..), FilterPredicate(..), FilterAction(..), |
| filterRuleOrder) |
| import Ganeti.OpCodes |
| import Ganeti.OpCodes.Lens |
| import Ganeti.Query.Language (Filter(..), FilterValue(..)) |
| import Ganeti.SlotMap |
| import Ganeti.Types |
| import Ganeti.Utils (isSubsequenceOf, newUUID) |
| |
| {-# ANN module "HLint: ignore Use camelCase" #-} |
| |
| |
| genRateLimitReason :: Gen String |
| genRateLimitReason = do |
| Slot{ slotLimit = n } <- arbitrary |
| l <- genTestKey |
| return $ "rate-limit:" ++ show n ++ ":" ++ l |
| |
| |
| instance Arbitrary QueuedJob where |
| arbitrary = do |
| -- For our scheduler testing purposes here, we only care about |
| -- opcodes, job ID and reason rate limits. |
| jid <- genJobId |
| |
| ops <- resize 5 . listOf1 $ do |
| o <- genQueuedOpCode |
| -- Put some rate limits into the OpCode. |
| limitString <- genRateLimitReason |
| return $ |
| o & qoInputL . validOpCodeL . metaParamsL . opReasonL . traverse . _2 |
| .~ limitString |
| |
| return $ QueuedJob jid ops justNoTs justNoTs justNoTs Nothing Nothing |
| |
| |
| instance Arbitrary JobWithStat where |
| arbitrary = nullJobWithStat <$> arbitrary |
| shrink job = [ job { jJob = x } | x <- shrink (jJob job) ] |
| |
| |
| instance Arbitrary Queue where |
| arbitrary = do |
| |
| let genJobsUniqueJIDs :: [JobWithStat] -> Gen [JobWithStat] |
| genJobsUniqueJIDs = listOfUniqueBy arbitrary (qjId . jJob) |
| |
| queued <- genJobsUniqueJIDs [] |
| running <- genJobsUniqueJIDs queued |
| manip <- genJobsUniqueJIDs (queued ++ running) |
| |
| return $ Queue queued running manip |
| shrink q = |
| [ q { qEnqueued = x } | x <- shrink (qEnqueued q) ] ++ |
| [ q { qRunning = x } | x <- shrink (qRunning q) ] ++ |
| [ q { qManipulated = x } | x <- shrink (qManipulated q) ] |
| |
| |
| -- * Test cases |
| |
| -- | Tests rate limit reason trail parsing. |
| case_parseReasonRateLimit :: Assertion |
| case_parseReasonRateLimit = do |
| |
| assertBool "default case" $ |
| let a = parseReasonRateLimit "rate-limit:20:my label" |
| b = parseReasonRateLimit "rate-limit:21:my label" |
| in and |
| [ a == Just ("20:my label", 20) |
| , b == Just ("21:my label", 21) |
| ] |
| |
| assertEqual "be picky about whitespace" |
| Nothing |
| (parseReasonRateLimit " rate-limit:20:my label") |
| |
| |
| -- | Tests that "rateLimit:n:..." and "rateLimit:m:..." become different |
| -- rate limiting buckets. |
| prop_slotMapFromJob_conflicting_buckets :: Property |
| prop_slotMapFromJob_conflicting_buckets = do |
| |
| let sameBucketReasonStringGen :: Gen (String, String) |
| sameBucketReasonStringGen = do |
| (Positive (n :: Int), Positive (m :: Int)) <- arbitrary |
| l <- genPrintableAsciiString |
| return ( "rate-limit:" ++ show n ++ ":" ++ l |
| , "rate-limit:" ++ show m ++ ":" ++ l ) |
| |
| forAll sameBucketReasonStringGen $ \(s1, s2) -> |
| (s1 /= s2) ==> do |
| (lab1, lim1) <- parseReasonRateLimit s1 |
| (lab2, _ ) <- parseReasonRateLimit s2 |
| let sm = Map.fromList [(lab1, Slot 1 lim1)] |
| cm = Map.fromList [(lab2, 1)] |
| in return $ |
| (sm `occupySlots` cm) ==? Map.fromList [ (lab1, Slot 1 lim1) |
| , (lab2, Slot 1 0) |
| ] :: Gen Property |
| |
| |
| -- | Tests some basic cases for reason rate limiting. |
| case_reasonRateLimit :: Assertion |
| case_reasonRateLimit = do |
| |
| let mkJobWithReason jobNum reasonTrail = do |
| opc <- genSample genQueuedOpCode |
| jid <- makeJobId jobNum |
| let opc' = opc & (qoInputL . validOpCodeL . metaParamsL . opReasonL) |
| .~ reasonTrail |
| return . nullJobWithStat |
| $ QueuedJob |
| { qjId = jid |
| , qjOps = [opc'] |
| , qjReceivedTimestamp = Nothing |
| , qjStartTimestamp = Nothing |
| , qjEndTimestamp = Nothing |
| , qjLivelock = Nothing |
| , qjProcessId = Nothing |
| } |
| |
| -- 3 jobs, limited to 2 of them running. |
| j1 <- mkJobWithReason 1 [("source1", "rate-limit:2:hello", 0)] |
| j2 <- mkJobWithReason 2 [("source1", "rate-limit:2:hello", 0)] |
| j3 <- mkJobWithReason 3 [("source1", "rate-limit:2:hello", 0)] |
| |
| assertEqual "[j1] should not be rate-limited" |
| [j1] |
| (reasonRateLimit (Queue [j1] [] []) [j1]) |
| |
| assertEqual "[j1, j2] should not be rate-limited" |
| [j1, j2] |
| (reasonRateLimit (Queue [j1, j2] [] []) [j1, j2]) |
| |
| assertEqual "j3 should be rate-limited 1" |
| [j1, j2] |
| (reasonRateLimit (Queue [j1, j2, j3] [] []) [j1, j2, j3]) |
| |
| assertEqual "j3 should be rate-limited 2" |
| [j2] |
| (reasonRateLimit (Queue [j2, j3] [j1] []) [j2, j3]) |
| |
| assertEqual "j3 should be rate-limited 3" |
| [] |
| (reasonRateLimit (Queue [j3] [j1] [j2]) [j3]) |
| |
| |
| -- | Tests the specified properties of `reasonRateLimit`, as defined in |
| -- `doc/design-optables.rst`. |
| prop_reasonRateLimit :: Property |
| prop_reasonRateLimit = |
| forAllShrink arbitrary shrink $ \q -> |
| |
| let slotMapFromJobWithStat = slotMapFromJobs . map jJob |
| |
| enqueued = qEnqueued q |
| |
| toRun = reasonRateLimit q enqueued |
| |
| oldSlots = slotMapFromJobWithStat (qRunning q) |
| newSlots = slotMapFromJobWithStat (qRunning q ++ toRun) |
| -- What would happen without rate limiting. |
| newSlotsNoLimits = slotMapFromJobWithStat (qRunning q ++ enqueued) |
| |
| in -- Ensure it's unlikely that jobs are all in different buckets. |
| cover |
| (any ((> 1) . slotOccupied) . Map.elems $ newSlotsNoLimits) |
| 50 |
| "some jobs have the same rate-limit bucket" |
| |
| -- Ensure it's likely that rate limiting has any effect. |
| . cover |
| (overfullKeys newSlotsNoLimits |
| `difference` overfullKeys oldSlots /= Set.empty) |
| 50 |
| "queued jobs cannot be started because of rate limiting" |
| |
| $ conjoin |
| [ counterexample "scheduled jobs must be subsequence" $ |
| toRun `isSubsequenceOf` enqueued |
| |
| -- This is the key property: |
| , counterexample "no job may exceed its bucket limits, except from\ |
| \ jobs that were already running with exceeded\ |
| \ limits; those must not increase" $ |
| conjoin |
| [ if occup <= limit |
| -- Within limits, all fine. |
| then passTest |
| -- Bucket exceeds limits - it must have exceeded them |
| -- in the initial running list already, with the same |
| -- slot count. |
| else Map.lookup k oldSlots ==? Just slot |
| | (k, slot@(Slot occup limit)) <- Map.toList newSlots ] |
| ] |
| |
| -- | Tests that filter rule ordering is determined (solely) by priority, |
| -- watermark and UUID, as defined in `doc/design-optables.rst`. |
| prop_filterRuleOrder :: Property |
| prop_filterRuleOrder = property $ do |
| a <- arbitrary |
| b <- arbitrary `suchThat` ((frUuid a /=) . frUuid) |
| return $ filterRuleOrder a b ==? (frPriority a, frWatermark a, frUuid a) |
| `compare` |
| (frPriority b, frWatermark b, frUuid b) |
| |
| |
| -- | Tests common inputs for `matchPredicate`, especially the predicates |
| -- and fields available to them as defined in the spec. |
| case_matchPredicate :: Assertion |
| case_matchPredicate = do |
| |
| jid1 <- makeJobId 1 |
| clusterName <- mkNonEmpty "cluster1" |
| |
| let job = |
| QueuedJob |
| { qjId = jid1 |
| , qjOps = |
| [ QueuedOpCode |
| { qoInput = ValidOpCode MetaOpCode |
| { metaParams = CommonOpParams |
| { opDryRun = Nothing |
| , opDebugLevel = Nothing |
| , opPriority = OpPrioHigh |
| , opDepends = Just [] |
| , opComment = Nothing |
| , opReason = [("source1", "reason1", 1234)] |
| } |
| , metaOpCode = OpClusterRename |
| { opName = clusterName |
| } |
| } |
| , qoStatus = OP_STATUS_QUEUED |
| , qoResult = JSNull |
| , qoLog = [] |
| , qoPriority = -1 |
| , qoStartTimestamp = Nothing |
| , qoExecTimestamp = Nothing |
| , qoEndTimestamp = Nothing |
| } |
| ] |
| , qjReceivedTimestamp = Nothing |
| , qjStartTimestamp = Nothing |
| , qjEndTimestamp = Nothing |
| , qjLivelock = Nothing |
| , qjProcessId = Nothing |
| } |
| |
| let watermark = jid1 |
| |
| check = matchPredicate job watermark |
| |
| -- jobid filters |
| |
| assertEqual "matching jobid filter" |
| True |
| . check $ FPJobId (EQFilter "id" (NumericValue 1)) |
| |
| assertEqual "non-matching jobid filter" |
| False |
| . check $ FPJobId (EQFilter "id" (NumericValue 2)) |
| |
| assertEqual "non-matching jobid filter (string passed)" |
| False |
| . check $ FPJobId (EQFilter "id" (QuotedString "1")) |
| |
| -- jobid filters: watermarks |
| |
| assertEqual "matching jobid watermark filter" |
| True |
| . check $ FPJobId (EQFilter "id" (QuotedString "watermark")) |
| |
| -- opcode filters |
| |
| assertEqual "matching opcode filter (type of opcode)" |
| True |
| . check $ FPOpCode (EQFilter "OP_ID" (QuotedString "OP_CLUSTER_RENAME")) |
| |
| assertEqual "non-matching opcode filter (type of opcode)" |
| False |
| . check $ FPOpCode (EQFilter "OP_ID" (QuotedString "OP_INSTANCE_CREATE")) |
| |
| assertEqual "matching opcode filter (nested access)" |
| True |
| . check $ FPOpCode (EQFilter "name" (QuotedString "cluster1")) |
| |
| assertEqual "non-matching opcode filter (nonexistent nested access)" |
| False |
| . check $ FPOpCode (EQFilter "something" (QuotedString "cluster1")) |
| |
| -- reason filters |
| |
| assertEqual "matching reason filter (reason field)" |
| True |
| . check $ FPReason (EQFilter "reason" (QuotedString "reason1")) |
| |
| assertEqual "non-matching reason filter (reason field)" |
| False |
| . check $ FPReason (EQFilter "reason" (QuotedString "reasonGarbage")) |
| |
| assertEqual "matching reason filter (source field)" |
| True |
| . check $ FPReason (EQFilter "source" (QuotedString "source1")) |
| |
| assertEqual "matching reason filter (timestamp field)" |
| True |
| . check $ FPReason (EQFilter "timestamp" (NumericValue 1234)) |
| |
| assertEqual "non-matching reason filter (nonexistent field)" |
| False |
| . check $ FPReason (EQFilter "something" (QuotedString "")) |
| |
| |
| -- | Tests that jobs selected by `applyingFilter` actually match |
| -- and have an effect (are not CONTINUE filters). |
| prop_applyingFilter :: Property |
| prop_applyingFilter = |
| forAllShrink arbitrary shrink $ \(job, filters) -> |
| |
| let applying = applyingFilter (Set.fromList filters) job |
| |
| in isJust applying ==> case applying of |
| Just f -> job `matches` f && frAction f /= Continue |
| Nothing -> True |
| |
| |
| case_jobFiltering :: Assertion |
| case_jobFiltering = do |
| |
| clusterName <- mkNonEmpty "cluster1" |
| jid1 <- makeJobId 1 |
| jid2 <- makeJobId 2 |
| jid3 <- makeJobId 3 |
| jid4 <- makeJobId 4 |
| unsetPrio <- mkNonNegative 1234 |
| uuid1 <- newUUID |
| |
| let j1 = |
| nullJobWithStat QueuedJob |
| { qjId = jid1 |
| , qjOps = |
| [ QueuedOpCode |
| { qoInput = ValidOpCode MetaOpCode |
| { metaParams = CommonOpParams |
| { opDryRun = Nothing |
| , opDebugLevel = Nothing |
| , opPriority = OpPrioHigh |
| , opDepends = Just [] |
| , opComment = Nothing |
| , opReason = [("source1", "reason1", 1234)]} |
| , metaOpCode = OpClusterRename |
| { opName = clusterName |
| } |
| } |
| , qoStatus = OP_STATUS_QUEUED |
| , qoResult = JSNull |
| , qoLog = [] |
| , qoPriority = -1 |
| , qoStartTimestamp = Nothing |
| , qoExecTimestamp = Nothing |
| , qoEndTimestamp = Nothing |
| } |
| ] |
| , qjReceivedTimestamp = Nothing |
| , qjStartTimestamp = Nothing |
| , qjEndTimestamp = Nothing |
| , qjLivelock = Nothing |
| , qjProcessId = Nothing |
| } |
| |
| j2 = j1 & jJobL . qjIdL .~ jid2 |
| j3 = j1 & jJobL . qjIdL .~ jid3 |
| j4 = j1 & jJobL . qjIdL .~ jid4 |
| |
| |
| fr1 = |
| FilterRule |
| { frWatermark = jid1 |
| , frPriority = unsetPrio |
| , frPredicates = [FPJobId (EQFilter "id" (NumericValue 1))] |
| , frAction = Reject |
| , frReasonTrail = [] |
| , frUuid = uuid1 |
| } |
| |
| -- Gives the rule a new UUID. |
| rule fr = do |
| uuid <- newUUID |
| return fr{ frUuid = uuid } |
| |
| -- Helper to create filter chains: assigns the filters in the list |
| -- increasing priorities, so that filters listed first are processed |
| -- first. |
| chain :: [FilterRule] -> Set FilterRule |
| chain frs |
| | any ((/= unsetPrio) . frPriority) frs = |
| error "Filter was passed to `chain` that already had a priority." |
| | otherwise = |
| Set.fromList |
| [ fr{ frPriority = prio } |
| | (fr, Just prio) <- zip frs (map mkNonNegative [1..]) ] |
| |
| fr2 <- rule fr1{ frAction = Accept } |
| fr3 <- rule fr1{ frAction = Pause } |
| |
| fr4 <- rule fr1{ frPredicates = |
| [FPJobId (GTFilter "id" (QuotedString "watermark"))] |
| } |
| |
| fr5 <- rule fr1{ frPredicates = [] } |
| |
| fr6 <- rule fr5{ frAction = Continue } |
| fr7 <- rule fr6{ frAction = RateLimit 2 } |
| |
| fr8 <- rule fr4{ frAction = Continue, frWatermark = jid1 } |
| fr9 <- rule fr8{ frAction = RateLimit 2 } |
| |
| assertEqual "j1 should be rejected (by fr1)" |
| [] |
| (jobFiltering (Queue [j1] [] []) (chain [fr1]) [j1]) |
| |
| assertEqual "j1 should be rejected (by fr1, it has priority)" |
| [] |
| (jobFiltering (Queue [j1] [] []) (chain [fr1, fr2]) [j1]) |
| |
| assertEqual "j1 should be accepted (by fr2, it has priority)" |
| [j1] |
| (jobFiltering (Queue [j1] [] []) (chain [fr2, fr1]) [j1]) |
| |
| assertEqual "j1 should be paused (by fr3)" |
| [] |
| (jobFiltering (Queue [j1] [] []) (chain [fr3]) [j1]) |
| |
| assertEqual "j2 should be rejected (over watermark1)" |
| [j1] |
| (jobFiltering (Queue [j1, j2] [] []) (chain [fr4]) [j1, j2]) |
| |
| assertEqual "all jobs should be rejected (since no predicates)" |
| [] |
| (jobFiltering (Queue [j1, j2] [] []) (chain [fr5]) [j1, j2]) |
| |
| assertEqual "j3 should be rate-limited" |
| [j1, j2] |
| (jobFiltering (Queue [j1, j2, j3] [] []) (chain [fr6, fr7]) [j1, j2, j3]) |
| |
| assertEqual "j4 should be rate-limited" |
| -- j1 doesn't apply to fr8/fr9 (since they match only watermark > jid1) |
| -- so j1 gets scheduled |
| [j1, j2, j3] |
| (jobFiltering (Queue [j1, j2, j3, j4] [] []) (chain [fr8, fr9]) |
| [j1, j2, j3, j4]) |
| |
| |
| -- | Tests the specified properties of `jobFiltering`, as defined in |
| -- `doc/design-optables.rst`. |
| prop_jobFiltering :: Property |
| prop_jobFiltering = |
| forAllShrink arbitrary shrink $ \q -> |
| forAllShrink (resize 4 arbitrary) shrink $ \(NonEmpty filterList) -> |
| |
| let running = qRunning q ++ qManipulated q |
| enqueued = qEnqueued q |
| |
| filters = Set.fromList filterList |
| |
| toRun = jobFiltering q filters enqueued -- do the filtering |
| |
| -- Helpers |
| |
| -- Whether `fr` applies to more than `n` of the `jobs` |
| -- (that is, more than allowed). |
| exceeds :: Int -> FilterRule -> [JobWithStat] -> Bool |
| exceeds n fr jobs = |
| n < (length |
| . filter ((frUuid fr ==) . frUuid) |
| . mapMaybe (applyingFilter filters) |
| $ map jJob jobs) |
| |
| -- Helpers for ensuring sensible coverage. |
| |
| -- Makes sure that each action appears with some probability. |
| actionName = head . words . show |
| allActions = map actionName [ Accept, Continue, Pause, Reject |
| , RateLimit 0 ] |
| applyingActions = map (actionName . frAction) |
| . mapMaybe (applyingFilter filters) |
| $ map jJob enqueued |
| perc = 4 -- percent; low because it's per action |
| actionCovers = |
| foldr (.) id |
| [ stableCover (a `elem` applyingActions) perc ("is " ++ a) |
| | a <- allActions ] |
| |
| -- `covers` should be after `==>` and before `conjoin` (see QuickCheck |
| -- bugs 25 and 27). |
| in (enqueued /= []) ==> actionCovers $ conjoin |
| |
| [ counterexample "scheduled jobs must be subsequence" $ |
| toRun `isSubsequenceOf` enqueued |
| |
| , counterexample "a reason for each job (not) being scheduled" . |
| |
| -- All enqueued jobs must have a reason why they were (not) |
| -- scheduled, determined by the filter that applies. |
| flip all enqueued $ \job -> |
| case applyingFilter filters (jJob job) of |
| -- If no filter matches, the job must run. |
| Nothing -> job `elem` toRun |
| Just fr@FilterRule{ frAction } -> case frAction of |
| -- ACCEPT filter permit the job immediately, |
| -- PAUSE/REJECT forbid running, CONTINUE filters cannot |
| -- be the output of `applyingFilter`, and |
| -- RATE_LIMIT filters have a more more complex property. |
| Accept -> job `elem` toRun |
| Continue -> error "must not happen" |
| Pause -> job `notElem` toRun |
| Reject -> job `notElem` toRun |
| RateLimit n -> |
| |
| let -- Jobs in queue before our job. |
| jobsBefore = takeWhile (/= job) enqueued |
| |
| in if job `elem` toRun |
| -- If it got scheduled, the job and any job |
| -- before it doesn't overfill the rate limit. |
| then not . exceeds n fr $ running |
| ++ jobsBefore ++ [job] |
| -- If didn't get scheduled, then the rate limit |
| -- was already full before scheduling or the job |
| -- or one of the jobs before made it full. |
| else any (exceeds n fr . (running ++)) |
| (inits $ jobsBefore ++ [job]) |
| -- The `inits` bit includes the [] and [...job] |
| -- cases. |
| |
| ] |
| |
| |
| testSuite "JQScheduler" |
| [ 'case_parseReasonRateLimit |
| , 'prop_slotMapFromJob_conflicting_buckets |
| , 'case_reasonRateLimit |
| , 'prop_reasonRateLimit |
| , 'prop_filterRuleOrder |
| , 'case_matchPredicate |
| , 'prop_applyingFilter |
| , 'case_jobFiltering |
| , 'prop_jobFiltering |
| ] |