Skip to content

Commit ad68908

Browse files
authored
[7.17] Fix _cluster/stats .nodes.fs deduplication (#94843)
* Refactor _cluster/stats .nodes.fs deduplication (#94780) * Fix FsInfo device deduplication (#94744) * Fix _cluster/stats .nodes.fs deduplication (#94798)
1 parent 9644135 commit ad68908

File tree

8 files changed

+266
-22
lines changed

8 files changed

+266
-22
lines changed

docs/changelog/94744.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 94744
2+
summary: Fix `FsInfo` device deduplication
3+
area: Stats
4+
type: bug
5+
issues: []

docs/changelog/94798.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 94798
2+
summary: Fix _cluster/stats `.nodes.fs` deduplication
3+
area: Stats
4+
type: bug
5+
issues:
6+
- 24472

server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsNodes.java

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import java.util.HashSet;
4242
import java.util.List;
4343
import java.util.Map;
44+
import java.util.Objects;
4445
import java.util.Set;
4546
import java.util.SortedMap;
4647
import java.util.TreeMap;
@@ -63,10 +64,10 @@ public class ClusterStatsNodes implements ToXContentFragment {
6364

6465
ClusterStatsNodes(List<ClusterStatsNodeResponse> nodeResponses) {
6566
this.versions = new HashSet<>();
66-
this.fs = new FsInfo.Path();
6767
this.plugins = new HashSet<>();
6868

69-
Set<InetAddress> seenAddresses = new HashSet<>(nodeResponses.size());
69+
ClusterFsStatsDeduplicator deduplicator = new ClusterFsStatsDeduplicator(nodeResponses.size());
70+
7071
List<NodeInfo> nodeInfos = new ArrayList<>(nodeResponses.size());
7172
List<NodeStats> nodeStats = new ArrayList<>(nodeResponses.size());
7273
for (ClusterStatsNodeResponse nodeResponse : nodeResponses) {
@@ -75,16 +76,12 @@ public class ClusterStatsNodes implements ToXContentFragment {
7576
this.versions.add(nodeResponse.nodeInfo().getVersion());
7677
this.plugins.addAll(nodeResponse.nodeInfo().getInfo(PluginsAndModules.class).getPluginInfos());
7778

78-
// now do the stats that should be deduped by hardware (implemented by ip deduping)
7979
TransportAddress publishAddress = nodeResponse.nodeInfo().getInfo(TransportInfo.class).address().publishAddress();
8080
final InetAddress inetAddress = publishAddress.address().getAddress();
81-
if (seenAddresses.add(inetAddress) == false) {
82-
continue;
83-
}
84-
if (nodeResponse.nodeStats().getFs() != null) {
85-
this.fs.add(nodeResponse.nodeStats().getFs().getTotal());
86-
}
81+
deduplicator.add(inetAddress, nodeResponse.nodeStats().getFs());
8782
}
83+
this.fs = deduplicator.getTotal();
84+
8885
this.counts = new Counts(nodeInfos);
8986
this.os = new OsStats(nodeInfos, nodeStats);
9087
this.process = new ProcessStats(nodeStats);
@@ -764,4 +761,75 @@ public XContentBuilder toXContent(final XContentBuilder builder, final Params pa
764761

765762
}
766763

764+
static class ClusterFsStatsDeduplicator {
765+
766+
private static class DedupEntry {
767+
768+
private final InetAddress inetAddress;
769+
private final String mount;
770+
private final String path;
771+
772+
DedupEntry(InetAddress inetAddress, String mount, String path) {
773+
this.inetAddress = inetAddress;
774+
this.mount = mount;
775+
this.path = path;
776+
}
777+
778+
@Override
779+
public boolean equals(Object o) {
780+
if (this == o) return true;
781+
if (o == null || getClass() != o.getClass()) return false;
782+
DedupEntry that = (DedupEntry) o;
783+
return Objects.equals(inetAddress, that.inetAddress)
784+
&& Objects.equals(mount, that.mount)
785+
&& Objects.equals(path, that.path);
786+
}
787+
788+
@Override
789+
public int hashCode() {
790+
return Objects.hash(inetAddress, mount, path);
791+
}
792+
}
793+
794+
private final Set<DedupEntry> seenAddressesMountsPaths;
795+
796+
private final FsInfo.Path total = new FsInfo.Path();
797+
798+
ClusterFsStatsDeduplicator(int expectedSize) {
799+
// each address+mount is stored twice (once without a path, and once with a path), thus 2x
800+
seenAddressesMountsPaths = new HashSet<>(2 * expectedSize);
801+
}
802+
803+
public void add(InetAddress inetAddress, FsInfo fsInfo) {
804+
if (fsInfo != null) {
805+
for (FsInfo.Path p : fsInfo) {
806+
final String mount = p.getMount();
807+
final String path = p.getPath();
808+
809+
// this deduplication logic is hard to get right. it might be impossible to make it work correctly in
810+
// *all* circumstances. this is best-effort only, but it's aimed at trying to solve 90%+ of cases.
811+
812+
// rule 0: we want to sum the unique mounts for each ip address, so if we *haven't* seen a particular
813+
// address and mount, then definitely add that to the total.
814+
815+
// rule 1: however, as a special case, if we see the same address+mount+path triple more than once, then we
816+
// override the ip+mount de-duplication logic -- using that as indicator that we're seeing a special
817+
// containerization situation, in which case we assume the operator is maintaining different disks for each node.
818+
819+
boolean seenAddressMount = seenAddressesMountsPaths.add(new DedupEntry(inetAddress, mount, null)) == false;
820+
boolean seenAddressMountPath = seenAddressesMountsPaths.add(new DedupEntry(inetAddress, mount, path)) == false;
821+
822+
if ((seenAddressMount == false) || seenAddressMountPath) {
823+
total.add(p);
824+
}
825+
}
826+
}
827+
}
828+
829+
public FsInfo.Path getTotal() {
830+
FsInfo.Path result = new FsInfo.Path();
831+
result.add(total);
832+
return result;
833+
}
834+
}
767835
}

server/src/main/java/org/elasticsearch/monitor/fs/FsInfo.java

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
import org.elasticsearch.common.io.stream.StreamOutput;
1515
import org.elasticsearch.common.io.stream.Writeable;
1616
import org.elasticsearch.common.unit.ByteSizeValue;
17-
import org.elasticsearch.core.Nullable;
1817
import org.elasticsearch.xcontent.ToXContentFragment;
1918
import org.elasticsearch.xcontent.ToXContentObject;
2019
import org.elasticsearch.xcontent.XContentBuilder;
2120

2221
import java.io.IOException;
22+
import java.nio.file.FileStore;
2323
import java.util.Arrays;
2424
import java.util.HashSet;
2525
import java.util.Iterator;
@@ -30,18 +30,17 @@ public class FsInfo implements Iterable<FsInfo.Path>, Writeable, ToXContentFragm
3030
public static class Path implements Writeable, ToXContentObject {
3131

3232
String path;
33-
@Nullable
34-
String mount;
35-
/** File system type from {@code java.nio.file.FileStore type()}, if available. */
36-
@Nullable
37-
String type;
33+
/** File system string from {@link FileStore#toString()}. The concrete subclasses of FileStore have meaningful toString methods. */
34+
String mount; // e.g. "/app (/dev/mapper/lxc-data)", "/System/Volumes/Data (/dev/disk1s2)", "Local Disk (C:)", etc.
35+
/** File system type from {@link FileStore#type()}. */
36+
String type; // e.g. "xfs", "apfs", "NTFS", etc.
3837
long total = -1;
3938
long free = -1;
4039
long available = -1;
4140

4241
public Path() {}
4342

44-
public Path(String path, @Nullable String mount, long total, long free, long available) {
43+
public Path(String path, String mount, long total, long free, long available) {
4544
this.path = path;
4645
this.mount = mount;
4746
this.total = total;
@@ -53,7 +52,7 @@ public Path(String path, @Nullable String mount, long total, long free, long ava
5352
* Read from a stream.
5453
*/
5554
public Path(StreamInput in) throws IOException {
56-
path = in.readOptionalString();
55+
path = in.readOptionalString(); // total aggregates do not have a path, mount, or type
5756
mount = in.readOptionalString();
5857
type = in.readOptionalString();
5958
total = in.readLong();
@@ -66,7 +65,7 @@ public Path(StreamInput in) throws IOException {
6665

6766
@Override
6867
public void writeTo(StreamOutput out) throws IOException {
69-
out.writeOptionalString(path); // total aggregates do not have a path
68+
out.writeOptionalString(path); // total aggregates do not have a path, mount, or type
7069
out.writeOptionalString(mount);
7170
out.writeOptionalString(type);
7271
out.writeLong(total);
@@ -504,8 +503,8 @@ private Path total() {
504503
Path res = new Path();
505504
Set<String> seenDevices = new HashSet<>(paths.length);
506505
for (Path subPath : paths) {
507-
if (subPath.path != null) {
508-
if (seenDevices.add(subPath.path) == false) {
506+
if (subPath.mount != null) {
507+
if (seenDevices.add(subPath.mount) == false) {
509508
continue; // already added numbers for this device;
510509
}
511510
}

server/src/main/java/org/elasticsearch/monitor/fs/FsProbe.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ public static FsInfo.Path getFSInfo(DataPath dataPath) throws IOException {
135135
FsInfo.Path fsPath = new FsInfo.Path();
136136
fsPath.path = dataPath.path.toString();
137137

138-
// NOTE: we use already cached (on node startup) FileStore and spins
138+
// NOTE: we use already cached (on node startup) FileStore
139139
// since recomputing these once per second (default) could be costly,
140140
// and they should not change:
141141
fsPath.total = getTotal(dataPath.fileStore);

server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsNodesTests.java

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
1212
import org.elasticsearch.action.admin.cluster.node.stats.NodeStats;
1313
import org.elasticsearch.action.admin.cluster.node.stats.NodeStatsTests;
1414
import org.elasticsearch.cluster.node.DiscoveryNode;
15+
import org.elasticsearch.common.network.InetAddresses;
1516
import org.elasticsearch.common.network.NetworkModule;
1617
import org.elasticsearch.common.settings.Settings;
18+
import org.elasticsearch.monitor.fs.FsInfo;
1719
import org.elasticsearch.test.ESTestCase;
1820
import org.elasticsearch.xcontent.XContentType;
1921

22+
import java.net.InetAddress;
2023
import java.util.Arrays;
2124
import java.util.Collections;
2225
import java.util.Iterator;
@@ -121,6 +124,128 @@ public void testIngestStats() throws Exception {
121124
);
122125
}
123126

127+
public void testClusterFsStatsDeduplicator() {
128+
{
129+
// single node, multiple data paths, different devices
130+
InetAddress address1 = InetAddresses.forString("192.168.0.1");
131+
FsInfo.Path path1 = new FsInfo.Path("/a", "/dev/sda", 3, 2, 1);
132+
FsInfo.Path path2 = new FsInfo.Path("/b", "/dev/sdb", 3, 2, 1);
133+
ClusterStatsNodes.ClusterFsStatsDeduplicator deduplicator = new ClusterStatsNodes.ClusterFsStatsDeduplicator(1);
134+
deduplicator.add(address1, newFsInfo(path1, path2));
135+
FsInfo.Path total = deduplicator.getTotal();
136+
137+
// since they're different devices, they sum
138+
assertThat(total.getTotal().getBytes(), equalTo(6L));
139+
assertThat(total.getFree().getBytes(), equalTo(4L));
140+
assertThat(total.getAvailable().getBytes(), equalTo(2L));
141+
}
142+
143+
{
144+
// single node, multiple data paths, same device
145+
InetAddress address1 = InetAddresses.forString("192.168.0.1");
146+
FsInfo.Path path1 = new FsInfo.Path("/data/a", "/dev/sda", 3, 2, 1);
147+
FsInfo.Path path2 = new FsInfo.Path("/data/b", "/dev/sda", 3, 2, 1);
148+
ClusterStatsNodes.ClusterFsStatsDeduplicator deduplicator = new ClusterStatsNodes.ClusterFsStatsDeduplicator(1);
149+
deduplicator.add(address1, newFsInfo(path1, path2));
150+
FsInfo.Path total = deduplicator.getTotal();
151+
152+
// since it's the same device, they don't sum, we just see the one
153+
assertThat(total.getTotal().getBytes(), equalTo(3L));
154+
assertThat(total.getFree().getBytes(), equalTo(2L));
155+
assertThat(total.getAvailable().getBytes(), equalTo(1L));
156+
}
157+
158+
{
159+
// two nodes, same ip address, but different data paths on different devices
160+
InetAddress address1 = InetAddresses.forString("192.168.0.1");
161+
FsInfo.Path path1 = new FsInfo.Path("/data/a", "/dev/sda", 3, 2, 1);
162+
InetAddress address2 = InetAddresses.forString("192.168.0.1");
163+
FsInfo.Path path2 = new FsInfo.Path("/data/b", "/dev/sdb", 3, 2, 1);
164+
ClusterStatsNodes.ClusterFsStatsDeduplicator deduplicator = new ClusterStatsNodes.ClusterFsStatsDeduplicator(1);
165+
deduplicator.add(address1, newFsInfo(path1));
166+
deduplicator.add(address2, newFsInfo(path2));
167+
FsInfo.Path total = deduplicator.getTotal();
168+
169+
// since they're different devices, they sum
170+
assertThat(total.getTotal().getBytes(), equalTo(6L));
171+
assertThat(total.getFree().getBytes(), equalTo(4L));
172+
assertThat(total.getAvailable().getBytes(), equalTo(2L));
173+
}
174+
175+
{
176+
// two nodes, different ip addresses, different data paths, same device
177+
InetAddress address1 = InetAddresses.forString("192.168.0.1");
178+
FsInfo.Path path1 = new FsInfo.Path("/data/a", "/dev/sda", 3, 2, 1);
179+
InetAddress address2 = InetAddresses.forString("192.168.0.2");
180+
FsInfo.Path path2 = new FsInfo.Path("/data/b", "/dev/sda", 3, 2, 1);
181+
ClusterStatsNodes.ClusterFsStatsDeduplicator deduplicator = new ClusterStatsNodes.ClusterFsStatsDeduplicator(1);
182+
deduplicator.add(address1, newFsInfo(path1));
183+
deduplicator.add(address2, newFsInfo(path2));
184+
FsInfo.Path total = deduplicator.getTotal();
185+
186+
// it's the same device, yeah, but on entirely different machines, so they sum
187+
assertThat(total.getTotal().getBytes(), equalTo(6L));
188+
assertThat(total.getFree().getBytes(), equalTo(4L));
189+
assertThat(total.getAvailable().getBytes(), equalTo(2L));
190+
}
191+
192+
{
193+
// two nodes, same ip address, same data path, same device
194+
InetAddress address1 = InetAddresses.forString("192.168.0.1");
195+
FsInfo.Path path1 = new FsInfo.Path("/app/data", "/app (/dev/mapper/lxc-data)", 3, 2, 1);
196+
InetAddress address2 = InetAddresses.forString("192.168.0.1");
197+
FsInfo.Path path2 = new FsInfo.Path("/app/data", "/app (/dev/mapper/lxc-data)", 3, 2, 1);
198+
ClusterStatsNodes.ClusterFsStatsDeduplicator deduplicator = new ClusterStatsNodes.ClusterFsStatsDeduplicator(1);
199+
deduplicator.add(address1, newFsInfo(path1));
200+
deduplicator.add(address2, newFsInfo(path2));
201+
FsInfo.Path total = deduplicator.getTotal();
202+
203+
// wait a second, this is the super-special case -- you can't actually have two nodes doing this unless something
204+
// very interesting is happening, so they sum (i.e. we assume the operator is doing smart things)
205+
assertThat(total.getTotal().getBytes(), equalTo(6L));
206+
assertThat(total.getFree().getBytes(), equalTo(4L));
207+
assertThat(total.getAvailable().getBytes(), equalTo(2L));
208+
}
209+
210+
{
211+
// two nodes, same ip address, different data paths, same device
212+
InetAddress address1 = InetAddresses.forString("192.168.0.1");
213+
FsInfo.Path path1 = new FsInfo.Path("/app/data1", "/dev/sda", 3, 2, 1);
214+
InetAddress address2 = InetAddresses.forString("192.168.0.1");
215+
FsInfo.Path path2 = new FsInfo.Path("/app/data2", "/dev/sda", 3, 2, 1);
216+
ClusterStatsNodes.ClusterFsStatsDeduplicator deduplicator = new ClusterStatsNodes.ClusterFsStatsDeduplicator(1);
217+
deduplicator.add(address1, newFsInfo(path1));
218+
deduplicator.add(address2, newFsInfo(path2));
219+
FsInfo.Path total = deduplicator.getTotal();
220+
221+
// since the paths aren't the same, it doesn't trigger the special case -- it's just the same device and doesn't sum
222+
assertThat(total.getTotal().getBytes(), equalTo(3L));
223+
assertThat(total.getFree().getBytes(), equalTo(2L));
224+
assertThat(total.getAvailable().getBytes(), equalTo(1L));
225+
}
226+
227+
{
228+
// two nodes, same ip address, same data path, different devices
229+
InetAddress address1 = InetAddresses.forString("192.168.0.1");
230+
FsInfo.Path path1 = new FsInfo.Path("/app/data", "/dev/sda", 3, 2, 1);
231+
InetAddress address2 = InetAddresses.forString("192.168.0.1");
232+
FsInfo.Path path2 = new FsInfo.Path("/app/data", "/dev/sdb", 3, 2, 1);
233+
ClusterStatsNodes.ClusterFsStatsDeduplicator deduplicator = new ClusterStatsNodes.ClusterFsStatsDeduplicator(1);
234+
deduplicator.add(address1, newFsInfo(path1));
235+
deduplicator.add(address2, newFsInfo(path2));
236+
FsInfo.Path total = deduplicator.getTotal();
237+
238+
// having the same path isn't special in this case, it's just unique ip/mount pairs, so they sum
239+
assertThat(total.getTotal().getBytes(), equalTo(6L));
240+
assertThat(total.getFree().getBytes(), equalTo(4L));
241+
assertThat(total.getAvailable().getBytes(), equalTo(2L));
242+
}
243+
}
244+
245+
private static FsInfo newFsInfo(FsInfo.Path... paths) {
246+
return new FsInfo(-1, null, paths);
247+
}
248+
124249
private static NodeInfo createNodeInfo(String nodeId, String transportType, String httpType) {
125250
Settings.Builder settings = Settings.builder();
126251
if (transportType != null) {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
package org.elasticsearch.monitor.fs;
10+
11+
import org.elasticsearch.monitor.fs.FsInfo.Path;
12+
import org.elasticsearch.test.ESTestCase;
13+
14+
import static org.hamcrest.Matchers.is;
15+
16+
public class FsInfoTests extends ESTestCase {
17+
18+
public void testTotalSummation() {
19+
Path p1 = new Path("J:\\data\\nodes\\0", "Disk10 (J:)", 4, 3, 2);
20+
Path p2 = new Path("K:\\data\\nodes\\0", "Disk11 (K:)", 8, 6, 4);
21+
FsInfo info = new FsInfo(-1, null, new Path[] { p1, p2 });
22+
23+
Path total = info.getTotal();
24+
assertThat(total.getTotal().getBytes(), is(12L));
25+
assertThat(total.getFree().getBytes(), is(9L));
26+
assertThat(total.getAvailable().getBytes(), is(6L));
27+
}
28+
29+
public void testTotalDeduplication() {
30+
Path p1 = new Path("/app/data/es-1", "/app (/dev/sda1)", 8, 6, 4);
31+
Path p2 = new Path("/app/data/es-2", "/app (/dev/sda1)", 8, 6, 4);
32+
FsInfo info = new FsInfo(-1, null, new Path[] { p1, p2 });
33+
34+
Path total = info.getTotal();
35+
assertThat(total.getTotal().getBytes(), is(8L));
36+
assertThat(total.getFree().getBytes(), is(6L));
37+
assertThat(total.getAvailable().getBytes(), is(4L));
38+
}
39+
40+
}

0 commit comments

Comments
 (0)