소스 검색

Speed up MappingStats Computation on Coordinating Node (#82830)

We can exploit the mapping deduplication logic to save deserializing the
same mapping repeatedly here. This should fix extremly long running
computations when the cache needs to be refreshed for these stats
in the common case of many duplicate mappings in a cluster.
In a follow-up we can probably do the same for `AnalysisStats` as well.
Armin Braun 3 년 전
부모
커밋
f2cb9100ff

+ 5 - 0
docs/changelog/82830.yaml

@@ -0,0 +1,5 @@
+pr: 82830
+summary: Speed up `MappingStats` Computation on Coordinating Node
+area: Stats
+type: enhancement
+issues: []

+ 2 - 1
server/src/internalClusterTest/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsIT.java

@@ -29,6 +29,7 @@ import org.hamcrest.Matchers;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
@@ -276,7 +277,7 @@ public class ClusterStatsIT extends ESIntegTestCase {
             }""").get();
         response = client().admin().cluster().prepareClusterStats().get();
         assertThat(response.getIndicesStats().getMappings().getFieldTypeStats().size(), equalTo(3));
-        Set<FieldStats> stats = response.getIndicesStats().getMappings().getFieldTypeStats();
+        List<FieldStats> stats = response.getIndicesStats().getMappings().getFieldTypeStats();
         for (FieldStats stat : stats) {
             if (stat.getName().equals("integer")) {
                 assertThat(stat.getCount(), greaterThanOrEqualTo(1));

+ 30 - 17
server/src/main/java/org/elasticsearch/action/admin/cluster/stats/AnalysisStats.java

@@ -27,6 +27,7 @@ import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.IdentityHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -51,6 +52,7 @@ public final class AnalysisStats implements ToXContentFragment, Writeable {
         final Map<String, IndexFeatureStats> usedBuiltInTokenFilters = new HashMap<>();
         final Map<String, IndexFeatureStats> usedBuiltInAnalyzers = new HashMap<>();
 
+        final Map<MappingMetadata, Integer> mappingCounts = new IdentityHashMap<>(metadata.getMappingsByHash().size());
         for (IndexMetadata indexMetadata : metadata) {
             ensureNotCancelled.run();
             if (indexMetadata.isSystem()) {
@@ -58,23 +60,6 @@ public final class AnalysisStats implements ToXContentFragment, Writeable {
                 // we care about the user's indices.
                 continue;
             }
-            Set<String> indexAnalyzers = new HashSet<>();
-            MappingMetadata mappingMetadata = indexMetadata.mapping();
-            if (mappingMetadata != null) {
-                MappingVisitor.visitMapping(mappingMetadata.getSourceAsMap(), (field, fieldMapping) -> {
-                    for (String key : new String[] { "analyzer", "search_analyzer", "search_quote_analyzer" }) {
-                        Object analyzerO = fieldMapping.get(key);
-                        if (analyzerO != null) {
-                            final String analyzer = analyzerO.toString();
-                            IndexFeatureStats stats = usedBuiltInAnalyzers.computeIfAbsent(analyzer, IndexFeatureStats::new);
-                            stats.count++;
-                            if (indexAnalyzers.add(analyzer)) {
-                                stats.indexCount++;
-                            }
-                        }
-                    }
-                });
-            }
 
             Set<String> indexCharFilters = new HashSet<>();
             Set<String> indexTokenizers = new HashSet<>();
@@ -133,7 +118,27 @@ public final class AnalysisStats implements ToXContentFragment, Writeable {
             Map<String, Settings> tokenFilterSettings = indexSettings.getGroups("index.analysis.filter");
             usedBuiltInTokenFilters.keySet().removeAll(tokenFilterSettings.keySet());
             aggregateAnalysisTypes(tokenFilterSettings.values(), usedTokenFilterTypes, indexTokenFilterTypes);
+            countMapping(mappingCounts, indexMetadata);
+        }
+        for (Map.Entry<MappingMetadata, Integer> mappingAndCount : mappingCounts.entrySet()) {
+            ensureNotCancelled.run();
+            Set<String> indexAnalyzers = new HashSet<>();
+            final int count = mappingAndCount.getValue();
+            MappingVisitor.visitMapping(mappingAndCount.getKey().getSourceAsMap(), (field, fieldMapping) -> {
+                for (String key : new String[] { "analyzer", "search_analyzer", "search_quote_analyzer" }) {
+                    Object analyzerO = fieldMapping.get(key);
+                    if (analyzerO != null) {
+                        final String analyzer = analyzerO.toString();
+                        IndexFeatureStats stats = usedBuiltInAnalyzers.computeIfAbsent(analyzer, IndexFeatureStats::new);
+                        stats.count += count;
+                        if (indexAnalyzers.add(analyzer)) {
+                            stats.indexCount += count;
+                        }
+                    }
+                }
+            });
         }
+
         return new AnalysisStats(
             usedCharFilterTypes.values(),
             usedTokenizerTypes.values(),
@@ -146,6 +151,14 @@ public final class AnalysisStats implements ToXContentFragment, Writeable {
         );
     }
 
+    public static void countMapping(Map<MappingMetadata, Integer> mappingCounts, IndexMetadata indexMetadata) {
+        final MappingMetadata mappingMetadata = indexMetadata.mapping();
+        if (mappingMetadata == null) {
+            return;
+        }
+        mappingCounts.compute(mappingMetadata, (k, count) -> count == null ? 1 : count + 1);
+    }
+
     private static void aggregateAnalysisTypes(
         Collection<Settings> settings,
         Map<String, IndexFeatureStats> stats,

+ 5 - 5
server/src/main/java/org/elasticsearch/action/admin/cluster/stats/FieldScriptStats.java

@@ -68,14 +68,14 @@ public final class FieldScriptStats implements Writeable, ToXContentFragment {
         return builder;
     }
 
-    void update(int chars, long lines, int sourceUsages, int docUsages) {
+    void update(int chars, long lines, int sourceUsages, int docUsages, int count) {
         this.maxChars = Math.max(this.maxChars, chars);
-        this.totalChars += chars;
+        this.totalChars += (long) chars * count;
         this.maxLines = Math.max(this.maxLines, lines);
-        this.totalLines += lines;
-        this.totalSourceUsages += sourceUsages;
+        this.totalLines += lines * count;
+        this.totalSourceUsages += (long) sourceUsages * count;
         this.maxSourceUsages = Math.max(this.maxSourceUsages, sourceUsages);
-        this.totalDocUsages += docUsages;
+        this.totalDocUsages += (long) docUsages * count;
         this.maxDocUsages = Math.max(this.maxDocUsages, docUsages);
     }
 

+ 64 - 62
server/src/main/java/org/elasticsearch/action/admin/cluster/stats/MappingStats.java

@@ -25,7 +25,7 @@ import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.LinkedHashSet;
+import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -48,85 +48,87 @@ public final class MappingStats implements ToXContentFragment, Writeable {
         Map<String, FieldStats> fieldTypes = new HashMap<>();
         Set<String> concreteFieldNames = new HashSet<>();
         Map<String, RuntimeFieldStats> runtimeFieldTypes = new HashMap<>();
+        final Map<MappingMetadata, Integer> mappingCounts = new IdentityHashMap<>(metadata.getMappingsByHash().size());
         for (IndexMetadata indexMetadata : metadata) {
-            ensureNotCancelled.run();
             if (indexMetadata.isSystem()) {
                 // Don't include system indices in statistics about mappings,
                 // we care about the user's indices.
                 continue;
             }
+            AnalysisStats.countMapping(mappingCounts, indexMetadata);
+        }
+        for (Map.Entry<MappingMetadata, Integer> mappingAndCount : mappingCounts.entrySet()) {
+            ensureNotCancelled.run();
             Set<String> indexFieldTypes = new HashSet<>();
             Set<String> indexRuntimeFieldTypes = new HashSet<>();
-            MappingMetadata mappingMetadata = indexMetadata.mapping();
-            if (mappingMetadata != null) {
-                final Map<String, Object> map = mappingMetadata.getSourceAsMap();
-                MappingVisitor.visitMapping(map, (field, fieldMapping) -> {
-                    concreteFieldNames.add(field);
-                    String type = null;
-                    Object typeO = fieldMapping.get("type");
-                    if (typeO != null) {
-                        type = typeO.toString();
-                    } else if (fieldMapping.containsKey("properties")) {
-                        type = "object";
-                    }
-                    if (type != null) {
-                        FieldStats stats = fieldTypes.computeIfAbsent(type, FieldStats::new);
-                        stats.count++;
-                        if (indexFieldTypes.add(type)) {
-                            stats.indexCount++;
-                        }
-                        Object scriptObject = fieldMapping.get("script");
-                        if (scriptObject instanceof Map<?, ?> script) {
-                            Object sourceObject = script.get("source");
-                            stats.scriptCount++;
-                            updateScriptParams(sourceObject, stats.fieldScriptStats);
-                            Object langObject = script.get("lang");
-                            if (langObject != null) {
-                                stats.scriptLangs.add(langObject.toString());
-                            }
-                        }
-                    }
-                });
-
-                MappingVisitor.visitRuntimeMapping(map, (field, fieldMapping) -> {
-                    Object typeObject = fieldMapping.get("type");
-                    if (typeObject == null) {
-                        return;
-                    }
-                    String type = typeObject.toString();
-                    RuntimeFieldStats stats = runtimeFieldTypes.computeIfAbsent(type, RuntimeFieldStats::new);
-                    stats.count++;
-                    if (indexRuntimeFieldTypes.add(type)) {
-                        stats.indexCount++;
-                    }
-                    if (concreteFieldNames.contains(field)) {
-                        stats.shadowedCount++;
+            final int count = mappingAndCount.getValue();
+            final Map<String, Object> map = mappingAndCount.getKey().getSourceAsMap();
+            MappingVisitor.visitMapping(map, (field, fieldMapping) -> {
+                concreteFieldNames.add(field);
+                String type = null;
+                Object typeO = fieldMapping.get("type");
+                if (typeO != null) {
+                    type = typeO.toString();
+                } else if (fieldMapping.containsKey("properties")) {
+                    type = "object";
+                }
+                if (type != null) {
+                    FieldStats stats = fieldTypes.computeIfAbsent(type, FieldStats::new);
+                    stats.count += count;
+                    if (indexFieldTypes.add(type)) {
+                        stats.indexCount += count;
                     }
                     Object scriptObject = fieldMapping.get("script");
-                    if (scriptObject == null) {
-                        stats.scriptLessCount++;
-                    } else if (scriptObject instanceof Map<?, ?> script) {
+                    if (scriptObject instanceof Map<?, ?> script) {
                         Object sourceObject = script.get("source");
-                        updateScriptParams(sourceObject, stats.fieldScriptStats);
+                        stats.scriptCount += count;
+                        updateScriptParams(sourceObject, stats.fieldScriptStats, count);
                         Object langObject = script.get("lang");
                         if (langObject != null) {
                             stats.scriptLangs.add(langObject.toString());
                         }
                     }
-                });
-            }
+                }
+            });
+
+            MappingVisitor.visitRuntimeMapping(map, (field, fieldMapping) -> {
+                Object typeObject = fieldMapping.get("type");
+                if (typeObject == null) {
+                    return;
+                }
+                String type = typeObject.toString();
+                RuntimeFieldStats stats = runtimeFieldTypes.computeIfAbsent(type, RuntimeFieldStats::new);
+                stats.count += count;
+                if (indexRuntimeFieldTypes.add(type)) {
+                    stats.indexCount += count;
+                }
+                if (concreteFieldNames.contains(field)) {
+                    stats.shadowedCount += count;
+                }
+                Object scriptObject = fieldMapping.get("script");
+                if (scriptObject == null) {
+                    stats.scriptLessCount += count;
+                } else if (scriptObject instanceof Map<?, ?> script) {
+                    Object sourceObject = script.get("source");
+                    updateScriptParams(sourceObject, stats.fieldScriptStats, count);
+                    Object langObject = script.get("lang");
+                    if (langObject != null) {
+                        stats.scriptLangs.add(langObject.toString());
+                    }
+                }
+            });
         }
         return new MappingStats(fieldTypes.values(), runtimeFieldTypes.values());
     }
 
-    private static void updateScriptParams(Object scriptSourceObject, FieldScriptStats scriptStats) {
+    private static void updateScriptParams(Object scriptSourceObject, FieldScriptStats scriptStats, int multiplier) {
         if (scriptSourceObject != null) {
             String scriptSource = scriptSourceObject.toString();
             int chars = scriptSource.length();
             long lines = scriptSource.lines().count();
             int docUsages = countOccurrences(scriptSource, DOC_PATTERN);
             int sourceUsages = countOccurrences(scriptSource, SOURCE_PATTERN);
-            scriptStats.update(chars, lines, sourceUsages, docUsages);
+            scriptStats.update(chars, lines, sourceUsages, docUsages, multiplier);
         }
     }
 
@@ -139,21 +141,21 @@ public final class MappingStats implements ToXContentFragment, Writeable {
         return occurrences;
     }
 
-    private final Set<FieldStats> fieldTypeStats;
-    private final Set<RuntimeFieldStats> runtimeFieldStats;
+    private final List<FieldStats> fieldTypeStats;
+    private final List<RuntimeFieldStats> runtimeFieldStats;
 
     MappingStats(Collection<FieldStats> fieldTypeStats, Collection<RuntimeFieldStats> runtimeFieldStats) {
         List<FieldStats> stats = new ArrayList<>(fieldTypeStats);
         stats.sort(Comparator.comparing(IndexFeatureStats::getName));
-        this.fieldTypeStats = Collections.unmodifiableSet(new LinkedHashSet<>(stats));
+        this.fieldTypeStats = Collections.unmodifiableList(stats);
         List<RuntimeFieldStats> runtimeStats = new ArrayList<>(runtimeFieldStats);
         runtimeStats.sort(Comparator.comparing(RuntimeFieldStats::type));
-        this.runtimeFieldStats = Collections.unmodifiableSet(new LinkedHashSet<>(runtimeStats));
+        this.runtimeFieldStats = Collections.unmodifiableList(runtimeStats);
     }
 
     MappingStats(StreamInput in) throws IOException {
-        fieldTypeStats = Collections.unmodifiableSet(new LinkedHashSet<>(in.readList(FieldStats::new)));
-        runtimeFieldStats = Collections.unmodifiableSet(new LinkedHashSet<>(in.readList(RuntimeFieldStats::new)));
+        fieldTypeStats = Collections.unmodifiableList(in.readList(FieldStats::new));
+        runtimeFieldStats = Collections.unmodifiableList(in.readList(RuntimeFieldStats::new));
     }
 
     @Override
@@ -165,14 +167,14 @@ public final class MappingStats implements ToXContentFragment, Writeable {
     /**
      * Return stats about field types.
      */
-    public Set<FieldStats> getFieldTypeStats() {
+    public List<FieldStats> getFieldTypeStats() {
         return fieldTypeStats;
     }
 
     /**
      * Return stats about runtime field types.
      */
-    public Set<RuntimeFieldStats> getRuntimeFieldStats() {
+    public List<RuntimeFieldStats> getRuntimeFieldStats() {
         return runtimeFieldStats;
     }
 

+ 1 - 1
server/src/main/java/org/elasticsearch/cluster/metadata/Metadata.java

@@ -949,7 +949,7 @@ public class Metadata implements Iterable<IndexMetadata>, Diffable<Metadata>, To
         return builder;
     }
 
-    Map<String, MappingMetadata> getMappingsByHash() {
+    public Map<String, MappingMetadata> getMappingsByHash() {
         return mappingsByHash;
     }
 

+ 33 - 7
server/src/test/java/org/elasticsearch/action/admin/cluster/stats/AnalysisStatsTests.java

@@ -232,13 +232,39 @@ public class AnalysisStatsTests extends AbstractWireSerializingTestCase<Analysis
             .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 4)
             .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 1)
             .build();
-        IndexMetadata.Builder indexMetadata = new IndexMetadata.Builder("foo").settings(settings).putMapping(mapping);
-        Metadata metadata = new Metadata.Builder().put(indexMetadata).build();
-        AnalysisStats analysisStats = AnalysisStats.of(metadata, () -> {});
-        IndexFeatureStats expectedStats = new IndexFeatureStats("german");
-        expectedStats.count = 1;
-        expectedStats.indexCount = 1;
-        assertEquals(Collections.singleton(expectedStats), analysisStats.getUsedBuiltInAnalyzers());
+        Metadata metadata = new Metadata.Builder().put(new IndexMetadata.Builder("foo").settings(settings).putMapping(mapping)).build();
+        {
+            AnalysisStats analysisStats = AnalysisStats.of(metadata, () -> {});
+            IndexFeatureStats expectedStats = new IndexFeatureStats("german");
+            expectedStats.count = 1;
+            expectedStats.indexCount = 1;
+            assertEquals(Collections.singleton(expectedStats), analysisStats.getUsedBuiltInAnalyzers());
+        }
+
+        Metadata metadata2 = Metadata.builder(metadata)
+            .put(new IndexMetadata.Builder("bar").settings(settings).putMapping(mapping))
+            .build();
+        {
+            AnalysisStats analysisStats = AnalysisStats.of(metadata2, () -> {});
+            IndexFeatureStats expectedStats = new IndexFeatureStats("german");
+            expectedStats.count = 2;
+            expectedStats.indexCount = 2;
+            assertEquals(Collections.singleton(expectedStats), analysisStats.getUsedBuiltInAnalyzers());
+        }
+
+        Metadata metadata3 = Metadata.builder(metadata2).put(new IndexMetadata.Builder("baz").settings(settings).putMapping("""
+            {"properties":{"bar1":{"type":"text","analyzer":"french"},
+            "bar2":{"type":"text","analyzer":"french"},"bar3":{"type":"text","analyzer":"french"}}}""")).build();
+        {
+            AnalysisStats analysisStats = AnalysisStats.of(metadata3, () -> {});
+            IndexFeatureStats expectedStatsGerman = new IndexFeatureStats("german");
+            expectedStatsGerman.count = 2;
+            expectedStatsGerman.indexCount = 2;
+            IndexFeatureStats expectedStatsFrench = new IndexFeatureStats("french");
+            expectedStatsFrench.count = 3;
+            expectedStatsFrench.indexCount = 1;
+            assertEquals(Set.of(expectedStatsGerman, expectedStatsFrench), analysisStats.getUsedBuiltInAnalyzers());
+        }
     }
 
     public void testIgnoreSystemIndices() {

+ 168 - 65
server/src/test/java/org/elasticsearch/action/admin/cluster/stats/MappingStatsTests.java

@@ -20,6 +20,7 @@ import org.elasticsearch.script.Script;
 import org.elasticsearch.tasks.TaskCancelledException;
 import org.elasticsearch.test.AbstractWireSerializingTestCase;
 import org.elasticsearch.test.VersionUtils;
+import org.hamcrest.Matchers;
 
 import java.io.IOException;
 import java.util.ArrayList;
@@ -29,73 +30,70 @@ import java.util.List;
 
 public class MappingStatsTests extends AbstractWireSerializingTestCase<MappingStats> {
 
-    public void testToXContent() {
-        Settings settings = Settings.builder()
-            .put("index.number_of_replicas", 0)
-            .put("index.number_of_shards", 1)
-            .put("index.version.created", Version.CURRENT)
-            .build();
-        Script script1 = new Script("doc['field'] + doc.field + params._source.field");
-        Script script2 = new Script("doc['field']");
-        Script script3 = new Script("params._source.field + params._source.field \n + params._source.field");
-        Script script4 = new Script("params._source.field");
-        String mapping = """
-            {
-                "runtime": {
-                    "keyword1": {
-                        "type": "keyword",
-                        "script": %s
-                    },
-                    "keyword2": {
-                        "type": "keyword"
-                    },
-                    "object.keyword3": {
-                        "type": "keyword",
-                        "script": %s
-                    },
-                    "long": {
-                        "type": "long",
-                        "script": %s
-                    },
-                    "long2": {
-                        "type": "long",
-                        "script": %s
-                    }
+    private static final Settings SINGLE_SHARD_NO_REPLICAS = Settings.builder()
+        .put("index.number_of_replicas", 0)
+        .put("index.number_of_shards", 1)
+        .put("index.version.created", Version.CURRENT)
+        .build();
+
+    public static final String MAPPING_TEMPLATE = """
+        {
+            "runtime": {
+                "keyword1": {
+                    "type": "keyword",
+                    "script": %s
+                },
+                "keyword2": {
+                    "type": "keyword"
+                },
+                "object.keyword3": {
+                    "type": "keyword",
+                    "script": %s
                 },
-                "properties": {
-                    "object": {
-                        "type": "object",
-                        "properties": {
-                            "keyword3": {
-                                "type": "keyword"
-                            }
+                "long": {
+                    "type": "long",
+                    "script": %s
+                },
+                "long2": {
+                    "type": "long",
+                    "script": %s
+                }
+            },
+            "properties": {
+                "object": {
+                    "type": "object",
+                    "properties": {
+                        "keyword3": {
+                            "type": "keyword"
                         }
-                    },
-                    "long3": {
-                        "type": "long",
-                        "script": %s
-                    },
-                    "long4": {
-                        "type": "long",
-                        "script": %s
-                    },
-                    "keyword3": {
-                        "type": "keyword",
-                        "script": %s
                     }
+                },
+                "long3": {
+                    "type": "long",
+                    "script": %s
+                },
+                "long4": {
+                    "type": "long",
+                    "script": %s
+                },
+                "keyword3": {
+                    "type": "keyword",
+                    "script": %s
                 }
-            }""".formatted(
-            Strings.toString(script1),
-            Strings.toString(script2),
-            Strings.toString(script3),
-            Strings.toString(script4),
-            Strings.toString(script3),
-            Strings.toString(script4),
-            Strings.toString(script1)
-        );
-        IndexMetadata meta = IndexMetadata.builder("index").settings(settings).putMapping(mapping).build();
-        IndexMetadata meta2 = IndexMetadata.builder("index2").settings(settings).putMapping(mapping).build();
+            }
+        }""";
+
+    private static final String SCRIPT_1 = scriptAsJSON("doc['field'] + doc.field + params._source.field");
+    private static final String SCRIPT_2 = scriptAsJSON("doc['field']");
+    private static final String SCRIPT_3 = scriptAsJSON("params._source.field + params._source.field \n + params._source.field");
+    private static final String SCRIPT_4 = scriptAsJSON("params._source.field");
+
+    public void testToXContent() {
+        String mapping = MAPPING_TEMPLATE.formatted(SCRIPT_1, SCRIPT_2, SCRIPT_3, SCRIPT_4, SCRIPT_3, SCRIPT_4, SCRIPT_1);
+        IndexMetadata meta = IndexMetadata.builder("index").settings(SINGLE_SHARD_NO_REPLICAS).putMapping(mapping).build();
+        IndexMetadata meta2 = IndexMetadata.builder("index2").settings(SINGLE_SHARD_NO_REPLICAS).putMapping(mapping).build();
         Metadata metadata = Metadata.builder().put(meta, false).put(meta2, false).build();
+        assertThat(metadata.getMappingsByHash(), Matchers.aMapWithSize(1));
         MappingStats mappingStats = MappingStats.of(metadata, () -> {});
         assertEquals("""
             {
@@ -184,6 +182,109 @@ public class MappingStatsTests extends AbstractWireSerializingTestCase<MappingSt
             }""", Strings.toString(mappingStats, true, true));
     }
 
+    public void testToXContentWithSomeSharedMappings() {
+        IndexMetadata meta = IndexMetadata.builder("index")
+            .settings(SINGLE_SHARD_NO_REPLICAS)
+            .putMapping(MAPPING_TEMPLATE.formatted(SCRIPT_1, SCRIPT_2, SCRIPT_3, SCRIPT_4, SCRIPT_3, SCRIPT_4, SCRIPT_1))
+            .build();
+        // make mappings that are slightly different because we shuffled 2 scripts between fields
+        final String mappingString2 = MAPPING_TEMPLATE.formatted(SCRIPT_1, SCRIPT_2, SCRIPT_3, SCRIPT_4, SCRIPT_4, SCRIPT_3, SCRIPT_1);
+        IndexMetadata meta2 = IndexMetadata.builder("index2").settings(SINGLE_SHARD_NO_REPLICAS).putMapping(mappingString2).build();
+        IndexMetadata meta3 = IndexMetadata.builder("index3").settings(SINGLE_SHARD_NO_REPLICAS).putMapping(mappingString2).build();
+        Metadata metadata = Metadata.builder().put(meta, false).put(meta2, false).put(meta3, false).build();
+        assertThat(metadata.getMappingsByHash(), Matchers.aMapWithSize(2));
+        MappingStats mappingStats = MappingStats.of(metadata, () -> {});
+        assertEquals("""
+            {
+              "mappings" : {
+                "field_types" : [
+                  {
+                    "name" : "keyword",
+                    "count" : 6,
+                    "index_count" : 3,
+                    "script_count" : 3,
+                    "lang" : [
+                      "painless"
+                    ],
+                    "lines_max" : 1,
+                    "lines_total" : 3,
+                    "chars_max" : 47,
+                    "chars_total" : 141,
+                    "source_max" : 1,
+                    "source_total" : 3,
+                    "doc_max" : 2,
+                    "doc_total" : 6
+                  },
+                  {
+                    "name" : "long",
+                    "count" : 6,
+                    "index_count" : 3,
+                    "script_count" : 6,
+                    "lang" : [
+                      "painless"
+                    ],
+                    "lines_max" : 2,
+                    "lines_total" : 9,
+                    "chars_max" : 68,
+                    "chars_total" : 264,
+                    "source_max" : 3,
+                    "source_total" : 12,
+                    "doc_max" : 0,
+                    "doc_total" : 0
+                  },
+                  {
+                    "name" : "object",
+                    "count" : 3,
+                    "index_count" : 3,
+                    "script_count" : 0
+                  }
+                ],
+                "runtime_field_types" : [
+                  {
+                    "name" : "keyword",
+                    "count" : 9,
+                    "index_count" : 3,
+                    "scriptless_count" : 3,
+                    "shadowed_count" : 3,
+                    "lang" : [
+                      "painless"
+                    ],
+                    "lines_max" : 1,
+                    "lines_total" : 6,
+                    "chars_max" : 47,
+                    "chars_total" : 177,
+                    "source_max" : 1,
+                    "source_total" : 3,
+                    "doc_max" : 2,
+                    "doc_total" : 9
+                  },
+                  {
+                    "name" : "long",
+                    "count" : 6,
+                    "index_count" : 3,
+                    "scriptless_count" : 0,
+                    "shadowed_count" : 0,
+                    "lang" : [
+                      "painless"
+                    ],
+                    "lines_max" : 2,
+                    "lines_total" : 9,
+                    "chars_max" : 68,
+                    "chars_total" : 264,
+                    "source_max" : 3,
+                    "source_total" : 12,
+                    "doc_max" : 0,
+                    "doc_total" : 0
+                  }
+                ]
+              }
+            }""", Strings.toString(mappingStats, true, true));
+    }
+
+    private static String scriptAsJSON(String script) {
+        return Strings.toString(new Script(script));
+    }
+
     @Override
     protected Reader<MappingStats> instanceReader() {
         return MappingStats::new;
@@ -219,6 +320,7 @@ public class MappingStatsTests extends AbstractWireSerializingTestCase<MappingSt
                 randomIntBetween(1, 100),
                 randomLongBetween(100, 1000),
                 randomIntBetween(1, 10),
+                randomIntBetween(1, 10),
                 randomIntBetween(1, 10)
             );
         }
@@ -237,6 +339,7 @@ public class MappingStatsTests extends AbstractWireSerializingTestCase<MappingSt
                 randomIntBetween(1, 100),
                 randomLongBetween(100, 1000),
                 randomIntBetween(1, 10),
+                randomIntBetween(1, 10),
                 randomIntBetween(1, 10)
             );
         }
@@ -285,7 +388,7 @@ public class MappingStatsTests extends AbstractWireSerializingTestCase<MappingSt
         FieldStats expectedStats = new FieldStats("long");
         expectedStats.count = 1;
         expectedStats.indexCount = 1;
-        assertEquals(Collections.singleton(expectedStats), mappingStats.getFieldTypeStats());
+        assertEquals(Collections.singletonList(expectedStats), mappingStats.getFieldTypeStats());
     }
 
     public void testIgnoreSystemIndices() {
@@ -299,7 +402,7 @@ public class MappingStatsTests extends AbstractWireSerializingTestCase<MappingSt
         IndexMetadata.Builder indexMetadata = new IndexMetadata.Builder("foo").settings(settings).putMapping(mapping).system(true);
         Metadata metadata = new Metadata.Builder().put(indexMetadata).build();
         MappingStats mappingStats = MappingStats.of(metadata, () -> {});
-        assertEquals(Collections.emptySet(), mappingStats.getFieldTypeStats());
+        assertEquals(Collections.emptyList(), mappingStats.getFieldTypeStats());
     }
 
     public void testChecksForCancellation() {
@@ -308,7 +411,7 @@ public class MappingStatsTests extends AbstractWireSerializingTestCase<MappingSt
             .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 4)
             .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 1)
             .build();
-        IndexMetadata.Builder indexMetadata = new IndexMetadata.Builder("foo").settings(settings);
+        IndexMetadata.Builder indexMetadata = new IndexMetadata.Builder("foo").settings(settings).putMapping("{}");
         Metadata metadata = new Metadata.Builder().put(indexMetadata).build();
         expectThrows(
             TaskCancelledException.class,