Parcourir la source

[8.x] Refactor DocumentDimensions to RoutingFields (#116321) (#116604)

* Refactor DocumentDimensions to RoutingFields (#116321)

* Refactor DocumentDimensions to RoutingFields

* update

* add test

* add test

* updates from review

* updates from review

* spotless

* remove final from subclass

* fix final

(cherry picked from commit 20543579024175b76a621a67dfb4e8924d240263)

# Conflicts:
#	server/src/main/java/org/elasticsearch/index/mapper/TimeSeriesIdFieldMapper.java

* fix imports
Kostas Krikellas il y a 11 mois
Parent
commit
de1db9877f
23 fichiers modifiés avec 557 ajouts et 475 suppressions
  1. 2 2
      modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/timeseries/InternalTimeSeries.java
  2. 5 4
      modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/timeseries/TimeSeriesAggregator.java
  3. 4 3
      modules/aggregations/src/test/java/org/elasticsearch/aggregations/bucket/timeseries/InternalTimeSeriesTests.java
  4. 7 5
      modules/aggregations/src/test/java/org/elasticsearch/aggregations/bucket/timeseries/TimeSeriesAggregatorTests.java
  5. 11 10
      server/src/main/java/org/elasticsearch/index/IndexMode.java
  6. 1 1
      server/src/main/java/org/elasticsearch/index/mapper/BooleanFieldMapper.java
  7. 0 92
      server/src/main/java/org/elasticsearch/index/mapper/DocumentDimensions.java
  8. 7 7
      server/src/main/java/org/elasticsearch/index/mapper/DocumentParserContext.java
  9. 1 1
      server/src/main/java/org/elasticsearch/index/mapper/IpFieldMapper.java
  10. 1 1
      server/src/main/java/org/elasticsearch/index/mapper/KeywordFieldMapper.java
  11. 1 1
      server/src/main/java/org/elasticsearch/index/mapper/NumberFieldMapper.java
  12. 85 0
      server/src/main/java/org/elasticsearch/index/mapper/RoutingFields.java
  13. 269 0
      server/src/main/java/org/elasticsearch/index/mapper/RoutingPathFields.java
  14. 33 290
      server/src/main/java/org/elasticsearch/index/mapper/TimeSeriesIdFieldMapper.java
  15. 1 4
      server/src/main/java/org/elasticsearch/index/mapper/flattened/FlattenedFieldParser.java
  16. 8 8
      server/src/main/java/org/elasticsearch/search/DocValueFormat.java
  17. 8 28
      server/src/test/java/org/elasticsearch/index/mapper/IdLoaderTests.java
  18. 91 0
      server/src/test/java/org/elasticsearch/index/mapper/RoutingPathFieldsTests.java
  19. 6 6
      server/src/test/java/org/elasticsearch/search/DocValueFormatTests.java
  20. 4 4
      x-pack/plugin/analytics/src/test/java/org/elasticsearch/xpack/analytics/rate/TimeSeriesRateAggregatorTests.java
  21. 7 4
      x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/TimeSeriesSortedSourceOperatorTests.java
  22. 1 1
      x-pack/plugin/mapper-unsigned-long/src/main/java/org/elasticsearch/xpack/unsignedlong/UnsignedLongFieldMapper.java
  23. 4 3
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/GeoLineAggregatorTests.java

+ 2 - 2
modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/timeseries/InternalTimeSeries.java

@@ -13,7 +13,7 @@ import org.apache.lucene.util.BytesRef;
 import org.apache.lucene.util.PriorityQueue;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
-import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper;
+import org.elasticsearch.index.mapper.RoutingPathFields;
 import org.elasticsearch.search.aggregations.AggregationReduceContext;
 import org.elasticsearch.search.aggregations.AggregatorReducer;
 import org.elasticsearch.search.aggregations.InternalAggregation;
@@ -68,7 +68,7 @@ public class InternalTimeSeries extends InternalMultiBucketAggregation<InternalT
 
         @Override
         public Map<String, Object> getKey() {
-            return TimeSeriesIdFieldMapper.decodeTsidAsMap(key);
+            return RoutingPathFields.decodeAsMap(key);
         }
 
         @Override

+ 5 - 4
modules/aggregations/src/main/java/org/elasticsearch/aggregations/bucket/timeseries/TimeSeriesAggregator.java

@@ -13,6 +13,7 @@ import org.apache.lucene.index.SortedNumericDocValues;
 import org.apache.lucene.util.BytesRef;
 import org.elasticsearch.core.Releasables;
 import org.elasticsearch.index.fielddata.SortedBinaryDocValues;
+import org.elasticsearch.index.mapper.RoutingPathFields;
 import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper;
 import org.elasticsearch.search.aggregations.AggregationExecutionContext;
 import org.elasticsearch.search.aggregations.Aggregator;
@@ -161,11 +162,11 @@ public class TimeSeriesAggregator extends BucketsAggregator {
                 if (currentTsidOrd == aggCtx.getTsidHashOrd()) {
                     tsid = currentTsid;
                 } else {
-                    TimeSeriesIdFieldMapper.TimeSeriesIdBuilder tsidBuilder = new TimeSeriesIdFieldMapper.TimeSeriesIdBuilder(null);
+                    RoutingPathFields routingPathFields = new RoutingPathFields(null);
                     for (TsidConsumer consumer : dimensionConsumers.values()) {
-                        consumer.accept(doc, tsidBuilder);
+                        consumer.accept(doc, routingPathFields);
                     }
-                    currentTsid = tsid = tsidBuilder.buildLegacyTsid().toBytesRef();
+                    currentTsid = tsid = TimeSeriesIdFieldMapper.buildLegacyTsid(routingPathFields).toBytesRef();
                 }
                 long bucketOrdinal = bucketOrds.add(bucket, tsid);
                 if (bucketOrdinal < 0) { // already seen
@@ -189,6 +190,6 @@ public class TimeSeriesAggregator extends BucketsAggregator {
 
     @FunctionalInterface
     interface TsidConsumer {
-        void accept(int docId, TimeSeriesIdFieldMapper.TimeSeriesIdBuilder tsidBuilder) throws IOException;
+        void accept(int docId, RoutingPathFields routingFields) throws IOException;
     }
 }

+ 4 - 3
modules/aggregations/src/test/java/org/elasticsearch/aggregations/bucket/timeseries/InternalTimeSeriesTests.java

@@ -15,6 +15,7 @@ import org.elasticsearch.aggregations.bucket.timeseries.InternalTimeSeries.Inter
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.util.MockBigArrays;
 import org.elasticsearch.common.util.MockPageCacheRecycler;
+import org.elasticsearch.index.mapper.RoutingPathFields;
 import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper;
 import org.elasticsearch.indices.breaker.NoneCircuitBreakerService;
 import org.elasticsearch.search.aggregations.AggregationReduceContext;
@@ -42,12 +43,12 @@ public class InternalTimeSeriesTests extends AggregationMultiBucketAggregationTe
         List<Map<String, Object>> keys = randomKeys(bucketKeys(randomIntBetween(1, 4)), numberOfBuckets);
         for (int j = 0; j < numberOfBuckets; j++) {
             long docCount = randomLongBetween(0, Long.MAX_VALUE / (20L * numberOfBuckets));
-            var builder = new TimeSeriesIdFieldMapper.TimeSeriesIdBuilder(null);
+            var routingPathFields = new RoutingPathFields(null);
             for (var entry : keys.get(j).entrySet()) {
-                builder.addString(entry.getKey(), (String) entry.getValue());
+                routingPathFields.addString(entry.getKey(), (String) entry.getValue());
             }
             try {
-                var key = builder.buildLegacyTsid().toBytesRef();
+                var key = TimeSeriesIdFieldMapper.buildLegacyTsid(routingPathFields).toBytesRef();
                 bucketList.add(new InternalBucket(key, docCount, aggregations, keyed));
             } catch (IOException e) {
                 throw new UncheckedIOException(e);

+ 7 - 5
modules/aggregations/src/test/java/org/elasticsearch/aggregations/bucket/timeseries/TimeSeriesAggregatorTests.java

@@ -30,8 +30,8 @@ import org.elasticsearch.index.mapper.KeywordFieldMapper;
 import org.elasticsearch.index.mapper.MappedFieldType;
 import org.elasticsearch.index.mapper.MapperBuilderContext;
 import org.elasticsearch.index.mapper.NumberFieldMapper;
+import org.elasticsearch.index.mapper.RoutingPathFields;
 import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper;
-import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper.TimeSeriesIdBuilder;
 import org.elasticsearch.search.aggregations.InternalAggregations;
 import org.elasticsearch.search.aggregations.bucket.histogram.DateHistogramAggregationBuilder;
 import org.elasticsearch.search.aggregations.bucket.histogram.DateHistogramInterval;
@@ -93,10 +93,10 @@ public class TimeSeriesAggregatorTests extends AggregationTestCase {
         final List<IndexableField> fields = new ArrayList<>();
         fields.add(new SortedNumericDocValuesField(DataStreamTimestampFieldMapper.DEFAULT_PATH, timestamp));
         fields.add(new LongPoint(DataStreamTimestampFieldMapper.DEFAULT_PATH, timestamp));
-        final TimeSeriesIdBuilder builder = new TimeSeriesIdBuilder(null);
+        RoutingPathFields routingPathFields = new RoutingPathFields(null);
         for (int i = 0; i < dimensions.length; i += 2) {
             if (dimensions[i + 1] instanceof Number n) {
-                builder.addLong(dimensions[i].toString(), n.longValue());
+                routingPathFields.addLong(dimensions[i].toString(), n.longValue());
                 if (dimensions[i + 1] instanceof Integer || dimensions[i + 1] instanceof Long) {
                     fields.add(new NumericDocValuesField(dimensions[i].toString(), ((Number) dimensions[i + 1]).longValue()));
                 } else if (dimensions[i + 1] instanceof Float) {
@@ -105,7 +105,7 @@ public class TimeSeriesAggregatorTests extends AggregationTestCase {
                     fields.add(new DoubleDocValuesField(dimensions[i].toString(), (double) dimensions[i + 1]));
                 }
             } else {
-                builder.addString(dimensions[i].toString(), dimensions[i + 1].toString());
+                routingPathFields.addString(dimensions[i].toString(), dimensions[i + 1].toString());
                 fields.add(new SortedSetDocValuesField(dimensions[i].toString(), new BytesRef(dimensions[i + 1].toString())));
             }
         }
@@ -118,7 +118,9 @@ public class TimeSeriesAggregatorTests extends AggregationTestCase {
                 fields.add(new DoubleDocValuesField(metrics[i].toString(), (double) metrics[i + 1]));
             }
         }
-        fields.add(new SortedDocValuesField(TimeSeriesIdFieldMapper.NAME, builder.buildLegacyTsid().toBytesRef()));
+        fields.add(
+            new SortedDocValuesField(TimeSeriesIdFieldMapper.NAME, TimeSeriesIdFieldMapper.buildLegacyTsid(routingPathFields).toBytesRef())
+        );
         iw.addDocument(fields);
     }
 

+ 11 - 10
server/src/main/java/org/elasticsearch/index/IndexMode.java

@@ -23,7 +23,6 @@ import org.elasticsearch.core.Nullable;
 import org.elasticsearch.index.codec.CodecService;
 import org.elasticsearch.index.mapper.DataStreamTimestampFieldMapper;
 import org.elasticsearch.index.mapper.DateFieldMapper;
-import org.elasticsearch.index.mapper.DocumentDimensions;
 import org.elasticsearch.index.mapper.FieldMapper;
 import org.elasticsearch.index.mapper.IdFieldMapper;
 import org.elasticsearch.index.mapper.KeywordFieldMapper;
@@ -33,6 +32,8 @@ import org.elasticsearch.index.mapper.MetadataFieldMapper;
 import org.elasticsearch.index.mapper.NestedLookup;
 import org.elasticsearch.index.mapper.ProvidedIdFieldMapper;
 import org.elasticsearch.index.mapper.RoutingFieldMapper;
+import org.elasticsearch.index.mapper.RoutingFields;
+import org.elasticsearch.index.mapper.RoutingPathFields;
 import org.elasticsearch.index.mapper.SourceFieldMapper;
 import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper;
 import org.elasticsearch.index.mapper.TimeSeriesRoutingHashFieldMapper;
@@ -111,8 +112,8 @@ public enum IndexMode {
         }
 
         @Override
-        public DocumentDimensions buildDocumentDimensions(IndexSettings settings) {
-            return DocumentDimensions.Noop.INSTANCE;
+        public RoutingFields buildRoutingFields(IndexSettings settings) {
+            return RoutingFields.Noop.INSTANCE;
         }
 
         @Override
@@ -209,9 +210,9 @@ public enum IndexMode {
         }
 
         @Override
-        public DocumentDimensions buildDocumentDimensions(IndexSettings settings) {
+        public RoutingFields buildRoutingFields(IndexSettings settings) {
             IndexRouting.ExtractFromSource routing = (IndexRouting.ExtractFromSource) settings.getIndexRouting();
-            return new TimeSeriesIdFieldMapper.TimeSeriesIdBuilder(routing.builder());
+            return new RoutingPathFields(routing.builder());
         }
 
         @Override
@@ -287,8 +288,8 @@ public enum IndexMode {
         }
 
         @Override
-        public DocumentDimensions buildDocumentDimensions(IndexSettings settings) {
-            return DocumentDimensions.Noop.INSTANCE;
+        public RoutingFields buildRoutingFields(IndexSettings settings) {
+            return RoutingFields.Noop.INSTANCE;
         }
 
         @Override
@@ -368,8 +369,8 @@ public enum IndexMode {
         }
 
         @Override
-        public DocumentDimensions buildDocumentDimensions(IndexSettings settings) {
-            return DocumentDimensions.Noop.INSTANCE;
+        public RoutingFields buildRoutingFields(IndexSettings settings) {
+            return RoutingFields.Noop.INSTANCE;
         }
 
         @Override
@@ -524,7 +525,7 @@ public enum IndexMode {
     /**
      * How {@code time_series_dimension} fields are handled by indices in this mode.
      */
-    public abstract DocumentDimensions buildDocumentDimensions(IndexSettings settings);
+    public abstract RoutingFields buildRoutingFields(IndexSettings settings);
 
     /**
      * @return Whether timestamps should be validated for being withing the time range of an index.

+ 1 - 1
server/src/main/java/org/elasticsearch/index/mapper/BooleanFieldMapper.java

@@ -499,7 +499,7 @@ public class BooleanFieldMapper extends FieldMapper {
         }
 
         if (fieldType().isDimension()) {
-            context.getDimensions().addBoolean(fieldType().name(), value).validate(context.indexSettings());
+            context.getRoutingFields().addBoolean(fieldType().name(), value);
         }
         if (indexed) {
             context.doc().add(new StringField(fieldType().name(), value ? Values.TRUE : Values.FALSE, Field.Store.NO));

+ 0 - 92
server/src/main/java/org/elasticsearch/index/mapper/DocumentDimensions.java

@@ -1,92 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the "Elastic License
- * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
- * Public License v 1"; you may not use this file except in compliance with, at
- * your election, the "Elastic License 2.0", the "GNU Affero General Public
- * License v3.0 only", or the "Server Side Public License, v 1".
- */
-
-package org.elasticsearch.index.mapper;
-
-import org.apache.lucene.util.BytesRef;
-import org.elasticsearch.index.IndexSettings;
-
-import java.net.InetAddress;
-
-/**
- * Collects dimensions from documents.
- */
-public interface DocumentDimensions {
-
-    /**
-     * Build an index's DocumentDimensions using its settings
-     */
-    static DocumentDimensions fromIndexSettings(IndexSettings indexSettings) {
-        return indexSettings.getMode().buildDocumentDimensions(indexSettings);
-    }
-
-    /**
-     * This overloaded method tries to take advantage of the fact that the UTF-8
-     * value is already computed in some cases when we want to collect
-     * dimensions, so we can save re-computing the UTF-8 encoding.
-     */
-    DocumentDimensions addString(String fieldName, BytesRef utf8Value);
-
-    default DocumentDimensions addString(String fieldName, String value) {
-        return addString(fieldName, new BytesRef(value));
-    }
-
-    DocumentDimensions addIp(String fieldName, InetAddress value);
-
-    DocumentDimensions addLong(String fieldName, long value);
-
-    DocumentDimensions addUnsignedLong(String fieldName, long value);
-
-    DocumentDimensions addBoolean(String fieldName, boolean value);
-
-    DocumentDimensions validate(IndexSettings settings);
-
-    /**
-     * Noop implementation that doesn't perform validations on dimension fields
-     */
-    enum Noop implements DocumentDimensions {
-
-        INSTANCE;
-
-        @Override
-        public DocumentDimensions addString(String fieldName, BytesRef utf8Value) {
-            return this;
-        }
-
-        @Override
-        public DocumentDimensions addString(String fieldName, String value) {
-            return this;
-        }
-
-        @Override
-        public DocumentDimensions addIp(String fieldName, InetAddress value) {
-            return this;
-        }
-
-        @Override
-        public DocumentDimensions addLong(String fieldName, long value) {
-            return this;
-        }
-
-        @Override
-        public DocumentDimensions addUnsignedLong(String fieldName, long value) {
-            return this;
-        }
-
-        @Override
-        public DocumentDimensions addBoolean(String fieldName, boolean value) {
-            return this;
-        }
-
-        @Override
-        public DocumentDimensions validate(IndexSettings settings) {
-            return this;
-        }
-    }
-}

+ 7 - 7
server/src/main/java/org/elasticsearch/index/mapper/DocumentParserContext.java

@@ -126,7 +126,7 @@ public abstract class DocumentParserContext {
     private final DynamicMapperSize dynamicMappersSize;
     private final Map<String, ObjectMapper> dynamicObjectMappers;
     private final Map<String, List<RuntimeField>> dynamicRuntimeFields;
-    private final DocumentDimensions dimensions;
+    private final RoutingFields routingFields;
     private final ObjectMapper parent;
     private final ObjectMapper.Dynamic dynamic;
     private String id;
@@ -158,7 +158,7 @@ public abstract class DocumentParserContext {
         String id,
         Field version,
         SeqNoFieldMapper.SequenceIDFields seqID,
-        DocumentDimensions dimensions,
+        RoutingFields routingFields,
         ObjectMapper parent,
         ObjectMapper.Dynamic dynamic,
         Set<String> fieldsAppliedFromTemplates,
@@ -178,7 +178,7 @@ public abstract class DocumentParserContext {
         this.id = id;
         this.version = version;
         this.seqID = seqID;
-        this.dimensions = dimensions;
+        this.routingFields = routingFields;
         this.parent = parent;
         this.dynamic = dynamic;
         this.fieldsAppliedFromTemplates = fieldsAppliedFromTemplates;
@@ -201,7 +201,7 @@ public abstract class DocumentParserContext {
             in.id,
             in.version,
             in.seqID,
-            in.dimensions,
+            in.routingFields,
             parent,
             dynamic,
             in.fieldsAppliedFromTemplates,
@@ -231,7 +231,7 @@ public abstract class DocumentParserContext {
             null,
             null,
             SeqNoFieldMapper.SequenceIDFields.emptySeqID(),
-            DocumentDimensions.fromIndexSettings(mappingParserContext.getIndexSettings()),
+            RoutingFields.fromIndexSettings(mappingParserContext.getIndexSettings()),
             parent,
             dynamic,
             new HashSet<>(),
@@ -762,8 +762,8 @@ public abstract class DocumentParserContext {
     /**
      * The collection of dimensions for this document.
      */
-    public DocumentDimensions getDimensions() {
-        return dimensions;
+    public RoutingFields getRoutingFields() {
+        return routingFields;
     }
 
     public abstract ContentPath path();

+ 1 - 1
server/src/main/java/org/elasticsearch/index/mapper/IpFieldMapper.java

@@ -567,7 +567,7 @@ public class IpFieldMapper extends FieldMapper {
 
     private void indexValue(DocumentParserContext context, InetAddress address) {
         if (dimension) {
-            context.getDimensions().addIp(fieldType().name(), address).validate(context.indexSettings());
+            context.getRoutingFields().addIp(fieldType().name(), address);
         }
         if (indexed) {
             Field field = new InetAddressPoint(fieldType().name(), address);

+ 1 - 1
server/src/main/java/org/elasticsearch/index/mapper/KeywordFieldMapper.java

@@ -946,7 +946,7 @@ public final class KeywordFieldMapper extends FieldMapper {
         final BytesRef binaryValue = new BytesRef(value);
 
         if (fieldType().isDimension()) {
-            context.getDimensions().addString(fieldType().name(), binaryValue).validate(context.indexSettings());
+            context.getRoutingFields().addString(fieldType().name(), binaryValue);
         }
 
         // If the UTF8 encoding of the field value is bigger than the max length 32766, Lucene fill fail the indexing request and, to

+ 1 - 1
server/src/main/java/org/elasticsearch/index/mapper/NumberFieldMapper.java

@@ -1990,7 +1990,7 @@ public class NumberFieldMapper extends FieldMapper {
      */
     public void indexValue(DocumentParserContext context, Number numericValue) {
         if (dimension && numericValue != null) {
-            context.getDimensions().addLong(fieldType().name(), numericValue.longValue()).validate(context.indexSettings());
+            context.getRoutingFields().addLong(fieldType().name(), numericValue.longValue());
         }
         fieldType().type.addFields(context.doc(), fieldType().name(), numericValue, indexed, hasDocValues, stored);
 

+ 85 - 0
server/src/main/java/org/elasticsearch/index/mapper/RoutingFields.java

@@ -0,0 +1,85 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.index.mapper;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.index.IndexSettings;
+
+import java.net.InetAddress;
+
+/**
+ * Collects fields contributing to routing from documents.
+ */
+public interface RoutingFields {
+
+    /**
+     * Collect routing fields from index settings
+     */
+    static RoutingFields fromIndexSettings(IndexSettings indexSettings) {
+        return indexSettings.getMode().buildRoutingFields(indexSettings);
+    }
+
+    /**
+     * This overloaded method tries to take advantage of the fact that the UTF-8
+     * value is already computed in some cases when we want to collect
+     * routing fields, so we can save re-computing the UTF-8 encoding.
+     */
+    RoutingFields addString(String fieldName, BytesRef utf8Value);
+
+    default RoutingFields addString(String fieldName, String value) {
+        return addString(fieldName, new BytesRef(value));
+    }
+
+    RoutingFields addIp(String fieldName, InetAddress value);
+
+    RoutingFields addLong(String fieldName, long value);
+
+    RoutingFields addUnsignedLong(String fieldName, long value);
+
+    RoutingFields addBoolean(String fieldName, boolean value);
+
+    /**
+     * Noop implementation that doesn't perform validations on routing fields
+     */
+    enum Noop implements RoutingFields {
+
+        INSTANCE;
+
+        @Override
+        public RoutingFields addString(String fieldName, BytesRef utf8Value) {
+            return this;
+        }
+
+        @Override
+        public RoutingFields addString(String fieldName, String value) {
+            return this;
+        }
+
+        @Override
+        public RoutingFields addIp(String fieldName, InetAddress value) {
+            return this;
+        }
+
+        @Override
+        public RoutingFields addLong(String fieldName, long value) {
+            return this;
+        }
+
+        @Override
+        public RoutingFields addUnsignedLong(String fieldName, long value) {
+            return this;
+        }
+
+        @Override
+        public RoutingFields addBoolean(String fieldName, boolean value) {
+            return this;
+        }
+    }
+}

+ 269 - 0
server/src/main/java/org/elasticsearch/index/mapper/RoutingPathFields.java

@@ -0,0 +1,269 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.index.mapper;
+
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.StringHelper;
+import org.elasticsearch.cluster.routing.IndexRouting;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.hash.Murmur3Hasher;
+import org.elasticsearch.common.hash.MurmurHash3;
+import org.elasticsearch.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.network.NetworkAddress;
+import org.elasticsearch.common.util.ByteUtils;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.search.DocValueFormat;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/**
+ * Implementation of routing fields, using field matching based on the routing path content.
+ */
+public final class RoutingPathFields implements RoutingFields {
+
+    private static final int SEED = 0;
+
+    private static final int MAX_ROUTING_FIELDS = 512;
+
+    private static final int MAX_HASH_LEN_BYTES = 2;
+    static {
+        assert MAX_HASH_LEN_BYTES == StreamOutput.putVInt(new byte[2], hashLen(MAX_ROUTING_FIELDS), 0);
+    }
+
+    /**
+     * A map of the serialized values of routing fields that will be used
+     * for generating the _tsid field. The map will be used by {@link RoutingPathFields}
+     * to build the _tsid field for the document.
+     */
+    private final SortedMap<BytesRef, List<BytesReference>> routingValues = new TreeMap<>();
+
+    /**
+     * Builds the routing. Used for building {@code _id}. If null then skipped.
+     */
+    @Nullable
+    private final IndexRouting.ExtractFromSource.Builder routingBuilder;
+
+    public RoutingPathFields(@Nullable IndexRouting.ExtractFromSource.Builder routingBuilder) {
+        this.routingBuilder = routingBuilder;
+    }
+
+    SortedMap<BytesRef, List<BytesReference>> routingValues() {
+        return Collections.unmodifiableSortedMap(routingValues);
+    }
+
+    IndexRouting.ExtractFromSource.Builder routingBuilder() {
+        return routingBuilder;
+    }
+
+    /**
+     * Here we build the hash of the routing values using a similarity function so that we have a result
+     * with the following pattern:
+     *
+     * hash128(concatenate(routing field names)) +
+     * foreach(routing field value, limit = MAX_ROUTING_FIELDS) { hash32(routing field value) } +
+     * hash128(concatenate(routing field values))
+     *
+     * The idea is to be able to place 'similar' values close to each other.
+     */
+    public BytesReference buildHash() {
+        Murmur3Hasher hasher = new Murmur3Hasher(SEED);
+
+        // NOTE: hash all routing field names
+        int numberOfFields = Math.min(MAX_ROUTING_FIELDS, routingValues.size());
+        int len = hashLen(numberOfFields);
+        // either one or two bytes are occupied by the vint since we're bounded by #MAX_ROUTING_FIELDS
+        byte[] hash = new byte[MAX_HASH_LEN_BYTES + len];
+        int index = StreamOutput.putVInt(hash, len, 0);
+
+        hasher.reset();
+        for (final BytesRef name : routingValues.keySet()) {
+            hasher.update(name.bytes);
+        }
+        index = writeHash128(hasher.digestHash(), hash, index);
+
+        // NOTE: concatenate all routing field value hashes up to a certain number of fields
+        int startIndex = index;
+        for (final List<BytesReference> values : routingValues.values()) {
+            if ((index - startIndex) >= 4 * numberOfFields) {
+                break;
+            }
+            assert values.isEmpty() == false : "routing values are empty";
+            final BytesRef routingValue = values.get(0).toBytesRef();
+            ByteUtils.writeIntLE(
+                StringHelper.murmurhash3_x86_32(routingValue.bytes, routingValue.offset, routingValue.length, SEED),
+                hash,
+                index
+            );
+            index += 4;
+        }
+
+        // NOTE: hash all routing field allValues
+        hasher.reset();
+        for (final List<BytesReference> values : routingValues.values()) {
+            for (BytesReference v : values) {
+                hasher.update(v.toBytesRef().bytes);
+            }
+        }
+        index = writeHash128(hasher.digestHash(), hash, index);
+
+        return new BytesArray(hash, 0, index);
+    }
+
+    private static int hashLen(int numberOfFields) {
+        return 16 + 16 + 4 * numberOfFields;
+    }
+
+    private static int writeHash128(final MurmurHash3.Hash128 hash128, byte[] buffer, int index) {
+        ByteUtils.writeLongLE(hash128.h1, buffer, index);
+        index += 8;
+        ByteUtils.writeLongLE(hash128.h2, buffer, index);
+        index += 8;
+        return index;
+    }
+
+    @Override
+    public RoutingFields addString(String fieldName, BytesRef utf8Value) {
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            out.write((byte) 's');
+            /*
+             * Write in utf8 instead of StreamOutput#writeString which is utf-16-ish
+             * so it's easier for folks to reason about the space taken up. Mostly
+             * it'll be smaller too.
+             */
+            out.writeBytesRef(utf8Value);
+            add(fieldName, out.bytes());
+
+            if (routingBuilder != null) {
+                routingBuilder.addMatching(fieldName, utf8Value);
+            }
+        } catch (IOException e) {
+            throw new IllegalArgumentException("Routing field cannot be serialized.", e);
+        }
+        return this;
+    }
+
+    @Override
+    public RoutingFields addIp(String fieldName, InetAddress value) {
+        return addString(fieldName, NetworkAddress.format(value));
+    }
+
+    @Override
+    public RoutingFields addLong(String fieldName, long value) {
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            out.write((byte) 'l');
+            out.writeLong(value);
+            add(fieldName, out.bytes());
+        } catch (IOException e) {
+            throw new IllegalArgumentException("Routing field cannot be serialized.", e);
+        }
+        return this;
+    }
+
+    @Override
+    public RoutingFields addUnsignedLong(String fieldName, long value) {
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            Object ul = DocValueFormat.UNSIGNED_LONG_SHIFTED.format(value);
+            if (ul instanceof Long l) {
+                out.write((byte) 'l');
+                out.writeLong(l);
+            } else {
+                out.write((byte) 'u');
+                out.writeLong(value);
+            }
+            add(fieldName, out.bytes());
+            return this;
+        } catch (IOException e) {
+            throw new IllegalArgumentException("Routing field cannot be serialized.", e);
+        }
+    }
+
+    @Override
+    public RoutingFields addBoolean(String fieldName, boolean value) {
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            out.write((byte) 'b');
+            out.write(value ? 't' : 'f');
+            add(fieldName, out.bytes());
+        } catch (IOException e) {
+            throw new IllegalArgumentException("Routing field cannot be serialized.", e);
+        }
+        return this;
+    }
+
+    private void add(String fieldName, BytesReference encoded) throws IOException {
+        BytesRef name = new BytesRef(fieldName);
+        List<BytesReference> values = routingValues.get(name);
+        if (values == null) {
+            // optimize for the common case where routing fields are not multi-valued
+            routingValues.put(name, List.of(encoded));
+        } else {
+            if (values.size() == 1) {
+                // converts the immutable list that's optimized for the common case of having only one value to a mutable list
+                BytesReference previousValue = values.get(0);
+                values = new ArrayList<>(4);
+                values.add(previousValue);
+                routingValues.put(name, values);
+            }
+            values.add(encoded);
+        }
+    }
+
+    public static Map<String, Object> decodeAsMap(BytesRef bytesRef) {
+        try (StreamInput in = new BytesArray(bytesRef).streamInput()) {
+            int size = in.readVInt();
+            Map<String, Object> result = new LinkedHashMap<>(size);
+
+            for (int i = 0; i < size; i++) {
+                String name = null;
+                try {
+                    name = in.readSlicedBytesReference().utf8ToString();
+                } catch (AssertionError ae) {
+                    throw new IllegalArgumentException("Error parsing routing field: " + ae.getMessage(), ae);
+                }
+
+                int type = in.read();
+                switch (type) {
+                    case (byte) 's' -> {
+                        // parse a string
+                        try {
+                            result.put(name, in.readSlicedBytesReference().utf8ToString());
+                        } catch (AssertionError ae) {
+                            throw new IllegalArgumentException("Error parsing routing field: " + ae.getMessage(), ae);
+                        }
+                    }
+                    case (byte) 'l' -> // parse a long
+                        result.put(name, in.readLong());
+                    case (byte) 'u' -> { // parse an unsigned_long
+                        Object ul = DocValueFormat.UNSIGNED_LONG_SHIFTED.format(in.readLong());
+                        result.put(name, ul);
+                    }
+                    case (byte) 'd' -> // parse a double
+                        result.put(name, in.readDouble());
+                    case (byte) 'b' -> // parse a boolean
+                        result.put(name, in.read() == 't');
+                    default -> throw new IllegalArgumentException("Cannot parse [" + name + "]: Unknown type [" + type + "]");
+                }
+            }
+            return result;
+        } catch (IOException | IllegalArgumentException e) {
+            throw new IllegalArgumentException("Routing field cannot be deserialized:" + e.getMessage(), e);
+        }
+    }
+}

+ 33 - 290
server/src/main/java/org/elasticsearch/index/mapper/TimeSeriesIdFieldMapper.java

@@ -12,20 +12,10 @@ package org.elasticsearch.index.mapper;
 import org.apache.lucene.document.SortedDocValuesField;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.util.BytesRef;
-import org.apache.lucene.util.StringHelper;
-import org.elasticsearch.cluster.routing.IndexRouting;
-import org.elasticsearch.common.bytes.BytesArray;
 import org.elasticsearch.common.bytes.BytesReference;
-import org.elasticsearch.common.hash.Murmur3Hasher;
-import org.elasticsearch.common.hash.MurmurHash3;
 import org.elasticsearch.common.io.stream.BytesStreamOutput;
 import org.elasticsearch.common.io.stream.StreamInput;
-import org.elasticsearch.common.io.stream.StreamOutput;
-import org.elasticsearch.common.network.NetworkAddress;
-import org.elasticsearch.common.util.ByteUtils;
-import org.elasticsearch.core.Nullable;
 import org.elasticsearch.index.IndexMode;
-import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.index.IndexVersion;
 import org.elasticsearch.index.IndexVersions;
 import org.elasticsearch.index.fielddata.FieldData;
@@ -39,16 +29,11 @@ import org.elasticsearch.search.DocValueFormat;
 import org.elasticsearch.search.aggregations.support.CoreValuesSourceType;
 
 import java.io.IOException;
-import java.net.InetAddress;
 import java.time.ZoneId;
-import java.util.ArrayList;
 import java.util.Base64;
 import java.util.Collections;
-import java.util.LinkedHashMap;
 import java.util.List;
-import java.util.Map;
 import java.util.SortedMap;
-import java.util.TreeMap;
 
 /**
  * Mapper for {@code _tsid} field included generated when the index is
@@ -137,15 +122,24 @@ public class TimeSeriesIdFieldMapper extends MetadataFieldMapper {
     public void postParse(DocumentParserContext context) throws IOException {
         assert fieldType().isIndexed() == false;
 
-        final TimeSeriesIdBuilder timeSeriesIdBuilder = (TimeSeriesIdBuilder) context.getDimensions();
-        final BytesRef timeSeriesId = getIndexVersionCreated(context).before(IndexVersions.TIME_SERIES_ID_HASHING)
-            ? timeSeriesIdBuilder.buildLegacyTsid().toBytesRef()
-            : timeSeriesIdBuilder.buildTsidHash().toBytesRef();
+        final RoutingPathFields routingPathFields = (RoutingPathFields) context.getRoutingFields();
+        final BytesRef timeSeriesId;
+        if (getIndexVersionCreated(context).before(IndexVersions.TIME_SERIES_ID_HASHING)) {
+            long limit = context.indexSettings().getValue(MapperService.INDEX_MAPPING_DIMENSION_FIELDS_LIMIT_SETTING);
+            int size = routingPathFields.routingValues().size();
+            if (size > limit) {
+                throw new MapperException("Too many dimension fields [" + size + "], max [" + limit + "] dimension fields allowed");
+            }
+            timeSeriesId = buildLegacyTsid(routingPathFields).toBytesRef();
+        } else {
+            timeSeriesId = routingPathFields.buildHash().toBytesRef();
+        }
         context.doc().add(new SortedDocValuesField(fieldType().name(), timeSeriesId));
+
         TsidExtractingIdFieldMapper.createField(
             context,
             getIndexVersionCreated(context).before(IndexVersions.TIME_SERIES_ROUTING_HASH_IN_ID)
-                ? timeSeriesIdBuilder.routingBuilder
+                ? routingPathFields.routingBuilder()
                 : null,
             timeSeriesId
         );
@@ -171,231 +165,6 @@ public class TimeSeriesIdFieldMapper extends MetadataFieldMapper {
         }
     }
 
-    public static class TimeSeriesIdBuilder implements DocumentDimensions {
-
-        private static final int SEED = 0;
-
-        public static final int MAX_DIMENSIONS = 512;
-
-        private final Murmur3Hasher tsidHasher = new Murmur3Hasher(0);
-
-        /**
-         * A map of the serialized values of dimension fields that will be used
-         * for generating the _tsid field. The map will be used by {@link TimeSeriesIdFieldMapper}
-         * to build the _tsid field for the document.
-         */
-        private final SortedMap<BytesRef, List<BytesReference>> dimensions = new TreeMap<>();
-        /**
-         * Builds the routing. Used for building {@code _id}. If null then skipped.
-         */
-        @Nullable
-        private final IndexRouting.ExtractFromSource.Builder routingBuilder;
-
-        public TimeSeriesIdBuilder(@Nullable IndexRouting.ExtractFromSource.Builder routingBuilder) {
-            this.routingBuilder = routingBuilder;
-        }
-
-        public BytesReference buildLegacyTsid() throws IOException {
-            if (dimensions.isEmpty()) {
-                throw new IllegalArgumentException("Dimension fields are missing.");
-            }
-
-            try (BytesStreamOutput out = new BytesStreamOutput()) {
-                out.writeVInt(dimensions.size());
-                for (Map.Entry<BytesRef, List<BytesReference>> entry : dimensions.entrySet()) {
-                    out.writeBytesRef(entry.getKey());
-                    List<BytesReference> value = entry.getValue();
-                    if (value.size() > 1) {
-                        // multi-value dimensions are only supported for newer indices that use buildTsidHash
-                        throw new IllegalArgumentException(
-                            "Dimension field [" + entry.getKey().utf8ToString() + "] cannot be a multi-valued field."
-                        );
-                    }
-                    assert value.isEmpty() == false : "dimension value is empty";
-                    value.get(0).writeTo(out);
-                }
-                return out.bytes();
-            }
-        }
-
-        private static final int MAX_HASH_LEN_BYTES = 2;
-
-        static {
-            assert MAX_HASH_LEN_BYTES == StreamOutput.putVInt(new byte[2], tsidHashLen(MAX_DIMENSIONS), 0);
-        }
-
-        /**
-         * Here we build the hash of the tsid using a similarity function so that we have a result
-         * with the following pattern:
-         *
-         * hash128(catenate(dimension field names)) +
-         * foreach(dimension field value, limit = MAX_DIMENSIONS) { hash32(dimension field value) } +
-         * hash128(catenate(dimension field values))
-         *
-         * The idea is to be able to place 'similar' time series close to each other. Two time series
-         * are considered 'similar' if they share the same dimensions (names and values).
-         */
-        public BytesReference buildTsidHash() {
-            // NOTE: hash all dimension field names
-            int numberOfDimensions = Math.min(MAX_DIMENSIONS, dimensions.size());
-            int len = tsidHashLen(numberOfDimensions);
-            // either one or two bytes are occupied by the vint since we're bounded by #MAX_DIMENSIONS
-            byte[] tsidHash = new byte[MAX_HASH_LEN_BYTES + len];
-            int tsidHashIndex = StreamOutput.putVInt(tsidHash, len, 0);
-
-            tsidHasher.reset();
-            for (final BytesRef name : dimensions.keySet()) {
-                tsidHasher.update(name.bytes);
-            }
-            tsidHashIndex = writeHash128(tsidHasher.digestHash(), tsidHash, tsidHashIndex);
-
-            // NOTE: concatenate all dimension value hashes up to a certain number of dimensions
-            int tsidHashStartIndex = tsidHashIndex;
-            for (final List<BytesReference> values : dimensions.values()) {
-                if ((tsidHashIndex - tsidHashStartIndex) >= 4 * numberOfDimensions) {
-                    break;
-                }
-                assert values.isEmpty() == false : "dimension values are empty";
-                final BytesRef dimensionValueBytesRef = values.get(0).toBytesRef();
-                ByteUtils.writeIntLE(
-                    StringHelper.murmurhash3_x86_32(
-                        dimensionValueBytesRef.bytes,
-                        dimensionValueBytesRef.offset,
-                        dimensionValueBytesRef.length,
-                        SEED
-                    ),
-                    tsidHash,
-                    tsidHashIndex
-                );
-                tsidHashIndex += 4;
-            }
-
-            // NOTE: hash all dimension field allValues
-            tsidHasher.reset();
-            for (final List<BytesReference> values : dimensions.values()) {
-                for (BytesReference v : values) {
-                    tsidHasher.update(v.toBytesRef().bytes);
-                }
-            }
-            tsidHashIndex = writeHash128(tsidHasher.digestHash(), tsidHash, tsidHashIndex);
-
-            return new BytesArray(tsidHash, 0, tsidHashIndex);
-        }
-
-        private static int tsidHashLen(int numberOfDimensions) {
-            return 16 + 16 + 4 * numberOfDimensions;
-        }
-
-        private int writeHash128(final MurmurHash3.Hash128 hash128, byte[] buffer, int tsidHashIndex) {
-            ByteUtils.writeLongLE(hash128.h1, buffer, tsidHashIndex);
-            tsidHashIndex += 8;
-            ByteUtils.writeLongLE(hash128.h2, buffer, tsidHashIndex);
-            tsidHashIndex += 8;
-            return tsidHashIndex;
-        }
-
-        @Override
-        public DocumentDimensions addString(String fieldName, BytesRef utf8Value) {
-            try (BytesStreamOutput out = new BytesStreamOutput()) {
-                out.write((byte) 's');
-                /*
-                 * Write in utf8 instead of StreamOutput#writeString which is utf-16-ish
-                 * so it's easier for folks to reason about the space taken up. Mostly
-                 * it'll be smaller too.
-                 */
-                out.writeBytesRef(utf8Value);
-                add(fieldName, out.bytes());
-
-                if (routingBuilder != null) {
-                    routingBuilder.addMatching(fieldName, utf8Value);
-                }
-            } catch (IOException e) {
-                throw new IllegalArgumentException("Dimension field cannot be serialized.", e);
-            }
-            return this;
-        }
-
-        @Override
-        public DocumentDimensions addIp(String fieldName, InetAddress value) {
-            return addString(fieldName, NetworkAddress.format(value));
-        }
-
-        @Override
-        public DocumentDimensions addLong(String fieldName, long value) {
-            try (BytesStreamOutput out = new BytesStreamOutput()) {
-                out.write((byte) 'l');
-                out.writeLong(value);
-                add(fieldName, out.bytes());
-            } catch (IOException e) {
-                throw new IllegalArgumentException("Dimension field cannot be serialized.", e);
-            }
-            return this;
-        }
-
-        @Override
-        public DocumentDimensions addUnsignedLong(String fieldName, long value) {
-            try (BytesStreamOutput out = new BytesStreamOutput()) {
-                Object ul = DocValueFormat.UNSIGNED_LONG_SHIFTED.format(value);
-                if (ul instanceof Long l) {
-                    out.write((byte) 'l');
-                    out.writeLong(l);
-                } else {
-                    out.write((byte) 'u');
-                    out.writeLong(value);
-                }
-                add(fieldName, out.bytes());
-                return this;
-            } catch (IOException e) {
-                throw new IllegalArgumentException("Dimension field cannot be serialized.", e);
-            }
-        }
-
-        @Override
-        public DocumentDimensions addBoolean(String fieldName, boolean value) {
-            try (BytesStreamOutput out = new BytesStreamOutput()) {
-                out.write((byte) 'b');
-                out.write(value ? 't' : 'f');
-                add(fieldName, out.bytes());
-            } catch (IOException e) {
-                throw new IllegalArgumentException("Dimension field cannot be serialized.", e);
-            }
-            return this;
-        }
-
-        @Override
-        public DocumentDimensions validate(final IndexSettings settings) {
-            if (settings.getIndexVersionCreated().before(IndexVersions.TIME_SERIES_ID_HASHING)
-                && dimensions.size() > settings.getValue(MapperService.INDEX_MAPPING_DIMENSION_FIELDS_LIMIT_SETTING)) {
-                throw new MapperException(
-                    "Too many dimension fields ["
-                        + dimensions.size()
-                        + "], max ["
-                        + settings.getValue(MapperService.INDEX_MAPPING_DIMENSION_FIELDS_LIMIT_SETTING)
-                        + "] dimension fields allowed"
-                );
-            }
-            return this;
-        }
-
-        private void add(String fieldName, BytesReference encoded) throws IOException {
-            BytesRef name = new BytesRef(fieldName);
-            List<BytesReference> values = dimensions.get(name);
-            if (values == null) {
-                // optimize for the common case where dimensions are not multi-valued
-                dimensions.put(name, List.of(encoded));
-            } else {
-                if (values.size() == 1) {
-                    // converts the immutable list that's optimized for the common case of having only one value to a mutable list
-                    BytesReference previousValue = values.get(0);
-                    values = new ArrayList<>(4);
-                    values.add(previousValue);
-                    dimensions.put(name, values);
-                }
-                values.add(encoded);
-            }
-        }
-    }
-
     public static Object encodeTsid(final BytesRef bytesRef) {
         return base64Encode(bytesRef);
     }
@@ -406,53 +175,27 @@ public class TimeSeriesIdFieldMapper extends MetadataFieldMapper {
         return BASE64_ENCODER.encodeToString(bytes);
     }
 
-    public static Map<String, Object> decodeTsidAsMap(BytesRef bytesRef) {
-        try (StreamInput input = new BytesArray(bytesRef).streamInput()) {
-            return decodeTsidAsMap(input);
-        } catch (IOException ex) {
-            throw new IllegalArgumentException("Dimension field cannot be deserialized.", ex);
-        }
-    }
-
-    public static Map<String, Object> decodeTsidAsMap(StreamInput in) {
-        try {
-            int size = in.readVInt();
-            Map<String, Object> result = new LinkedHashMap<>(size);
-
-            for (int i = 0; i < size; i++) {
-                String name = null;
-                try {
-                    name = in.readSlicedBytesReference().utf8ToString();
-                } catch (AssertionError ae) {
-                    throw new IllegalArgumentException("Error parsing keyword dimension: " + ae.getMessage(), ae);
-                }
-
-                int type = in.read();
-                switch (type) {
-                    case (byte) 's' -> {
-                        // parse a string
-                        try {
-                            result.put(name, in.readSlicedBytesReference().utf8ToString());
-                        } catch (AssertionError ae) {
-                            throw new IllegalArgumentException("Error parsing keyword dimension: " + ae.getMessage(), ae);
-                        }
-                    }
-                    case (byte) 'l' -> // parse a long
-                        result.put(name, in.readLong());
-                    case (byte) 'u' -> { // parse an unsigned_long
-                        Object ul = DocValueFormat.UNSIGNED_LONG_SHIFTED.format(in.readLong());
-                        result.put(name, ul);
-                    }
-                    case (byte) 'd' -> // parse a double
-                        result.put(name, in.readDouble());
-                    case (byte) 'b' -> // parse a boolean
-                        result.put(name, in.read() == 't');
-                    default -> throw new IllegalArgumentException("Cannot parse [" + name + "]: Unknown type [" + type + "]");
+    public static BytesReference buildLegacyTsid(RoutingPathFields routingPathFields) throws IOException {
+        SortedMap<BytesRef, List<BytesReference>> routingValues = routingPathFields.routingValues();
+        if (routingValues.isEmpty()) {
+            throw new IllegalArgumentException("Dimension fields are missing.");
+        }
+
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            out.writeVInt(routingValues.size());
+            for (var entry : routingValues.entrySet()) {
+                out.writeBytesRef(entry.getKey());
+                List<BytesReference> value = entry.getValue();
+                if (value.size() > 1) {
+                    // multi-value dimensions are only supported for newer indices that use buildTsidHash
+                    throw new IllegalArgumentException(
+                        "Dimension field [" + entry.getKey().utf8ToString() + "] cannot be a multi-valued field."
+                    );
                 }
+                assert value.isEmpty() == false : "dimension value is empty";
+                value.get(0).writeTo(out);
             }
-            return result;
-        } catch (IOException | IllegalArgumentException e) {
-            throw new IllegalArgumentException("Error formatting " + NAME + ": " + e.getMessage(), e);
+            return out.bytes();
         }
     }
 }

+ 1 - 4
server/src/main/java/org/elasticsearch/index/mapper/flattened/FlattenedFieldParser.java

@@ -184,10 +184,7 @@ class FlattenedFieldParser {
             final String keyedFieldName = FlattenedFieldParser.extractKey(bytesKeyedValue).utf8ToString();
             if (fieldType.isDimension() && fieldType.dimensions().contains(keyedFieldName)) {
                 final BytesRef keyedFieldValue = FlattenedFieldParser.extractValue(bytesKeyedValue);
-                context.documentParserContext()
-                    .getDimensions()
-                    .addString(rootFieldFullPath + "." + keyedFieldName, keyedFieldValue)
-                    .validate(context.documentParserContext().indexSettings());
+                context.documentParserContext().getRoutingFields().addString(rootFieldFullPath + "." + keyedFieldName, keyedFieldValue);
             }
         }
     }

+ 8 - 8
server/src/main/java/org/elasticsearch/search/DocValueFormat.java

@@ -22,8 +22,8 @@ import org.elasticsearch.common.time.DateMathParser;
 import org.elasticsearch.common.util.LocaleUtils;
 import org.elasticsearch.geometry.utils.Geohash;
 import org.elasticsearch.index.mapper.DateFieldMapper;
+import org.elasticsearch.index.mapper.RoutingPathFields;
 import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper;
-import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper.TimeSeriesIdBuilder;
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils;
 
 import java.io.IOException;
@@ -729,7 +729,7 @@ public interface DocValueFormat extends NamedWriteable {
             try {
                 // NOTE: if the tsid is a map of dimension key/value pairs (as it was before introducing
                 // tsid hashing) we just decode the map and return it.
-                return TimeSeriesIdFieldMapper.decodeTsidAsMap(value);
+                return RoutingPathFields.decodeAsMap(value);
             } catch (Exception e) {
                 // NOTE: otherwise the _tsid field is just a hash and we can't decode it
                 return TimeSeriesIdFieldMapper.encodeTsid(value);
@@ -760,20 +760,20 @@ public interface DocValueFormat extends NamedWriteable {
             }
 
             Map<?, ?> m = (Map<?, ?>) value;
-            TimeSeriesIdBuilder builder = new TimeSeriesIdBuilder(null);
+            RoutingPathFields routingPathFields = new RoutingPathFields(null);
             for (Map.Entry<?, ?> entry : m.entrySet()) {
                 String f = entry.getKey().toString();
                 Object v = entry.getValue();
 
                 if (v instanceof String s) {
-                    builder.addString(f, s);
+                    routingPathFields.addString(f, s);
                 } else if (v instanceof Long l) {
-                    builder.addLong(f, l);
+                    routingPathFields.addLong(f, l);
                 } else if (v instanceof Integer i) {
-                    builder.addLong(f, i.longValue());
+                    routingPathFields.addLong(f, i.longValue());
                 } else if (v instanceof BigInteger ul) {
                     long ll = UNSIGNED_LONG_SHIFTED.parseLong(ul.toString(), false, () -> 0L);
-                    builder.addUnsignedLong(f, ll);
+                    routingPathFields.addUnsignedLong(f, ll);
                 } else {
                     throw new IllegalArgumentException("Unexpected value in tsid object [" + v + "]");
                 }
@@ -781,7 +781,7 @@ public interface DocValueFormat extends NamedWriteable {
 
             try {
                 // NOTE: we can decode the tsid only if it is not hashed (represented as a map)
-                return builder.buildLegacyTsid().toBytesRef();
+                return TimeSeriesIdFieldMapper.buildLegacyTsid(routingPathFields).toBytesRef();
             } catch (IOException e) {
                 throw new IllegalArgumentException(e);
             }

+ 8 - 28
server/src/test/java/org/elasticsearch/index/mapper/IdLoaderTests.java

@@ -27,11 +27,7 @@ import org.apache.lucene.store.Directory;
 import org.apache.lucene.tests.analysis.MockAnalyzer;
 import org.apache.lucene.tests.util.LuceneTestCase;
 import org.apache.lucene.util.BytesRef;
-import org.elasticsearch.cluster.metadata.IndexMetadata;
-import org.elasticsearch.cluster.routing.IndexRouting;
 import org.elasticsearch.core.CheckedConsumer;
-import org.elasticsearch.index.IndexSettings;
-import org.elasticsearch.index.IndexVersion;
 import org.elasticsearch.test.ESTestCase;
 
 import java.io.IOException;
@@ -72,8 +68,6 @@ public class IdLoaderTests extends ESTestCase {
     }
 
     public void testSynthesizeIdMultipleSegments() throws Exception {
-        var routingPaths = List.of("dim1");
-        var routing = createRouting(routingPaths);
         var idLoader = IdLoader.createTsIdLoader(null, null);
 
         long startTime = DateFieldMapper.DEFAULT_DATE_TIME_FORMATTER.parseMillis("2023-01-01T00:00:00Z");
@@ -144,8 +138,6 @@ public class IdLoaderTests extends ESTestCase {
     }
 
     public void testSynthesizeIdRandom() throws Exception {
-        var routingPaths = List.of("dim1");
-        var routing = createRouting(routingPaths);
         var idLoader = IdLoader.createTsIdLoader(null, null);
 
         long startTime = DateFieldMapper.DEFAULT_DATE_TIME_FORMATTER.parseMillis("2023-01-01T00:00:00Z");
@@ -153,7 +145,6 @@ public class IdLoaderTests extends ESTestCase {
         List<Doc> randomDocs = new ArrayList<>();
         int numberOfTimeSeries = randomIntBetween(8, 64);
         for (int i = 0; i < numberOfTimeSeries; i++) {
-            long routingId = 0;
             int numberOfDimensions = randomIntBetween(1, 6);
             List<Dimension> dimensions = new ArrayList<>(numberOfDimensions);
             for (int j = 1; j <= numberOfDimensions; j++) {
@@ -165,7 +156,6 @@ public class IdLoaderTests extends ESTestCase {
                     value = randomAlphaOfLength(4);
                 }
                 dimensions.add(new Dimension(fieldName, value));
-                routingId = value.hashCode();
             }
             int numberOfSamples = randomIntBetween(1, 16);
             for (int j = 0; j < numberOfSamples; j++) {
@@ -225,21 +215,21 @@ public class IdLoaderTests extends ESTestCase {
     }
 
     private static void indexDoc(IndexWriter iw, Doc doc, int routingHash) throws IOException {
-        final TimeSeriesIdFieldMapper.TimeSeriesIdBuilder builder = new TimeSeriesIdFieldMapper.TimeSeriesIdBuilder(null);
+        var routingFields = new RoutingPathFields(null);
 
         final List<IndexableField> fields = new ArrayList<>();
         fields.add(new SortedNumericDocValuesField(DataStreamTimestampFieldMapper.DEFAULT_PATH, doc.timestamp));
         fields.add(new LongPoint(DataStreamTimestampFieldMapper.DEFAULT_PATH, doc.timestamp));
         for (Dimension dimension : doc.dimensions) {
             if (dimension.value instanceof Number n) {
-                builder.addLong(dimension.field, n.longValue());
+                routingFields.addLong(dimension.field, n.longValue());
                 fields.add(new SortedNumericDocValuesField(dimension.field, ((Number) dimension.value).longValue()));
             } else {
-                builder.addString(dimension.field, dimension.value.toString());
+                routingFields.addString(dimension.field, dimension.value.toString());
                 fields.add(new SortedSetDocValuesField(dimension.field, new BytesRef(dimension.value.toString())));
             }
         }
-        BytesRef tsid = builder.buildTsidHash().toBytesRef();
+        BytesRef tsid = routingFields.buildHash().toBytesRef();
         fields.add(new SortedDocValuesField(TimeSeriesIdFieldMapper.NAME, tsid));
         fields.add(
             new SortedDocValuesField(
@@ -251,25 +241,15 @@ public class IdLoaderTests extends ESTestCase {
     }
 
     private static String expectedId(Doc doc, int routingHash) throws IOException {
-        var timeSeriesIdBuilder = new TimeSeriesIdFieldMapper.TimeSeriesIdBuilder(null);
+        var routingFields = new RoutingPathFields(null);
         for (Dimension dimension : doc.dimensions) {
             if (dimension.value instanceof Number n) {
-                timeSeriesIdBuilder.addLong(dimension.field, n.longValue());
+                routingFields.addLong(dimension.field, n.longValue());
             } else {
-                timeSeriesIdBuilder.addString(dimension.field, dimension.value.toString());
+                routingFields.addString(dimension.field, dimension.value.toString());
             }
         }
-        return TsidExtractingIdFieldMapper.createId(routingHash, timeSeriesIdBuilder.buildTsidHash().toBytesRef(), doc.timestamp);
-    }
-
-    private static IndexRouting.ExtractFromSource createRouting(List<String> routingPaths) {
-        var settings = indexSettings(IndexVersion.current(), 2, 1).put(IndexSettings.MODE.getKey(), "time_series")
-            .put(IndexSettings.TIME_SERIES_START_TIME.getKey(), "2000-01-01T00:00:00.000Z")
-            .put(IndexSettings.TIME_SERIES_END_TIME.getKey(), "2001-01-01T00:00:00.000Z")
-            .putList(IndexMetadata.INDEX_ROUTING_PATH.getKey(), routingPaths)
-            .build();
-        var indexMetadata = IndexMetadata.builder("index").settings(settings).build();
-        return (IndexRouting.ExtractFromSource) IndexRouting.fromIndexMetadata(indexMetadata);
+        return TsidExtractingIdFieldMapper.createId(routingHash, routingFields.buildHash().toBytesRef(), doc.timestamp);
     }
 
     record Doc(long timestamp, List<Dimension> dimensions) {}

+ 91 - 0
server/src/test/java/org/elasticsearch/index/mapper/RoutingPathFieldsTests.java

@@ -0,0 +1,91 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.index.mapper;
+
+import org.elasticsearch.cluster.metadata.IndexMetadata;
+import org.elasticsearch.cluster.routing.IndexRouting;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.index.IndexSettings;
+import org.elasticsearch.index.IndexVersion;
+import org.elasticsearch.test.ESTestCase;
+
+public class RoutingPathFieldsTests extends ESTestCase {
+
+    public void testWithBuilder() throws Exception {
+        IndexSettings settings = new IndexSettings(
+            IndexMetadata.builder("test")
+                .settings(
+                    indexSettings(IndexVersion.current(), 1, 1).put(
+                        Settings.builder().put("index.mode", "time_series").put("index.routing_path", "path.*").build()
+                    )
+                )
+                .build(),
+            Settings.EMPTY
+        );
+        IndexRouting.ExtractFromSource routing = (IndexRouting.ExtractFromSource) settings.getIndexRouting();
+
+        var routingPathFields = new RoutingPathFields(routing.builder());
+        BytesReference current, previous;
+
+        routingPathFields.addString("path.string_name", randomAlphaOfLengthBetween(1, 10));
+        current = previous = routingPathFields.buildHash();
+        assertNotNull(current);
+
+        routingPathFields.addBoolean("path.boolean_name", randomBoolean());
+        current = routingPathFields.buildHash();
+        assertTrue(current.length() > previous.length());
+        previous = current;
+
+        routingPathFields.addLong("path.long_name", randomLong());
+        current = routingPathFields.buildHash();
+        assertTrue(current.length() > previous.length());
+        previous = current;
+
+        routingPathFields.addIp("path.ip_name", randomIp(randomBoolean()));
+        current = routingPathFields.buildHash();
+        assertTrue(current.length() > previous.length());
+        previous = current;
+
+        routingPathFields.addUnsignedLong("path.unsigned_long_name", randomLongBetween(0, Long.MAX_VALUE));
+        current = routingPathFields.buildHash();
+        assertTrue(current.length() > previous.length());
+        assertArrayEquals(current.array(), routingPathFields.buildHash().array());
+    }
+
+    public void testWithoutBuilder() throws Exception {
+        var routingPathFields = new RoutingPathFields(null);
+        BytesReference current, previous;
+
+        routingPathFields.addString("path.string_name", randomAlphaOfLengthBetween(1, 10));
+        current = previous = routingPathFields.buildHash();
+        assertNotNull(current);
+
+        routingPathFields.addBoolean("path.boolean_name", randomBoolean());
+        current = routingPathFields.buildHash();
+        assertTrue(current.length() > previous.length());
+        previous = current;
+
+        routingPathFields.addLong("path.long_name", randomLong());
+        current = routingPathFields.buildHash();
+        assertTrue(current.length() > previous.length());
+        previous = current;
+
+        routingPathFields.addIp("path.ip_name", randomIp(randomBoolean()));
+        current = routingPathFields.buildHash();
+        assertTrue(current.length() > previous.length());
+        previous = current;
+
+        routingPathFields.addUnsignedLong("path.unsigned_long_name", randomLongBetween(0, Long.MAX_VALUE));
+        current = routingPathFields.buildHash();
+        assertTrue(current.length() > previous.length());
+        assertArrayEquals(current.array(), routingPathFields.buildHash().array());
+    }
+}

+ 6 - 6
server/src/test/java/org/elasticsearch/search/DocValueFormatTests.java

@@ -20,7 +20,7 @@ import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.network.InetAddresses;
 import org.elasticsearch.common.time.DateFormatter;
 import org.elasticsearch.index.mapper.DateFieldMapper.Resolution;
-import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper.TimeSeriesIdBuilder;
+import org.elasticsearch.index.mapper.RoutingPathFields;
 import org.elasticsearch.index.mapper.TimeSeriesRoutingHashFieldMapper;
 import org.elasticsearch.test.ESTestCase;
 
@@ -379,11 +379,11 @@ public class DocValueFormatTests extends ESTestCase {
     }
 
     public void testParseTsid() throws IOException {
-        TimeSeriesIdBuilder timeSeriesIdBuilder = new TimeSeriesIdBuilder(null);
-        timeSeriesIdBuilder.addString("string", randomAlphaOfLength(10));
-        timeSeriesIdBuilder.addLong("long", randomLong());
-        timeSeriesIdBuilder.addUnsignedLong("ulong", randomLong());
-        BytesRef expected = timeSeriesIdBuilder.buildTsidHash().toBytesRef();
+        var routingFields = new RoutingPathFields(null);
+        routingFields.addString("string", randomAlphaOfLength(10));
+        routingFields.addLong("long", randomLong());
+        routingFields.addUnsignedLong("ulong", randomLong());
+        BytesRef expected = routingFields.buildHash().toBytesRef();
         byte[] expectedBytes = new byte[expected.length];
         System.arraycopy(expected.bytes, 0, expectedBytes, 0, expected.length);
         BytesRef actual = DocValueFormat.TIME_SERIES_ID.parseBytesRef(expected);

+ 4 - 4
x-pack/plugin/analytics/src/test/java/org/elasticsearch/xpack/analytics/rate/TimeSeriesRateAggregatorTests.java

@@ -24,7 +24,7 @@ import org.elasticsearch.index.mapper.KeywordFieldMapper;
 import org.elasticsearch.index.mapper.MappedFieldType;
 import org.elasticsearch.index.mapper.MapperBuilderContext;
 import org.elasticsearch.index.mapper.NumberFieldMapper;
-import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper;
+import org.elasticsearch.index.mapper.RoutingPathFields;
 import org.elasticsearch.index.mapper.TimeSeriesParams;
 import org.elasticsearch.plugins.SearchPlugin;
 import org.elasticsearch.search.aggregations.AggregatorTestCase;
@@ -163,9 +163,9 @@ public class TimeSeriesRateAggregatorTests extends AggregatorTestCase {
     }
 
     private static BytesReference tsid(String dim) throws IOException {
-        TimeSeriesIdFieldMapper.TimeSeriesIdBuilder idBuilder = new TimeSeriesIdFieldMapper.TimeSeriesIdBuilder(null);
-        idBuilder.addString("dim", dim);
-        return idBuilder.buildTsidHash();
+        var routingFields = new RoutingPathFields(null);
+        routingFields.addString("dim", dim);
+        return routingFields.buildHash();
     }
 
     private Document doc(long timestamp, BytesReference tsid, long counterValue, String dim) {

+ 7 - 4
x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/TimeSeriesSortedSourceOperatorTests.java

@@ -45,6 +45,7 @@ import org.elasticsearch.index.mapper.DataStreamTimestampFieldMapper;
 import org.elasticsearch.index.mapper.DateFieldMapper;
 import org.elasticsearch.index.mapper.KeywordFieldMapper;
 import org.elasticsearch.index.mapper.NumberFieldMapper;
+import org.elasticsearch.index.mapper.RoutingPathFields;
 import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper;
 import org.hamcrest.Matcher;
 import org.junit.After;
@@ -363,12 +364,12 @@ public class TimeSeriesSortedSourceOperatorTests extends AnyOperatorTestCase {
         final List<IndexableField> fields = new ArrayList<>();
         fields.add(new SortedNumericDocValuesField(DataStreamTimestampFieldMapper.DEFAULT_PATH, timestamp));
         fields.add(new LongPoint(DataStreamTimestampFieldMapper.DEFAULT_PATH, timestamp));
-        final TimeSeriesIdFieldMapper.TimeSeriesIdBuilder builder = new TimeSeriesIdFieldMapper.TimeSeriesIdBuilder(null);
+        var routingPathFields = new RoutingPathFields(null);
         for (int i = 0; i < dimensions.length; i += 2) {
             if (dimensions[i + 1] instanceof Number n) {
-                builder.addLong(dimensions[i].toString(), n.longValue());
+                routingPathFields.addLong(dimensions[i].toString(), n.longValue());
             } else {
-                builder.addString(dimensions[i].toString(), dimensions[i + 1].toString());
+                routingPathFields.addString(dimensions[i].toString(), dimensions[i + 1].toString());
                 fields.add(new SortedSetDocValuesField(dimensions[i].toString(), new BytesRef(dimensions[i + 1].toString())));
             }
         }
@@ -382,7 +383,9 @@ public class TimeSeriesSortedSourceOperatorTests extends AnyOperatorTestCase {
             }
         }
         // Use legacy tsid to make tests easier to understand:
-        fields.add(new SortedDocValuesField(TimeSeriesIdFieldMapper.NAME, builder.buildLegacyTsid().toBytesRef()));
+        fields.add(
+            new SortedDocValuesField(TimeSeriesIdFieldMapper.NAME, TimeSeriesIdFieldMapper.buildLegacyTsid(routingPathFields).toBytesRef())
+        );
         iw.addDocument(fields);
     }
 }

+ 1 - 1
x-pack/plugin/mapper-unsigned-long/src/main/java/org/elasticsearch/xpack/unsignedlong/UnsignedLongFieldMapper.java

@@ -645,7 +645,7 @@ public class UnsignedLongFieldMapper extends FieldMapper {
         }
 
         if (dimension && numericValue != null) {
-            context.getDimensions().addUnsignedLong(fieldType().name(), numericValue).validate(context.indexSettings());
+            context.getRoutingFields().addUnsignedLong(fieldType().name(), numericValue);
         }
 
         List<Field> fields = new ArrayList<>();

+ 4 - 3
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/GeoLineAggregatorTests.java

@@ -46,6 +46,7 @@ import org.elasticsearch.index.mapper.KeywordFieldMapper;
 import org.elasticsearch.index.mapper.MappedFieldType;
 import org.elasticsearch.index.mapper.MapperBuilderContext;
 import org.elasticsearch.index.mapper.NumberFieldMapper;
+import org.elasticsearch.index.mapper.RoutingPathFields;
 import org.elasticsearch.index.mapper.TimeSeriesIdFieldMapper;
 import org.elasticsearch.plugins.SearchPlugin;
 import org.elasticsearch.search.aggregations.AggregationBuilder;
@@ -797,12 +798,12 @@ public class GeoLineAggregatorTests extends AggregatorTestCase {
                 ArrayList<GeoPoint> points = testData.pointsForGroup(g);
                 ArrayList<Long> timestamps = testData.timestampsForGroup(g);
                 for (int i = 0; i < points.size(); i++) {
-                    final TimeSeriesIdFieldMapper.TimeSeriesIdBuilder builder = new TimeSeriesIdFieldMapper.TimeSeriesIdBuilder(null);
-                    builder.addString("group_id", testData.groups[g]);
+                    var routingFields = new RoutingPathFields(null);
+                    routingFields.addString("group_id", testData.groups[g]);
                     ArrayList<Field> fields = new ArrayList<>(
                         Arrays.asList(
                             new SortedDocValuesField("group_id", new BytesRef(testData.groups[g])),
-                            new SortedDocValuesField(TimeSeriesIdFieldMapper.NAME, builder.buildTsidHash().toBytesRef())
+                            new SortedDocValuesField(TimeSeriesIdFieldMapper.NAME, routingFields.buildHash().toBytesRef())
                         )
                     );
                     GeoPoint point = points.get(i);