diff --git a/CHANGELOG.md b/CHANGELOG.md index 36903f6..5e72677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Please mark backwards incompatible changes with an exclamation mark at the start ## [Unreleased] +### Added +- The `#nodes` method to the `Elasticsearch::Stats` class. This method gives the + user access to the node-related statistics of the Elasticsearch cluster. + ## [28.1.0] - 2025-05-19 ### Added diff --git a/documentation/source/user_guidelines/elasticsearch/stats.rst b/documentation/source/user_guidelines/elasticsearch/stats.rst index 4dfe27a..3b4b473 100644 --- a/documentation/source/user_guidelines/elasticsearch/stats.rst +++ b/documentation/source/user_guidelines/elasticsearch/stats.rst @@ -45,3 +45,35 @@ The ``Stats::Index`` objects have the following methods: ``#name`` The name of the index. + +#nodes +------ + +This method gives you access to node-related statistics. The method returns an +instance of the ``Stats::Nodes`` class, which in turn allows you to access +information on each of the nodes that make up the Elasticsearch cluster through +the following methods: + +#size ++++++ + +This method returns the number of nodes in the cluster. + +#all +++++ + +This method returns an ``Enumerator`` whose elements are instances of the +``Stats::Node`` class, one for each of the nodes in the cluster. + +The ``Stats::Node`` class has the following methods: + +``#name`` + The name of the node. (Usually a random string of numbers and letters) + +``#storage`` + The method returns an instance of ``Stats::Node::Storage``, a class which + offers information about the storage of the node. ``Storage`` objects can be + added together to calculate the total storage of the cluster. + + The ``Storage`` classes provides three methods that return number of bytes: + ``#total``, ``#free`` and ``#available``. diff --git a/lib/jay_api/elasticsearch/stats.rb b/lib/jay_api/elasticsearch/stats.rb index be978d4..7de960d 100644 --- a/lib/jay_api/elasticsearch/stats.rb +++ b/lib/jay_api/elasticsearch/stats.rb @@ -2,6 +2,8 @@ require_relative 'stats/index' require_relative 'stats/indices' +require_relative 'stats/node' +require_relative 'stats/nodes' module JayAPI module Elasticsearch @@ -21,19 +23,37 @@ def initialize(transport_client) # to the Statistics API endpoint fails. def indices # DO NOT MEMOIZE! Leave it to the caller. - ::JayAPI::Elasticsearch::Stats::Indices.new(response['indices']) + ::JayAPI::Elasticsearch::Stats::Indices.new(indices_stats['indices']) + end + + # @return [JayAPI::Elasticsearch::Stats::Nodes] Information about the + # nodes that make up the Elasticsearch cluster. + # @raise [Elasticsearch::Transport::Transport::ServerError] If the request + # to the Statistics API endpoint fails. + def nodes + # DO NOT MEMOIZE! Leave it to the caller. + ::JayAPI::Elasticsearch::Stats::Nodes.new(nodes_stats['nodes']) end private - # @return [Hash] The Hash with the statistics returned by the - # Elasticsearch cluster. + # @return [Hash] The Hash with the index-related statistics returned by + # the Elasticsearch cluster. # @raise [Elasticsearch::Transport::Transport::ServerError] If the # request fails. - def response + def indices_stats # DO NOT MEMOIZE! Leave it to the caller. transport_client.indices.stats end + + # @return [Hash] The Hash with the node-related statistics returned by the + # Elasticsearch cluster. + # @raise [Elasticsearch::Transport::Transport::ServerError] If the + # request fails. + def nodes_stats + # DO NOT MEMOIZE! Leave it to the caller. + transport_client.nodes.stats + end end end end diff --git a/lib/jay_api/elasticsearch/stats/errors/stats_data_not_available.rb b/lib/jay_api/elasticsearch/stats/errors/stats_data_not_available.rb new file mode 100644 index 0000000..841ec22 --- /dev/null +++ b/lib/jay_api/elasticsearch/stats/errors/stats_data_not_available.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative '../../../errors/error' + +module JayAPI + module Elasticsearch + class Stats + module Errors + # An error to be raised when a particular Stats element is requested for + # which there is no data in the response received from the cluster. + class StatsDataNotAvailable < ::JayAPI::Errors::Error; end + end + end + end +end diff --git a/lib/jay_api/elasticsearch/stats/node.rb b/lib/jay_api/elasticsearch/stats/node.rb new file mode 100644 index 0000000..a9a42c7 --- /dev/null +++ b/lib/jay_api/elasticsearch/stats/node.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative 'errors/stats_data_not_available' +require_relative 'node/storage' + +module JayAPI + module Elasticsearch + class Stats + # Holds information about one of the nodes in the Elasticsearch cluster. + class Node + attr_reader :name + + # @param [String] name The name of the node. + # @param [Hash] data Information about the node. + def initialize(name, data) + @name = name + @data = data + end + + # @return [JayAPI::Elasticsearch::Stats::Node::Storage] Storage + # information about the node. + # @raise [JayAPI::Elasticsearch::Stats::Errors::StatsDataNotAvailable] + # If there is no storage information for the node. + def storage + @storage ||= ::JayAPI::Elasticsearch::Stats::Node::Storage.new(fs_totals) + end + + private + + attr_reader :data + + # @return [Hash] Aggregated information about the +Node+'s + # filesystem. + # @raise [JayAPI::Elasticsearch::Stats::Errors::StatsDataNotAvailable] + # If there is no filesystem information for the node. + def fs_totals + @fs_totals ||= data.dig('fs', 'total') || raise( + ::JayAPI::Elasticsearch::Stats::Errors::StatsDataNotAvailable, + "Filesystem data not available for node #{name}" + ) + end + end + end + end +end diff --git a/lib/jay_api/elasticsearch/stats/node/storage.rb b/lib/jay_api/elasticsearch/stats/node/storage.rb new file mode 100644 index 0000000..2ed083d --- /dev/null +++ b/lib/jay_api/elasticsearch/stats/node/storage.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module JayAPI + module Elasticsearch + class Stats + class Node + # Holds storage information related to one of the nodes in the + # Elasticsearch cluster. + class Storage + TOTAL_KEY = 'total_in_bytes' + FREE_KEY = 'free_in_bytes' + AVAILABLE_KEY = 'available_in_bytes' + + # @param [Hash] data Data about the Node's storage. + def initialize(data) + @data = data + end + + # @return [Integer] The total size of the storage (in bytes) + def total + @total ||= data[TOTAL_KEY] + end + + # @return [Integer] The total number of bytes that are free on the + # node. + def free + @free ||= data[FREE_KEY] + end + + # @return [Integer] The total number of bytes that are available on + # the node. In general this is equal to #free, but not always. + def available + @available ||= data[AVAILABLE_KEY] + end + + # @return [self] A new instance of the class with the added storage of + # the receiver and +other+. + def +(other) + raise ArgumentError, "Cannot add #{self.class} and #{other.class} together" unless other.is_a?(self.class) + + self.class.new( + TOTAL_KEY => total + other.total, + FREE_KEY => free + other.free, + AVAILABLE_KEY => available + other.available + ) + end + + private + + attr_reader :data + end + end + end + end +end diff --git a/lib/jay_api/elasticsearch/stats/nodes.rb b/lib/jay_api/elasticsearch/stats/nodes.rb new file mode 100644 index 0000000..87d4e5f --- /dev/null +++ b/lib/jay_api/elasticsearch/stats/nodes.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'forwardable' + +require_relative 'node' + +module JayAPI + module Elasticsearch + class Stats + # Provides access to the list of nodes returned by Elasticsearch's Stats API + class Nodes + extend Forwardable + + def_delegator :nodes, :size + + # @param [Hash] nodes Information about the nodes in the Elasticsearch + # cluster. + def initialize(nodes) + @nodes = nodes + end + + # @return [Enumerator::Lazy] A lazy + # enumerator of +Node+ objects, one for each of the nodes. + def all + @all ||= with_lazy_instantiation { nodes } + end + + private + + attr_reader :nodes + + def build_node(args) + ::JayAPI::Elasticsearch::Stats::Node.new(*args) + end + + # Calls the given block and turns its return value into a lazy + # enumerator that instantiates a +Node+ object for each of the + # elements of the collection returned by the block. + # @return [Enumerator::Lazy] + def with_lazy_instantiation(&block) + block.call.lazy.map(&method(:build_node)) + end + end + end + end +end diff --git a/spec/jay_api/elasticsearch/stats/node/storage_spec.rb b/spec/jay_api/elasticsearch/stats/node/storage_spec.rb new file mode 100644 index 0000000..6b0f82c --- /dev/null +++ b/spec/jay_api/elasticsearch/stats/node/storage_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'jay_api/elasticsearch/stats/node/storage' + +RSpec.describe JayAPI::Elasticsearch::Stats::Node::Storage do + subject(:storage) { described_class.new(data) } + + let(:data) do + { + 'total_in_bytes' => 5_126_127_616, + 'free_in_bytes' => 4_576_702_464, + 'available_in_bytes' => 4_559_925_248 + } + end + + describe '#total' do + subject(:method_call) { storage.total } + + it 'returns the total number of bytes' do + expect(method_call).to eq(5_126_127_616) + end + end + + describe '#free' do + subject(:method_call) { storage.free } + + it 'returns the total number of free bytes' do + expect(method_call).to eq(4_576_702_464) + end + end + + describe '#available' do + subject(:method_call) { storage.available } + + it 'returns the total number of available bytes' do + expect(method_call).to eq(4_559_925_248) + end + end + + describe '#+' do + subject(:method_call) { storage + other } + + let(:other) do + described_class.new( + 'total_in_bytes' => 316_863_741_952, + 'free_in_bytes' => 25_923_690_496, + 'available_in_bytes' => 25_906_913_280 + ) + end + + context "when 'other' is not an instance of #{described_class}" do + let(:other) do + { + 'total_in_bytes' => 316_863_741_952, + 'free_in_bytes' => 25_923_690_496, + 'available_in_bytes' => 25_906_913_280 + } + end + + it 'raises an ArgumentError' do + expect { method_call }.to raise_error( + ArgumentError, + 'Cannot add JayAPI::Elasticsearch::Stats::Node::Storage and Hash together' + ) + end + end + + it 'does not return the receiver nor other' do + expect(method_call).not_to be(storage) + expect(method_call).not_to be(other) + method_call + end + + it "returns a new instance of #{described_class}" do + expect(method_call).to be_a(described_class) + end + + it "returns an instance of #{described_class} with the expected number of total bytes" do + expect(method_call.total).to eq(321_989_869_568) + end + + it "returns an instance of #{described_class} with the expected number of free bytes" do + expect(method_call.free).to eq(30_500_392_960) + end + + it "returns an instance of #{described_class} with the expected number of available bytes" do + expect(method_call.available).to eq(30_466_838_528) + end + end +end diff --git a/spec/jay_api/elasticsearch/stats/node_spec.rb b/spec/jay_api/elasticsearch/stats/node_spec.rb new file mode 100644 index 0000000..eda083d --- /dev/null +++ b/spec/jay_api/elasticsearch/stats/node_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'jay_api/elasticsearch/stats/node' + +RSpec.describe JayAPI::Elasticsearch::Stats::Node do + subject(:node) { described_class.new(name, data) } + + let(:name) { 'Q9CAvRyRSBSBV3mxxbnPjQ' } + let(:data) { {} } + + describe '#storage' do + subject(:method_call) { node.storage } + + shared_examples_for '#storage when no storage-related data is available' do + it 'raises a StatsDataNotAvailable error' do + expect { method_call }.to raise_error( + JayAPI::Elasticsearch::Stats::Errors::StatsDataNotAvailable, + 'Filesystem data not available for node Q9CAvRyRSBSBV3mxxbnPjQ' + ) + end + end + + context "when the node data doesn't include filesystem data" do + let(:data) { {} } + + it_behaves_like '#storage when no storage-related data is available' + end + + context 'when the node data includes filesystem data' do + let(:fs_data) do + { + 'data' => [{ + 'type' => 'ext4', + 'total_in_bytes' => 316_863_741_952, + 'free_in_bytes' => 24_591_085_568, + 'available_in_bytes' => 24_574_308_352 + }] + } + end + + let(:data) do + { 'fs' => fs_data } + end + + context "when the node data doesn't include aggregated data about the filesystem" do + it_behaves_like '#storage when no storage-related data is available' + end + + context 'when the node data contains aggregated data about the filesystem' do + let(:fs_data) do + super().merge( + 'total' => { + 'total_in_bytes' => 316_863_741_952, + 'free_in_bytes' => 24_591_085_568, + 'available_in_bytes' => 24_574_308_352 + } + ) + end + + let(:expected_storage_data) do + { + 'total_in_bytes' => 316_863_741_952, + 'free_in_bytes' => 24_591_085_568, + 'available_in_bytes' => 24_574_308_352 + } + end + + let(:storage) do + instance_double( + JayAPI::Elasticsearch::Stats::Node::Storage + ) + end + + before do + allow(JayAPI::Elasticsearch::Stats::Node::Storage) + .to receive(:new).and_return(storage) + end + + it 'initializes the Storage instance from the aggregated data' do + expect(JayAPI::Elasticsearch::Stats::Node::Storage).to receive(:new) + .with(expected_storage_data) + + method_call + end + + it 'returns the Storage instance' do + expect(method_call).to be(storage) + end + end + end + end +end diff --git a/spec/jay_api/elasticsearch/stats/nodes_spec.rb b/spec/jay_api/elasticsearch/stats/nodes_spec.rb new file mode 100644 index 0000000..2379971 --- /dev/null +++ b/spec/jay_api/elasticsearch/stats/nodes_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'jay_api/elasticsearch/stats/nodes' + +RSpec.describe JayAPI::Elasticsearch::Stats::Nodes do + subject(:nodes) { described_class.new(nodes_hash) } + + let(:nodes_hash) do + { + 'Q9CAvRyRSBSBV3mxxbnPjQ' => { + 'indices' => { 'docs_count' => 218_737_641 } + }, + 'ntd-0MYVRe-PnPSAH6jDDg' => { + 'indices' => { 'docs_count' => 197_437_505 } + }, + 'MVSLeteKR_aiLjccChNYpA' => { + 'indices' => { 'docs_count' => 0 } + } + } + end + + describe '#size' do + subject(:method_call) { nodes.size } + + it 'does not initialize any Node objets' do + expect(JayAPI::Elasticsearch::Stats::Node).not_to receive(:new) + method_call + end + + it 'returns the number of nodes in the given hash' do + expect(method_call).to eq(3) + end + end + + describe '#all' do + subject(:method_call) { nodes.all } + + let(:expected_nodes) do + %w[Q9CAvRyRSBSBV3mxxbnPjQ ntd-0MYVRe-PnPSAH6jDDg MVSLeteKR_aiLjccChNYpA] + end + + it 'returns an Enumerator::Lazy' do + expect(method_call).to be_a(Enumerator::Lazy) + end + + it 'includes the expected number of nodes' do + expect(method_call.size).to eq(3) + end + + it 'includes only instances of JayAPI::Elasticsearch::Stats::Node' do + expect(method_call).to all(be_a(JayAPI::Elasticsearch::Stats::Node)) + end + + it 'includes the expected list of indices' do + # #to_a is needed here because of the lazy enumerator. + expect(method_call.map(&:name).to_a).to eq(expected_nodes) + end + end +end diff --git a/spec/jay_api/elasticsearch/stats_spec.rb b/spec/jay_api/elasticsearch/stats_spec.rb index 742b44f..fcb2dbc 100644 --- a/spec/jay_api/elasticsearch/stats_spec.rb +++ b/spec/jay_api/elasticsearch/stats_spec.rb @@ -6,53 +6,52 @@ RSpec.describe JayAPI::Elasticsearch::Stats do subject(:stats) { described_class.new(transport_client) } - let(:stats_hash) do - { - '_all' => { - 'total' => { - 'store' => { - 'size_in_bytes' => 830_124_136, - 'reserved_in_bytes' => 0 - } - } - }, - 'indices' => { - 'xyz01_integration_test' => { - 'uuid' => 'OXRb8_IYTseG2epNa9Ls3g' - }, - 'xyz01_unit_tests' => { - 'uuid' => 'hxDdhi-3TFSndxhLesspFw' - }, - '.kibana_views' => { - 'uuid' => 'pr-VjrPARlG3lAoAfPqNog' - }, - 'xyz02_manual_verification' => { - 'uuid' => 'uaZ_kKQuSM-HaKH_LcI7BQ' - }, - '.backup' => { - 'uuid' => 'N7TZOstjRHu8mTwsLZuQ5w' - } - } - } - end - - let(:indices_client) do - instance_double( - Elasticsearch::API::Indices::IndicesClient, - stats: stats_hash - ) - end - let(:transport_client) do instance_double( - Elasticsearch::Transport::Client, - indices: indices_client + Elasticsearch::Transport::Client ) end describe '#indices' do subject(:method_call) { stats.indices } + let(:stats_hash) do + { + '_all' => { + 'total' => { + 'store' => { + 'size_in_bytes' => 830_124_136, + 'reserved_in_bytes' => 0 + } + } + }, + 'indices' => { + 'xyz01_integration_test' => { + 'uuid' => 'OXRb8_IYTseG2epNa9Ls3g' + }, + 'xyz01_unit_tests' => { + 'uuid' => 'hxDdhi-3TFSndxhLesspFw' + }, + '.kibana_views' => { + 'uuid' => 'pr-VjrPARlG3lAoAfPqNog' + }, + 'xyz02_manual_verification' => { + 'uuid' => 'uaZ_kKQuSM-HaKH_LcI7BQ' + }, + '.backup' => { + 'uuid' => 'N7TZOstjRHu8mTwsLZuQ5w' + } + } + } + end + + let(:indices_client) do + instance_double( + Elasticsearch::API::Indices::IndicesClient, + stats: stats_hash + ) + end + let(:indices) do instance_double( JayAPI::Elasticsearch::Stats::Indices @@ -80,6 +79,7 @@ end before do + allow(transport_client).to receive(:indices).and_return(indices_client) allow(JayAPI::Elasticsearch::Stats::Indices).to receive(:new).and_return(indices) end @@ -117,4 +117,86 @@ expect(method_call).to be(indices) end end + + describe '#nodes' do + subject(:method_call) { stats.nodes } + + let(:stats_hash) do + { + '_nodes' => { 'total' => 6, 'successful' => 6, 'failed' => 0 }, + 'cluster_name' => '130592744203', + 'nodes' => { + 'Q9CAvRyRSBSBV3mxxbnPjQ' => { 'name' => '3be27c55fbd0bea8278c8e67f5e0dafa' }, + 'ntd-0MYVRe-PnPSAH6jDDg' => { 'name' => '7aa07fa030f7e9ac302e7898fd400ded' }, + 'MVSLeteKR_aiLjccChNYpA' => { 'name' => '443b4912dfc7c104afa8074e62246c22' }, + 'uUZyFyxuThaHzsjmFRTxXw' => { 'name' => '30512cbcb29eeba7b854ef4f4663f42d' }, + 'cNvDkFDWQ2miz5MvMmmVZg' => { 'name' => '9703b4532fa4f05e9fb305722bec0623' }, + '4Yk4GeazTE6gSId3OuOV1A' => { 'name' => 'ef6f30de3e7d26567960e0247026c2b8' } + } + } + end + + let(:nodes_client) do + instance_double( + Elasticsearch::API::Nodes::NodesClient, + stats: stats_hash + ) + end + + let(:nodes) do + instance_double( + JayAPI::Elasticsearch::Stats::Nodes + ) + end + + let(:expected_nodes_data) do + { + 'Q9CAvRyRSBSBV3mxxbnPjQ' => { 'name' => '3be27c55fbd0bea8278c8e67f5e0dafa' }, + 'ntd-0MYVRe-PnPSAH6jDDg' => { 'name' => '7aa07fa030f7e9ac302e7898fd400ded' }, + 'MVSLeteKR_aiLjccChNYpA' => { 'name' => '443b4912dfc7c104afa8074e62246c22' }, + 'uUZyFyxuThaHzsjmFRTxXw' => { 'name' => '30512cbcb29eeba7b854ef4f4663f42d' }, + 'cNvDkFDWQ2miz5MvMmmVZg' => { 'name' => '9703b4532fa4f05e9fb305722bec0623' }, + '4Yk4GeazTE6gSId3OuOV1A' => { 'name' => 'ef6f30de3e7d26567960e0247026c2b8' } + } + end + + before do + allow(transport_client).to receive(:nodes).and_return(nodes_client) + allow(JayAPI::Elasticsearch::Stats::Nodes).to receive(:new).and_return(nodes) + end + + it "requests the nodes' statistics from the Elasticsearch cluster" do + expect(transport_client).to receive(:nodes) + expect(nodes_client).to receive(:stats) + method_call + end + + context 'when the API request fails' do + let(:error) do + [ + Elasticsearch::Transport::Transport::Errors::Forbidden, + 'You do not have permission to perform this action' + ] + end + + before do + allow(nodes_client).to receive(:stats).and_raise(*error) + end + + it 're-raises the error' do + expect { method_call }.to raise_error(*error) + end + end + + it 'creates an instance of JayAPI::Elasticsearch::Stats::Nodes and passes the nodes data to it' do + expect(JayAPI::Elasticsearch::Stats::Nodes) + .to receive(:new).with(expected_nodes_data) + + method_call + end + + it 'returns the instance of JayAPI::Elasticsearch::Stats::Nodes' do + expect(method_call).to be(nodes) + end + end end