packages feed

shibuya-core-0.8.0.0: test/Shibuya/Runner/BatcherSpec.hs

module Shibuya.Runner.BatcherSpec (spec) where

import Control.Concurrent (threadDelay)
import Data.List (sort)
import Data.List.NonEmpty qualified as NE
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Word (Word64)
import Effectful (Effect)
import Shibuya.Batch
  ( BatchConfig (..),
    BatchInfo (..),
    BatchKey (..),
    BatchTrigger (..),
  )
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Ingested (..), mkIngested)
import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..), mkEnvelope)
import Shibuya.Internal.Runner.Batcher
  ( ReadyBatch,
    emptyBatcherState,
    runBatcher,
    stepArrival,
    stepFlush,
    stepTick,
  )
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream qualified as Stream
import Test.Hspec
import Test.QuickCheck

-- The engine is parameterized by an effect stack and payload; the pure core
-- treats them as phantom. We pick es = 'E' (an empty effect stack, kind pinned
-- to '[Effect]') and msg = String for the tests.

type E = ('[] :: [Effect])

tshow :: Int -> Text
tshow = Text.pack . show

-- | Build a no-op-ack Ingested with a unique id, a sequence cursor, and a
-- partition equal to its batch key.
mkIng :: Int -> BatchKey -> Ingested E String
mkIng i (BatchKey k) =
  mkIngested
    ( (mkEnvelope (MessageId ("m-" <> tshow i)) ("payload-" <> show i))
        { cursor = Just (CursorInt i),
          partition = Just k
        }
    )
    (AckHandle (\_ -> pure ()))

-- Key the config on the message's partition (Just k -> BatchKey k).
partitionKeyConfig :: Int -> BatchConfig E String
partitionKeyConfig sz =
  BatchConfig
    { batchSize = sz,
      batchTimeout = 5,
      batchKey = \env -> BatchKey (fromMaybe "default" env.partition),
      tickInterval = Nothing
    }

secondsToNanos :: Double -> Word64
secondsToNanos seconds = round (seconds * 1e9)

-- | Run a synthetic event list through the pure core and collect emitted batches.
-- Events carry their own virtual time; a final flush drains the remainder so the
-- conservation property can compare the full input against the full output.
data Ev
  = EvArr Int BatchKey -- an arrival: unique id, key
  | EvTick -- a ticker scan
  deriving stock (Show)

runEvents :: BatchConfig E String -> [(Double, Ev)] -> [ReadyBatch E String]
runEvents cfg = go emptyBatcherState
  where
    go st [] = snd (stepFlush st)
    go st ((secs, ev) : rest) =
      let now = secondsToNanos secs
          (st', out) = case ev of
            EvArr i k -> stepArrival cfg now (mkIng i k) st
            EvTick -> stepTick cfg now st
       in out ++ go st' rest

batchIds :: [ReadyBatch E String] -> [MessageId]
batchIds bs = [ing.envelope.messageId | (_, ne) <- bs, ing <- NE.toList ne]

batchCursors :: NE.NonEmpty (Ingested E String) -> [Int]
batchCursors ne = [c | ing <- NE.toList ne, Just (CursorInt c) <- [ing.envelope.cursor]]

spec :: Spec
spec = describe "Shibuya.Internal.Runner.Batcher" $ do
  describe "pure core (deterministic, no threads)" $ do
    it "emits a size-triggered batch of exactly batchSize" $ do
      let cfg = partitionKeyConfig 2
          evs = [(0, EvArr 0 "a"), (0, EvArr 1 "a")]
          out = runEvents cfg evs
      map (\(info, _) -> (info.trigger, info.size)) out
        `shouldBe` [(TriggerSize, 2)]
      batchIds out `shouldBe` [MessageId "m-0", MessageId "m-1"]

    it "keeps different keys in independent groups" $ do
      let cfg = partitionKeyConfig 2
          evs = [(0, EvArr 0 "a"), (0, EvArr 1 "b"), (0, EvArr 2 "a"), (0, EvArr 3 "b")]
          out = runEvents cfg evs
      -- Two size batches, one per key; both size 2.
      map (\(info, _) -> (info.batchKey, info.size, info.trigger)) out
        `shouldMatchList` [(BatchKey "a", 2, TriggerSize), (BatchKey "b", 2, TriggerSize)]

    it "flushes a partial group at end of input with TriggerFlush" $ do
      let cfg = partitionKeyConfig 10
          evs = [(0, EvArr 0 "a"), (0, EvArr 1 "a")]
          out = runEvents cfg evs
      map (\(info, _) -> (info.trigger, info.size)) out `shouldBe` [(TriggerFlush, 2)]

    it "emits a timeout batch once batchTimeout has elapsed" $ do
      let cfg = partitionKeyConfig 10 -- large size so only timeout fires
      -- first message at t=0, tick at t=5 (== batchTimeout) fires it
          evs = [(0, EvArr 0 "a"), (5, EvTick)]
          out = runEvents cfg evs
      map (\(info, _) -> (info.trigger, info.size)) out `shouldBe` [(TriggerTimeout, 1)]

    it "does not emit on a tick before the timeout has elapsed" $ do
      let cfg = partitionKeyConfig 10
          evs = [(0, EvArr 0 "a"), (4, EvTick)] -- 4s < 5s timeout
          -- only the final flush emits
          out = runEvents cfg evs
      map (\(info, _) -> info.trigger) out `shouldBe` [TriggerFlush]

    it "copies the first message's partition into BatchInfo" $ do
      let cfg = partitionKeyConfig 2
          out = runEvents cfg [(0, EvArr 0 "tenant-7"), (0, EvArr 1 "tenant-7")]
      map (\(info, _) -> info.partition) out `shouldBe` [Just "tenant-7"]

  describe "message conservation (property)" $ do
    it "every input message appears in exactly one emitted batch" $
      property prop_conservation

    it "within each emitted batch, cursors are strictly ascending (per-key FIFO)" $
      property prop_fifo

    it "every TriggerSize batch has size == batchSize" $
      property prop_sizeTrigger

  describe "IO engine runBatcher" $ do
    it "conserves messages over a finite stream" $ do
      let cfg = partitionKeyConfig 3
          ings = [mkIng i (BatchKey (if even i then "a" else "b")) | i <- [0 .. 19]]
      out <- Stream.fold Fold.toList (runBatcher 8 cfg (Stream.fromList ings))
      sort (batchIds out) `shouldBe` sort [ing.envelope.messageId | ing <- ings]

    it "emits full size-3 batches for a single key" $ do
      let cfg = partitionKeyConfig 3
          ings = [mkIng i "a" | i <- [0 .. 6]] -- 7 messages
      out <- Stream.fold Fold.toList (runBatcher 8 cfg (Stream.fromList ings))
      map (\(info, _) -> (info.trigger, info.size)) out
        `shouldBe` [(TriggerSize, 3), (TriggerSize, 3), (TriggerFlush, 1)]

    it "emits a timeout batch on a slow stream before it ends" $ do
      -- A stream that yields message 0, then stalls > batchTimeout before 1.
      let cfg =
            (partitionKeyConfig 100) -- big size => only timeout/flush can fire
              { batchTimeout = 0.1, -- 100 ms
                tickInterval = Just 0.02 -- scan every 20 ms
              }
          slow =
            Stream.unfoldrM
              ( \n ->
                  if n >= 2
                    then pure Nothing
                    else do
                      -- delay before the SECOND message so message 0 times out
                      if n == 1 then threadDelay 250000 else pure ()
                      pure (Just (mkIng n "a", n + 1))
              )
              (0 :: Int)
      out <- Stream.fold Fold.toList (runBatcher 8 cfg slow)
      any (\(info, _) -> info.trigger == TriggerTimeout) out `shouldBe` True
      sort (batchIds out) `shouldBe` [MessageId "m-0", MessageId "m-1"]

-- QuickCheck generators and properties -------------------------------------

-- | A random schedule: arrivals with unique ids and random keys, interspersed
-- with ticks, plus a random batchSize. Time advances 1s per event; batchTimeout
-- is fixed at 5s in partitionKeyConfig, so ticks can fire real timeout batches.
genSchedule :: Gen (Int, [(Double, Ev)])
genSchedule = do
  sz <- choose (1, 8)
  n <- choose (0, 60)
  keys <- vectorOf n (elements ["a", "b", "c"])
  let arrivals = zipWith EvArr [0 ..] (map BatchKey keys)
  -- Interleave ticks randomly.
  evs <- interleaveTicks arrivals
  let timed = zip (map fromIntegral [0 :: Int ..]) evs
  pure (sz, timed)
  where
    interleaveTicks [] = pure []
    interleaveTicks (a : as) = do
      addTick <- arbitrary
      rest <- interleaveTicks as
      pure (if addTick then a : EvTick : rest else a : rest)

arrivalIds :: [(Double, Ev)] -> [MessageId]
arrivalIds evs = [MessageId ("m-" <> tshow i) | (_, EvArr i _) <- evs]

prop_conservation :: Property
prop_conservation = forAll genSchedule $ \(sz, evs) ->
  let cfg = partitionKeyConfig sz
      out = runEvents cfg evs
   in sort (batchIds out) === sort (arrivalIds evs)

prop_fifo :: Property
prop_fifo = forAll genSchedule $ \(sz, evs) ->
  let cfg = partitionKeyConfig sz
      out = runEvents cfg evs
   in conjoin [strictlyAscending (batchCursors ne) | (_, ne) <- out]
  where
    strictlyAscending xs = xs === sort xs

prop_sizeTrigger :: Property
prop_sizeTrigger = forAll genSchedule $ \(sz, evs) ->
  let cfg = partitionKeyConfig sz
      out = runEvents cfg evs
   in conjoin
        [ info.size === sz
        | (info, _) <- out,
          info.trigger == TriggerSize
        ]