packages feed

kiroku-metrics-0.1.0.0: test/Test/CollectorSpec.hs

{- | Pure unit tests for the metrics collector: feed scripted 'KirokuEvent' and
'Observation' values through the callback wrappers and assert the resulting
snapshot. No store is opened — the two store-level gauges are supplied by fake
STM readers via 'newKirokuMetricsWith'.
-}
module Test.CollectorSpec (spec) where

import Control.Concurrent.STM (STM)
import Control.Exception (SomeException, toException)
import Control.Monad (forM_)
import Data.Map.Strict qualified as Map
import Data.UUID (UUID, fromWords)
import Hasql.Pool (UsageError (..))
import Test.Hspec

import Kiroku.Metrics (
    LifecycleCounters (..),
    MetricsSnapshot (..),
    StoreGauges (..),
    SubscriptionMetrics (..),
    metricsEventHandler,
    metricsObservationHandler,
    newKirokuMetricsWith,
    snapshotMetrics,
 )
import Kiroku.Store (
    ConnectionReadyForUseReason (..),
    ConnectionStatus (..),
    ConnectionTerminationReason (..),
    KirokuEvent (..),
    Observation (..),
    SubscriptionDbPhase (..),
    SubscriptionGroupContext (..),
    SubscriptionStopReason (..),
 )
import Kiroku.Store.Observability (SubscriptionDeliveryPhase (..))
import Kiroku.Store.Subscription.Types (SubscriptionName (..))
import Kiroku.Store.Types (GlobalPosition (..))

{- | A collector whose fake store readers report the given global position and
active-subscriber count, fed the given scripted events and observations.
-}
runScript ::
    STM GlobalPosition ->
    STM Int ->
    [KirokuEvent] ->
    [Observation] ->
    IO MetricsSnapshot
runScript readPos readSubs events obs = do
    km <- newKirokuMetricsWith readPos readSubs
    forM_ events (metricsEventHandler km Nothing)
    forM_ obs (metricsObservationHandler km Nothing)
    snapshotMetrics km

sub :: SubscriptionName
sub = SubscriptionName "p"

uuidN :: Word -> UUID
uuidN n = fromWords 0 0 0 (fromIntegral n)

-- | A dummy exception for the strict 'SomeException' field of notifier events.
someExc :: SomeException
someExc = toException (userError "test")

-- | A dummy 'UsageError' for the strict field of database-error events.
dbErr :: UsageError
dbErr = AcquisitionTimeoutUsageError

spec :: Spec
spec = describe "Kiroku.Metrics.Collector" $ do
    it "counts notifier reconnect events" $ do
        snap <-
            runScript
                (pure (GlobalPosition 0))
                (pure 0)
                [ KirokuEventNotifierReconnecting 1 someExc
                , KirokuEventNotifierReconnecting 2 someExc
                , KirokuEventNotifierReconnected
                ]
                []
        snap.counters.notifierReconnecting `shouldBe` 2
        snap.counters.notifierReconnected `shouldBe` 1

    it "counts publisher pool and loop errors separately" $ do
        snap <-
            runScript
                (pure (GlobalPosition 0))
                (pure 0)
                [ KirokuEventPublisherPoolError dbErr
                , KirokuEventPublisherLoopError someExc
                , KirokuEventPublisherLoopError someExc
                ]
                []
        snap.counters.publisherPoolErrors `shouldBe` 1
        snap.counters.publisherLoopErrors `shouldBe` 2

    it "records subscription position and derives lag from the global position" $ do
        snap <-
            runScript
                (pure (GlobalPosition 12))
                (pure 3)
                [KirokuEventSubscriptionStarted sub (GlobalPosition 5) NonGroup]
                []
        snap.store.globalPosition `shouldBe` 12
        snap.store.activeSubscribers `shouldBe` 3
        snap.counters.subscriptionsStarted `shouldBe` 1
        case Map.lookup "p" snap.subscriptions of
            Nothing -> expectationFailure "expected subscription \"p\" in snapshot"
            Just m -> do
                m.lastKnownPosition `shouldBe` 5
                m.lag `shouldBe` 7

    it "advances last-known position monotonically and never reports negative lag" $ do
        snap <-
            runScript
                (pure (GlobalPosition 8))
                (pure 1)
                [ KirokuEventSubscriptionStarted sub (GlobalPosition 5) NonGroup
                , KirokuEventSubscriptionCaughtUp sub (GlobalPosition 10) NonGroup
                ]
                []
        case Map.lookup "p" snap.subscriptions of
            Nothing -> expectationFailure "expected subscription \"p\""
            Just m -> do
                m.lastKnownPosition `shouldBe` 10
                m.lag `shouldBe` 0 -- max 0 (8 - 10)
    it "tallies stop reasons and records the last one per subscription" $ do
        snap <-
            runScript
                (pure (GlobalPosition 20))
                (pure 0)
                [ KirokuEventSubscriptionStarted sub (GlobalPosition 0) NonGroup
                , KirokuEventSubscriptionStopped sub (GlobalPosition 20) StopHandlerRequested NonGroup
                ]
                []
        snap.counters.subscriptionsStoppedHandler `shouldBe` 1
        (Map.lookup "p" snap.subscriptions >>= (.lastStopReason)) `shouldBe` Just "handler"

    it "tallies per-phase database errors and the per-subscription error count" $ do
        snap <-
            runScript
                (pure (GlobalPosition 0))
                (pure 0)
                [ KirokuEventSubscriptionDbError sub LoadCheckpoint dbErr NonGroup
                , KirokuEventSubscriptionDbError sub FetchBatch dbErr NonGroup
                , KirokuEventSubscriptionDbError sub FetchBatch dbErr NonGroup
                ]
                []
        snap.counters.subscriptionDbErrorsLoad `shouldBe` 1
        snap.counters.subscriptionDbErrorsFetch `shouldBe` 2
        (Map.lookup "p" snap.subscriptions >>= Just . (.dbErrorCount)) `shouldBe` Just 3

    it "counts delivered batches and sums delivered events" $ do
        snap <-
            runScript
                (pure (GlobalPosition 0))
                (pure 0)
                [ KirokuEventSubscriptionDelivered sub 3 DeliveredCatchUp NonGroup
                , KirokuEventSubscriptionDelivered sub 2 DeliveredLive NonGroup
                , KirokuEventSubscriptionFetched sub 2 NonGroup
                ]
                []
        snap.counters.batchesDelivered `shouldBe` 2
        snap.counters.eventsDelivered `shouldBe` 5
        snap.counters.liveFetches `shouldBe` 1

    it "tracks pool connection gauges and cumulative counters from observations" $ do
        let u = uuidN 1
        snap <-
            runScript
                (pure (GlobalPosition 0))
                (pure 0)
                []
                [ ConnectionObservation u ConnectingConnectionStatus
                , ConnectionObservation u (ReadyForUseConnectionStatus EstablishedConnectionReadyForUseReason)
                , ConnectionObservation u InUseConnectionStatus
                ]
        snap.store.poolConnecting `shouldBe` 0
        snap.store.poolReady `shouldBe` 0
        snap.store.poolInUse `shouldBe` 1
        snap.store.poolEstablishedTotal `shouldBe` 1
        snap.store.poolTerminatedTotal `shouldBe` 0

    it "removes terminated connections from the gauge and bumps the terminated counter" $ do
        let u = uuidN 2
        snap <-
            runScript
                (pure (GlobalPosition 0))
                (pure 0)
                []
                [ ConnectionObservation u (ReadyForUseConnectionStatus EstablishedConnectionReadyForUseReason)
                , ConnectionObservation u (TerminatedConnectionStatus ReleaseConnectionTerminationReason)
                ]
        snap.store.poolReady `shouldBe` 0
        snap.store.poolInUse `shouldBe` 0
        snap.store.poolEstablishedTotal `shouldBe` 1
        snap.store.poolTerminatedTotal `shouldBe` 1