packages feed

bloodhound-1.0.0.0: tests/Test/ResizeSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Test.ResizeSpec where

import Control.Exception (finally)
import Data.Aeson.KeyMap qualified as KM
import Data.List qualified as L
import Database.Bloodhound.Common.Client qualified as Client
import Database.Bloodhound.Common.Requests as Requests
import TestsUtils.Common
import TestsUtils.Import

-- Test fixtures: a source index plus one target per operation. Each
-- integration test seeds the source, runs the operation, then asserts
-- the target was created with the requested shard count. Cleanup is
-- always run before AND after so partial state from a previous failed
-- run does not poison the next one.
resizeSrcIndex :: IndexName
resizeSrcIndex = [qqIndexName|bh-resize-src|]

shrinkTargetIndex :: IndexName
shrinkTargetIndex = [qqIndexName|bh-resize-shrunk|]

splitTargetIndex :: IndexName
splitTargetIndex = [qqIndexName|bh-resize-split|]

cloneTargetIndex :: IndexName
cloneTargetIndex = [qqIndexName|bh-resize-cloned|]

-- | Delete every index used by this spec, ignoring any that do not
-- exist. Called both before seeding (defensive cleanup of leftover
-- state) and after the assertions (exception-safe teardown).
cleanupResizeIndices :: BH IO ()
cleanupResizeIndices = do
  _ <- tryEsError $ performBHRequest $ deleteIndex resizeSrcIndex
  _ <- tryEsError $ performBHRequest $ deleteIndex shrinkTargetIndex
  _ <- tryEsError $ performBHRequest $ deleteIndex splitTargetIndex
  _ <- tryEsError $ performBHRequest $ deleteIndex cloneTargetIndex
  pure ()

-- | Create @bh-resize-src@ with the requested primary shard count (and
-- zero replicas, to keep the operation local to a single node), then
-- mark it read-only. Shrink, split and clone all refuse to operate on
-- a source index that is still writable.
--
-- Replica count is forced to zero because the docker-compose test
-- cluster is single-node; assigning replicas that can never be
-- allocated would leave the index YELLOW and could trip the
-- @wait_for_active_shards@ check on the resize call.
seedReadOnlySource :: ShardCount -> BH IO ()
seedReadOnlySource shardCount = do
  _ <- cleanupResizeIndices
  let settings = IndexSettings shardCount (ReplicaCount 0) defaultIndexMappingsLimits
  _ <- performBHRequest $ createIndex settings resizeSrcIndex
  -- Source must be marked read-only before the resize call: shrink,
  -- split and clone all refuse to operate on a still-writable index.
  _ <- performBHRequest $ updateIndexSettings (BlocksWrite True :| []) resizeSrcIndex
  pure ()

-- | Wait for the cluster to settle on the resize result and confirm
-- the target index exists with the expected shard count. ES returns
-- Acknowledged=True before the target is fully allocated, so we ask
-- the cluster to reach yellow health before reading it back.
assertTargetIndex :: IndexName -> ShardCount -> BH IO ()
assertTargetIndex target expectedShards = do
  _ <- performBHRequest $ Requests.waitForYellowIndex target
  info <- performBHRequest $ getIndex target
  liftIO $ do
    iiIndexName info `shouldBe` target
    indexShards (iiFixedSettings info) `shouldBe` expectedShards

-- | Body for the three 'defaultResizeSettings' values: an empty object
-- because no @settings@ \/ @mappings@ \/ @aliases@ are supplied.

-- Single-alias payload reused by the "carries aliases" assertion.
sampleAliases :: KM.KeyMap Value
sampleAliases = KM.singleton "bh-resize-alias" (object [])

-- | Sort query-string pairs so the assertion is order-insensitive —
-- 'withQueries' is documented as order-insensitive, so the test
-- shouldn't depend on how the params happen to be sequenced.
normalizeQueryString :: (Ord a, Ord b) => [(a, b)] -> [(a, b)]
normalizeQueryString = L.sort

spec :: Spec
spec = do
  describe "shrinkIndex / splitIndex / cloneIndex (request shape)" $ do
    -- The body builder delegates to 'createIndexOptionsBody', so an
    -- empty 'ShrinkSettings' must produce an empty body (not 'Nothing',
    -- not "{}") — the server treats an absent body as "copy source
    -- verbatim", which is exactly the documented behaviour.
    it "shrinkIndex with defaultShrinkSettings sends an empty body" $ do
      let req = Requests.shrinkIndex resizeSrcIndex shrinkTargetIndex defaultShrinkSettings
      bhRequestBody req `shouldBe` Just ""

    it "splitIndex with defaultSplitSettings sends an empty body" $ do
      let req = Requests.splitIndex resizeSrcIndex splitTargetIndex defaultSplitSettings
      bhRequestBody req `shouldBe` Just ""

    it "cloneIndex with defaultCloneSettings sends an empty body" $ do
      let req = Requests.cloneIndex resizeSrcIndex cloneTargetIndex defaultCloneSettings
      bhRequestBody req `shouldBe` Just ""

    -- When 'cioSettings' is populated, the body must contain a
    -- top-level @settings.index.number_of_shards@ field — this is how
    -- the caller tells the server the target shard count for shrink
    -- (must be a divisor of the source) or split (must be a multiple).
    it "shrinkIndex forwards cioSettings as {settings:{index:{...}}}" $ do
      let settings =
            ShrinkSettings
              defaultCreateIndexOptions
                { cioSettings =
                    Just $
                      IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits
                }
      let req = Requests.shrinkIndex resizeSrcIndex shrinkTargetIndex settings
      let expected =
            object
              [ "settings"
                  .= object
                    [ "index"
                        .= object
                          [ "number_of_shards" .= (1 :: Int),
                            "number_of_replicas" .= (0 :: Int),
                            "mapping" .= defaultIndexMappingsLimits
                          ]
                    ]
              ]
      (decode =<< bhRequestBody req) `shouldBe` Just expected

    -- Likewise, aliases supplied via 'cioAliases' must land under the
    -- top-level @aliases@ key. Verified on cloneIndex so we exercise
    -- the third newtype's path through 'resizeEndpoint'.
    it "cloneIndex forwards cioAliases as {aliases:{...}}" $ do
      let settings =
            CloneSettings
              defaultCreateIndexOptions
                { cioAliases = Just sampleAliases
                }
      let req = Requests.cloneIndex resizeSrcIndex cloneTargetIndex settings
      let expected = object ["aliases" .= object ["bh-resize-alias" .= object []]]
      (decode =<< bhRequestBody req) `shouldBe` Just expected

    -- The newtypes must also forward URI parameters through
    -- 'createIndexOptionsParams'. This is the whole point of wrapping
    -- the full 'CreateIndexOptions' rather than rolling a narrower
    -- record — the typed resize settings preserve the entire
    -- body+param surface of create-index.
    it "splitIndex forwards cioWaitForActiveShards / master_timeout / timeout as query params" $ do
      let settings =
            SplitSettings
              defaultCreateIndexOptions
                { cioWaitForActiveShards = Just AllActiveShards,
                  cioMasterTimeout = Just (TimeUnitSeconds, 30),
                  cioTimeout = Just (TimeUnitMilliseconds, 500)
                }
      let req = Requests.splitIndex resizeSrcIndex splitTargetIndex settings
      normalizeQueryString (getRawEndpointQueries (bhRequestEndpoint req))
        `shouldBe` [ ("master_timeout", Just "30s"),
                     ("timeout", Just "500ms"),
                     ("wait_for_active_shards", Just "all")
                   ]

  describe "shrinkIndex (integration)" $ do
    -- Source @bh-resize-src@ has 2 primary shards; the shrink target
    -- @bh-resize-shrunk@ requests 1 shard (1 is a divisor of 2). The
    -- single-node docker-compose cluster satisfies the "all shards on
    -- one node" precondition trivially.
    it "shrinks a 2-shard source into a 1-shard target" $
      withTestEnv
        ( do
            _ <- seedReadOnlySource (ShardCount 2)
            resp <-
              Client.shrinkIndex
                resizeSrcIndex
                shrinkTargetIndex
                ( ShrinkSettings
                    defaultCreateIndexOptions
                      { cioSettings =
                          Just $
                            IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits
                      }
                )
            liftIO $ resp `shouldBe` Acknowledged True
            assertTargetIndex shrinkTargetIndex (ShardCount 1)
        )
        `finally` withTestEnv cleanupResizeIndices

  describe "splitIndex (integration)" $ do
    -- Source @bh-resize-src@ has 1 primary shard; the split target
    -- @bh-resize-split@ requests 2 shards (2 is a multiple of 1).
    it "splits a 1-shard source into a 2-shard target" $
      withTestEnv
        ( do
            _ <- seedReadOnlySource (ShardCount 1)
            resp <-
              Client.splitIndex
                resizeSrcIndex
                splitTargetIndex
                ( SplitSettings
                    defaultCreateIndexOptions
                      { cioSettings =
                          Just $
                            IndexSettings (ShardCount 2) (ReplicaCount 0) defaultIndexMappingsLimits
                      }
                )
            liftIO $ resp `shouldBe` Acknowledged True
            assertTargetIndex splitTargetIndex (ShardCount 2)
        )
        `finally` withTestEnv cleanupResizeIndices

  describe "cloneIndex (integration)" $ do
    -- Source @bh-resize-src@ has 1 primary shard; the clone target
    -- @bh-resize-cloned@ requests no overriding settings, so it
    -- inherits the source's shard count.
    it "clones a 1-shard source into a 1-shard target" $
      withTestEnv
        ( do
            _ <- seedReadOnlySource (ShardCount 1)
            resp <- Client.cloneIndex resizeSrcIndex cloneTargetIndex defaultCloneSettings
            liftIO $ resp `shouldBe` Acknowledged True
            assertTargetIndex cloneTargetIndex (ShardCount 1)
        )
        `finally` withTestEnv cleanupResizeIndices