From 2711701ca8adfcc4dfcfd042a6a7fd0f65efc673 Mon Sep 17 00:00:00 2001 From: Sergio Bobillier Date: Fri, 13 Feb 2026 12:52:37 +0100 Subject: [PATCH] [JAY-737] Add the Index#force_merge method The method starts a forced segment merge on the index. More information about this procedure can be found here: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-forcemerge The method does little in terms of logic, it basically forwards the request directly to the transport client. The only check it performs is making sure that the index has been set to read-only before starting the process. --- CHANGELOG.md | 2 + lib/jay_api/elasticsearch/errors.rb | 1 + .../errors/writable_index_error.rb | 13 ++ lib/jay_api/elasticsearch/index.rb | 24 ++++ spec/jay_api/elasticsearch/index_spec.rb | 129 ++++++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 lib/jay_api/elasticsearch/errors/writable_index_error.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 634d62f..6068615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Please mark backwards incompatible changes with an exclamation mark at the start when `active_support/core_ext/string` hadn't been loaded. ### Added +- The `#force_merge` method to the `Elasticsearch::Index` class. This method + starts a Forced Segment Merge on the index. - The `#totals` method to `Elasticsearch::Stats::Index`, this gives the caller access to the index's total metrics. - The `Elasticsearch::Stats::Index::Totals` class. The class contains information diff --git a/lib/jay_api/elasticsearch/errors.rb b/lib/jay_api/elasticsearch/errors.rb index d0f4943..e953a0c 100644 --- a/lib/jay_api/elasticsearch/errors.rb +++ b/lib/jay_api/elasticsearch/errors.rb @@ -6,6 +6,7 @@ require_relative 'errors/query_execution_failure' require_relative 'errors/query_execution_timeout' require_relative 'errors/search_after_error' +require_relative 'errors/writable_index_error' module JayAPI module Elasticsearch diff --git a/lib/jay_api/elasticsearch/errors/writable_index_error.rb b/lib/jay_api/elasticsearch/errors/writable_index_error.rb new file mode 100644 index 0000000..b4463ff --- /dev/null +++ b/lib/jay_api/elasticsearch/errors/writable_index_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative '../../errors/error' + +module JayAPI + module Elasticsearch + module Errors + # An error to be raised when an attempt is made to perform force_merge + # over an index which hasn't been set to be read-only. + class WritableIndexError < ::JayAPI::Errors::Error; end + end + end +end diff --git a/lib/jay_api/elasticsearch/index.rb b/lib/jay_api/elasticsearch/index.rb index 0999228..df15a97 100644 --- a/lib/jay_api/elasticsearch/index.rb +++ b/lib/jay_api/elasticsearch/index.rb @@ -2,6 +2,7 @@ require_relative 'indexable' require_relative 'indices/settings' +require_relative 'errors/writable_index_error' module JayAPI module Elasticsearch @@ -57,6 +58,29 @@ def settings # DO NOT MEMOIZE! Leave it to the caller. ::JayAPI::Elasticsearch::Indices::Settings.new(client.transport_client, index_name) end + + # Starts a Forced Segment Merge process on the index. + # + # ⚠️ For big indexes this process can take a very long time, make sure to + # adjust the timeout when creating the client. + # @param [Boolean] only_expunge_deletes Specifies whether the operation + # should only remove deleted documents. + # @raise [JayAPI::Elasticsearch::Errors::WritableIndexError] If the index + # is writable (hasn't been set to read-only). + # @return [Hash] A +Hash+ with the result of the index merging process, + # it looks like this: + # + # { "_shards" => { "total" => 10, "successful" => 10, "failed" => 0 } } + def force_merge(only_expunge_deletes: nil) + unless settings.blocks.write_blocked? + raise ::JayAPI::Elasticsearch::Errors::WritableIndexError, + "Write block for '#{index_name}' has not been enabled. " \ + "Please enable the index's write block before performing a segment merge" + end + + params = { index: index_name, only_expunge_deletes: }.compact + client.transport_client.indices.forcemerge(**params) + end end end end diff --git a/spec/jay_api/elasticsearch/index_spec.rb b/spec/jay_api/elasticsearch/index_spec.rb index d39cf52..c3ba7f7 100644 --- a/spec/jay_api/elasticsearch/index_spec.rb +++ b/spec/jay_api/elasticsearch/index_spec.rb @@ -231,6 +231,135 @@ end end + describe '#force_merge' do + subject(:method_call) { index.force_merge(**method_params) } + + let(:method_params) { {} } + + let(:write_blocked?) { true } + + let(:blocks) do + instance_double( + JayAPI::Elasticsearch::Indices::Settings::Blocks, + write_blocked?: write_blocked? + ) + end + + let(:settings) do + instance_double( + JayAPI::Elasticsearch::Indices::Settings, + blocks: blocks + ) + end + + let(:request_result) do + { '_shards' => { 'total' => 10, 'successful' => 10, 'failed' => 0 } } + end + + let(:indices_client) do + instance_double( + Elasticsearch::API::Indices::IndicesClient, + forcemerge: request_result + ) + end + + before do + allow(JayAPI::Elasticsearch::Indices::Settings).to receive(:new).and_return(settings) + allow(transport_client).to receive(:indices).and_return(indices_client) + end + + context 'when the fetching of the settings raises an HTTP error' do + let(:error) do + [ + Elasticsearch::Transport::Transport::Errors::RequestTimeout, + '408 - Timed out' + ] + end + + before do + allow(blocks).to receive(:write_blocked?).and_raise(*error) + end + + it 're-raises the error' do + expect { method_call }.to raise_error(*error) + end + end + + context 'when the fetching of the settings raises a KeyError' do + let(:error) { [KeyError, 'key not found: "blocks"'] } + + before do + allow(blocks).to receive(:write_blocked?).and_raise(*error) + end + + it 're-raises the error' do + expect { method_call }.to raise_error(*error) + end + end + + context 'when the index is not in read-only mode' do + let(:write_blocked?) { false } + + it 'raises a WritableIndexError' do + expect { method_call }.to raise_error( + JayAPI::Elasticsearch::Errors::WritableIndexError, + "Write block for 'elite_unit_tests' has not been enabled. " \ + "Please enable the index's write block before performing a segment merge" + ) + end + end + + context "when 'only_expunge_deletes' is omitted" do + it 'starts a Force Merge process for the index, with no additional parameters' do + expect(indices_client).to receive(:forcemerge).with(index: 'elite_unit_tests') + method_call + end + end + + context "when 'only_expunge_deletes' set to false" do + let(:method_params) { super().merge(only_expunge_deletes: false) } + + it 'starts a Force Merge process for the index, with only_expunge_deletes set to false' do + expect(indices_client).to receive(:forcemerge) + .with(index: 'elite_unit_tests', only_expunge_deletes: false) + + method_call + end + end + + context "when 'only_expunge_deletes' set to true" do + let(:method_params) { super().merge(only_expunge_deletes: true) } + + it 'starts a Force Merge process for the index, with only_expunge_deletes set to true' do + expect(indices_client).to receive(:forcemerge) + .with(index: 'elite_unit_tests', only_expunge_deletes: true) + + method_call + end + end + + context 'when the Force Merge API call fails' do + let(:error) do + [ + Elasticsearch::Transport::Transport::Errors::Forbidden, + '408 - You do not have permissions to perform this action' + ] + end + + before do + allow(indices_client).to receive(:forcemerge).and_raise(*error) + end + + it 're-raises the error' do + expect { method_call }.to raise_error(*error) + end + end + + it 'returns the Hash with the result of the request' do + expect(method_call).to be(request_result) + end + end + describe '#queue_size' do subject(:method_call) { index.queue_size }