shibuya-core-0.8.0.0: test/Shibuya/App/BatchSpec.hs
module Shibuya.App.BatchSpec (spec) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.STM (TVar, atomically, check, newTVarIO, readTVar, writeTVar)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.List (nub)
import Data.Text qualified as Text
import Data.Time (UTCTime (..), fromGregorian)
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import Shibuya.Adapter (Adapter (..))
import Shibuya.Adapter.Mock (TrackingAck (..), newTrackingAck, trackingAckHandle)
import Shibuya.App
( AppError (..),
QueueProcessor (..),
defaultAppConfig,
mkBatchProcessor,
runApp,
stopApp,
waitApp,
)
import Shibuya.Batch
( BatchConfig (..),
BatchHandler,
BatchInfo (..),
BatchTrigger (..),
ackAllOk,
defaultBatchConfig,
)
import Shibuya.Core.Ingested (Ingested, mkIngested)
import Shibuya.Core.Metrics (ProcessorId (..))
import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..), mkEnvelope)
import Shibuya.Policy (Concurrency (..), OrderingPolicy (..))
import Shibuya.Telemetry.Effect (runTracingNoop)
import Streamly.Data.Stream qualified as Stream
import Test.Hspec
spec :: Spec
spec = describe "Shibuya.App.Batch" $ do
describe "runApp with a BatchingProcessor" $ do
-- Milestone 1: a bad batch config is rejected by runApp with AppBatchConfigError.
it "rejects a batch config with size 0 (AppBatchConfigError)" $ do
result <- runEff $ runTracingNoop $ do
sizeRef <- liftIO $ newIORef []
tracking <- newTrackingAck
messages <- createTrackedMessages tracking 1
let adapter = listAdapter' messages
badConfig = defaultBatchConfig {batchSize = 0}
processor = mkBatchProcessor adapter (recordingHandler sizeRef) badConfig
runApp defaultAppConfig [(ProcessorId "bad", processor)]
case result of
Left (AppBatchConfigError _) -> pure ()
Left e -> expectationFailure ("expected AppBatchConfigError, got: " ++ show e)
Right _ -> expectationFailure "expected Left, got a running AppHandle"
it "rejects PartitionedInOrder with concurrent batching" $ do
result <- runEff $ runTracingNoop $ do
sizeRef <- liftIO $ newIORef []
tracking <- newTrackingAck
messages <- createTrackedMessages tracking 1
let adapter = listAdapter' messages
processor =
BatchingProcessor
adapter
(recordingHandler sizeRef)
defaultBatchConfig
PartitionedInOrder
(Async 2)
runApp defaultAppConfig [(ProcessorId "bad-policy", processor)]
case result of
Left (AppPolicyError _) -> pure ()
Left e -> expectationFailure ("expected AppPolicyError, got: " ++ show e)
Right _ -> expectationFailure "expected Left, got a running AppHandle"
-- Milestone 2: all N messages are acked exactly once, seen in batches.
it "processes all messages in batches and acks each exactly once" $ do
(sizes, ids) <- runEff $ runTracingNoop $ do
tracking <- newTrackingAck
sizeRef <- liftIO $ newIORef []
messages <- createTrackedMessages tracking 10
let adapter = listAdapter' messages
config = defaultBatchConfig {batchSize = 4, batchTimeout = 60}
processor = mkBatchProcessor adapter (recordingHandler sizeRef) config
res <- runApp defaultAppConfig [(ProcessorId "batch", processor)]
case res of
Left err -> liftIO $ ioError (userError (show err))
Right appHandle -> do
waitApp appHandle
ss <- liftIO $ readIORef sizeRef
decs <- liftIO $ readIORef tracking.trackedDecisions
pure (ss, map fst decs)
sum (map fst sizes) `shouldBe` 10
length ids `shouldBe` 10 -- normal path: each message finalized once
length (nub ids) `shouldBe` 10 -- and all distinct
-- Milestone 3: a partial batch (< batchSize, timeout far off) is flushed on shutdown.
it "flushes a pending partial batch on graceful shutdown" $ do
(sizes, ids, triggers) <- runEff $ runTracingNoop $ do
tracking <- newTrackingAck
sizeRef <- liftIO $ newIORef []
gate <- liftIO $ newTVarIO False
messages <- createTrackedMessages tracking 3
let adapter = gatedAdapter gate messages
-- Never fills (size 100) and never times out during the test (60s).
config = defaultBatchConfig {batchSize = 100, batchTimeout = 60}
processor = mkBatchProcessor adapter (recordingHandler sizeRef) config
res <- runApp defaultAppConfig [(ProcessorId "flush", processor)]
case res of
Left err -> liftIO $ ioError (userError (show err))
Right appHandle -> do
-- Let the 3 messages be ingested and accumulated.
liftIO $ threadDelay 200000
stopApp appHandle -- ends source -> EOF flush -> waits for done
ss <- liftIO $ readIORef sizeRef
decs <- liftIO $ readIORef tracking.trackedDecisions
pure (ss, map fst decs, map snd ss)
sum (map fst sizes) `shouldBe` 3
length ids `shouldBe` 3
length (nub ids) `shouldBe` 3
all (== TriggerFlush) triggers `shouldBe` True
-- Handler that records (batch size, trigger) and acks everything OK.
recordingHandler ::
(IOE :> es) => IORef [(Int, BatchTrigger)] -> BatchHandler es String
recordingHandler ref info _batch = do
liftIO $ modifyIORef' ref ((info.size, info.trigger) :)
pure ackAllOk
testTime :: UTCTime
testTime = UTCTime (fromGregorian 2024 1 1) 0
createTrackedMessages :: (IOE :> es) => TrackingAck -> Int -> Eff es [Ingested es String]
createTrackedMessages tracking n = mapM mk [1 .. n]
where
mk i = do
let msgId = MessageId $ "msg-" <> (if i < 10 then "0" else "") <> Text.pack (show i)
env =
(mkEnvelope msgId ("message-" <> show i))
{ cursor = Just (CursorInt i),
enqueuedAt = Just testTime
}
pure $ mkIngested env (trackingAckHandle tracking msgId)
-- Finite adapter: ends as soon as its list is exhausted.
listAdapter' :: [Ingested es String] -> Adapter es String
listAdapter' messages =
Adapter {adapterName = "test:batch", source = Stream.fromList messages, shutdown = pure ()}
-- Gated adapter: emits the messages, then blocks until 'shutdown' opens the gate,
-- so a partial batch stays pending until stopApp triggers the flush. Implemented
-- as an unfold that yields each message and, once the list is exhausted, blocks
-- on the gate before ending the stream.
gatedAdapter :: (IOE :> es) => TVar Bool -> [Ingested es String] -> Adapter es String
gatedAdapter gate messages =
Adapter
{ adapterName = "test:gated",
source = Stream.unfoldrM step messages,
shutdown = liftIO $ atomically $ writeTVar gate True
}
where
step [] = do
waitGate
pure Nothing
step (m : ms) = pure (Just (m, ms))
waitGate = liftIO $ atomically $ readTVar gate >>= check