packages feed

shibuya-core-0.11.0.0: src/Shibuya/Core/Metrics.hs

-- | Metrics and state tracking for processors.
-- Provides introspection into what's happening in the system.
-- Detailed accounting is opt-in through 'MetricsDetailed': a bounded synchronized
-- slot per concurrent attempt measures handler plus finalization duration and
-- publishes oldest identity/start/age. Interrupted attempts are abandoned; basic
-- handles retain counters without detail clocks or ownership brackets. Detailed
-- progress records successful acknowledgements with a bounded partition snapshot.
module Shibuya.Core.Metrics
  ( -- * Processor State
    ProcessorState (..),
    ProcessorId (..),

    -- * In-Flight Tracking
    InFlightInfo (..),
    emptyInFlightInfo,

    -- * Stream Statistics
    StreamStats (..),
    emptyStreamStats,

    -- * Batch Statistics
    BatchStats (..),
    emptyBatchStats,
    incBatchesEmitted,
    addBatchedMessages,
    incPartialFailures,
    incSizeTriggered,
    incTimeoutTriggered,
    incFlushTriggered,

    -- * Combined Metrics
    ProcessorMetrics (..),
    emptyProcessorMetrics,
    HotCounters (..),
    MetricsHandle (..),
    metricsDetailLevel,
    MetricsDetail (..),
    DetailOptions (..),
    defaultDetailOptions,
    defaultMetricsDetail,
    LatencyBucket (..),
    LatencySummary (..),
    OldestInFlight (..),
    ProgressSnapshot (..),
    recordAcknowledgedCursor,
    latencyBucketBoundsSeconds,
    latencyBucketLabels,
    InFlightSlots,
    claimInFlightSlot,
    releaseInFlightSlot,
    abandonInFlightSlot,
    slotMisses,
    newMetricsHandleWith,
    newMetricsHandleWithClockAndDetail,
    newMetricsHandle,
    newMetricsHandleWithClock,
    sampleMetrics,
    markPaused,
    markResumed,
    markPausedSTM,
    markResumedSTM,
    incrementReceived,
    beginProcessing,
    AckDecisionMetric (..),
    finishProcessing,
    finishFinalizationFailure,
    BatchTriggerMetric (..),
    recordBatchOutcomeMetrics,

    -- * Metrics Map
    MetricsMap,

    -- * Metrics Updates
    incReceived,
    incProcessed,
    incFailed,
  )
where

import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar, readMVar)
import Control.Concurrent.STM (STM, TVar, atomically, modifyTVar', newTVarIO, readTVarIO)
import Control.Exception (mask_)
import Control.Monad (void, when)
import Data.Aeson (FromJSON (..), FromJSONKey (..), ToJSON (..), ToJSONKey (..), object, withObject, (.:), (.:?))
import Data.Aeson qualified as Aeson
import Data.Atomics.Counter (AtomicCounter, incrCounter, readCounter)
import Data.Atomics.Counter qualified as Counter
import Data.IORef (IORef, atomicModifyIORef', atomicWriteIORef, newIORef, readIORef, writeIORef)
import Data.IntMap.Strict qualified as IntMap
import Data.List (minimumBy)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Ord (comparing)
import Data.Text qualified as Text
import Data.Time.Clock (addUTCTime)
import Data.Word (Word64)
import GHC.Clock (getMonotonicTimeNSec)
import Shibuya.Core.Types (Cursor, MessageId)
import Shibuya.Prelude

-- | Processor identifier.
newtype ProcessorId = ProcessorId {unProcessorId :: Text}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (ToJSON, FromJSON, ToJSONKey, FromJSONKey)

-- | Tracks concurrent in-flight messages.
data InFlightInfo = InFlightInfo
  { -- | Currently processing count
    inFlight :: !Int,
    -- | Configured max concurrency (1 for Serial)
    maxConcurrency :: !Int
  }
  deriving stock (Eq, Show, Generic)

instance ToJSON InFlightInfo where
  toJSON info =
    object
      [ "inFlight" Aeson..= info.inFlight,
        "maxConcurrency" Aeson..= info.maxConcurrency
      ]

instance FromJSON InFlightInfo where
  parseJSON = withObject "InFlightInfo" $ \v ->
    InFlightInfo
      <$> v .: "inFlight"
      <*> v .: "maxConcurrency"

-- | Create empty in-flight info with given max concurrency.
emptyInFlightInfo :: Int -> InFlightInfo
emptyInFlightInfo = InFlightInfo 0

-- | Processor runtime state.
data ProcessorState
  = -- | Waiting for messages
    Idle
  | -- | Currently processing (in-flight info, sampled burst start, sampled last progress)
    Processing !InFlightInfo !UTCTime !UTCTime
  | -- | Intake paused (live in-flight info, pause time, sampled last progress)
    Paused !InFlightInfo !UTCTime !UTCTime
  | -- | Failed with error (error message, timestamp)
    Failed !Text !UTCTime
  | -- | Processor has been stopped
    Stopped
  deriving stock (Eq, Show, Generic)

instance ToJSON ProcessorState where
  toJSON Idle = object ["status" Aeson..= ("idle" :: Text)]
  toJSON (Processing info lastActivity lastProgress) =
    object
      [ "status" Aeson..= ("processing" :: Text),
        "inFlight" Aeson..= info.inFlight,
        "maxConcurrency" Aeson..= info.maxConcurrency,
        "lastActivity" Aeson..= lastActivity,
        "lastProgress" Aeson..= lastProgress
      ]
  toJSON (Paused info pausedAt lastProgress) =
    object
      [ "status" Aeson..= ("paused" :: Text),
        "inFlight" Aeson..= info.inFlight,
        "maxConcurrency" Aeson..= info.maxConcurrency,
        "pausedAt" Aeson..= pausedAt,
        "lastProgress" Aeson..= lastProgress
      ]
  toJSON (Failed err timestamp) =
    object
      [ "status" Aeson..= ("failed" :: Text),
        "error" Aeson..= err,
        "timestamp" Aeson..= timestamp
      ]
  toJSON Stopped = object ["status" Aeson..= ("stopped" :: Text)]

instance FromJSON ProcessorState where
  parseJSON = withObject "ProcessorState" $ \v -> do
    status <- v .: "status"
    case status :: Text of
      "idle" -> pure Idle
      "processing" -> do
        inFlightCount <- v .: "inFlight"
        maxConc <- v .: "maxConcurrency"
        lastActivity <- v .: "lastActivity"
        lastProgress <- v .:? "lastProgress"
        pure $ Processing (InFlightInfo inFlightCount maxConc) lastActivity (fromMaybe lastActivity lastProgress)
      "paused" -> Paused <$> (InFlightInfo <$> v .: "inFlight" <*> v .: "maxConcurrency") <*> v .: "pausedAt" <*> v .: "lastProgress"
      "failed" -> Failed <$> v .: "error" <*> v .: "timestamp"
      "stopped" -> pure Stopped
      other -> fail $ "Unknown processor state: " <> Text.unpack other

-- | Stream statistics.
data StreamStats = StreamStats
  { -- | Messages received from stream
    received :: !Int,
    -- | Messages successfully processed
    processed :: !Int,
    -- | Messages that failed processing
    failed :: !Int
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

-- | Empty stream stats.
emptyStreamStats :: StreamStats
emptyStreamStats = StreamStats 0 0 0

-- | Batch-processing statistics, tracked alongside per-message stream stats.
data BatchStats = BatchStats
  { -- | Number of batches emitted and executed.
    batchesEmitted :: !Int,
    -- | Total messages across all emitted batches.
    batchedMessages :: !Int,
    -- | Batches with a genuine partial failure: the handler returned normally
    -- and named at least one message in its decision map with a failing
    -- decision (dead-letter or retry) while acking the rest. Counted per batch,
    -- not per message, so it does not double-count the per-message 'failed'
    -- counter.
    partialFailures :: !Int,
    -- | Batches emitted because they reached the configured size.
    sizeTriggered :: !Int,
    -- | Batches emitted because their timeout elapsed.
    timeoutTriggered :: !Int,
    -- | Batches emitted because the processor was draining (flush).
    flushTriggered :: !Int
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

-- | Empty batch stats (all zero).
emptyBatchStats :: BatchStats
emptyBatchStats = BatchStats 0 0 0 0 0 0

-- | Basic accounting is the default. Detailed timing includes handler execution
-- and all finalization retries, once per message attempt or emitted batch.
data MetricsDetail = MetricsBasic | MetricsDetailed !DetailOptions
  deriving stock (Eq, Show, Generic)

-- | Bounds for optional detail. Zero partitions reserves latest-only progress.
data DetailOptions = DetailOptions {maxTrackedPartitions :: !Int}
  deriving stock (Eq, Show, Generic)

defaultDetailOptions :: DetailOptions
defaultDetailOptions = DetailOptions 256

defaultMetricsDetail :: MetricsDetail
defaultMetricsDetail = MetricsBasic

data LatencyBucket = LatencyBucket {le :: !Double, count :: !Integer}
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data LatencySummary = LatencySummary
  {count :: !Integer, sumSeconds :: !Double, buckets :: ![LatencyBucket]}
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

data OldestInFlight = OldestInFlight
  {messageId :: !MessageId, startedAt :: !UTCTime, ageSeconds :: !Double}
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

-- | Last successful finalization recorded, with bounded partition detail.
-- Cursors are opaque; concurrent completion order need not be source order.
data ProgressSnapshot = ProgressSnapshot
  { latest :: !(Maybe Cursor),
    partitions :: !(Map Text Cursor),
    truncated :: !Bool
  }
  deriving stock (Eq, Show, Generic)

instance ToJSON ProgressSnapshot where
  toJSON = Aeson.genericToJSON omitNothingOptions

instance FromJSON ProgressSnapshot where
  parseJSON = Aeson.genericParseJSON omitNothingOptions

data ProgressTable = ProgressTable
  {snapshot :: !(TVar ProgressSnapshot), maxPartitions :: !Int}

-- | Record only after successful AckOk or AckDeadLetter finalization.
-- Missing cursors and basic handles do not enter STM.
recordAcknowledgedCursor :: MetricsHandle -> Maybe Text -> Maybe Cursor -> IO ()
recordAcknowledgedCursor handle partition cursor = case (handle.progressTable, cursor) of
  (Just table, Just position) -> atomically $ modifyTVar' table.snapshot $ \old ->
    let (partitions, truncated) = case partition of
          Nothing -> (old.partitions, old.truncated)
          Just key
            | Map.member key old.partitions || Map.size old.partitions < table.maxPartitions ->
                (Map.insert key position old.partitions, old.truncated)
            | otherwise -> (old.partitions, True)
     in ProgressSnapshot (Just position) partitions truncated
  _ -> pure ()

latencyBucketBoundsSeconds :: [Double]
latencyBucketBoundsSeconds = [0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60]

latencyBucketBoundsNs :: [Word64]
latencyBucketBoundsNs = [100_000, 250_000, 500_000, 1_000_000, 2_500_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 100_000_000, 250_000_000, 500_000_000, 1_000_000_000, 2_500_000_000, 5_000_000_000, 10_000_000_000, 30_000_000_000, 60_000_000_000]

latencyBucketLabels :: [Text]
latencyBucketLabels = ["0.0001", "0.00025", "0.0005", "0.001", "0.0025", "0.005", "0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1", "2.5", "5", "10", "30", "60", "+Inf"]

-- Each slot is also a histogram shard. Its lock is never held across user code.
data DetailSlot = DetailSlot
  { start :: !(Maybe (MessageId, Word64)),
    observations :: !Integer,
    totalNs :: !Integer,
    bins :: !(IntMap.IntMap Integer)
  }

data InFlightSlots = InFlightSlots
  {cells :: ![MVar DetailSlot], misses :: !(IORef Integer)}

-- | Diagnostic exhaustion count. Supported runners must never miss a slot.
slotMisses :: MetricsHandle -> IO Integer
slotMisses handle = maybe (pure 0) (readIORef . (.misses)) handle.slots

-- | Claim a bounded slot, publishing identity and start under the same lock.
-- Callers must mask ownership transfer until they install exception cleanup.
claimInFlightSlot :: MetricsHandle -> MessageId -> IO Int
claimInFlightSlot handle messageId = case handle.slots of
  Nothing -> pure (-1)
  Just table -> mask_ $ do
    now <- handle.progressClock
    let claim _ [] = do
          atomicModifyIORef' table.misses (\n -> (n + 1, ()))
          pure (-1)
        claim index (cell : rest) = do
          owned <- modifyMVar cell $ \slot -> case slot.start of
            Nothing -> pure (slot {start = Just (messageId, now)}, True)
            Just _ -> pure (slot, False)
          if owned then pure index else claim (index + 1) rest
    claim 0 table.cells
{-# INLINE claimInFlightSlot #-}

-- | Complete one attempt, including finalization. Interrupted attempts instead
-- use 'abandonInFlightSlot' and contribute no completed observation.
releaseInFlightSlot :: MetricsHandle -> Int -> IO ()
releaseInFlightSlot handle index = withSlot handle index $ \cell -> mask_ $ do
  now <- handle.progressClock
  modifyMVar_ cell $ \slot -> case slot.start of
    Nothing -> pure slot
    Just (_, began) -> do
      let duration = if now >= began then now - began else 0
          bucketIndex = length (takeWhile (< duration) latencyBucketBoundsNs)
      pure $!
        DetailSlot
          Nothing
          (slot.observations + 1)
          (slot.totalNs + toInteger duration)
          (IntMap.insertWith (+) bucketIndex 1 slot.bins)

abandonInFlightSlot :: MetricsHandle -> Int -> IO ()
abandonInFlightSlot handle index = withSlot handle index $ \cell ->
  modifyMVar_ cell $ \slot -> pure $! slot {start = Nothing}

withSlot :: MetricsHandle -> Int -> (MVar DetailSlot -> IO ()) -> IO ()
withSlot handle index action
  | index < 0 = pure ()
  | otherwise = case handle.slots of
      Nothing -> pure ()
      Just table -> case drop index table.cells of
        cell : _ -> action cell
        [] -> pure ()

-- Copy each strict shard under its lock, format only after releasing it.
-- Sampling across slots is best effort; identity and timestamp always agree.
sampleDetail :: MetricsHandle -> IO (Maybe LatencySummary, Maybe OldestInFlight)
sampleDetail handle = case handle.slots of
  Nothing -> pure (Nothing, Nothing)
  Just table -> do
    copies <- mapM readMVar table.cells
    let observations = sum (map (.observations) copies)
        totalNs = sum (map (.totalNs) copies)
        bins = IntMap.unionsWith (+) (map (.bins) copies)
        cumulative = drop 1 (scanl (+) 0 [IntMap.findWithDefault 0 i bins | i <- [0 .. 17]])
        summary =
          if observations == 0
            then Nothing
            else
              Just
                ( LatencySummary
                    observations
                    (fromInteger totalNs / 1_000_000_000)
                    (zipWith LatencyBucket latencyBucketBoundsSeconds cumulative)
                )
        starts = [start | DetailSlot {start = Just start} <- copies]
    oldest <- case starts of
      [] -> pure Nothing
      _ -> do
        now <- handle.progressClock
        let (messageId, began) = minimumBy (comparing snd) starts
            elapsed = if now >= began then now - began else 0
        pure $
          Just
            ( OldestInFlight
                messageId
                (monotonicToUTC handle began)
                (fromIntegral elapsed / 1_000_000_000)
            )
    pure (summary, oldest)

-- | Combined processor metrics.
data ProcessorMetrics = ProcessorMetrics
  { -- | Current state
    state :: !ProcessorState,
    -- | Per-message statistics
    stats :: !StreamStats,
    -- | Batch statistics
    batch :: !BatchStats,
    -- | When the processor started
    startedAt :: !UTCTime,
    latency :: !(Maybe LatencySummary),
    oldestInFlight :: !(Maybe OldestInFlight),
    progress :: !(Maybe ProgressSnapshot)
  }
  deriving stock (Eq, Show, Generic)

instance ToJSON ProcessorMetrics where
  toJSON = Aeson.genericToJSON omitNothingOptions

instance FromJSON ProcessorMetrics where
  parseJSON = Aeson.genericParseJSON omitNothingOptions

omitNothingOptions :: Aeson.Options
omitNothingOptions = Aeson.defaultOptions {Aeson.omitNothingFields = True}

-- | Empty processor metrics.
emptyProcessorMetrics :: UTCTime -> ProcessorMetrics
emptyProcessorMetrics now =
  ProcessorMetrics
    { state = Idle,
      stats = emptyStreamStats,
      batch = emptyBatchStats,
      startedAt = now,
      latency = Nothing,
      oldestInFlight = Nothing,
      progress = Nothing
    }

-- | Map of processor IDs to their metrics.
type MetricsMap = Map ProcessorId ProcessorMetrics

-- | Hot per-message counters for one processor.
--
-- These counters are updated by fetch-and-add operations on the message hot
-- path. The colder 'ProcessorMetrics' TVar inside 'MetricsHandle' stores state,
-- batch counters, and metadata that change less frequently.
data HotCounters = HotCounters
  { received :: !AtomicCounter,
    processed :: !AtomicCounter,
    failed :: !AtomicCounter,
    inFlight :: !AtomicCounter
  }

-- | Write-side metrics handle for one processor.
data MetricsHandle = MetricsHandle
  { hot :: !HotCounters,
    maxConcurrencyRef :: !(IORef Int),
    burstStartedRef :: !(IORef UTCTime),
    lastProgressRef :: !(IORef Word64),
    progressOriginNs :: !Word64,
    progressOriginTime :: !UTCTime,
    progressClock :: !(IO Word64),
    lastObservedProgress :: !(IORef (Int, Int, Int)),
    stateActiveRef :: !(IORef Bool),
    detail :: !MetricsDetail,
    slots :: !(Maybe InFlightSlots),
    progressTable :: !(Maybe ProgressTable),
    cold :: !(TVar ProcessorMetrics)
  }

-- | Read the configured level once, outside the processing loop. Keep this
-- accessor opaque: exposing the record match lets GHC carry the individual
-- counter references through each basic worker closure instead of one handle.
{-# OPAQUE metricsDetailLevel #-}
metricsDetailLevel :: MetricsHandle -> MetricsDetail
metricsDetailLevel handle = handle.detail

newMetricsHandle :: UTCTime -> IO MetricsHandle
newMetricsHandle = newMetricsHandleWithClock getMonotonicTimeNSec

-- | Create a metrics handle with an injectable monotonic clock. The custom
-- clock is intended for deterministic tests; production callers should use
-- 'newMetricsHandle'.
newMetricsHandleWithClock :: IO Word64 -> UTCTime -> IO MetricsHandle
newMetricsHandleWithClock clock = newMetricsHandleWithClockAndDetail clock MetricsBasic 1

newMetricsHandleWith :: MetricsDetail -> Int -> UTCTime -> IO MetricsHandle
newMetricsHandleWith = newMetricsHandleWithClockAndDetail getMonotonicTimeNSec

newMetricsHandleWithClockAndDetail :: IO Word64 -> MetricsDetail -> Int -> UTCTime -> IO MetricsHandle
newMetricsHandleWithClockAndDetail clock detail maxConcurrency now = do
  when (maxConcurrency < 1) $ ioError (userError "metrics concurrency must be positive")
  case detail of
    MetricsDetailed options
      | options.maxTrackedPartitions < 0 ->
          ioError (userError "maxTrackedPartitions must be nonnegative")
    _ -> pure ()
  slots <- case detail of
    MetricsBasic -> pure Nothing
    MetricsDetailed _ -> do
      cells <- mapM (const (newMVar (DetailSlot Nothing 0 0 IntMap.empty))) [1 .. maxConcurrency]
      misses <- newIORef 0
      pure (Just (InFlightSlots cells misses))
  progressOriginNs <- clock
  progressTable <- case detail of
    MetricsBasic -> pure Nothing
    MetricsDetailed options -> do
      snapshot <- newTVarIO (ProgressSnapshot Nothing Map.empty False)
      pure (Just (ProgressTable snapshot options.maxTrackedPartitions))
  received <- Counter.newCounter 0
  processed <- Counter.newCounter 0
  failed <- Counter.newCounter 0
  inFlight <- Counter.newCounter 0
  maxConcurrencyRef <- newIORef 1
  burstStartedRef <- newIORef now
  lastProgressRef <- newIORef progressOriginNs
  lastObservedProgress <- newIORef (0, 0, 0)
  stateActiveRef <- newIORef False
  cold <- newTVarIO (emptyProcessorMetrics now)
  pure
    MetricsHandle
      { hot =
          HotCounters
            { received = received,
              processed = processed,
              failed = failed,
              inFlight = inFlight
            },
        maxConcurrencyRef = maxConcurrencyRef,
        burstStartedRef = burstStartedRef,
        lastProgressRef = lastProgressRef,
        progressOriginNs = progressOriginNs,
        progressOriginTime = now,
        progressClock = clock,
        lastObservedProgress = lastObservedProgress,
        stateActiveRef = stateActiveRef,
        detail = detail,
        slots = slots,
        progressTable = progressTable,
        cold = cold
      }

sampleMetrics :: MetricsHandle -> IO ProcessorMetrics
sampleMetrics handle = do
  coldSnapshot <- readTVarIO handle.cold
  received <- readCounter handle.hot.received
  processed <- readCounter handle.hot.processed
  failed <- readCounter handle.hot.failed
  inFlight <- readCounter handle.hot.inFlight
  maxConcurrency <- readIORef handle.maxConcurrencyRef
  observeProgress handle processed failed inFlight
  burstStartedAt <- readIORef handle.burstStartedRef
  lastProgressNs <- readIORef handle.lastProgressRef
  let lastProgressAt = monotonicToUTC handle lastProgressNs
  let sampledStats =
        StreamStats
          { received = received,
            processed = processed,
            failed = failed
          }
      sampledState = case coldSnapshot.state of
        Failed err timestamp -> Failed err timestamp
        Stopped -> Stopped
        Paused _ since _ -> Paused (InFlightInfo inFlight maxConcurrency) since lastProgressAt
        _ | inFlight > 0 -> Processing (InFlightInfo inFlight maxConcurrency) burstStartedAt lastProgressAt
        _ -> Idle
  (latency, oldestInFlight) <- sampleDetail handle
  progress <- case handle.progressTable of
    Nothing -> pure Nothing
    Just table -> do
      snapshot <- readTVarIO table.snapshot
      pure $ case snapshot.latest of
        Nothing -> Nothing
        Just _ -> Just snapshot
  pure coldSnapshot {state = sampledState, stats = sampledStats, latency, oldestInFlight, progress}

-- | Publish pause intent without resetting the last-progress clock.
markPaused :: MetricsHandle -> UTCTime -> IO ()
markPaused handle = atomically . markPausedSTM handle

markPausedSTM :: MetricsHandle -> UTCTime -> STM ()
markPausedSTM handle since = modifyTVar' handle.cold $ \m -> case m.state of
  Failed {} -> m
  Stopped -> m
  Paused {} -> m
  _ -> m {state = Paused (InFlightInfo 0 0) since since}

-- | Clear only a pause; terminal and failure observations retain precedence.
markResumed :: MetricsHandle -> IO ()
markResumed = atomically . markResumedSTM

markResumedSTM :: MetricsHandle -> STM ()
markResumedSTM handle = modifyTVar' handle.cold $ \m -> case m.state of
  Paused {} -> m {state = Idle}
  _ -> m

incrementReceived :: MetricsHandle -> IO ()
incrementReceived handle =
  void $ incrCounter 1 handle.hot.received

beginProcessing :: MetricsHandle -> Int -> IO Int
beginProcessing handle maxConcurrency = do
  currentInflight <- incrCounter 1 handle.hot.inFlight
  when (currentInflight == 1) $ do
    writeIORef handle.maxConcurrencyRef maxConcurrency
    writeIORef handle.stateActiveRef True
  pure currentInflight

finishProcessing :: MetricsHandle -> Either Text AckDecisionMetric -> IO ()
finishProcessing handle result = do
  case result of
    Right CountProcessed -> void $ incrCounter 1 handle.hot.processed
    Right CountFailed -> void $ incrCounter 1 handle.hot.failed
    Right CountNeither -> pure ()
    Right (CountHalt _) -> pure ()
    Left _ -> void $ incrCounter 1 handle.hot.failed
  remaining <- decrementCounterFloorZero handle.hot.inFlight
  when (remaining == 0) $ writeIORef handle.stateActiveRef False
  case result of
    Left failureText -> setFailed failureText
    Right (CountHalt reasonText) -> setFailed reasonText
    _ -> pure ()
  where
    setFailed failureText = do
      now <- getCurrentTime
      writeIORef handle.stateActiveRef False
      atomically $
        modifyTVar' handle.cold $ \m ->
          m {state = Failed failureText now}

finishFinalizationFailure :: MetricsHandle -> Text -> IO ()
finishFinalizationFailure handle failureText =
  finishProcessing handle (Left failureText)

data AckDecisionMetric
  = CountProcessed
  | CountFailed
  | CountNeither
  | CountHalt !Text

recordBatchOutcomeMetrics ::
  MetricsHandle ->
  BatchTriggerMetric ->
  Int ->
  Bool ->
  Bool ->
  [AckDecisionMetric] ->
  Maybe Text ->
  IO ()
recordBatchOutcomeMetrics handle trigger size handlerThrew partialInc decisions firstHalt = do
  let (processedDelta, failedDelta) =
        foldl'
          ( \(processedAcc, failedAcc) decision ->
              if handlerThrew
                then (processedAcc, failedAcc + 1)
                else case decision of
                  CountProcessed -> (processedAcc + 1, failedAcc)
                  CountFailed -> (processedAcc, failedAcc + 1)
                  CountNeither -> (processedAcc, failedAcc)
                  CountHalt _ -> (processedAcc, failedAcc)
          )
          (0, 0)
          decisions
  when (processedDelta /= 0) $
    void $
      incrCounter processedDelta handle.hot.processed
  when (failedDelta /= 0) $
    void $
      incrCounter failedDelta handle.hot.failed
  remaining <- decrementCounterFloorZero handle.hot.inFlight
  now <- getCurrentTime
  atomically $
    modifyTVar' handle.cold $ \m ->
      let newState = case firstHalt of
            Just reasonText -> Failed reasonText now
            Nothing -> case m.state of
              Failed {} -> m.state
              Stopped -> Stopped
              _ -> m.state
          newBatch =
            incTriggerMetric trigger
              . (if partialInc then incPartialFailures else id)
              . addBatchedMessages size
              . incBatchesEmitted
              $ m.batch
       in m {state = newState, batch = newBatch}
  case firstHalt of
    Just _ -> writeIORef handle.stateActiveRef False
    Nothing -> when (remaining == 0) $ writeIORef handle.stateActiveRef False

observeProgress :: MetricsHandle -> Int -> Int -> Int -> IO ()
observeProgress handle processed failed inFlight = do
  let current = (processed, failed, inFlight)
  previous@(_, _, previousInFlight) <- readIORef handle.lastObservedProgress
  when (current /= previous) $ do
    progressNs <- handle.progressClock
    atomicWriteIORef handle.lastProgressRef progressNs
    atomicWriteIORef handle.lastObservedProgress current
    -- Burst boundaries are sampled observations: an observed idle-to-active
    -- transition restamps lastActivity, while a continuously active processor
    -- only advances lastProgress. An idle gap wholly between samples cannot
    -- affect stuck detection because changed counters still advance progress.
    when (inFlight > 0 && previousInFlight == 0) $
      atomicWriteIORef handle.burstStartedRef (monotonicToUTC handle progressNs)

monotonicToUTC :: MetricsHandle -> Word64 -> UTCTime
monotonicToUTC handle progressNs =
  let elapsedNs
        | progressNs >= handle.progressOriginNs = progressNs - handle.progressOriginNs
        | otherwise = 0
      elapsedSeconds = fromIntegral elapsedNs / 1_000_000_000
   in addUTCTime elapsedSeconds handle.progressOriginTime

decrementCounterFloorZero :: AtomicCounter -> IO Int
decrementCounterFloorZero counter = do
  remaining <- incrCounter (-1) counter
  if remaining >= 0
    then pure remaining
    else do
      -- Underflow means a caller completed work it did not begin. Repair the
      -- decrement so externally sampled in-flight never remains below zero;
      -- the valid hot path stays one fetch-and-add operation.
      Counter.incrCounter_ 1 counter
      pure 0

data BatchTriggerMetric
  = CountTriggerSize
  | CountTriggerTimeout
  | CountTriggerFlush

incTriggerMetric :: BatchTriggerMetric -> BatchStats -> BatchStats
incTriggerMetric CountTriggerSize = incSizeTriggered
incTriggerMetric CountTriggerTimeout = incTimeoutTriggered
incTriggerMetric CountTriggerFlush = incFlushTriggered

-- | Increment received count.
incReceived :: StreamStats -> StreamStats
incReceived StreamStats {received, processed, failed} =
  StreamStats {received = received + 1, processed = processed, failed = failed}

-- | Increment processed count.
incProcessed :: StreamStats -> StreamStats
incProcessed StreamStats {received, processed, failed} =
  StreamStats {received = received, processed = processed + 1, failed = failed}

-- | Increment failed count.
incFailed :: StreamStats -> StreamStats
incFailed StreamStats {received, processed, failed} =
  StreamStats {received = received, processed = processed, failed = failed + 1}

-- | Increment the emitted-batch counter.
incBatchesEmitted :: BatchStats -> BatchStats
incBatchesEmitted s = s {batchesEmitted = s.batchesEmitted + 1}

-- | Add to the total batched-messages counter.
addBatchedMessages :: Int -> BatchStats -> BatchStats
addBatchedMessages n s = s {batchedMessages = s.batchedMessages + n}

-- | Increment the partial-failure batch counter.
incPartialFailures :: BatchStats -> BatchStats
incPartialFailures s = s {partialFailures = s.partialFailures + 1}

-- | Increment the size-trigger counter.
incSizeTriggered :: BatchStats -> BatchStats
incSizeTriggered s = s {sizeTriggered = s.sizeTriggered + 1}

-- | Increment the timeout-trigger counter.
incTimeoutTriggered :: BatchStats -> BatchStats
incTimeoutTriggered s = s {timeoutTriggered = s.timeoutTriggered + 1}

-- | Increment the flush-trigger counter.
incFlushTriggered :: BatchStats -> BatchStats
incFlushTriggered s = s {flushTriggered = s.flushTriggered + 1}