shibuya-core-0.8.0.0: test/Shibuya/Runner/PartitionOrderingSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
module Shibuya.Runner.PartitionOrderingSpec (spec) where
import Control.Concurrent.NQE.Supervisor (Strategy (..))
import Control.Concurrent.STM (atomically, check, readTVar)
import Data.IORef (atomicModifyIORef', newIORef, readIORef)
import Data.List (nub, sort)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import Shibuya.Adapter.Mock (getTrackedDecisions, newTrackingAck, trackedListAdapter)
import Shibuya.Core.Ack (AckDecision (..))
import Shibuya.Core.Ingested (Message (..))
import Shibuya.Core.Metrics (ProcessorId (..))
import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..))
import Shibuya.Core.Types qualified as Core
import Shibuya.Internal.Runner.Master (startMaster, stopMaster)
import Shibuya.Internal.Runner.Supervised (SupervisedProcessor (..), runSupervised)
import Shibuya.Policy (Concurrency (..), OrderingPolicy (..))
import Shibuya.Telemetry.Effect (runTracingNoop)
import Test.Hspec
import Test.QuickCheck
import UnliftIO.Concurrent (threadDelay)
data Payload = Payload
{ delayMicros :: !Int
}
deriving stock (Eq, Show)
data PartitionCase = PartitionCase
{ caseMessages :: ![(Maybe Text, Int)],
caseConcurrency :: !Concurrency
}
deriving stock (Show)
instance Arbitrary PartitionCase where
arbitrary = do
messageCount <- chooseInt (1, 24)
messages <-
vectorOf messageCount $
(,)
<$> elements [Nothing, Just "p0", Just "p1", Just "p2"]
<*> chooseInt (0, 2000)
concurrency <-
oneof
[ Ahead <$> chooseInt (2, 8),
Async <$> chooseInt (2, 8)
]
pure PartitionCase {caseMessages = messages, caseConcurrency = concurrency}
spec :: Spec
spec = describe "Shibuya.Runner.PartitionOrdering" $ do
it "finalizes each partition in arrival order and exactly once" $
property $
withMaxSuccess 30 $ \(PartitionCase messages concurrency) ->
ioProperty $ do
finalized <- runPartitioned messages concurrency
let ids = makeIds (length messages)
partitionById = Map.fromList (zip ids (map fst messages))
finalizedIds = map fst finalized
partitions = nub [p | Just p <- map fst messages]
finalizedFor p =
[ msgId
| msgId <- finalizedIds,
Map.lookup msgId partitionById == Just (Just p)
]
arrivedFor p =
[ msgId
| (msgId, Just p') <- zip ids (map fst messages),
p == p'
]
pure $
counterexample ("finalized=" <> show finalizedIds) $
sort finalizedIds === sort ids
.&&. conjoin [finalizedFor p === arrivedFor p | p <- partitions]
it "respects the global concurrency bound" $ do
maxInFlightRef <- newIORef (0 :: Int)
currentInFlightRef <- newIORef (0 :: Int)
_ <-
runPartitionedWithHandler
(zip (map Just ["p0", "p1", "p2", "p3", "p4", "p5", "p6", "p7"]) (repeat 50_000))
(Async 3)
( \_ -> do
cur <-
liftIO $
atomicModifyIORef' currentInFlightRef (\n -> let n' = n + 1 in (n', n'))
liftIO $ atomicModifyIORef' maxInFlightRef (\m -> (max m cur, ()))
liftIO $ threadDelay 50_000
liftIO $ atomicModifyIORef' currentInFlightRef (\n -> (n - 1, ()))
pure AckOk
)
maxInFlight <- readIORef maxInFlightRef
maxInFlight `shouldSatisfy` (<= 3)
it "does not let a slow partition block fast partitions" $ do
finalized <-
runPartitioned
[ (Just "slow", 80_000),
(Just "fast", 1_000),
(Just "slow", 80_000),
(Just "fast", 1_000),
(Just "slow", 80_000),
(Just "fast", 1_000)
]
(Async 4)
let orderedIds = map fst finalized
lastSlowIndex = fromMaybe maxBound $ elemIndex' "msg-4" orderedIds
fastIndexes =
map
(`elemIndexOrMax` orderedIds)
["msg-1", "msg-3", "msg-5"]
all (< lastSlowIndex) fastIndexes `shouldBe` True
runPartitioned ::
[(Maybe Text, Int)] ->
Concurrency ->
IO [(MessageId, AckDecision)]
runPartitioned messages concurrency =
runPartitionedWithHandler messages concurrency $ \ingested -> do
let Message {envelope = Envelope {payload = Payload delay}} = ingested
liftIO $ threadDelay delay
pure AckOk
runPartitionedWithHandler ::
[(Maybe Text, Int)] ->
Concurrency ->
(forall es. (IOE :> es) => Message es Payload -> Eff es AckDecision) ->
IO [(MessageId, AckDecision)]
runPartitionedWithHandler messages concurrency handler =
runEff $ runTracingNoop $ do
tracking <- newTrackingAck
let envs = zipWith mkEnvelope [0 :: Int ..] messages
adapter = trackedListAdapter tracking envs
master <- startMaster IgnoreAll
sp <- runSupervised master 50 (ProcessorId "partitioned") PartitionedInOrder concurrency adapter handler
liftIO $ atomically $ readTVar sp.done >>= check
decisions <- getTrackedDecisions tracking
stopMaster master
pure (reverse decisions)
mkEnvelope :: Int -> (Maybe Text, Int) -> Envelope Payload
mkEnvelope i (partition, delayMicros) =
(Core.mkEnvelope (makeId i) Payload {delayMicros})
{ cursor = Just (CursorInt i),
partition = partition
}
makeIds :: Int -> [MessageId]
makeIds n = map makeId [0 .. n - 1]
makeId :: Int -> MessageId
makeId i = MessageId ("msg-" <> Text.pack (show i))
elemIndexOrMax :: MessageId -> [MessageId] -> Int
elemIndexOrMax needle haystack = fromMaybe maxBound (elemIndex' needle haystack)
elemIndex' :: (Eq a) => a -> [a] -> Maybe Int
elemIndex' needle = go 0
where
go _ [] = Nothing
go i (x : xs)
| x == needle = Just i
| otherwise = go (i + 1) xs