packages feed

kiroku-metrics-0.4.0.0: test/Test/IntegrationSpec.hs

{-# LANGUAGE OverloadedLabels #-}

-- | Integration test: wire the collector into a real ephemeral
-- PostgreSQL-backed store, run a live @$all@ subscription, append events, and
-- assert the snapshot reflects real store activity (not just scripted inputs).
--
-- The ordering wrinkle is that 'newKirokuMetrics' needs the 'KirokuStore' but the
-- callbacks must be installed on 'ConnectionSettings' before 'withStore' creates
-- it. We resolve it with a @TVar (Maybe KirokuStore)@ the snapshot-time STM
-- readers consult; it is filled inside 'withStore'. (A 'TVar', not an 'IORef',
-- because the readers are 'STM'.)
module Test.IntegrationSpec (spec) where

import Control.Concurrent.STM
  ( STM,
    TVar,
    atomically,
    check,
    newTVarIO,
    orElse,
    readTVar,
    registerDelay,
    writeTVar,
  )
import Control.Lens ((&), (.~))
import Control.Monad (unless)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IntMap.Strict qualified as IntMap
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Test.Hspec

import Kiroku.Metrics
  ( LifecycleCounters (..),
    MetricsSnapshot (..),
    StoreGauges (..),
    SubscriptionMetrics (..),
    metricsEventHandler,
    metricsObservationHandler,
    newKirokuMetricsWith,
    snapshotMetrics,
  )
import Kiroku.Store
  ( EventData (..),
    EventType (..),
    ExpectedVersion (..),
    KirokuStore (..),
    StreamName (..),
    SubscriptionResult (..),
    SubscriptionTarget (..),
    appendToStream,
    cancel,
    defaultConnectionSettings,
    defaultSubscriptionConfig,
    runStoreIO,
    subscribe,
    wait,
    withStore,
  )
import Kiroku.Store.Subscription.EventPublisher (EventPublisher (..), publisherPosition)
import Kiroku.Store.Subscription.Types (SubscriptionName (..))
import Kiroku.Store.Types (GlobalPosition (..))
import Kiroku.Test.Postgres (withMigratedTestDatabase)

subName :: SubscriptionName
subName = SubscriptionName "metrics-it"

spec :: Spec
spec = describe "Kiroku.Metrics.Collector (integration)" $ do
  it "observes real store activity: appends, a running subscription, and lag" $
    withMigratedTestDatabase $ \connStr -> do
      -- Deferred store handle for the snapshot-time STM readers.
      storeVar <- newTVarIO Nothing
      km <-
        newKirokuMetricsWith
          (readPosition storeVar)
          (readSubscribers storeVar)
      let settings =
            defaultConnectionSettings connStr
              & #eventHandler .~ Just (metricsEventHandler km Nothing)
              & #observationHandler .~ Just (metricsObservationHandler km Nothing)
      withStore settings $ \store -> do
        atomically (writeTVar storeVar (Just store))

        -- Subscribe to $all on an empty store: the worker goes live
        -- immediately, so the 5 events we append next flow through the
        -- live publisher path and advance publisherPosition.
        delivered <- newTVarIO (0 :: Int)
        let cfg =
              defaultSubscriptionConfig subName AllStreams $ \_event -> do
                atomically (modifyCount delivered)
                pure Continue
        handle <- subscribe store cfg

        appendStoreEvents store (StreamName "metrics-it-stream") 5
        waitForCount delivered 5 10_000_000

        cancel handle
        _ <- wait handle

        snap <- snapshotMetrics km
        snap.store.globalPosition `shouldSatisfy` (>= 5)
        snap.counters.subscriptionsStarted `shouldBe` 1
        snap.counters.subscriptionsStoppedCancelled `shouldBe` 1
        snap.counters.eventsDelivered `shouldSatisfy` (>= 5)
        case Map.lookup "metrics-it" snap.subscriptions of
          Nothing -> expectationFailure "expected subscription \"metrics-it\" in snapshot"
          Just m -> m.lag `shouldSatisfy` (>= 0)

-- | Snapshot-time reader for the store global position, defaulting to 0 until
-- the store is filled in.
readPosition :: TVar (Maybe KirokuStore) -> STM GlobalPosition
readPosition storeVar =
  readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))

-- | Snapshot-time reader for the active-subscriber count.
readSubscribers :: TVar (Maybe KirokuStore) -> STM Int
readSubscribers storeVar =
  readTVar storeVar
    >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))

modifyCount :: TVar Int -> STM ()
modifyCount v = readTVar v >>= \c -> writeTVar v (c + 1)

-- | Append @n@ trivial events to a fresh stream via the IO store interpreter.
appendStoreEvents :: KirokuStore -> StreamName -> Int -> IO ()
appendStoreEvents store stream n = do
  let events =
        [ EventData
            { eventId = Nothing,
              eventType = EventType ("E" <> T.pack (show i)),
              payload = Aeson.Null,
              metadata = Nothing,
              causationId = Nothing,
              correlationId = Nothing
            }
        | i <- [1 .. n]
        ]
  result <- runStoreIO store (appendToStream stream NoStream events)
  case result of
    Right _ -> pure ()
    Left err -> expectationFailure ("appendStoreEvents failed: " <> show err)

-- | Wait until the counter reaches @target@ or the timeout (micros) fires.
waitForCount :: TVar Int -> Int -> Int -> IO ()
waitForCount countVar target timeoutMicros = do
  timeoutVar <- registerDelay timeoutMicros
  ok <-
    atomically $
      (do c <- readTVar countVar; check (c >= target); pure True)
        `orElse` (do t <- readTVar timeoutVar; check t; pure False)
  unless ok $ do
    actual <- atomically (readTVar countVar)
    expectationFailure ("Timed out waiting for " <> show target <> ", got " <> show actual)