bloodhound-1.0.0.0: tests/Test/NeuralStatsSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Test.NeuralStatsSpec (spec) where
import Control.Exception (SomeException)
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List (isInfixOf, sortOn)
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Database.Bloodhound.OpenSearch3.Client qualified as OS3.Client
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types
import TestsUtils.Common
import TestsUtils.Import
import Prelude
-- | A representative three-section response body for
-- @GET /_plugins/_neural/stats@. The stat-name set is plugin-version
-- dependent, so we exercise a small subset whose keys are confirmed against
-- a live OS 3.7.0 cluster (see 'liveCapturedResponse'). The section bodies
-- are arbitrary nested JSON; we deliberately round-trip them as 'Value'
-- rather than modelling individual stats.
--
-- This fixture intentionally omits the @_nodes@ \/ @cluster_name@ envelope
-- the live endpoint emits, so it can also exercise the
-- @decode . encode@ round-trip (those envelope keys are dropped on re-encode
-- because 'NeuralStats' only models the three sections). The envelope is
-- covered separately by 'liveCapturedResponse'.
sampleFullResponse :: LBS.ByteString
sampleFullResponse =
"{\
\ \"info\": {\
\ \"cluster_version\": \"3.3.0\",\
\ \"processors\": {\
\ \"ingest\": { \"text_embedding_processors_in_pipelines\": 1 }\
\ }\
\ },\
\ \"all_nodes\": {\
\ \"processors\": {\
\ \"ingest\": { \"text_embedding_executions\": { \"count\": 7 } }\
\ }\
\ },\
\ \"nodes\": {\
\ \"AaaBbbCccDddEeeFffGgHh\": {\
\ \"processors\": {\
\ \"ingest\": { \"text_embedding_executions\": { \"count\": 7 } }\
\ }\
\ }\
\ }\
\}"
-- | A response carrying only the @info@ section. The other two sections are
-- independently toggled by the request's @include_all_nodes@ and
-- @include_individual_nodes@ flags; the decoder must accept their absence.
sampleInfoOnlyResponse :: LBS.ByteString
sampleInfoOnlyResponse =
"{\
\ \"info\": {\
\ \"cluster_version\": \"3.3.0\"\
\ }\
\}"
-- | A response carrying only the @nodes@ section with two nodes. Verifies the
-- per-node map is decoded as 'Map.Map Text Value' without losing entries.
sampleNodesOnlyResponse :: LBS.ByteString
sampleNodesOnlyResponse =
"{\
\ \"nodes\": {\
\ \"nodeOneIdTwentyTwoC00\": {\
\ \"processors\": { \"ingest\": { \"text_embedding_executions\": { \"count\": 3 } } }\
\ },\
\ \"nodeTwoIdTwentyTwoC00X\": {\
\ \"processors\": { \"ingest\": { \"text_embedding_executions\": { \"count\": 4 } } }\
\ }\
\ }\
\}"
-- | A response body captured from a live OS 3.7.0 cluster against
-- @GET /_plugins/_neural/stats/hybrid_query_requests@ (single-stat filter).
-- Unlike the synthetic fixtures above, this carries the real @_nodes@ and
-- @cluster_name@ envelope keys the endpoint emits, confirming the decoder
-- ignores unknown top-level keys. The @nodes@ section is keyed by a real
-- 22-character node ID. The single-stat path segment filters the stat keys
-- /within/ each section (here down to just @hybrid_query_requests@); it does
-- not suppress the section envelopes, so all three sections remain present.
-- Captured with the cluster setting
-- @plugins.neural_search.stats_enabled=true@; with the setting off (the
-- default) the endpoint returns HTTP 403 instead of a body.
liveCapturedResponse :: LBS.ByteString
liveCapturedResponse =
"{\
\ \"_nodes\": { \"total\": 1, \"successful\": 1, \"failed\": 0 },\
\ \"cluster_name\": \"docker-cluster\",\
\ \"info\": {},\
\ \"all_nodes\": {\
\ \"query\": { \"hybrid\": { \"hybrid_query_requests\": 0 } }\
\ },\
\ \"nodes\": {\
\ \"5aGfOefgRluhqf92Qn9D7g\": {\
\ \"query\": { \"hybrid\": { \"hybrid_query_requests\": 0 } }\
\ }\
\ }\
\}"
spec :: Spec
spec = describe "Neural Stats API" $ do
describe "NeuralNodeId / NeuralStatName JSON" $ do
it "NeuralNodeId round-trips as a bare JSON string" $ do
encode (NeuralNodeId "node-id-22-chars-000") `shouldBe` "\"node-id-22-chars-000\""
decode "\"abc\"" `shouldBe` Just (NeuralNodeId "abc")
it "NeuralStatName round-trips as a bare JSON string" $ do
encode (NeuralStatName "text_embedding_executions")
`shouldBe` "\"text_embedding_executions\""
decode "\"hybrid_query_requests\""
`shouldBe` Just (NeuralStatName "hybrid_query_requests")
describe "NeuralStats JSON" $ do
it "decodes a fully-populated three-section response" $ do
let Just decoded = decode sampleFullResponse :: Maybe NeuralStats
neuralStatsInfo decoded `shouldSatisfy` isJust
neuralStatsAllNodes decoded `shouldSatisfy` isJust
neuralStatsNodes decoded `shouldSatisfy` isJust
it "decodes the per-node map with the right number of entries" $ do
let Just decoded = decode sampleFullResponse :: Maybe NeuralStats
Just nodesMap = neuralStatsNodes decoded
Map.size nodesMap `shouldBe` 1
Map.member "AaaBbbCccDddEeeFffGgHh" nodesMap `shouldBe` True
it "decodes an info-only response (missing sections become Nothing)" $ do
let Just decoded = decode sampleInfoOnlyResponse :: Maybe NeuralStats
neuralStatsInfo decoded `shouldSatisfy` isJust
neuralStatsAllNodes decoded `shouldBe` Nothing
neuralStatsNodes decoded `shouldBe` Nothing
it "decodes a nodes-only response with multiple node IDs" $ do
let Just decoded = decode sampleNodesOnlyResponse :: Maybe NeuralStats
Just nodesMap = neuralStatsNodes decoded
Map.size nodesMap `shouldBe` 2
Map.member "nodeOneIdTwentyTwoC00" nodesMap `shouldBe` True
Map.member "nodeTwoIdTwentyTwoC00X" nodesMap `shouldBe` True
it "decodes an empty object as all-Nothing sections" $ do
let Just decoded = decode "{}" :: Maybe NeuralStats
neuralStatsInfo decoded `shouldBe` Nothing
neuralStatsAllNodes decoded `shouldBe` Nothing
neuralStatsNodes decoded `shouldBe` Nothing
it "rejects a top-level array" $ do
let decoded = decode "[]" :: Maybe NeuralStats
decoded `shouldBe` Nothing
it "rejects malformed JSON" $ do
let decoded = decode "{ not json" :: Maybe NeuralStats
decoded `shouldBe` Nothing
it "round-trips a full body through ToJSON/FromJSON" $ do
let Just decoded = decode sampleFullResponse :: Maybe NeuralStats
(decode . encode) decoded `shouldBe` Just decoded
it "round-trips a nodes-only body through ToJSON/FromJSON" $ do
let Just decoded = decode sampleNodesOnlyResponse :: Maybe NeuralStats
(decode . encode) decoded `shouldBe` Just decoded
it "ToJSON omits Nothing sections (no null keys)" $ do
let Just decoded = decode sampleInfoOnlyResponse :: Maybe NeuralStats
encoded = LBS.toStrict (encode decoded)
encoded `shouldSatisfy` not . BS.isInfixOf "\"all_nodes\""
encoded `shouldSatisfy` not . BS.isInfixOf "\"nodes\""
encoded `shouldSatisfy` BS.isInfixOf "\"info\""
it "ToJSON omits info/all_nodes when only nodes is present" $ do
let Just decoded = decode sampleNodesOnlyResponse :: Maybe NeuralStats
encoded = LBS.toStrict (encode decoded)
encoded `shouldSatisfy` not . BS.isInfixOf "\"info\""
encoded `shouldSatisfy` not . BS.isInfixOf "\"all_nodes\""
encoded `shouldSatisfy` BS.isInfixOf "\"nodes\""
it "decodes a live-captured body (envelope keys ignored, all sections present)" $ do
let Just decoded = decode liveCapturedResponse :: Maybe NeuralStats
neuralStatsInfo decoded `shouldSatisfy` isJust
neuralStatsAllNodes decoded `shouldSatisfy` isJust
neuralStatsNodes decoded `shouldSatisfy` isJust
it "decodes the live 22-char node ID into the per-node map" $ do
let Just decoded = decode liveCapturedResponse :: Maybe NeuralStats
Just nodesMap = neuralStatsNodes decoded
Map.size nodesMap `shouldBe` 1
Map.member "5aGfOefgRluhqf92Qn9D7g" nodesMap `shouldBe` True
it "treats an empty info section ({}) as present (Just)" $ do
let Just decoded = decode liveCapturedResponse :: Maybe NeuralStats
neuralStatsInfo decoded `shouldBe` Just (object [])
describe "getNeuralStats endpoint shape" $ do
-- Pure checks against the BHRequest shape; no live backend needed.
it "GETs /_plugins/_neural/stats when given Nothing/Nothing" $ do
let req = OS3Requests.getNeuralStats Nothing Nothing
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_neural", "stats"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "GETs /_plugins/_neural/stats/{stat} when only the stat name is set" $ do
let req =
OS3Requests.getNeuralStats
Nothing
(Just (NeuralStatName "text_embedding_executions"))
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_neural", "stats", "text_embedding_executions"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "GETs /_plugins/_neural/{nodeId}/stats when only the node id is set" $ do
let req =
OS3Requests.getNeuralStats
(Just (NeuralNodeId "node-id-22-chars-000"))
Nothing
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_neural", "node-id-22-chars-000", "stats"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "GETs /_plugins/_neural/{nodeId}/stats/{stat} when both are set" $ do
let req =
OS3Requests.getNeuralStats
(Just (NeuralNodeId "node-id-22-chars-000"))
(Just (NeuralStatName "hybrid_query_requests"))
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` [ "_plugins",
"_neural",
"node-id-22-chars-000",
"stats",
"hybrid_query_requests"
]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "uses the GET method" $ do
let req = OS3Requests.getNeuralStats Nothing Nothing
bhRequestMethod req `shouldBe` "GET"
it "does not attach a body (GET semantics)" $ do
let req = OS3Requests.getNeuralStats Nothing Nothing
bhRequestBody req `shouldBe` Nothing
describe "getNeuralStatsWith endpoint shape" $ do
-- 'defaultNeuralStatsOptions' must reproduce the legacy wire shape
-- exactly: same path segments and no query string.
it "defaultNeuralStatsOptions is byte-identical to getNeuralStats" $ do
let legacy = OS3Requests.getNeuralStats Nothing Nothing
withDef = OS3Requests.getNeuralStatsWith Nothing Nothing defaultNeuralStatsOptions
getRawEndpoint (bhRequestEndpoint legacy)
`shouldBe` getRawEndpoint (bhRequestEndpoint withDef)
getRawEndpointQueries (bhRequestEndpoint legacy)
`shouldBe` getRawEndpointQueries (bhRequestEndpoint withDef)
it "defaultNeuralStatsOptions produces no query string" $ do
let req = OS3Requests.getNeuralStatsWith Nothing Nothing defaultNeuralStatsOptions
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "keeps the same path segments regardless of options" $ do
let req =
OS3Requests.getNeuralStatsWith
(Just (NeuralNodeId "node-id-22-chars-000"))
(Just (NeuralStatName "hybrid_query_requests"))
( defaultNeuralStatsOptions
{ nsoIncludeInfo = Just True,
nsoIncludeAllNodes = Just True,
nsoIncludeIndividualNodes = Just True
}
)
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` [ "_plugins",
"_neural",
"node-id-22-chars-000",
"stats",
"hybrid_query_requests"
]
it "renders all three flags as =true when all set to Just True" $ do
let req =
OS3Requests.getNeuralStatsWith
Nothing
Nothing
( defaultNeuralStatsOptions
{ nsoIncludeInfo = Just True,
nsoIncludeAllNodes = Just True,
nsoIncludeIndividualNodes = Just True
}
)
sortPairs (getRawEndpointQueries (bhRequestEndpoint req))
`shouldBe` sortPairs
[ ("include_info", Just "true"),
("include_all_nodes", Just "true"),
("include_individual_nodes", Just "true")
]
it "renders a single flag and omits the rest" $ do
let req =
OS3Requests.getNeuralStatsWith
Nothing
Nothing
defaultNeuralStatsOptions {nsoIncludeAllNodes = Just True}
sortPairs (getRawEndpointQueries (bhRequestEndpoint req))
`shouldBe` [("include_all_nodes", Just "true")]
it "renders Just False as include_*=false (tri-state)" $ do
let req =
OS3Requests.getNeuralStatsWith
Nothing
Nothing
( defaultNeuralStatsOptions
{ nsoIncludeInfo = Just False,
nsoIncludeIndividualNodes = Just False
}
)
sortPairs (getRawEndpointQueries (bhRequestEndpoint req))
`shouldBe` sortPairs
[ ("include_info", Just "false"),
("include_individual_nodes", Just "false")
]
it "handles a mixed Just True / Just False / Nothing combination" $ do
let req =
OS3Requests.getNeuralStatsWith
Nothing
Nothing
( defaultNeuralStatsOptions
{ nsoIncludeInfo = Just True,
nsoIncludeAllNodes = Just False,
nsoIncludeIndividualNodes = Nothing
}
)
sortPairs (getRawEndpointQueries (bhRequestEndpoint req))
`shouldBe` sortPairs
[ ("include_info", Just "true"),
("include_all_nodes", Just "false")
]
it "Nothing and Just False produce distinct wire shapes" $ do
let omissions =
OS3Requests.getNeuralStatsWith
Nothing
Nothing
defaultNeuralStatsOptions {nsoIncludeInfo = Nothing}
explicitFalse =
OS3Requests.getNeuralStatsWith
Nothing
Nothing
defaultNeuralStatsOptions {nsoIncludeInfo = Just False}
getRawEndpointQueries (bhRequestEndpoint omissions) `shouldBe` []
getRawEndpointQueries (bhRequestEndpoint explicitFalse)
`shouldBe` [("include_info", Just "false")]
it "still uses the GET method and no body" $ do
let req =
OS3Requests.getNeuralStatsWith
Nothing
Nothing
defaultNeuralStatsOptions {nsoIncludeInfo = Just True}
bhRequestMethod req `shouldBe` "GET"
bhRequestBody req `shouldBe` Nothing
-- ---------------------------------------------------------------- --
-- Live round-trip: gated on OpenSearch 3 (the Neural Search plugin --
-- is OS-specific). The endpoint is disabled by default and returns --
-- HTTP 403 until the cluster setting --
-- @plugins.neural_search.stats_enabled@ is set to @true@. The test --
-- gracefully pends when the setting is off, so it does not fail on --
-- a default cluster; to exercise it, enable the setting on the OS3 --
-- docker container (port 9205) before running. --
-- --
-- Wire shape live-verified against OS 3.7.0 in bead bloodhound-6py: --
-- the default response carries all three sections (@info@, --
-- @all_nodes@, @nodes@) plus an @_nodes@ \/ @cluster_name@ envelope --
-- that the decoder ignores. --
-- ---------------------------------------------------------------- --
describe "getNeuralStats live round-trip" $ do
os3It <- runIO os3OnlyIT
os3It "OS3: decodes NeuralStats when stats_enabled, pends on 403 otherwise" $
withTestEnv $ do
-- The endpoint is gated behind a cluster setting and, when it is
-- off, OS replies with HTTP 403 + a plain-text body
-- @"Stats endpoint is disabled"@. Because that body is not JSON,
-- the client cannot surface it as an 'EsError' and throws an
-- 'EsProtocolException' instead (the exception type lives in a
-- hidden library module, so we catch 'SomeException' and match on
-- the disabled-state marker in its 'show' representation — the
-- same approach 'Test.SearchApplicationsSpec' takes for 'EsError'
-- messages). Any other failure is a real test failure.
result <- try $ OS3.Client.getNeuralStats Nothing Nothing
liftIO $ case result of
Left (ex :: SomeException)
| "disabled" `isInfixOf` show ex ->
pendingWith $
"plugins.neural_search.stats_enabled is false (the "
<> "default) on this cluster; the endpoint returns "
<> "HTTP 403 with a plain-text body. Set the "
<> "persistent cluster setting to true to exercise "
<> "the live neural stats endpoint."
| otherwise ->
expectationFailure $
"getNeuralStats failed unexpectedly: " <> show ex
Right (Left e) ->
expectationFailure $
"getNeuralStats returned an EsError: HTTP "
<> show (errorStatus e)
<> " — "
<> Text.unpack (errorMessage e)
Right (Right stats) -> do
neuralStatsInfo stats `shouldSatisfy` isJust
neuralStatsAllNodes stats `shouldSatisfy` isJust
neuralStatsNodes stats `shouldSatisfy` isJust
-- | Order-insensitive comparison for @(key, value)@ query-param pairs.
-- 'withQueries' is order-insensitive, but the test-suite's @shouldBe@
-- is not; this helper sorts both sides so a stable but unspecified
-- ordering does not cause spurious failures.
sortPairs :: [(Text.Text, Maybe Text.Text)] -> [(Text.Text, Maybe Text.Text)]
sortPairs = sortOn fst