shibuya-core-0.12.0.0: test/Shibuya/Runner/BatchProcessorSpec.hs
module Shibuya.Runner.BatchProcessorSpec (spec) where
import Control.Concurrent.NQE.Supervisor (Strategy (..))
import Control.Concurrent.STM (atomically, check, modifyTVar', newTVarIO, readTVar, readTVarIO, writeTVar)
import Control.Monad (forM_)
import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef)
import Data.List (sort)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NE
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (UTCTime (..), fromGregorian)
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import Shibuya (ProcessorFailure (..), ProcessorHalt (..))
import Shibuya.Adapter (Adapter (..))
import Shibuya.Adapter.Mock
( TrackingAck (..),
getTrackedDecisions,
newTrackingAck,
trackingAckHandle,
)
import Shibuya.Batch
( BatchConfig (..),
BatchHandler,
BatchInfo (..),
BatchKey (..),
BatchTrigger (..),
ackAllOk,
ackExcept,
defaultBatchConfig,
defaultBatchKey,
withFallback,
)
import Shibuya.Core.Ack
( AckDecision (..),
DeadLetterCode,
DeadLetterReason (..),
HaltReason (..),
RetryDelay (..),
mkDeadLetterCode,
)
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (DeliveryStatus (..), Ingested (..), Message (..), mkIngested)
import Shibuya.Core.Metrics
( BatchStats (..),
ProcessorId (..),
ProcessorMetrics (..),
StreamStats (..),
)
import Shibuya.Core.Metrics qualified as Metrics
import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..), mkEnvelope)
import Shibuya.Internal.Runner.BatchProcessor (runBatchesWithMetrics)
import Shibuya.Internal.Runner.Master (startMaster, stopMaster)
import Shibuya.Internal.Runner.Supervised (SupervisedProcessor (..), runSupervisedBatchWith)
import Shibuya.Policy (Concurrency (..))
import Shibuya.Telemetry.Effect (Tracing, runTracingNoop)
import Streamly.Data.Stream qualified as Stream
import Test.Hspec
import UnliftIO (throwIO, try)
import UnliftIO.Concurrent (threadDelay)
spec :: Spec
spec = describe "Shibuya.Internal.Runner.BatchProcessor" $ do
describe "delivery status" $ do
forM_ [Metrics.MetricsBasic, Metrics.MetricsDetailed Metrics.defaultDetailOptions] $ \detail -> do
forM_ [[1, 3, 5], [1 .. 5], []] $ \retained ->
it ("samples every status once and retains current members " <> show retained <> " / " <> show detail) $ do
calls <- newIORef []
handled <- newIORef []
(tracked, metrics) <- runEff $ runTracingNoop $ do
tracking <- newTrackingAck
(info, batch) <- buildBatch tracking 5 TriggerSize
let members =
NE.zipWith
( \n item ->
item
{ envelope = item.envelope {cursor = Just (CursorInt (fromIntegral n))},
deliveryStatus = liftIO $ do
previous <- atomicModifyIORef' calls (\xs -> (xs <> [n], xs))
-- A superseded status flips on a second sample; two reads
-- would wrongly re-admit it and fail both assertions.
pure $ if n `elem` retained || n `elem` previous then DeliveryCurrent else DeliverySuperseded
}
)
(NE.fromList [1 .. 5 :: Int])
batch
handler actual messages = do
liftIO $ modifyIORef' handled (<> [(actual, map (.envelope.messageId) (NE.toList messages))])
pure ackAllOk
m <- runStatusBatches detail handler [(info, members)]
t <- getTrackedDecisions tracking
pure (t, m)
readIORef calls `shouldReturn` [1 .. 5]
map fst (reverse tracked) `shouldBe` [MessageId ("msg-" <> Text.pack (show n)) | n <- retained]
observed <- readIORef handled
case retained of
[] -> do
observed `shouldBe` []
metrics.batch.batchesEmitted `shouldBe` 0
_ -> case observed of
[(actual, ids)] -> do
actual.size `shouldBe` length retained
actual.batchKey `shouldBe` defaultBatchKey
actual.trigger `shouldBe` TriggerSize
actual.partition `shouldBe` Nothing
ids `shouldBe` map fst (reverse tracked)
_ -> expectationFailure (show observed)
metrics.stats.processed `shouldBe` length retained
metrics.stats.failed `shouldBe` 0
metrics.oldestInFlight `shouldBe` Nothing
case detail of
Metrics.MetricsBasic -> metrics.latency `shouldBe` Nothing
_ -> do
fmap (.count) metrics.latency `shouldBe` (if null retained then Nothing else Just 1)
(metrics.progress >>= (.latest)) `shouldBe` (if null retained then Nothing else Just (CursorInt (fromIntegral (last retained))))
it "checks a later batch after the previous batch finalizes" $ do
current <- newIORef True
handled <- newIORef []
finalized <- newIORef []
metrics <- runEff $ runTracingNoop $ do
tracking <- newTrackingAck
(info, first) <- buildBatch tracking 1 TriggerSize
let a = (NE.head first) {ack = AckHandle (\_ -> liftIO $ atomicModifyIORef' current (const (False, ())))}
b =
(NE.head first)
{ deliveryStatus = liftIO $ (\x -> if x then DeliveryCurrent else DeliverySuperseded) <$> readIORef current,
ack = AckHandle (\_ -> liftIO $ modifyIORef' finalized (<> [2 :: Int]))
}
handler _ _ = liftIO (modifyIORef' handled (<> [1 :: Int])) >> pure ackAllOk
runBatchesWithMetrics (ProcessorId "status-later") Serial handler [(info, NE.singleton a), (info, NE.singleton b)]
readIORef handled `shouldReturn` [1]
readIORef finalized `shouldReturn` []
metrics.batch.batchesEmitted `shouldBe` 1
it "lets throwing status escape as infrastructure failure" $ do
handlerCalls <- newIORef (0 :: Int)
finalized <- newIORef (0 :: Int)
result <- try @IO @IOError $ runEff $ runTracingNoop $ do
tracking <- newTrackingAck
(info, batch) <- buildBatch tracking 1 TriggerSize
let item = (NE.head batch) {deliveryStatus = liftIO $ ioError (userError "batch status infrastructure failure"), ack = AckHandle (\_ -> liftIO $ modifyIORef' finalized (+ 1))}
runBatchesWithMetrics (ProcessorId "status-throw") Serial (\_ _ -> liftIO (modifyIORef' handlerCalls (+ 1)) >> pure ackAllOk) [(info, NE.singleton item)]
case result of
Left _ -> pure ()
Right _ -> expectationFailure "expected infrastructure failure"
readIORef handlerCalls `shouldReturn` 0
readIORef finalized `shouldReturn` 0
describe "decision resolution and finalization (M1)" $ do
it "finalizes each of 5 messages successfully with per-message decisions" $ do
(tracked, metrics) <- runEff $ runTracingNoop $ do
tracking <- newTrackingAck
batch <- buildBatch tracking 5 TriggerSize
let handler _info _msgs =
pure $
ackExcept
[ (MessageId "msg-2", AckDeadLetter MaxRetriesExceeded),
(MessageId "msg-4", AckDeadLetter (PoisonPill "x"))
]
m <- runBatchesWithMetrics (ProcessorId "m1") Serial handler [batch]
t <- getTrackedDecisions tracking
pure (t, m)
-- Each id appears exactly once in the successful-finalization list.
sort (map fst tracked) `shouldBe` expectedIds
lookup (MessageId "msg-1") tracked `shouldBe` Just AckOk
lookup (MessageId "msg-2") tracked `shouldBe` Just (AckDeadLetter MaxRetriesExceeded)
lookup (MessageId "msg-3") tracked `shouldBe` Just AckOk
lookup (MessageId "msg-4") tracked `shouldBe` Just (AckDeadLetter (PoisonPill "x"))
lookup (MessageId "msg-5") tracked `shouldBe` Just AckOk
metrics.batch.batchesEmitted `shouldBe` 1
metrics.batch.batchedMessages `shouldBe` 5
metrics.batch.partialFailures `shouldBe` 1
metrics.batch.sizeTriggered `shouldBe` 1
metrics.stats.processed `shouldBe` 3
metrics.stats.failed `shouldBe` 2
it "retries a transient finalizer failure and records one successful finalization" $ do
tracked <- runEff $ runTracingNoop $ do
tracking <- newTrackingAck
counter <- liftIO $ newIORef (0 :: Int)
-- A single-message batch whose finalizer throws its first two attempts
-- then records success on the third.
let mid = MessageId "flaky-1"
ing =
mkIngested (mkEnv 1 mid) (flakyAckHandle counter tracking mid)
info =
BatchInfo
{ batchKey = defaultBatchKey,
size = 1,
trigger = TriggerSize,
partition = Nothing
}
_ <- runBatchesWithMetrics (ProcessorId "m1-flaky") Serial (\_ _ -> pure ackAllOk) [(info, NE.fromList [ing])]
getTrackedDecisions tracking
-- Exactly one successful finalization despite two prior throws.
tracked `shouldBe` [(MessageId "flaky-1", AckOk)]
it "preserves application failures in explicit and fallback decisions" $ do
let code = validDeadLetterCode "keiro.router.selection.recipient_overflow"
fallbackReason = ApplicationFailure code "fallback policy rejection"
explicitReason = ApplicationFailure code "explicit policy rejection"
(tracked, metrics) <- runEff $ runTracingNoop $ do
tracking <- newTrackingAck
batch <- buildBatch tracking 5 TriggerSize
let handler _info _msgs =
pure $
withFallback
(AckDeadLetter fallbackReason)
[ (MessageId "msg-2", AckDeadLetter explicitReason),
(MessageId "msg-4", AckOk)
]
m <- runBatchesWithMetrics (ProcessorId "m1-application-failure") Serial handler [batch]
t <- getTrackedDecisions tracking
pure (t, m)
sort (map fst tracked) `shouldBe` expectedIds
lookup (MessageId "msg-1") tracked `shouldBe` Just (AckDeadLetter fallbackReason)
lookup (MessageId "msg-2") tracked `shouldBe` Just (AckDeadLetter explicitReason)
lookup (MessageId "msg-3") tracked `shouldBe` Just (AckDeadLetter fallbackReason)
lookup (MessageId "msg-4") tracked `shouldBe` Just AckOk
lookup (MessageId "msg-5") tracked `shouldBe` Just (AckDeadLetter fallbackReason)
metrics.batch.partialFailures `shouldBe` 1
metrics.stats.processed `shouldBe` 1
metrics.stats.failed `shouldBe` 4
describe "exception fallback (M2)" $ do
it "finalizes all 5 with AckRetry when the handler throws" $ do
(tracked, metrics) <- runEff $ runTracingNoop $ do
tracking <- newTrackingAck
batch <- buildBatch tracking 5 TriggerTimeout
let handler _info _msgs = error "boom"
m <- runBatchesWithMetrics (ProcessorId "m2-exc") Serial handler [batch]
t <- getTrackedDecisions tracking
pure (t, m)
length tracked `shouldBe` 5
sort (map fst tracked) `shouldBe` expectedIds
all ((== AckRetry (RetryDelay 0)) . snd) tracked `shouldBe` True
metrics.stats.failed `shouldBe` 5
metrics.batch.partialFailures `shouldBe` 0
metrics.batch.timeoutTriggered `shouldBe` 1
describe "halt (M2)" $ do
it "finalizes all 5, sets halt, and the driver throws ProcessorHalt" $ do
-- Build the tracking ack OUTSIDE the aborted action so it survives the throw.
tracking <- runEff $ runTracingNoop newTrackingAck
result <- try $ runEff $ runTracingNoop $ do
batch <- buildBatch tracking 5 TriggerFlush
let handler _info _msgs =
pure $
withFallback
AckOk
[(MessageId "msg-3", AckHalt (HaltFatal "halt on 3"))]
_ <- runBatchesWithMetrics (ProcessorId "m2-halt") Serial handler [batch]
pure ()
case result of
Left (ProcessorHalt reason) -> reason `shouldBe` HaltFatal "halt on 3"
Right () -> expectationFailure "expected ProcessorHalt"
tracked <- runEff $ runTracingNoop $ getTrackedDecisions tracking
sort (map fst tracked) `shouldBe` expectedIds
lookup (MessageId "msg-3") tracked `shouldBe` Just (AckHalt (HaltFatal "halt on 3"))
it "skips already-ready batches after halt and finalizes them with retry" $ do
tracking <- runEff $ runTracingNoop newTrackingAck
observedRef <- newIORef ([] :: [[MessageId]])
result <- try $ runEff $ runTracingNoop $ do
first <- buildRangeBatch tracking [1, 2] TriggerSize
second <- buildRangeBatch tracking [3, 4, 5] TriggerFlush
let handler _info msgs = do
let ids = [ing.envelope.messageId | ing <- NE.toList msgs]
liftIO $ modifyIORef' observedRef (ids :)
if MessageId "msg-2" `elem` ids
then pure $ withFallback AckOk [(MessageId "msg-2", AckHalt (HaltFatal "halt on 2"))]
else pure ackAllOk
_ <- runBatchesWithMetrics (ProcessorId "m3-halt-skip") Serial handler [first, second]
pure ()
case result of
Left (ProcessorHalt reason) -> reason `shouldBe` HaltFatal "halt on 2"
Right () -> expectationFailure "expected ProcessorHalt"
observed <- readIORef observedRef
observed `shouldBe` [[MessageId "msg-1", MessageId "msg-2"]]
tracked <- runEff $ runTracingNoop $ getTrackedDecisions tracking
lookup (MessageId "msg-1") tracked `shouldBe` Just AckOk
lookup (MessageId "msg-2") tracked `shouldBe` Just (AckHalt (HaltFatal "halt on 2"))
lookup (MessageId "msg-3") tracked `shouldBe` Just (AckRetry (RetryDelay 0))
lookup (MessageId "msg-4") tracked `shouldBe` Just (AckRetry (RetryDelay 0))
lookup (MessageId "msg-5") tracked `shouldBe` Just (AckRetry (RetryDelay 0))
sort (map fst tracked) `shouldBe` expectedIds
describe "finalizer failure (M2)" $ do
it "exhausts retries, names the failed MessageId, and still attempts the rest" $ do
-- Two-message batch: msg-1 finalizer always throws; msg-2 records normally.
tracking <- runEff $ runTracingNoop newTrackingAck
result <- try $ runEff $ runTracingNoop $ do
let mid1 = MessageId "perm-1"
mid2 = MessageId "perm-2"
ing1 =
mkIngested (mkEnv 1 mid1) alwaysFailAckHandle
ing2 =
mkIngested (mkEnv 2 mid2) (trackingAckHandle tracking mid2)
info =
BatchInfo
{ batchKey = defaultBatchKey,
size = 2,
trigger = TriggerSize,
partition = Nothing
}
_ <- runBatchesWithMetrics (ProcessorId "m2-fin") Serial (\_ _ -> pure ackAllOk) [(info, NE.fromList [ing1, ing2])]
pure ()
case result of
Left (ProcessorFailure msg (Just messageId)) -> do
msg `shouldSatisfy` Text.isInfixOf "perm-1"
messageId `shouldBe` MessageId "perm-1"
Left other -> expectationFailure ("unexpected processor failure: " <> show other)
Right () -> expectationFailure "expected ProcessorFailure from exhausted finalization"
-- The other message was still attempted and finalized despite msg-1 failing.
tracked <- runEff $ runTracingNoop $ getTrackedDecisions tracking
tracked `shouldBe` [(MessageId "perm-2", AckOk)]
describe "keyed concurrency (M2)" $ do
it "does not overlap batches with the same BatchKey under Async" $ do
violation <- runEff $ runTracingNoop $ do
activeKeys <- liftIO $ newTVarIO Set.empty
violated <- liftIO $ newTVarIO False
-- Handler marks its key active on entry (flagging a violation if it was
-- already active), holds it briefly, then clears it.
let handler info _msgs = do
liftIO $ atomically $ do
active <- readTVar activeKeys
if info.batchKey `Set.member` active
then writeTVar violated True
else pure ()
modifyTVar' activeKeys (Set.insert info.batchKey)
liftIO $ threadDelay 20000
liftIO $ atomically $ modifyTVar' activeKeys (Set.delete info.batchKey)
pure ackAllOk
-- Three batches for key "a" (must serialize) plus two for "b".
batches <- traverse (\(i, k) -> buildKeyedBatch i (BatchKey k)) (zip [1 ..] ["a", "a", "a", "b", "b"])
_ <- runBatchesWithMetrics (ProcessorId "m2-keyed") (Async 3) handler batches
liftIO $ readTVarIO violated
violation `shouldBe` False
expectedIds :: [MessageId]
expectedIds = [MessageId ("msg-" <> tshow i) | i <- [1 .. 5 :: Int]]
-- | Envelope for message @i@ with the given id.
mkEnv :: Int -> MessageId -> Envelope String
mkEnv i mid =
(mkEnvelope mid ("payload-" <> show i)) {enqueuedAt = Just testTime}
-- | Build a batch of @n@ messages (ids @msg-1@..@msg-n@) whose acks record into
-- the given TrackingAck.
buildBatch ::
(IOE :> es) =>
TrackingAck ->
Int ->
BatchTrigger ->
Eff es (BatchInfo, NonEmpty (Ingested es String))
buildBatch tracking n trig = do
let mk i =
let mid = MessageId ("msg-" <> tshow i)
in mkIngested (mkEnv i mid) (trackingAckHandle tracking mid)
msgs = map mk [1 .. n]
info =
BatchInfo
{ batchKey = defaultBatchKey,
size = n,
trigger = trig,
partition = Nothing
}
pure (info, NE.fromList msgs)
-- | Build a batch containing the exact message numbers requested.
buildRangeBatch ::
(IOE :> es) =>
TrackingAck ->
[Int] ->
BatchTrigger ->
Eff es (BatchInfo, NonEmpty (Ingested es String))
buildRangeBatch tracking ids trig = do
let mk i =
let mid = MessageId ("msg-" <> tshow i)
in mkIngested (mkEnv i mid) (trackingAckHandle tracking mid)
msgs = map mk ids
info =
BatchInfo
{ batchKey = defaultBatchKey,
size = length ids,
trigger = trig,
partition = Nothing
}
pure (info, NE.fromList msgs)
-- | A single-message batch with the given batch key (no-op tracking ack); used
-- to exercise the keyed scheduler.
buildKeyedBatch ::
(IOE :> es) =>
Int ->
BatchKey ->
Eff es (BatchInfo, NonEmpty (Ingested es String))
buildKeyedBatch i key = do
let mid = MessageId ("k-" <> tshow i)
ing =
mkIngested (mkEnv i mid) (AckHandle (\_ -> pure ()))
info =
BatchInfo
{ batchKey = key,
size = 1,
trigger = TriggerSize,
partition = Nothing
}
pure (info, NE.fromList [ing])
-- | A finalizer that throws its first two attempts, then records success.
flakyAckHandle :: (IOE :> es) => IORef Int -> TrackingAck -> MessageId -> AckHandle es
flakyAckHandle counter tracking mid =
AckHandle $ \decision -> do
n <- liftIO $ atomicModifyIORef' counter (\c -> (c + 1, c))
if n < 2
then liftIO $ throwIO (userError "flaky finalize")
else liftIO $ modifyIORef' tracking.trackedDecisions ((mid, decision) :)
-- | A finalizer that always throws (permanent adapter failure).
alwaysFailAckHandle :: (IOE :> es) => AckHandle es
alwaysFailAckHandle = AckHandle $ \_ -> liftIO $ throwIO (userError "permanent finalize failure")
testTime :: UTCTime
testTime = UTCTime (fromGregorian 2026 1 1) 0
tshow :: (Show a) => a -> Text
tshow = Text.pack . show
validDeadLetterCode :: Text -> DeadLetterCode
validDeadLetterCode code =
case mkDeadLetterCode code of
Left err -> error $ "invalid test fixture: " <> show err
Right valid -> valid
-- Exercise the actual supervised batch runner in both metrics modes.
runStatusBatches :: (IOE :> es, Tracing :> es) => Metrics.MetricsDetail -> BatchHandler es msg -> [(BatchInfo, NonEmpty (Ingested es msg))] -> Eff es ProcessorMetrics
runStatusBatches detail handler batches = do
master <- startMaster IgnoreAll
let values = concatMap (NE.toList . snd) batches
adapter = Adapter "status-batch" (Stream.fromList values) (pure ())
config = defaultBatchConfig {batchSize = length values}
proc <- runSupervisedBatchWith detail master 10 (ProcessorId "status-batch") Serial config adapter handler
liftIO $ atomically $ readTVar proc.done >>= check
metrics <- liftIO $ Metrics.sampleMetrics proc.metrics
liftIO $ Metrics.slotMisses proc.metrics `shouldReturn` 0
stopMaster master
pure metrics