packages feed

shibuya-core-0.8.0.0: src/Shibuya/Internal/Runner/Batcher.hs

-- | __Internal module.__ Exposed for the test suite and benchmarks only.
-- No PVP guarantees: anything here may change or disappear in any release.
-- Application authors should import "Shibuya" instead.
--
-- Batch accumulation engine.
--
-- Groups a stream of individual ingested messages into batches by batch key.
-- A batch is emitted when it reaches the configured size, when its per-key
-- timeout elapses, or when the input ends / the processor drains. This module
-- only /regroups/ messages: it never runs a handler and never touches an
-- an ack handle. Its correctness property is message conservation — every input
-- message appears in exactly one emitted batch, and per batch key arrival order
-- is preserved.
--
-- The accumulation logic is a pure, deterministic core (see 'stepArrival',
-- 'stepTick', 'stepFlush') that takes the current time as an argument, so it is
-- property-tested with no threads or wall-clock. 'runBatcher' is a thin IO
-- wrapper that drives the core from a background consumer and a single timeout
-- ticker, buffering results in a bounded queue for backpressure.
module Shibuya.Internal.Runner.Batcher
  ( -- * Pure accumulation core
    Accum (..),
    BatcherState (..),
    emptyBatcherState,
    ReadyBatch,
    stepArrival,
    stepTick,
    stepFlush,

    -- * IO engine
    runBatcher,
  )
where

import Control.Concurrent (threadDelay)
import Control.Concurrent.STM
  ( TVar,
    atomically,
    newTVarIO,
    readTVar,
    readTVarIO,
    retry,
    writeTVar,
  )
import Control.Monad (when)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Sequence (Seq (..), (><))
import Data.Sequence qualified as Seq
import Data.Word (Word64)
import GHC.Clock (getMonotonicTimeNSec)
import Shibuya.Batch (BatchConfig (..), BatchInfo (..), BatchKey, BatchTrigger (..))
import Shibuya.Core.Ingested (Ingested (..))
import Shibuya.Core.Types (Envelope (..))
import Shibuya.Prelude
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream (Stream)
import Streamly.Data.Stream qualified as Stream
import UnliftIO (finally, throwIO)
import UnliftIO.Async (Async, async, cancel, waitCatch)

-- | In-progress state for one batch key.
data Accum es msg = Accum
  { -- | Messages accumulated so far, newest first (reversed arrival order).
    messages :: ![Ingested es msg],
    -- | How many messages are buffered (== length of 'messages'). Always >= 1.
    count :: !Int,
    -- | When the first message for this key arrived (drives the timeout).
    firstArrivalAt :: !Word64,
    -- | Partition of the first message, copied into the emitted batch info.
    partition0 :: !(Maybe Text)
  }

-- | The engine's memory: one accumulator per active batch key.
newtype BatcherState es msg = BatcherState
  { accums :: Map BatchKey (Accum es msg)
  }

-- | An empty state with no groups in progress.
emptyBatcherState :: BatcherState es msg
emptyBatcherState = BatcherState Map.empty

-- | A finished group ready to hand downstream: metadata plus its messages in
-- arrival order. This is the exact value the execution stage (EP-18) consumes.
type ReadyBatch es msg = (BatchInfo, NonEmpty (Ingested es msg))

data DrainStep es msg
  = DrainReady !(ReadyBatch es msg)
  | DrainDone

-- | Build a ReadyBatch from a completed accumulator. Reverses the buffered
-- messages back into arrival order; safe because count >= 1.
emitAccum :: BatchKey -> BatchTrigger -> Accum es msg -> ReadyBatch es msg
emitAccum key trig acc =
  ( BatchInfo
      { batchKey = key,
        size = acc.count,
        trigger = trig,
        partition = acc.partition0
      },
    NE.fromList (reverse acc.messages)
  )

-- | Fold one arriving message into the state. Emits a size-triggered batch iff
-- the message fills its key's accumulator to batchSize.
stepArrival ::
  BatchConfig es msg ->
  Word64 ->
  Ingested es msg ->
  BatcherState es msg ->
  (BatcherState es msg, [ReadyBatch es msg])
stepArrival cfg now ing (BatcherState accums) =
  let key = cfg.batchKey ing.envelope
      (ready, accums') = Map.alterF update key accums
   in (BatcherState accums', ready)
  where
    update Nothing =
      let acc =
            Accum
              { messages = [ing],
                count = 1,
                firstArrivalAt = now,
                partition0 = ing.envelope.partition
              }
       in if cfg.batchSize <= 1
            then ([emitAccum (cfg.batchKey ing.envelope) TriggerSize acc], Nothing)
            else ([], Just acc)
    update (Just acc) =
      let key = cfg.batchKey ing.envelope
          acc' = acc {messages = ing : acc.messages, count = acc.count + 1}
       in if acc'.count >= cfg.batchSize
            then ([emitAccum key TriggerSize acc'], Nothing)
            else ([], Just acc')

-- | Emit every accumulator whose timeout has elapsed as of the supplied time.
stepTick ::
  BatchConfig es msg ->
  Word64 ->
  BatcherState es msg ->
  (BatcherState es msg, [ReadyBatch es msg])
stepTick cfg now (BatcherState accums) =
  let timedOut :: Accum es msg -> Bool
      timedOut acc = now >= acc.firstArrivalAt && now - acc.firstArrivalAt >= nominalToNanos cfg.batchTimeout
      (ripe, keep) = Map.partition timedOut accums
      ready = [emitAccum k TriggerTimeout acc | (k, acc) <- Map.toList ripe]
   in (BatcherState keep, ready)

-- | Emit all remaining accumulators (end-of-input / drain). Leaves the state empty.
stepFlush ::
  BatcherState es msg ->
  (BatcherState es msg, [ReadyBatch es msg])
stepFlush (BatcherState accums) =
  (emptyBatcherState, [emitAccum k TriggerFlush acc | (k, acc) <- Map.toList accums])

-- | Run one pure step in the same STM transaction that appends emitted batches
-- to the pending output buffer. Admission waits while the pending buffer is at
-- capacity, so backpressure blocks without holding a separate mutex. One step
-- may append a burst larger than the remaining capacity; the bound is therefore
-- capacity plus one step's burst.
emitStep ::
  TVar (BatcherState es msg, Seq (ReadyBatch es msg)) ->
  Int ->
  (BatcherState es msg -> (BatcherState es msg, [ReadyBatch es msg])) ->
  IO ()
emitStep stateVar capacity step =
  atomically $ do
    (state, pending) <- readTVar stateVar
    when (Seq.length pending >= capacity) retry
    let (state', ready) = step state
    writeTVar stateVar (state', pending >< Seq.fromList ready)

-- | Convert a 'NominalDiffTime' (seconds) into whole microseconds for
-- 'threadDelay', clamped to at least 1 so a misconfiguration cannot spin.
nominalToMicros :: NominalDiffTime -> Int
nominalToMicros d = max 1 (round (realToFrac d * 1e6 :: Double))

nominalToNanos :: NominalDiffTime -> Word64
nominalToNanos d = round (realToFrac d * 1e9 :: Double)

-- | Group a stream of individual messages into a stream of batches.
--
-- @outputCapacity@ bounds how many finished batches may sit un-consumed (>= 1);
-- when full, the engine stalls, propagating backpressure upstream. @cfg@ carries
-- the size, timeout, key function, and tick interval. The @es@/@msg@ parameters
-- are phantom to the engine (it never runs an effect or acks a message).
runBatcher ::
  Natural ->
  BatchConfig es msg ->
  Stream IO (Ingested es msg) ->
  Stream IO (ReadyBatch es msg)
runBatcher outputCapacity cfg input =
  Stream.bracketIO acquire release consume
  where
    tickMicros = nominalToMicros (fromMaybe cfg.batchTimeout cfg.tickInterval)
    outputCapacityInt = max 1 (fromIntegral outputCapacity)

    acquire = do
      stateVar <- newTVarIO (emptyBatcherState, Seq.empty)
      doneVar <- newTVarIO False

      let onArrival ing = do
            now <- getMonotonicTimeNSec
            emitStep stateVar outputCapacityInt (stepArrival cfg now ing)

          -- Consume the whole input, flush the remainder, then mark done.
          -- 'finally' guarantees doneVar is set even if the input stream throws,
          -- so the output stream below always reaches the drain point. The drain
          -- then observes this async with 'waitCatch' and rethrows any consumer
          -- failure instead of reporting clean completion.
          consumer =
            ( do
                Stream.fold Fold.drain (Stream.mapM onArrival input)
                emitStep stateVar outputCapacityInt stepFlush
            )
              `finally` atomically (writeTVar doneVar True)

          tickerLoop = do
            threadDelay tickMicros
            done <- readTVarIO doneVar
            if done
              then pure ()
              else do
                now <- getMonotonicTimeNSec
                emitStep stateVar outputCapacityInt (stepTick cfg now)
                tickerLoop

      consumerA <- async consumer
      tickerA <- async tickerLoop
      pure (stateVar, doneVar, consumerA, tickerA)

    release (_stateVar, _doneVar, consumerA, tickerA) = do
      cancel tickerA
      cancel consumerA

    consume (stateVar, doneVar, consumerA, _tickerA) = drainQueue consumerA stateVar doneVar

-- | Stream finished batches out of the bounded queue, ending when the consumer
-- has flushed everything (doneVar), the queue has drained, and the consumer
-- async is known to have completed successfully.
drainQueue ::
  Async () ->
  TVar (BatcherState es msg, Seq (ReadyBatch es msg)) ->
  TVar Bool ->
  Stream IO (ReadyBatch es msg)
drainQueue consumerA stateVar doneVar = Stream.unfoldrM step ()
  where
    step _ = do
      drainStep <-
        atomically $ do
          (state, pending) <- readTVar stateVar
          case Seq.viewl pending of
            rb Seq.:< rest -> do
              writeTVar stateVar (state, rest)
              pure (DrainReady rb)
            Seq.EmptyL -> do
              done <- readTVar doneVar
              if done
                then pure DrainDone
                else retry
      case drainStep of
        DrainReady rb -> pure (Just (rb, ()))
        DrainDone ->
          waitCatch consumerA >>= \case
            Left ex -> throwIO ex
            Right () -> pure Nothing