shibuya-core-0.11.0.0: test/Shibuya/Core/MetricsSpec.hs
module Shibuya.Core.MetricsSpec (spec) where
import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar)
import Control.Monad (forM_, replicateM, replicateM_)
import Data.Aeson (eitherDecode, encode)
import Data.IORef
import Data.Map.Strict qualified as Map
import Data.Maybe (fromJust)
import Data.Text qualified as Text
import Data.Time (UTCTime (..), fromGregorian)
import Data.Word (Word64)
import Shibuya.Core.Metrics
import Shibuya.Core.Types (Cursor (..), MessageId (..))
import Test.Hspec
import UnliftIO qualified as UIO
spec :: Spec
spec = describe "Detailed metrics" $ do
it "accepts timestamp zero, bounds claims, and counts slot misses" $ do
(_, h) <- handle 2
sampleMetrics h >>= \m -> m.latency `shouldBe` Nothing
a <- claimInFlightSlot h (MessageId "a")
b <- claimInFlightSlot h (MessageId "b")
a `shouldNotBe` b
claimInFlightSlot h (MessageId "c") `shouldReturn` (-1)
slotMisses h `shouldReturn` 1
sampleMetrics h >>= \m -> fmap (.messageId) m.oldestInFlight `shouldBe` Just (MessageId "a")
abandonInFlightSlot h a
abandonInFlightSlot h b
sampleMetrics h >>= \m -> m.oldestInFlight `shouldBe` Nothing
it "records cumulative buckets and nanosecond sums on release and reuse" $ do
(clock, h) <- handle 1
replicateM_ 100 $ do
start <- readIORef clock
slot <- claimInFlightSlot h (MessageId "a")
writeIORef clock (start + 3_000_000)
releaseInFlightSlot h slot
m <- sampleMetrics h
let summary = fromJust m.latency
summary.count `shouldBe` 100
summary.sumSeconds `shouldBe` 0.3
map (.count) summary.buckets `shouldBe` replicate 5 0 ++ replicate 13 100
m.oldestInFlight `shouldBe` Nothing
it "reports oldest identity, start and growing age together" $ do
(clock, h) <- handle 2
a <- claimInFlightSlot h (MessageId "a")
writeIORef clock 1_000_000_000
_ <- claimInFlightSlot h (MessageId "b")
writeIORef clock 2_000_000_000
sampleMetrics h >>= \m -> m.oldestInFlight `shouldBe` Just (OldestInFlight (MessageId "a") epoch 2)
releaseInFlightSlot h a
sampleMetrics h >>= \m -> fmap (.messageId) m.oldestInFlight `shouldBe` Just (MessageId "b")
it "counts observations beyond the finite bounds without wrapping the sum" $ do
(clock, h) <- handle 1
slot <- claimInFlightSlot h (MessageId "long")
writeIORef clock 70_000_000_000
releaseInFlightSlot h slot
m <- sampleMetrics h
let summary = fromJust m.latency
summary.count `shouldBe` 1
summary.sumSeconds `shouldBe` 70
map (.count) summary.buckets `shouldBe` replicate 18 0
it "never reads the detail clock on the basic hooks" $ do
calls <- newIORef (0 :: Int)
h <- newMetricsHandleWithClock (modifyIORef' calls (+ 1) >> pure 0) epoch
initialCalls <- readIORef calls
claimInFlightSlot h (MessageId "a") `shouldReturn` (-1)
releaseInFlightSlot h (-1)
abandonInFlightSlot h (-1)
m <- sampleMetrics h
m.latency `shouldBe` Nothing
m.oldestInFlight `shouldBe` Nothing
readIORef calls `shouldReturn` initialCalls
it "aggregates lifetime nanoseconds beyond a Word64 across occupied slots" $ do
(clock, h) <- handle 2
a <- claimInFlightSlot h (MessageId "a")
b <- claimInFlightSlot h (MessageId "b")
writeIORef clock maxBound
releaseInFlightSlot h a
releaseInFlightSlot h b
m <- sampleMetrics h
let summary = fromJust m.latency
summary.count `shouldBe` 2
summary.sumSeconds `shouldBe` (fromInteger (2 * toInteger (maxBound :: Word64)) / 1e9)
map (.count) summary.buckets `shouldBe` replicate 18 0
m.oldestInFlight `shouldBe` Nothing
it "publishes complete slots and reuses bounded storage at 1/4/64/256 concurrency" $ do
forM_ [1, 4, 64, 256] $ \capacity -> do
(_, h) <- handle capacity
replicateM_ 100 $ do
gate <- newEmptyMVar
results <- replicateM capacity newEmptyMVar
mapM_ (\out -> forkIO (takeMVar gate >> claimInFlightSlot h (MessageId "same") >>= putMVar out)) results
replicateM_ capacity (putMVar gate ())
claimed <- mapM takeMVar results
length (filter (>= 0) claimed) `shouldBe` capacity
slotMisses h `shouldReturn` 0
sampleMetrics h >>= \m -> fmap (.messageId) m.oldestInFlight `shouldBe` Just (MessageId "same")
mapM_ (abandonInFlightSlot h) claimed
sampleMetrics h >>= \m -> m.oldestInFlight `shouldBe` Nothing
it "samples coherently at gated claim and release clock boundaries" $ replicateM_ 100 $ do
calls <- newIORef (0 :: Int)
claiming <- newEmptyMVar
publish <- newEmptyMVar
releasing <- newEmptyMVar
complete <- newEmptyMVar
let clock = do
n <- atomicModifyIORef' calls (\n -> (n + 1, n))
case n of
0 -> pure 0
1 -> putMVar claiming () >> takeMVar publish >> pure 0
2 -> putMVar releasing () >> takeMVar complete >> pure 3_000_000
_ -> pure 3_000_000
h <- newMetricsHandleWithClockAndDetail clock (MetricsDetailed defaultDetailOptions) 1 epoch
claim <- UIO.async $ claimInFlightSlot h (MessageId "gated")
takeMVar claiming
sampleMetrics h >>= \m -> m.oldestInFlight `shouldBe` Nothing
putMVar publish ()
slot <- UIO.wait claim
release <- UIO.async $ releaseInFlightSlot h slot
takeMVar releasing
m <- sampleMetrics h
fmap (.messageId) m.oldestInFlight `shouldBe` Just (MessageId "gated")
m.latency `shouldBe` Nothing
putMVar complete ()
UIO.wait release
final <- sampleMetrics h
final.oldestInFlight `shouldBe` Nothing
fmap (.count) final.latency `shouldBe` Just 1
it "rejects invalid detail options and concurrency before reading the clock" $ do
let noClock = error "clock acquired before validation"
newMetricsHandleWithClockAndDetail noClock (MetricsDetailed (DetailOptions (-1))) 1 epoch `shouldThrow` anyIOException
newMetricsHandleWithClockAndDetail noClock (MetricsDetailed defaultDetailOptions) 0 epoch `shouldThrow` anyIOException
describe "acknowledged cursor progress" $ do
it "omits missing cursors and ignores basic accounting" $ do
basic <- newMetricsHandle epoch
(_, detailed) <- handle 1
recordAcknowledgedCursor basic (Just "a") (Just (CursorInt 7))
recordAcknowledgedCursor detailed (Just "a") Nothing
mapM_ (\h -> sampleMetrics h >>= \m -> m.progress `shouldBe` Nothing) [basic, detailed]
it "records latest completion, replaces known keys and keeps truncation sticky" $ do
h <- newMetricsHandleWith (MetricsDetailed (DetailOptions 2)) 1 epoch
recordAcknowledgedCursor h Nothing (Just (CursorInt 7))
sampleMetrics h >>= \m -> m.progress `shouldBe` Just (ProgressSnapshot (Just (CursorInt 7)) Map.empty False)
forM_ [("a", 8), ("b", 9), ("c", 10), ("a", 3)] $ \(key, n) -> recordAcknowledgedCursor h (Just key) (Just (CursorInt n))
sampleMetrics h >>= \m -> m.progress `shouldBe` Just (ProgressSnapshot (Just (CursorInt 3)) (Map.fromList [("a", CursorInt 3), ("b", CursorInt 9)]) True)
recordAcknowledgedCursor h (Just "a") Nothing
sampleMetrics h >>= \m -> fmap (.latest) m.progress `shouldBe` Just (Just (CursorInt 3))
it "atomically caps concurrent partitions at capacity zero, one and two" $ do
forM_ [0, 1, 2] $ \capacity -> do
h <- newMetricsHandleWith (MetricsDetailed (DetailOptions capacity)) 1 epoch
UIO.mapConcurrently_ (\n -> recordAcknowledgedCursor h (Just (Text.pack (show n))) (Just (CursorInt n))) [1 .. 64]
m <- sampleMetrics h
let snapshot = fromJust m.progress
Map.size snapshot.partitions `shouldBe` capacity
snapshot.truncated `shouldBe` True
snapshot.latest `shouldSatisfy` maybe False (const True)
it "round trips lossless tagged cursor identities and rejects noncanonical integers" $ do
forM_ [CursorInt minBound, CursorInt maxBound, CursorInt 9007199254740993, CursorText "9007199254740993", CursorText ""] $ \cursor ->
eitherDecode (encode cursor) `shouldBe` Right cursor
forM_ ["01", "-0", "+1", "1.5", " 1", "9223372036854775808"] $ \value ->
(eitherDecode ("{\"kind\":\"integer\",\"value\":\"" <> value <> "\"}") :: Either String Cursor) `shouldSatisfy` either (const True) (const False)
(eitherDecode "{\"kind\":\"integer\",\"value\":1}" :: Either String Cursor) `shouldSatisfy` either (const True) (const False)
(eitherDecode "{\"kind\":\"unknown\",\"value\":\"1\"}" :: Either String Cursor) `shouldSatisfy` either (const True) (const False)
forM_ [ProgressSnapshot Nothing Map.empty False, ProgressSnapshot (Just (CursorText "a")) Map.empty True] $ \snapshot ->
eitherDecode (encode snapshot) `shouldBe` Right snapshot
epoch :: UTCTime
epoch = UTCTime (fromGregorian 2026 1 1) 0
handle :: Int -> IO (IORef Word64, MetricsHandle)
handle n = do
clock <- newIORef 0
h <- newMetricsHandleWithClockAndDetail (readIORef clock) (MetricsDetailed defaultDetailOptions) n epoch
pure (clock, h)