packages feed

glue-core-0.8.0.0: test/Glue/WeightedLoadBalancerSpec.hs

{-# LANGUAGE ScopedTypeVariables #-}

module Glue.WeightedLoadBalancerSpec (spec) where

import qualified Data.ByteString       as BS
import qualified Data.ByteString.Char8 as BC
import           Data.Either           (isRight)
import           Data.List.NonEmpty    (NonEmpty (..))
import qualified Data.Map.Strict       as M
import           Glue.LoadBalancer
import           Test.Hspec
import           Test.QuickCheck
import           UnliftIO.Exception    (Exception, throwIO)

data BackendDown = BackendDown
  deriving (Eq, Show)

instance Exception BackendDown

referenceKey :: (Integer, Integer)
referenceKey = (0x0706050403020100, 0x0f0e0d0c0b0a0908)

referenceHash :: Int -> Integer
referenceHash n = toInteger (sipHash24 (fromInteger (fst referenceKey)) (fromInteger (snd referenceKey)) (BS.pack (fmap fromIntegral [0 .. n - 1])))

keys :: [BS.ByteString]
keys = fmap (BC.pack . ("session-" ++) . show) [1 .. 20000 :: Int]

spec :: Spec
spec = do
  describe "sipHash24" $
    it "Matches the reference vectors (key 00..0f, message 00..n-1)" $ do
      referenceHash 0  `shouldBe` 0x726fdb47dd0e0e31
      referenceHash 1  `shouldBe` 0x74f839c593dc67fd
      referenceHash 7  `shouldBe` 0xab0200f58b01d137
      referenceHash 8  `shouldBe` 0x93f5f5799a932462
      referenceHash 15 `shouldBe` 0xa129ca6149be45e5
      referenceHash 63 `shouldBe` 0x958a324ceb064572

  describe "validateWeights" $ do
    it "Accepts non-negative weights summing to 10000, including zero weights" $ do
      validateWeights (10000 :| []) `shouldBe` Right ()
      validateWeights (8000 :| [2000, 0]) `shouldBe` Right ()

    it "Rejects negative weights and wrong sums" $ do
      validateWeights (10500 :| [-500]) `shouldBe` Left (NegativeWeight 1 (-500))
      validateWeights (5000 :| [4000]) `shouldBe` Left (WeightsDoNotSumToTotal 9000)

  describe "chooseIndex" $ do
    it "Is deterministic for a key and salt" $
      property $ \(key :: String) -> do
        let salt = saltFromBytes (BC.pack "split")
        chooseIndex salt (BC.pack key) (5000 :| [5000]) `shouldBe` chooseIndex salt (BC.pack key) (5000 :| [5000])

    it "Never chooses a zero-weight backend and gives a full-weight backend everything" $
      property $ \(key :: String) -> do
        let salt = saltFromBytes (BC.pack "split")
        chooseIndex salt (BC.pack key) (0 :| [10000, 0]) `shouldBe` 1
        chooseIndex salt (BC.pack key) (10000 :| [0]) `shouldBe` 0

    it "Maps buckets to backends by their cumulative ranges" $
      property $ \(key :: String) -> do
        let salt   = saltFromBytes (BC.pack "split")
            bucket = bucketOf salt (BC.pack key)
            index  = chooseIndex salt (BC.pack key) (2500 :| [2500, 5000])
        index `shouldBe` (if bucket < 2500 then 0 else if bucket < 5000 then 1 else 2)

    it "Keeps a key's backend when unrelated weights change order" $
      property $ \(key :: String) -> do
        let salt   = saltFromBytes (BC.pack "split")
            original = chooseIndex salt (BC.pack key) (5000 :| [3000, 2000])
            reordered = chooseIndex salt (BC.pack key) (5000 :| [2000, 3000])
        -- Keys in the first backend's range are unaffected by reordering the others.
        (original == 0) `shouldBe` (reordered == 0)

    it "Splits traffic in proportion to the weights" $ do
      let salt   = saltFromBytes (BC.pack "split")
          counts = M.fromListWith (+) [(chooseIndex salt key (8000 :| [2000]), 1 :: Int) | key <- keys]
          share index = fromIntegral (M.findWithDefault 0 index counts) / fromIntegral (length keys) :: Double
      share 0 `shouldSatisfy` (\s -> abs (s - 0.8) < 0.015)
      share 1 `shouldSatisfy` (\s -> abs (s - 0.2) < 0.015)

    it "Gives independent assignments for different salts" $ do
      let a = saltFromBytes (BC.pack "split-a")
          b = saltFromBytes (BC.pack "split-b")
          both = length [() | key <- keys, chooseIndex a key (5000 :| [5000]) == 0, chooseIndex b key (5000 :| [5000]) == 0]
          ratio = fromIntegral both / fromIntegral (length keys) :: Double
      ratio `shouldSatisfy` (\r -> abs (r - 0.25) < 0.015)

  describe "weightedBy" $ do
    it "Rejects invalid weights up front" $ do
      let backends = (6000, pure . (+ 1)) :| [(6000, pure . (+ 2))] :: NonEmpty (Int, Int -> IO Int)
      either Just (const Nothing) (weightedBy (BC.pack . show) (Salt 1 2) backends) `shouldBe` Just (WeightsDoNotSumToTotal 12000)

    it "Routes every request to the backend chooseIndex picks" $
      property $ \(request :: Int) -> do
        let salt     = Salt 1 2
            backends = (3000, \r -> pure (0, r)) :| [(7000, \r -> pure (1, r))] :: NonEmpty (Int, Int -> IO (Int, Int))
            keyOf    = BC.pack . show
        case weightedBy keyOf salt backends of
          Left err      -> expectationFailure (show err)
          Right service -> service request `shouldReturn` (chooseIndex salt (keyOf request) (3000 :| [7000]), request)

    it "Propagates a failure of the chosen backend" $ do
      let backends = (10000, \_ -> throwIO BackendDown) :| [(0, pure)] :: NonEmpty (Int, Int -> IO Int)
          built    = weightedBy (BC.pack . show) (Salt 1 2) backends
      isRight built `shouldBe` True
      either (const (pure ())) (\service -> service 1 `shouldThrow` (== BackendDown)) built