bloodhound-1.0.0.0: tests/Test/KnnStatsSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Test.KnnStatsSpec (spec) where
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Map.Strict qualified as Map
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types
import TestsUtils.Import
import Prelude
-- | The canonical k-NN stats response: cluster-level scalars at the top
-- level (one key per cluster stat) plus a @nodes@ object whose value is a
-- map keyed by opaque node ID. Drawn from the documented example shape in
-- the OS 3.x k-NN plugin source (RestKnnStatsAction + KnnStatsResponse).
-- Stat values are arbitrary nested JSON; we deliberately round-trip them
-- as 'Value' rather than modelling individual stats, since the stat-name
-- set is plugin-version dependent.
sampleFullResponse :: LBS.ByteString
sampleFullResponse =
"{\
\ \"total_load_time\": 123456,\
\ \"hit_count\": 42,\
\ \"eviction_count\": 3,\
\ \"circuit_breaker_triggered\": false,\
\ \"nodes\": {\
\ \"AaaBbbCccDddEeeFffGgHh\": {\
\ \"hit_count\": 42,\
\ \"miss_count\": 7,\
\ \"knn_query_cache_size\": 1024,\
\ \"knn_query_cache_eviction_count\": 0\
\ }\
\ }\
\}"
-- | A nodes-only response, as returned when a node-level @stat@ filter is
-- supplied (e.g. @GET /_plugins/_knn/stats/hit_count@). The cluster-level
-- stats are omitted because the filter restricts to node-level values; the
-- decoder must accept their absence.
sampleNodesOnlyResponse :: LBS.ByteString
sampleNodesOnlyResponse =
"{\
\ \"nodes\": {\
\ \"nodeOneIdTwentyTwoC00\": {\
\ \"hit_count\": 3,\
\ \"miss_count\": 1\
\ },\
\ \"nodeTwoIdTwentyTwoC00X\": {\
\ \"hit_count\": 4,\
\ \"miss_count\": 2\
\ }\
\ }\
\}"
spec :: Spec
spec = describe "k-NN Stats API" $ do
describe "KnnNodeId / KnnStatName JSON" $ do
it "KnnNodeId round-trips as a bare JSON string" $ do
encode (KnnNodeId "node-id-22-chars-000") `shouldBe` "\"node-id-22-chars-000\""
decode "\"abc\"" `shouldBe` Just (KnnNodeId "abc")
it "KnnStatName round-trips as a bare JSON string" $ do
encode (KnnStatName "hit_count") `shouldBe` "\"hit_count\""
decode "\"miss_count\"" `shouldBe` Just (KnnStatName "miss_count")
describe "KnnStats JSON" $ do
it "decodes a full response with cluster stats and a nodes map" $ do
let Just decoded = decode sampleFullResponse :: Maybe KnnStats
entries = knnStatsEntries decoded
-- 4 cluster-level keys + 1 "nodes" key.
Map.size entries `shouldBe` 5
Map.member "nodes" entries `shouldBe` True
Map.member "total_load_time" entries `shouldBe` True
Map.member "hit_count" entries `shouldBe` True
Map.member "circuit_breaker_triggered" entries `shouldBe` True
it "preserves cluster-level scalars as bare aeson Values" $ do
let Just decoded = decode sampleFullResponse :: Maybe KnnStats
entries = knnStatsEntries decoded
Map.lookup "total_load_time" entries `shouldBe` Just (toJSON (123456 :: Int))
Map.lookup "hit_count" entries `shouldBe` Just (toJSON (42 :: Int))
Map.lookup "circuit_breaker_triggered" entries
`shouldBe` Just (toJSON False)
Map.lookup "eviction_count" entries `shouldBe` Just (toJSON (3 :: Int))
it "preserves the per-node map under the \"nodes\" key as an aeson Value" $ do
let Just decoded = decode sampleFullResponse :: Maybe KnnStats
Just (Object nodesObj) = Map.lookup "nodes" (knnStatsEntries decoded)
nodeIds = [k | (k, _) <- KM.toList nodesObj]
length nodeIds `shouldBe` 1
"AaaBbbCccDddEeeFffGgHh" `elem` nodeIds `shouldBe` True
it "decodes the per-node body of a specific node" $ do
let Just decoded = decode sampleFullResponse :: Maybe KnnStats
Just (Object nodesObj) = Map.lookup "nodes" (knnStatsEntries decoded)
Just nodeBody = KM.lookup "AaaBbbCccDddEeeFffGgHh" nodesObj
nodeBody
`shouldBe` object
[ "hit_count" .= (42 :: Int),
"miss_count" .= (7 :: Int),
"knn_query_cache_size" .= (1024 :: Int),
"knn_query_cache_eviction_count" .= (0 :: Int)
]
it "decodes a multi-node nodes-only response without losing entries" $ do
let Just decoded = decode sampleNodesOnlyResponse :: Maybe KnnStats
entries = knnStatsEntries decoded
Just (Object nodesObj) = Map.lookup "nodes" entries
nodeIds = [k | (k, _) <- KM.toList nodesObj]
Map.size entries `shouldBe` 1
length nodeIds `shouldBe` 2
"nodeOneIdTwentyTwoC00" `elem` nodeIds `shouldBe` True
"nodeTwoIdTwentyTwoC00X" `elem` nodeIds `shouldBe` True
-- None of the cluster-level keys are present.
Map.member "total_load_time" entries `shouldBe` False
it "decodes an empty object as an empty map" $ do
let Just decoded = decode "{}" :: Maybe KnnStats
knnStatsEntries decoded `shouldBe` Map.empty
it "decodes an empty nodes map as an Object with no entries" $ do
let Just decoded = decode "{ \"nodes\": {} }" :: Maybe KnnStats
Just (Object nodesObj) = Map.lookup "nodes" (knnStatsEntries decoded)
KM.null nodesObj `shouldBe` True
it "rejects a top-level null" $ do
let decoded = decode "null" :: Maybe KnnStats
decoded `shouldBe` Nothing
it "rejects a top-level array" $ do
let decoded = decode "[]" :: Maybe KnnStats
decoded `shouldBe` Nothing
it "rejects a top-level scalar" $ do
let decoded = decode "42" :: Maybe KnnStats
decoded `shouldBe` Nothing
it "rejects malformed JSON" $ do
let decoded = decode "{ not json" :: Maybe KnnStats
decoded `shouldBe` Nothing
-- Lenient contract: the flat-Map decoder does NOT validate the shape of
-- the @nodes@ value (it is just another entry in the map). This test
-- pins the current behaviour so any future tightening of the decoder is
-- a deliberate, breaking change rather than a silent one.
it "accepts a non-object nodes value (lenient contract — pinned)" $ do
let Just decoded = decode "{ \"nodes\": \"unexpected\" }" :: Maybe KnnStats
Map.lookup "nodes" (knnStatsEntries decoded)
`shouldBe` Just (toJSON ("unexpected" :: Text))
it "round-trips a full response through ToJSON/FromJSON" $ do
let Just decoded = decode sampleFullResponse :: Maybe KnnStats
(decode . encode) decoded `shouldBe` Just decoded
it "round-trips a nodes-only response through ToJSON/FromJSON" $ do
let Just decoded = decode sampleNodesOnlyResponse :: Maybe KnnStats
(decode . encode) decoded `shouldBe` Just decoded
it "ToJSON emits both cluster and nodes keys" $ do
let Just decoded = decode sampleFullResponse :: Maybe KnnStats
encoded = LBS.toStrict (encode decoded)
encoded `shouldSatisfy` BS.isInfixOf "\"nodes\""
encoded `shouldSatisfy` BS.isInfixOf "\"total_load_time\""
encoded `shouldSatisfy` BS.isInfixOf "\"AaaBbbCccDddEeeFffGgHh\""
describe "getKnnStats endpoint shape" $ do
-- Pure checks against the BHRequest shape; no live backend needed.
it "GETs /_plugins/_knn/stats when given Nothing/Nothing" $ do
let req = OS3Requests.getKnnStats Nothing Nothing
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_knn", "stats"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "GETs /_plugins/_knn/stats/{stat} when only the stat name is set" $ do
let req =
OS3Requests.getKnnStats
Nothing
(Just (KnnStatName "hit_count"))
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_knn", "stats", "hit_count"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "GETs /_plugins/_knn/{nodeId}/stats when only the node id is set" $ do
let req =
OS3Requests.getKnnStats
(Just (KnnNodeId "node-id-22-chars-000"))
Nothing
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_knn", "node-id-22-chars-000", "stats"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "GETs /_plugins/_knn/{nodeId}/stats/{stat} when both are set" $ do
let req =
OS3Requests.getKnnStats
(Just (KnnNodeId "node-id-22-chars-000"))
(Just (KnnStatName "miss_count"))
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` [ "_plugins",
"_knn",
"node-id-22-chars-000",
"stats",
"miss_count"
]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "uses the GET method" $ do
let req = OS3Requests.getKnnStats Nothing Nothing
bhRequestMethod req `shouldBe` "GET"
it "does not attach a body (GET semantics)" $ do
let req = OS3Requests.getKnnStats Nothing Nothing
bhRequestBody req `shouldBe` Nothing