shibuya-core-0.8.0.0: test/Shibuya/Runner/BatchProcessorSpec.hs
module Shibuya.Runner.BatchProcessorSpec (spec) where
import Control.Concurrent.STM
( atomically,
modifyTVar',
newTVarIO,
readTVar,
readTVarIO,
writeTVar,
)
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 (ProcessorHalt (..))
import Shibuya.Adapter.Mock
( TrackingAck (..),
getTrackedDecisions,
newTrackingAck,
trackingAckHandle,
)
import Shibuya.Batch
( BatchInfo (..),
BatchKey (..),
BatchTrigger (..),
ackAllOk,
ackExcept,
defaultBatchKey,
withFallback,
)
import Shibuya.Core.Ack
( AckDecision (..),
DeadLetterReason (..),
HaltReason (..),
RetryDelay (..),
)
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Ingested, Message (..), mkIngested)
import Shibuya.Core.Metrics
( BatchStats (..),
ProcessorId (..),
ProcessorMetrics (..),
StreamStats (..),
)
import Shibuya.Core.Types (Envelope (..), MessageId (..), mkEnvelope)
import Shibuya.Internal.Runner.BatchProcessor (runBatchesWithMetrics)
import Shibuya.Policy (Concurrency (..))
import Shibuya.Telemetry.Effect (runTracingNoop)
import Test.Hspec
import UnliftIO (throwIO, try)
import UnliftIO.Concurrent (threadDelay)
spec :: Spec
spec = describe "Shibuya.Internal.Runner.BatchProcessor" $ do
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)]
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 (ProcessorHalt (HaltFatal msg)) ->
msg `shouldSatisfy` Text.isInfixOf "perm-1"
Left other -> expectationFailure ("unexpected halt reason: " <> show other)
Right () -> expectationFailure "expected ProcessorHalt 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