Browse Source

Support position time_series_metric on geo_point fields (#93946)

Added position time_series_metric:

* start creating position time_series_metric
* Add yaml tests for queries and aggs
* Disallow multi-values for geo_point as ts-metric
* Limit running on older versions, some parts of the time-series syntax were not supported on all versions
* ScaledFloatFieldMapper does not support POSITION, We should only test it against COUNTER and GAUGE, since it only supports those two metric types
* Expand unit tests and allow parsing of dimension. We expand the tests to cover all cases tested in DoubleFieldMapperTests which also tests the behaviour of setting the dimension to true or false, so we enable parsing that for symmetry, but reject `true` as illegal for geo_point.
* Add unit tests for position metric multi-values
Craig Taverner 2 years ago
parent
commit
e7a2c44bbf

+ 5 - 0
docs/changelog/93946.yaml

@@ -0,0 +1,5 @@
+pr: 93946
+summary: Support position `time_series_metric` on `geo_point` fields
+area: "TSDB"
+type: enhancement
+issues: []

+ 1 - 1
modules/mapper-extras/src/test/java/org/elasticsearch/index/mapper/extras/ScaledFloatFieldMapperTests.java

@@ -299,7 +299,7 @@ public class ScaledFloatFieldMapperTests extends MapperTestCase {
     }
 
     public void testTimeSeriesIndexDefault() throws Exception {
-        var randomMetricType = randomFrom(TimeSeriesParams.MetricType.values());
+        var randomMetricType = randomFrom(TimeSeriesParams.MetricType.scalar());
         var indexSettings = getIndexSettingsBuilder().put(IndexSettings.MODE.getKey(), IndexMode.TIME_SERIES.getName())
             .put(IndexMetadata.INDEX_ROUTING_PATH.getKey(), "dimension_field");
         var mapperService = createMapperService(indexSettings.build(), fieldMapping(b -> {

+ 316 - 0
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/tsdb/130_position_fields.yml

@@ -0,0 +1,316 @@
+---
+setup:
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+  - do:
+      indices.create:
+        index: locations
+        body:
+          settings:
+            index:
+              mode: time_series
+              routing_path: [ city, name ]
+              time_series:
+                start_time: 2023-01-01T00:00:00Z
+                end_time: 2024-01-01T00:00:00Z
+          mappings:
+            properties:
+              "@timestamp":
+                type: date
+              city:
+                type: keyword
+                time_series_dimension: true
+              name:
+                type: keyword
+                time_series_dimension: true
+              location:
+                type: geo_point
+                time_series_metric: position
+
+  - do:
+      bulk:
+        index: locations
+        refresh: true
+        body: |
+          {"index":{}}
+          {"@timestamp": "2023-01-01T12:00:00Z", "location": "POINT(4.912350 52.374081)", "city": "Amsterdam", "name": "NEMO Science Museum"}
+          {"index":{}}
+          {"@timestamp": "2023-01-02T12:00:00Z", "location": "POINT(4.901618 52.369219)", "city": "Amsterdam", "name": "Museum Het Rembrandthuis"}
+          {"index":{}}
+          {"@timestamp": "2023-01-03T12:00:00Z", "location": "POINT(4.914722 52.371667)", "city": "Amsterdam", "name": "Nederlands Scheepvaartmuseum"}
+          {"index":{}}
+          {"@timestamp": "2023-01-04T12:00:00Z", "location": "POINT(4.405200 51.222900)", "city": "Antwerp", "name": "Letterenhuis"}
+          {"index":{}}
+          {"@timestamp": "2023-01-05T12:00:00Z", "location": "POINT(2.336389 48.861111)", "city": "Paris", "name": "Musée du Louvre"}
+          {"index":{}}
+          {"@timestamp": "2023-01-06T12:00:00Z", "location": "POINT(2.327000 48.860000)", "city": "Paris", "name": "Musée dOrsay"}
+  - do:
+      indices.refresh: { }
+
+---
+multi-valued fields unsupported:
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+  - do:
+      bulk:
+        index: locations
+        refresh: true
+        body: |
+          {"index" : {}}
+          {"@timestamp": "2023-01-01T12:00:00Z", "city": "Rock", "name": "On", "location": {"lat": 13.0, "lon" : 34.0} }
+          {"index" : {}}
+          {"@timestamp": "2023-01-01T12:00:00Z", "city": "Hey", "name": "There", "location": [{"lat": 13.0, "lon" : 34.0}] }
+          {"index" : {}}
+          {"@timestamp": "2023-01-01T12:00:00Z", "city": "Boo", "name": "Hoo", "location": [{"lat": 13.0, "lon" : 34.0}, {"lat": 14.0, "lon" : 35.0}] }
+  - match: { errors: true }
+  - match: { items.0.index.result: "created" }
+  - match: { items.1.index.result: "created" }
+  - match: { items.2.index.error.reason: "failed to parse" }
+  - match: { items.2.index.error.caused_by.reason: "field type for [location] does not accept more than single value" }
+
+---
+"avg aggregation on position field unsupported":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+  - do:
+      catch: /Field \[location\] of type \[geo_point\] is not supported for aggregation \[avg\]/
+      search:
+        index: locations
+        body:
+          aggs:
+            avg_location:
+              avg:
+                field: location
+
+---
+"geo_bounds on position field":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+      features: close_to
+
+  - do:
+      search:
+        index: locations
+        rest_total_hits_as_int: true
+        body:
+          aggregations:
+            view_port:
+              geo_bounds:
+                field: location
+                wrap_longitude: true
+  - match: { hits.total: 6 }
+  - close_to: { aggregations.view_port.bounds.top_left.lat: { value: 52.374081, error: 0.00001 } }
+  - close_to: { aggregations.view_port.bounds.top_left.lon: { value: 2.327000, error: 0.00001 } }
+  - close_to: { aggregations.view_port.bounds.bottom_right.lat: { value: 48.860000, error: 0.00001 } }
+  - close_to: { aggregations.view_port.bounds.bottom_right.lon: { value: 4.914722, error: 0.00001 } }
+
+  - do:
+      search:
+        rest_total_hits_as_int: true
+        body:
+          aggregations:
+            centroid:
+              geo_centroid:
+                field: location
+  - match: { hits.total: 6 }
+  - match: { aggregations.centroid.location.lat: 51.00982965203002 }
+  - match: { aggregations.centroid.location.lon: 3.9662131341174245 }
+  - match: { aggregations.centroid.count: 6 }
+
+
+---
+"geo bounding box query":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+  - do:
+      search:
+        index: locations
+        body:
+          query:
+            geo_bounding_box:
+              location:
+                top_left:
+                  lat: 53
+                  lon: 4
+                bottom_right:
+                  lat: 50
+                  lon: 5
+  - match: { hits.total.value: 4 }
+
+---
+"geo shape intersects query":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+  - do:
+      search:
+        index: locations
+        body:
+          query:
+            geo_shape:
+              location:
+                shape:
+                  type: "envelope"
+                  coordinates: [ [ 0, 50 ], [ 4, 45 ] ]
+  - match: { hits.total.value: 2 }
+
+---
+"geo shape within query":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+  - do:
+      search:
+        index: locations
+        body:
+          query:
+            geo_shape:
+              location:
+                shape:
+                  type: "envelope"
+                  coordinates: [ [ 0, 50 ], [ 4, 45 ] ]
+                relation: within
+  - match: { hits.total.value: 2 }
+
+---
+"geo shape disjoint query":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+  - do:
+      search:
+        index: locations
+        body:
+          query:
+            geo_shape:
+              location:
+                shape:
+                  type: "envelope"
+                  coordinates: [ [ 0, 50 ], [ 4, 45 ] ]
+                relation: disjoint
+  - match: { hits.total.value: 4 }
+
+---
+"geo shape contains query":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+  - do:
+      search:
+        index: locations
+        body:
+          query:
+            geo_shape:
+              location:
+                shape:
+                  type: "envelope"
+                  coordinates: [ [ 0, 50 ], [ 4, 45 ] ]
+                relation: contains
+  - match: { hits.total.value: 0 }
+
+---
+"geo distance query":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+  - do:
+      search:
+        index: locations
+        body:
+          query:
+            geo_distance:
+              distance: "100km"
+              location:
+                lat: 52
+                lon: 5
+  - match: { hits.total.value: 4 }
+
+---
+"bounds agg":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+      features: close_to
+
+  - do:
+      search:
+        index: locations
+        body:
+          aggs:
+            bounds:
+              geo_bounds:
+                field: "location"
+                wrap_longitude: false
+  - match: { hits.total.value: 6 }
+  - close_to: { aggregations.bounds.bounds.top_left.lat: { value: 52.374081, error: 0.00001 } }
+  - close_to: { aggregations.bounds.bounds.top_left.lon: { value: 2.327000, error: 0.00001 } }
+  - close_to: { aggregations.bounds.bounds.bottom_right.lat: { value: 48.860000, error: 0.00001 } }
+  - close_to: { aggregations.bounds.bounds.bottom_right.lon: { value: 4.914722, error: 0.00001 } }
+
+---
+"geo_distance sort":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+      features: close_to
+
+  - do:
+      search:
+        index: locations
+        body:
+          sort:
+            _geo_distance:
+              location:
+                lat: 52.0
+                lon: 5.0
+  - match: { hits.total.value: 6 }
+  - close_to: { hits.hits.0._source.location.lat: { value: 52.369219, error: 0.00001 } }
+  - close_to: { hits.hits.0._source.location.lon: { value: 4.901618, error: 0.00001 } }
+  - close_to: { hits.hits.1._source.location.lat: { value: 52.371667, error: 0.00001 } }
+  - close_to: { hits.hits.1._source.location.lon: { value: 4.914722, error: 0.00001 } }
+  - close_to: { hits.hits.2._source.location.lat: { value: 52.374081, error: 0.00001 } }
+  - close_to: { hits.hits.2._source.location.lon: { value: 4.912350, error: 0.00001 } }
+  - close_to: { hits.hits.3._source.location.lat: { value: 51.222900, error: 0.00001 } }
+  - close_to: { hits.hits.3._source.location.lon: { value: 4.405200, error: 0.00001 } }
+  - close_to: { hits.hits.4._source.location.lat: { value: 48.861111, error: 0.00001 } }
+  - close_to: { hits.hits.4._source.location.lon: { value: 2.336389, error: 0.00001 } }
+  - close_to: { hits.hits.5._source.location.lat: { value: 48.860000, error: 0.00001 } }
+  - close_to: { hits.hits.5._source.location.lon: { value: 2.327000, error: 0.00001 } }
+
+
+---
+"distance_feature query":
+  - skip:
+      version: " - 8.7.99"
+      reason: position metric introduced in 8.8.0
+      features: close_to
+
+  - do:
+      search:
+        index: locations
+        body:
+          query:
+            bool:
+              should:
+                distance_feature:
+                  field: "location"
+                  pivot: "100km"
+                  origin: [ 5.0, 52.0 ]
+  - match: { hits.total.value: 6 }
+  - close_to: { hits.hits.0._source.location.lat: { value: 52.369219, error: 0.00001 } }
+  - close_to: { hits.hits.0._source.location.lon: { value: 4.901618, error: 0.00001 } }
+  - close_to: { hits.hits.1._source.location.lat: { value: 52.371667, error: 0.00001 } }
+  - close_to: { hits.hits.1._source.location.lon: { value: 4.914722, error: 0.00001 } }
+  - close_to: { hits.hits.2._source.location.lat: { value: 52.374081, error: 0.00001 } }
+  - close_to: { hits.hits.2._source.location.lon: { value: 4.912350, error: 0.00001 } }
+  - close_to: { hits.hits.3._source.location.lat: { value: 51.222900, error: 0.00001 } }
+  - close_to: { hits.hits.3._source.location.lon: { value: 4.405200, error: 0.00001 } }
+  - close_to: { hits.hits.4._source.location.lat: { value: 48.861111, error: 0.00001 } }
+  - close_to: { hits.hits.4._source.location.lon: { value: 2.336389, error: 0.00001 } }
+  - close_to: { hits.hits.5._source.location.lat: { value: 48.860000, error: 0.00001 } }
+  - close_to: { hits.hits.5._source.location.lon: { value: 2.327000, error: 0.00001 } }

+ 62 - 1
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/tsdb/20_mapping.yml

@@ -1,7 +1,7 @@
 ecs style:
   - skip:
       version: " - 8.0.99"
-      reason: introduced in 8.1.0
+      reason: index.mode and routing_path introduced in 8.1.0
 
   - do:
       indices.create:
@@ -535,3 +535,64 @@ source include/exclude:
                       uid:
                         type: keyword
                         time_series_dimension: true
+
+---
+Unsupported metric type position:
+  - skip:
+      version: " - 8.0.99, 8.8.0 - "
+      reason: index.mode and routing_path introduced in 8.1.0 and time series metric position introduced in 8.8.0
+
+  - do:
+      catch: '/unknown parameter \[time_series_metric\] on mapper \[location\] of type \[geo_point\]/'
+      indices.create:
+        index: test_position
+        body:
+          settings:
+            number_of_shards: 1
+            number_of_replicas: 0
+            index:
+              mode: time_series
+              routing_path: [metricset]
+              time_series:
+                start_time: 2021-04-28T00:00:00Z
+                end_time: 2021-04-29T00:00:00Z
+          mappings:
+            properties:
+              "@timestamp":
+                type: date
+              metricset:
+                type: keyword
+                time_series_dimension: true
+              location:
+                type: geo_point
+                time_series_metric: position
+
+---
+Supported metric type position:
+  - skip:
+      version: " - 8.7.99"
+      reason: "position introduced in 8.8.0"
+
+  - do:
+      indices.create:
+        index: test_position
+        body:
+          settings:
+            number_of_shards: 1
+            number_of_replicas: 0
+            index:
+              mode: time_series
+              routing_path: [metricset]
+              time_series:
+                start_time: 2021-04-28T00:00:00Z
+                end_time: 2021-04-29T00:00:00Z
+          mappings:
+            properties:
+              "@timestamp":
+                type: date
+              metricset:
+                type: keyword
+                time_series_dimension: true
+              location:
+                type: geo_point
+                time_series_metric: position

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

@@ -71,19 +71,22 @@ public abstract class AbstractPointGeometryFieldMapper<T> extends AbstractGeomet
         private final T nullValue;
         private final boolean ignoreZValue;
         protected final boolean ignoreMalformed;
+        private final boolean allowMultipleValues;
 
         protected PointParser(
             String field,
             CheckedFunction<XContentParser, T, IOException> objectParser,
             T nullValue,
             boolean ignoreZValue,
-            boolean ignoreMalformed
+            boolean ignoreMalformed,
+            boolean allowMultipleValues
         ) {
             this.field = field;
             this.objectParser = objectParser;
             this.nullValue = nullValue == null ? null : validate(nullValue);
             this.ignoreZValue = ignoreZValue;
             this.ignoreMalformed = ignoreMalformed;
+            this.allowMultipleValues = allowMultipleValues;
         }
 
         protected abstract T validate(T in);
@@ -115,7 +118,11 @@ public abstract class AbstractPointGeometryFieldMapper<T> extends AbstractGeomet
                     T point = createPoint(x, y);
                     consumer.accept(validate(point));
                 } else {
+                    int count = 0;
                     while (token != XContentParser.Token.END_ARRAY) {
+                        if (allowMultipleValues == false && ++count > 1) {
+                            throw new ElasticsearchParseException("field type for [{}] does not accept more than single value", field);
+                        }
                         if (parser.currentToken() == XContentParser.Token.VALUE_NULL) {
                             if (nullValue != null) {
                                 consumer.accept(nullValue);

+ 89 - 13
server/src/main/java/org/elasticsearch/index/mapper/GeoPointFieldMapper.java

@@ -28,6 +28,7 @@ import org.elasticsearch.common.geo.SimpleVectorTileFormatter;
 import org.elasticsearch.common.unit.DistanceUnit;
 import org.elasticsearch.core.CheckedFunction;
 import org.elasticsearch.geometry.Point;
+import org.elasticsearch.index.IndexMode;
 import org.elasticsearch.index.fielddata.FieldDataContext;
 import org.elasticsearch.index.fielddata.IndexFieldData;
 import org.elasticsearch.index.fielddata.SourceValueFetcherMultiGeoPointIndexFieldData;
@@ -66,7 +67,11 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
     public static final String CONTENT_TYPE = "geo_point";
 
     private static Builder builder(FieldMapper in) {
-        return ((GeoPointFieldMapper) in).builder;
+        return toType(in).builder;
+    }
+
+    private static GeoPointFieldMapper toType(FieldMapper in) {
+        return (GeoPointFieldMapper) in;
     }
 
     public static class Builder extends FieldMapper.Builder {
@@ -74,7 +79,7 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
         final Parameter<Explicit<Boolean>> ignoreMalformed;
         final Parameter<Explicit<Boolean>> ignoreZValue = ignoreZValueParam(m -> builder(m).ignoreZValue.get());
         final Parameter<GeoPoint> nullValue;
-        final Parameter<Boolean> indexed = Parameter.indexParam(m -> builder(m).indexed.get(), true);
+        final Parameter<Boolean> indexed;
         final Parameter<Boolean> hasDocValues = Parameter.docValuesParam(m -> builder(m).hasDocValues.get(), true);
         final Parameter<Boolean> stored = Parameter.storeParam(m -> builder(m).stored.get(), false);
         private final Parameter<Script> script = Parameter.scriptParam(m -> builder(m).script.get());
@@ -83,8 +88,17 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
 
         private final ScriptCompiler scriptCompiler;
         private final Version indexCreatedVersion;
+        private final Parameter<TimeSeriesParams.MetricType> metric;  // either null, or POSITION if this is a time series metric
+        private final Parameter<Boolean> dimension; // can only support time_series_dimension: false
+        private final IndexMode indexMode;  // either STANDARD or TIME_SERIES
 
-        public Builder(String name, ScriptCompiler scriptCompiler, boolean ignoreMalformedByDefault, Version indexCreatedVersion) {
+        public Builder(
+            String name,
+            ScriptCompiler scriptCompiler,
+            boolean ignoreMalformedByDefault,
+            Version indexCreatedVersion,
+            IndexMode mode
+        ) {
             super(name);
             this.ignoreMalformed = ignoreMalformedParam(m -> builder(m).ignoreMalformed.get(), ignoreMalformedByDefault);
             this.nullValue = nullValueParam(
@@ -96,7 +110,32 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
             this.scriptCompiler = Objects.requireNonNull(scriptCompiler);
             this.indexCreatedVersion = Objects.requireNonNull(indexCreatedVersion);
             this.script.precludesParameters(nullValue, ignoreMalformed, ignoreZValue);
+            this.indexMode = mode;
+            this.indexed = Parameter.indexParam(
+                m -> toType(m).indexed,
+                () -> indexMode != IndexMode.TIME_SERIES || getMetric().getValue() != TimeSeriesParams.MetricType.POSITION
+            );
             addScriptValidation(script, indexed, hasDocValues);
+
+            this.metric = TimeSeriesParams.metricParam(m -> toType(m).metricType, TimeSeriesParams.MetricType.POSITION).addValidator(v -> {
+                if (v != null && hasDocValues.getValue() == false) {
+                    throw new IllegalArgumentException(
+                        "Field [" + TimeSeriesParams.TIME_SERIES_METRIC_PARAM + "] requires that [" + hasDocValues.name + "] is true"
+                    );
+                }
+            });
+            // We allow `time_series_dimension` parameter to be parsed, but only allow it to be `false`
+            this.dimension = TimeSeriesParams.dimensionParam(m -> false).addValidator(v -> {
+                if (v) {
+                    throw new IllegalArgumentException(
+                        "Parameter [" + TimeSeriesParams.TIME_SERIES_DIMENSION_PARAM + "] cannot be set to geo_point"
+                    );
+                }
+            });
+        }
+
+        private Parameter<TimeSeriesParams.MetricType> getMetric() {
+            return metric;
         }
 
         @Override
@@ -110,7 +149,9 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
                 nullValue,
                 script,
                 onScriptError,
-                meta };
+                meta,
+                dimension,
+                metric };
         }
 
         public Builder docValues(boolean hasDocValues) {
@@ -155,7 +196,8 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
                 (parser) -> GeoUtils.parseGeoPoint(parser, ignoreZValue.get().value()),
                 nullValue.get(),
                 ignoreZValue.get().value(),
-                ignoreMalformed.get().value()
+                ignoreMalformed.get().value(),
+                metric.get() != TimeSeriesParams.MetricType.POSITION
             );
             GeoPointFieldType ft = new GeoPointFieldType(
                 context.buildFullName(name),
@@ -164,7 +206,8 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
                 hasDocValues.get(),
                 geoParser,
                 scriptValues(),
-                meta.get()
+                meta.get(),
+                metric.get()
             );
             if (this.script.get() == null) {
                 return new GeoPointFieldMapper(name, ft, multiFieldsBuilder.build(this, context), copyTo.build(), geoParser, this);
@@ -177,13 +220,22 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
     private static final Version MINIMUM_COMPATIBILITY_VERSION = Version.fromString("5.0.0");
 
     public static TypeParser PARSER = new TypeParser(
-        (n, c) -> new Builder(n, c.scriptCompiler(), IGNORE_MALFORMED_SETTING.get(c.getSettings()), c.indexVersionCreated()),
+        (n, c) -> new Builder(
+            n,
+            c.scriptCompiler(),
+            IGNORE_MALFORMED_SETTING.get(c.getSettings()),
+            c.indexVersionCreated(),
+            c.getIndexSettings().getMode()
+        ),
         MINIMUM_COMPATIBILITY_VERSION
     );
 
     private final Builder builder;
     private final FieldValues<GeoPoint> scriptValues;
     private final Version indexCreatedVersion;
+    private final TimeSeriesParams.MetricType metricType;
+    private final IndexMode indexMode;
+    private final boolean indexed;
 
     public GeoPointFieldMapper(
         String simpleName,
@@ -206,6 +258,9 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
         this.builder = builder;
         this.scriptValues = null;
         this.indexCreatedVersion = builder.indexCreatedVersion;
+        this.metricType = builder.metric.get();
+        this.indexMode = builder.indexMode;
+        this.indexed = builder.indexed.get();
     }
 
     public GeoPointFieldMapper(String simpleName, MappedFieldType mappedFieldType, Parser<GeoPoint> parser, Builder builder) {
@@ -213,12 +268,20 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
         this.builder = builder;
         this.scriptValues = builder.scriptValues();
         this.indexCreatedVersion = builder.indexCreatedVersion;
+        this.metricType = builder.metric.get();
+        this.indexMode = builder.indexMode;
+        this.indexed = builder.indexed.get();
     }
 
     @Override
     public FieldMapper.Builder getMergeBuilder() {
-        return new Builder(simpleName(), builder.scriptCompiler, builder.ignoreMalformed.getDefaultValue().value(), indexCreatedVersion)
-            .init(this);
+        return new Builder(
+            simpleName(),
+            builder.scriptCompiler,
+            builder.ignoreMalformed.getDefaultValue().value(),
+            indexCreatedVersion,
+            indexMode
+        ).init(this);
     }
 
     @Override
@@ -291,6 +354,7 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
     }
 
     public static class GeoPointFieldType extends AbstractGeometryFieldType<GeoPoint> implements GeoShapeQueryable {
+        private final TimeSeriesParams.MetricType metricType;
 
         public static final GeoFormatterFactory<GeoPoint> GEO_FORMATTER_FACTORY = new GeoFormatterFactory<>(
             List.of(new SimpleVectorTileFormatter())
@@ -305,15 +369,17 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
             boolean hasDocValues,
             Parser<GeoPoint> parser,
             FieldValues<GeoPoint> scriptValues,
-            Map<String, String> meta
+            Map<String, String> meta,
+            TimeSeriesParams.MetricType metricType
         ) {
             super(name, indexed, stored, hasDocValues, parser, meta);
             this.scriptValues = scriptValues;
+            this.metricType = metricType;
         }
 
         // only used in test
         public GeoPointFieldType(String name) {
-            this(name, true, false, true, null, null, Collections.emptyMap());
+            this(name, true, false, true, null, null, Collections.emptyMap(), null);
         }
 
         @Override
@@ -428,6 +494,15 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
                 );
             }
         }
+
+        /**
+         * If field is a time series metric field, returns its metric type
+         * @return the metric type or null
+         */
+        @Override
+        public TimeSeriesParams.MetricType getMetricType() {
+            return metricType;
+        }
     }
 
     /** GeoPoint parser implementation */
@@ -438,9 +513,10 @@ public class GeoPointFieldMapper extends AbstractPointGeometryFieldMapper<GeoPoi
             CheckedFunction<XContentParser, GeoPoint, IOException> objectParser,
             GeoPoint nullValue,
             boolean ignoreZValue,
-            boolean ignoreMalformed
+            boolean ignoreMalformed,
+            boolean allowMultipleValues
         ) {
-            super(field, objectParser, nullValue, ignoreZValue, ignoreMalformed);
+            super(field, objectParser, nullValue, ignoreZValue, ignoreMalformed, allowMultipleValues);
         }
 
         protected GeoPoint validate(GeoPoint in) {

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

@@ -23,20 +23,41 @@ public final class TimeSeriesParams {
 
     private TimeSeriesParams() {}
 
+    /**
+     * There are various types of metric used in time-series aggregations and downsampling.
+     * Each supports different field types and different calculations.
+     * Two of these, the COUNTER and GAUGE apply to most numerical types, mapping a single number,
+     * while the POSITION metric applies only to geo_point and therefor a pair of numbers (lat,lon).
+     * To simplify code that depends on this difference, we use the parameter `scalar==true` for
+     * single number metrics, and `false` for the POSITION metric.
+     */
     public enum MetricType {
         GAUGE(new String[] { "max", "min", "value_count", "sum" }),
-        COUNTER(new String[] { "last_value" });
+        COUNTER(new String[] { "last_value" }),
+        POSITION(new String[] {}, false);
 
         private final String[] supportedAggs;
+        private final boolean scalar;
 
         MetricType(String[] supportedAggs) {
+            this(supportedAggs, true);
+        }
+
+        MetricType(String[] supportedAggs, boolean scalar) {
             this.supportedAggs = supportedAggs;
+            this.scalar = scalar;
         }
 
+        /** list of aggregations supported for downsampling this metric */
         public String[] supportedAggs() {
             return supportedAggs;
         }
 
+        /** an array of metrics representing simple numerical values, like GAUGE and COUNTER */
+        public static MetricType[] scalar() {
+            return Arrays.stream(MetricType.values()).filter(m -> m.scalar).toArray(MetricType[]::new);
+        }
+
         @Override
         public final String toString() {
             return name().toLowerCase(Locale.ROOT);

+ 1 - 1
server/src/test/java/org/elasticsearch/index/fielddata/AbstractFieldDataTestCase.java

@@ -155,7 +155,7 @@ public abstract class AbstractFieldDataTestCase extends ESSingleNodeTestCase {
                 null
             ).docValues(docValues).build(context).fieldType();
         } else if (type.equals("geo_point")) {
-            fieldType = new GeoPointFieldMapper.Builder(fieldName, ScriptCompiler.NONE, false, Version.CURRENT).docValues(docValues)
+            fieldType = new GeoPointFieldMapper.Builder(fieldName, ScriptCompiler.NONE, false, Version.CURRENT, null).docValues(docValues)
                 .build(context)
                 .fieldType();
         } else if (type.equals("binary")) {

+ 141 - 2
server/src/test/java/org/elasticsearch/index/mapper/GeoPointFieldMapperTests.java

@@ -10,13 +10,19 @@ package org.elasticsearch.index.mapper;
 import org.apache.lucene.document.LatLonDocValuesField;
 import org.apache.lucene.document.LatLonPoint;
 import org.apache.lucene.geo.GeoEncodingUtils;
+import org.apache.lucene.index.DocValuesType;
 import org.apache.lucene.index.IndexableField;
 import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.cluster.metadata.IndexMetadata;
 import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.geo.GeoJson;
 import org.elasticsearch.common.geo.GeoPoint;
 import org.elasticsearch.core.Tuple;
 import org.elasticsearch.geo.GeometryTestUtils;
 import org.elasticsearch.geometry.Point;
+import org.elasticsearch.geometry.utils.WellKnownText;
+import org.elasticsearch.index.IndexMode;
+import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentFactory;
 import org.elasticsearch.xcontent.XContentParser;
@@ -30,6 +36,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.TreeMap;
 
 import static org.elasticsearch.geometry.utils.Geohash.stringEncode;
@@ -41,6 +48,7 @@ import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.hasToString;
 import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.notNullValue;
 import static org.hamcrest.Matchers.nullValue;
 
@@ -85,6 +93,138 @@ public class GeoPointFieldMapperTests extends MapperTestCase {
         assertAggregatableConsistency(mapperService.fieldType("field"));
     }
 
+    public void testDimension() throws IOException {
+        // Test default setting
+        MapperService mapperService = createMapperService(fieldMapping(this::minimalMapping));
+        GeoPointFieldMapper.GeoPointFieldType ft = (GeoPointFieldMapper.GeoPointFieldType) mapperService.fieldType("field");
+        assertFalse(ft.isDimension());
+
+        // dimension = false is allowed
+        assertDimension(false, GeoPointFieldMapper.GeoPointFieldType::isDimension);
+
+        // dimension = true is not allowed
+        Exception e = expectThrows(MapperParsingException.class, () -> createDocumentMapper(fieldMapping(b -> {
+            minimalMapping(b);
+            b.field("time_series_dimension", true);
+        })));
+        assertThat(e.getCause().getMessage(), containsString("Parameter [time_series_dimension] cannot be set"));
+    }
+
+    public void testMetricType() throws IOException {
+        // Test default setting
+        MapperService mapperService = createMapperService(fieldMapping(this::minimalMapping));
+        GeoPointFieldMapper.GeoPointFieldType ft = (GeoPointFieldMapper.GeoPointFieldType) mapperService.fieldType("field");
+        assertNull(ft.getMetricType());
+
+        assertMetricType("position", GeoPointFieldMapper.GeoPointFieldType::getMetricType);
+
+        {
+            // Test invalid metric type for this field type
+            Exception e = expectThrows(MapperParsingException.class, () -> createMapperService(fieldMapping(b -> {
+                minimalMapping(b);
+                b.field("time_series_metric", "gauge");
+            })));
+            assertThat(
+                e.getCause().getMessage(),
+                containsString("Unknown value [gauge] for field [time_series_metric] - accepted values are [position]")
+            );
+        }
+        {
+            // Test invalid metric type for this field type
+            Exception e = expectThrows(MapperParsingException.class, () -> createMapperService(fieldMapping(b -> {
+                minimalMapping(b);
+                b.field("time_series_metric", "counter");
+            })));
+            assertThat(
+                e.getCause().getMessage(),
+                containsString("Unknown value [counter] for field [time_series_metric] - accepted values are [position]")
+            );
+        }
+    }
+
+    public final void testPositionMetricType() throws IOException {
+        MapperService mapperService = createMapperService(fieldMapping(b -> {
+            minimalMapping(b);
+            b.field("time_series_metric", "position");
+        }));
+        assertExistsQuery(mapperService);
+        assertParseMinimalWarnings();
+    }
+
+    public void testTimeSeriesIndexDefault() throws Exception {
+        var positionMetricType = TimeSeriesParams.MetricType.POSITION;
+        var indexSettings = getIndexSettingsBuilder().put(IndexSettings.MODE.getKey(), IndexMode.TIME_SERIES.getName())
+            .put(IndexMetadata.INDEX_ROUTING_PATH.getKey(), "dimension_field");
+        var mapperService = createMapperService(indexSettings.build(), fieldMapping(b -> {
+            minimalMapping(b);
+            b.field("time_series_metric", positionMetricType.toString());
+        }));
+        var ft = (GeoPointFieldMapper.GeoPointFieldType) mapperService.fieldType("field");
+        assertThat(ft.getMetricType(), equalTo(positionMetricType));
+        assertThat(ft.isIndexed(), is(false));
+    }
+
+    public void testMetricAndDocvalues() {
+        Exception e = expectThrows(MapperParsingException.class, () -> createDocumentMapper(fieldMapping(b -> {
+            minimalMapping(b);
+            b.field("time_series_metric", "position").field("doc_values", false);
+        })));
+        assertThat(e.getCause().getMessage(), containsString("Field [time_series_metric] requires that [doc_values] is true"));
+    }
+
+    public void testMetricAndMultiValues() throws Exception {
+        DocumentMapper nonMetricMapper = createDocumentMapper(fieldMapping(this::minimalMapping));
+        DocumentMapper metricMapper = createDocumentMapper(fieldMapping(b -> {
+            minimalMapping(b);
+            b.field("time_series_metric", "position");
+        }));
+        // Multi-valued test data with various supported formats
+        Point pointA = new Point(1, 2);
+        Point pointB = new Point(3, 4);
+        Object[][] data = new Object[][] {
+            new Object[] { WellKnownText.toWKT(pointA), WellKnownText.toWKT(pointB) },
+            new Object[] { new Double[] { pointA.getX(), pointA.getY() }, new Double[] { pointB.getX(), pointB.getY() } },
+            new Object[] { pointA.getY() + "," + pointA.getX(), pointB.getY() + "," + pointB.getX() },
+            new Object[] { GeoJson.toMap(pointA), GeoJson.toMap(pointB) } };
+        IndexableField expectedPointA = new LatLonPoint("field", pointA.getY(), pointA.getX());
+        IndexableField expectedPointB = new LatLonPoint("field", pointB.getY(), pointB.getX());
+
+        // Verify that metric and non-metric mappers behave the same on single valued fields
+        for (Object[] values : data) {
+            for (DocumentMapper mapper : new DocumentMapper[] { nonMetricMapper, metricMapper }) {
+                ParsedDocument doc = mapper.parse(source(b -> b.field("field", values[0])));
+                assertThat(doc.rootDoc().getField("field"), notNullValue());
+                IndexableField field = doc.rootDoc().getField("field");
+                assertThat(field, instanceOf(LatLonPoint.class));
+                assertThat(field.toString(), equalTo(expectedPointA.toString()));
+            }
+        }
+
+        // Verify that multi-valued fields behave differently for metric and non-metric mappers
+        for (Object[] values : data) {
+            // Non-metric mapper works with multi-valued data
+            {
+                ParsedDocument doc = nonMetricMapper.parse(source(b -> b.field("field", values)));
+                assertThat(doc.rootDoc().getField("field"), notNullValue());
+                Object[] fields = doc.rootDoc()
+                    .getFields()
+                    .stream()
+                    .filter(f -> f.name().equals("field") && f.fieldType().docValuesType() == DocValuesType.NONE)
+                    .toArray();
+                assertThat(fields.length, equalTo(2));
+                assertThat(fields[0], instanceOf(LatLonPoint.class));
+                assertThat(fields[0].toString(), equalTo(expectedPointA.toString()));
+                assertThat(fields[1], instanceOf(LatLonPoint.class));
+                assertThat(fields[1].toString(), equalTo(expectedPointB.toString()));
+            }
+            // Metric mapper rejects multi-valued data
+            {
+                Exception e = expectThrows(MapperParsingException.class, () -> metricMapper.parse(source(b -> b.field("field", values))));
+                assertThat(e.getCause().getMessage(), containsString("field type for [field] does not accept more than single value"));
+            }
+        }
+    }
+
     public void testGeoHashValue() throws Exception {
         DocumentMapper mapper = createDocumentMapper(fieldMapping(this::minimalMapping));
         ParsedDocument doc = mapper.parse(source(b -> b.field("field", stringEncode(1.3, 1.2))));
@@ -260,7 +400,6 @@ public class GeoPointFieldMapperTests extends MapperTestCase {
     public void testKeywordWithGeopointSubfield() throws Exception {
         DocumentMapper mapper = createDocumentMapper(fieldMapping(b -> {
             b.field("type", "keyword").field("doc_values", false);
-            ;
             b.startObject("fields");
             {
                 b.startObject("geopoint").field("type", "geo_point").field("doc_values", false).endObject();
@@ -346,7 +485,7 @@ public class GeoPointFieldMapperTests extends MapperTestCase {
 
         doc = mapper.parse(source(b -> b.startArray("field").nullValue().value("3, 4").endArray()));
         assertMap(
-            Arrays.stream(doc.rootDoc().getFields("field")).map(IndexableField::binaryValue).filter(v -> v != null).toList(),
+            Arrays.stream(doc.rootDoc().getFields("field")).map(IndexableField::binaryValue).filter(Objects::nonNull).toList(),
             matchesList().item(equalTo(defaultValue)).item(equalTo(threeFour))
         );
     }

+ 4 - 4
server/src/test/java/org/elasticsearch/index/mapper/GeoPointFieldTypeTests.java

@@ -24,9 +24,9 @@ public class GeoPointFieldTypeTests extends FieldTypeTestCase {
 
     public void testFetchSourceValue() throws IOException {
         boolean ignoreMalformed = randomBoolean();
-        MappedFieldType mapper = new GeoPointFieldMapper.Builder("field", ScriptCompiler.NONE, ignoreMalformed, Version.CURRENT).build(
-            MapperBuilderContext.root(false)
-        ).fieldType();
+        MappedFieldType mapper = new GeoPointFieldMapper.Builder("field", ScriptCompiler.NONE, ignoreMalformed, Version.CURRENT, null)
+            .build(MapperBuilderContext.root(false))
+            .fieldType();
 
         Map<String, Object> jsonPoint = Map.of("type", "Point", "coordinates", List.of(42.0, 27.1));
         Map<String, Object> otherJsonPoint = Map.of("type", "Point", "coordinates", List.of(30.0, 50.0));
@@ -84,7 +84,7 @@ public class GeoPointFieldTypeTests extends FieldTypeTestCase {
     }
 
     public void testFetchVectorTile() throws IOException {
-        MappedFieldType mapper = new GeoPointFieldMapper.Builder("field", ScriptCompiler.NONE, false, Version.CURRENT).build(
+        MappedFieldType mapper = new GeoPointFieldMapper.Builder("field", ScriptCompiler.NONE, false, Version.CURRENT, null).build(
             MapperBuilderContext.root(false)
         ).fieldType();
         final int z = randomIntBetween(1, 10);

+ 1 - 1
server/src/test/java/org/elasticsearch/index/mapper/NumberFieldMapperTests.java

@@ -293,7 +293,7 @@ public abstract class NumberFieldMapperTests extends MapperTestCase {
     }
 
     public void testTimeSeriesIndexDefault() throws Exception {
-        var randomMetricType = randomFrom(TimeSeriesParams.MetricType.values());
+        var randomMetricType = randomFrom(TimeSeriesParams.MetricType.scalar());
         var indexSettings = getIndexSettingsBuilder().put(IndexSettings.MODE.getKey(), IndexMode.TIME_SERIES.getName())
             .put(IndexMetadata.INDEX_ROUTING_PATH.getKey(), "dimension_field");
         var mapperService = createMapperService(indexSettings.build(), fieldMapping(b -> {

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

@@ -317,7 +317,7 @@ public class UnsignedLongFieldMapperTests extends MapperTestCase {
     }
 
     public void testTimeSeriesIndexDefault() throws Exception {
-        var randomMetricType = randomFrom(TimeSeriesParams.MetricType.values());
+        var randomMetricType = randomFrom(TimeSeriesParams.MetricType.scalar());
         var indexSettings = getIndexSettingsBuilder().put(IndexSettings.MODE.getKey(), IndexMode.TIME_SERIES.getName())
             .put(IndexMetadata.INDEX_ROUTING_PATH.getKey(), "dimension_field");
         var mapperService = createMapperService(indexSettings.build(), fieldMapping(b -> {

+ 2 - 0
x-pack/plugin/rollup/src/main/java/org/elasticsearch/xpack/downsample/FieldValueFetcher.java

@@ -57,6 +57,8 @@ class FieldValueFetcher {
             return switch (fieldType.getMetricType()) {
                 case GAUGE -> new MetricFieldProducer.GaugeMetricFieldProducer(name());
                 case COUNTER -> new MetricFieldProducer.CounterMetricFieldProducer(name());
+                // TODO: Support POSITION in downsampling
+                case POSITION -> throw new IllegalArgumentException("Unsupported metric type [position] for down-sampling");
             };
         } else {
             // If field is not a metric, we downsample it as a label

+ 1 - 1
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/mapper/PointFieldMapper.java

@@ -236,7 +236,7 @@ public class PointFieldMapper extends AbstractPointGeometryFieldMapper<Cartesian
             boolean ignoreZValue,
             boolean ignoreMalformed
         ) {
-            super(field, objectParser, nullValue, ignoreZValue, ignoreMalformed);
+            super(field, objectParser, nullValue, ignoreZValue, ignoreMalformed, true);
         }
 
         @Override