Browse Source

New GeoHexGrid aggregation (#82924)

This commit introduces a new geogrid aggregation called GeoHexGridAggregation that
is based in Uber h3 grid. It only supports geo_point fields.
Ignacio Vera 3 years ago
parent
commit
0873893bb7
22 changed files with 1308 additions and 8 deletions
  1. 5 0
      docs/changelog/82924.yaml
  2. 2 0
      docs/reference/aggregations/bucket.asciidoc
  3. 249 0
      docs/reference/aggregations/bucket/geohexgrid-aggregation.asciidoc
  4. 1 1
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/InternalGeoGridBucket.java
  5. 1 1
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/ParsedGeoGrid.java
  6. 10 4
      test/framework/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/GeoGridTestCase.java
  7. 2 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/spatial/action/SpatialStatsAction.java
  8. 1 0
      x-pack/plugin/spatial/build.gradle
  9. 59 1
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java
  10. 133 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexCellIdSource.java
  11. 123 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregationBuilder.java
  12. 62 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregator.java
  13. 81 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregatorFactory.java
  14. 67 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/InternalGeoHexGrid.java
  15. 42 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/InternalGeoHexGridBucket.java
  16. 34 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/ParsedGeoHexGrid.java
  17. 34 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/ParsedGeoHexGridBucket.java
  18. 41 0
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialPluginTests.java
  19. 65 0
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexAggregationBuilderTests.java
  20. 87 0
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexAggregatorTests.java
  21. 66 0
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridTests.java
  22. 143 0
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/spatial/80_geohex_grid.yml

+ 5 - 0
docs/changelog/82924.yaml

@@ -0,0 +1,5 @@
+pr: 82924
+summary: New `GeoHexGrid` aggregation
+area: Geo
+type: feature
+issues: []

+ 2 - 0
docs/reference/aggregations/bucket.asciidoc

@@ -40,6 +40,8 @@ include::bucket/geodistance-aggregation.asciidoc[]
 
 include::bucket/geohashgrid-aggregation.asciidoc[]
 
+include::bucket/geohexgrid-aggregation.asciidoc[]
+
 include::bucket/geotilegrid-aggregation.asciidoc[]
 
 include::bucket/global-aggregation.asciidoc[]

+ 249 - 0
docs/reference/aggregations/bucket/geohexgrid-aggregation.asciidoc

@@ -0,0 +1,249 @@
+[role="xpack"]
+[[search-aggregations-bucket-geohexgrid-aggregation]]
+=== Geohex grid aggregation
+++++
+<titleabbrev>Geohex grid</titleabbrev>
+++++
+
+A multi-bucket aggregation that groups <<geo-point,`geo_point`>>
+values into buckets that represent a grid.
+The resulting grid can be sparse and only
+contains cells that have matching data. Each cell corresponds to a
+https://h3geo.org/docs/core-library/h3Indexing#h3-cell-indexp[H3 cell index] and is
+labeled using the https://h3geo.org/docs/core-library/h3Indexing#h3index-representation[H3Index representation].
+
+See https://h3geo.org/docs/core-library/restable[the table of cell areas for H3
+resolutions] on how precision (zoom) correlates to size on the ground.
+Precision for this aggregation can be between 0 and 15, inclusive.
+
+WARNING: High-precision requests can be very expensive in terms of RAM and
+result sizes. For example, the highest-precision geohex with a precision of 15
+produces cells that cover less than 10cm by 10cm. We recommend you use a
+filter to limit high-precision requests to a smaller geographic area. For an example,
+refer to <<geohexgrid-high-precision>>.
+
+[[geohexgrid-low-precision]]
+==== Simple low-precision request
+
+[source,console,id=geohexgrid-aggregation-example]
+--------------------------------------------------
+PUT /museums
+{
+  "mappings": {
+    "properties": {
+      "location": {
+        "type": "geo_point"
+      }
+    }
+  }
+}
+
+POST /museums/_bulk?refresh
+{"index":{"_id":1}}
+{"location": "52.374081,4.912350", "name": "NEMO Science Museum"}
+{"index":{"_id":2}}
+{"location": "52.369219,4.901618", "name": "Museum Het Rembrandthuis"}
+{"index":{"_id":3}}
+{"location": "52.371667,4.914722", "name": "Nederlands Scheepvaartmuseum"}
+{"index":{"_id":4}}
+{"location": "51.222900,4.405200", "name": "Letterenhuis"}
+{"index":{"_id":5}}
+{"location": "48.861111,2.336389", "name": "Musée du Louvre"}
+{"index":{"_id":6}}
+{"location": "48.860000,2.327000", "name": "Musée d'Orsay"}
+
+POST /museums/_search?size=0
+{
+  "aggregations": {
+    "large-grid": {
+      "geohex_grid": {
+        "field": "location",
+        "precision": 4
+      }
+    }
+  }
+}
+--------------------------------------------------
+
+Response:
+
+[source,console-result]
+--------------------------------------------------
+{
+  ...
+  "aggregations": {
+    "large-grid": {
+      "buckets": [
+        {
+          "key": "841969dffffffff",
+          "doc_count": 3
+        },
+        {
+          "key": "841fb47ffffffff",
+          "doc_count": 2
+        },
+        {
+          "key": "841fa4dffffffff",
+          "doc_count": 1
+        }
+      ]
+    }
+  }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/\.\.\./"took": $body.took,"_shards": $body._shards,"hits":$body.hits,"timed_out":false,/]
+
+[[geohexgrid-high-precision]]
+==== High-precision requests
+
+When requesting detailed buckets (typically for displaying a "zoomed in" map),
+a filter like <<query-dsl-geo-bounding-box-query,geo_bounding_box>> should be
+applied to narrow the subject area. Otherwise, potentially millions of buckets
+will be created and returned.
+
+[source,console,id=geohexgrid-high-precision-ex]
+--------------------------------------------------
+POST /museums/_search?size=0
+{
+  "aggregations": {
+    "zoomed-in": {
+      "filter": {
+        "geo_bounding_box": {
+          "location": {
+            "top_left": "52.4, 4.9",
+            "bottom_right": "52.3, 5.0"
+          }
+        }
+      },
+      "aggregations": {
+        "zoom1": {
+          "geohex_grid": {
+            "field": "location",
+            "precision": 12
+          }
+        }
+      }
+    }
+  }
+}
+--------------------------------------------------
+// TEST[continued]
+
+Response:
+
+[source,console-result]
+--------------------------------------------------
+{
+  ...
+  "aggregations": {
+    "zoomed-in": {
+      "doc_count": 3,
+      "zoom1": {
+        "buckets": [
+          {
+            "key": "8c1969c9b2617ff",
+            "doc_count": 1
+          },
+          {
+            "key": "8c1969526d753ff",
+            "doc_count": 1
+          },
+          {
+            "key": "8c1969526d26dff",
+            "doc_count": 1
+          }
+        ]
+      }
+    }
+  }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/\.\.\./"took": $body.took,"_shards": $body._shards,"hits":$body.hits,"timed_out":false,/]
+
+[[geohexgrid-addtl-bounding-box-filtering]]
+==== Requests with additional bounding box filtering
+
+The `geohex_grid` aggregation supports an optional `bounds` parameter
+that restricts the cells considered to those that intersect the
+provided bounds. The `bounds` parameter accepts the same
+<<query-dsl-geo-bounding-box-query-accepted-formats,bounding box formats>>
+as the geo-bounding box query. This bounding box can be used with or
+without an additional `geo_bounding_box` query for filtering the points prior to aggregating.
+It is an independent bounding box that can intersect with, be equal to, or be disjoint
+to any additional `geo_bounding_box` queries defined in the context of the aggregation.
+
+[source,console,id=geohexgrid-aggregation-with-bounds]
+--------------------------------------------------
+POST /museums/_search?size=0
+{
+  "aggregations": {
+    "tiles-in-bounds": {
+      "geohex_grid": {
+        "field": "location",
+        "precision": 12,
+        "bounds": {
+          "top_left": "52.4, 4.9",
+          "bottom_right": "52.3, 5.0"
+        }
+      }
+    }
+  }
+}
+--------------------------------------------------
+// TEST[continued]
+
+Response:
+
+[source,console-result]
+--------------------------------------------------
+{
+  ...
+  "aggregations": {
+    "tiles-in-bounds": {
+      "buckets": [
+        {
+          "key": "8c1969c9b2617ff",
+          "doc_count": 1
+        },
+        {
+          "key": "8c1969526d753ff",
+          "doc_count": 1
+        },
+        {
+          "key": "8c1969526d26dff",
+          "doc_count": 1
+        }
+      ]
+    }
+  }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/\.\.\./"took": $body.took,"_shards": $body._shards,"hits":$body.hits,"timed_out":false,/]
+
+[[geohexgrid-options]]
+==== Options
+
+[horizontal]
+field::
+(Required, string) Field containing indexed geo-point values. Must be explicitly
+mapped as a <<geo-point,`geo_point`>> field. If the field contains an array,
+`geohex_grid` aggregates all array values.
+
+precision::
+(Optional, integer) Integer zoom of the key used to define cells/buckets in
+the results. Defaults to `6`. Values outside of [`0`,`15`] will be rejected.
+
+bounds::
+(Optional, object) Bounding box used to filter the geo-points in each bucket.
+Accepts the same bounding box formats as the
+<<query-dsl-geo-bounding-box-query-accepted-formats,geo-bounding box query>>.
+
+size::
+(Optional, integer) Maximum number of buckets to return. Defaults to 10,000.
+When results are trimmed, buckets are prioritized based on the volume of
+documents they contain.
+
+shard_size::
+(Optional, integer) Number of buckets returned from each shard. Defaults to
+`max(10,(size x number-of-shards))` to allow for more a accurate count of the
+top cells in the final result.

+ 1 - 1
server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/InternalGeoGridBucket.java

@@ -51,7 +51,7 @@ public abstract class InternalGeoGridBucket extends InternalMultiBucketAggregati
         aggregations.writeTo(out);
     }
 
-    protected long hashAsLong() {
+    public long hashAsLong() {
         return hashAsLong;
     }
 

+ 1 - 1
server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/ParsedGeoGrid.java

@@ -34,7 +34,7 @@ public abstract class ParsedGeoGrid extends ParsedMultiBucketAggregation<ParsedG
         return parser;
     }
 
-    protected void setName(String name) {
+    public void setName(String name) {
         super.setName(name);
     }
 }

+ 10 - 4
test/framework/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/GeoGridTestCase.java

@@ -55,16 +55,22 @@ public abstract class GeoGridTestCase<B extends InternalGeoGridBucket, T extends
     @Override
     protected T createTestInstance(String name, Map<String, Object> metadata, InternalAggregations aggregations) {
         final int precision = randomPrecision();
-        int size = randomNumberOfBuckets();
-        List<InternalGeoGridBucket> buckets = new ArrayList<>(size);
+        final int size = randomNumberOfBuckets();
+        final List<InternalGeoGridBucket> buckets = new ArrayList<>(size);
+        final List<Long> seen = new ArrayList<>(size);
+        int finalSize = 0;
         for (int i = 0; i < size; i++) {
             double latitude = randomDoubleBetween(-90.0, 90.0, false);
             double longitude = randomDoubleBetween(-180.0, 180.0, false);
 
             long hashAsLong = longEncode(longitude, latitude, precision);
-            buckets.add(createInternalGeoGridBucket(hashAsLong, randomInt(IndexWriter.MAX_DOCS), aggregations));
+            if (seen.contains(hashAsLong) == false) { // make sure we don't add twice the same bucket
+                buckets.add(createInternalGeoGridBucket(hashAsLong, randomInt(IndexWriter.MAX_DOCS), aggregations));
+                seen.add(hashAsLong);
+                finalSize++;
+            }
         }
-        return createInternalGeoGrid(name, size, buckets, metadata);
+        return createInternalGeoGrid(name, finalSize, buckets, metadata);
     }
 
     @Override

+ 2 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/spatial/action/SpatialStatsAction.java

@@ -39,7 +39,8 @@ public class SpatialStatsAction extends ActionType<SpatialStatsAction.Response>
      * Items to track. Serialized by ordinals. Append only, don't remove or change order of items in this list.
      */
     public enum Item {
-        GEOLINE
+        GEOLINE,
+        GEOHEX
     }
 
     public static class Request extends BaseNodesRequest<Request> implements ToXContentObject {

+ 1 - 0
x-pack/plugin/spatial/build.gradle

@@ -14,6 +14,7 @@ dependencies {
   compileOnly project(path: ':modules:legacy-geo')
   compileOnly project(':modules:lang-painless:spi')
   compileOnly project(path: xpackModule('core'))
+  api project(":libs:elasticsearch-h3")
   testImplementation(testArtifact(project(xpackModule('core'))))
   testImplementation project(path: xpackModule('vector-tile'))
 }

+ 59 - 1
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java

@@ -31,6 +31,8 @@ import org.elasticsearch.search.aggregations.metrics.GeoBoundsAggregationBuilder
 import org.elasticsearch.search.aggregations.metrics.GeoCentroidAggregationBuilder;
 import org.elasticsearch.search.aggregations.metrics.ValueCountAggregationBuilder;
 import org.elasticsearch.search.aggregations.metrics.ValueCountAggregator;
+import org.elasticsearch.search.aggregations.support.CoreValuesSourceType;
+import org.elasticsearch.search.aggregations.support.ValuesSource;
 import org.elasticsearch.search.aggregations.support.ValuesSourceRegistry;
 import org.elasticsearch.xcontent.ContextParser;
 import org.elasticsearch.xpack.core.XPackPlugin;
@@ -50,9 +52,13 @@ import org.elasticsearch.xpack.spatial.search.aggregations.InternalGeoLine;
 import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.BoundedGeoHashGridTiler;
 import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.BoundedGeoTileGridTiler;
 import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoGridTiler;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexCellIdSource;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexGridAggregationBuilder;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexGridAggregator;
 import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoShapeCellIdSource;
 import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoShapeHashGridAggregator;
 import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoShapeTileGridAggregator;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.InternalGeoHexGrid;
 import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.UnboundedGeoHashGridTiler;
 import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.UnboundedGeoTileGridTiler;
 import org.elasticsearch.xpack.spatial.search.aggregations.metrics.GeoShapeBoundsAggregator;
@@ -87,6 +93,12 @@ public class SpatialPlugin extends Plugin implements ActionPlugin, MapperPlugin,
         License.OperationMode.GOLD
     );
 
+    private final LicensedFeature.Momentary GEO_HEX_AGG_FEATURE = LicensedFeature.momentary(
+        "spatial",
+        "geo-hex-agg",
+        License.OperationMode.GOLD
+    );
+
     // to be overriden by tests
     protected XPackLicenseState getLicenseState() {
         return XPackPlugin.getSharedLicenseState();
@@ -139,7 +151,12 @@ public class SpatialPlugin extends Plugin implements ActionPlugin, MapperPlugin,
                 GeoLineAggregationBuilder.NAME,
                 GeoLineAggregationBuilder::new,
                 usage.track(SpatialStatsAction.Item.GEOLINE, checkLicense(GeoLineAggregationBuilder.PARSER, GEO_LINE_AGG_FEATURE))
-            ).addResultReader(InternalGeoLine::new).setAggregatorRegistrar(GeoLineAggregationBuilder::registerUsage)
+            ).addResultReader(InternalGeoLine::new).setAggregatorRegistrar(GeoLineAggregationBuilder::registerUsage),
+            new AggregationSpec(
+                GeoHexGridAggregationBuilder.NAME,
+                GeoHexGridAggregationBuilder::new,
+                usage.track(SpatialStatsAction.Item.GEOHEX, checkLicense(GeoHexGridAggregationBuilder.PARSER, GEO_HEX_AGG_FEATURE))
+            ).addResultReader(InternalGeoHexGrid::new).setAggregatorRegistrar(this::registerGeoHexGridAggregator)
         );
     }
 
@@ -171,6 +188,47 @@ public class SpatialPlugin extends Plugin implements ActionPlugin, MapperPlugin,
         );
     }
 
+    private void registerGeoHexGridAggregator(ValuesSourceRegistry.Builder builder) {
+        builder.register(
+            GeoHexGridAggregationBuilder.REGISTRY_KEY,
+            CoreValuesSourceType.GEOPOINT,
+            (
+                name,
+                factories,
+                valuesSource,
+                precision,
+                geoBoundingBox,
+                requiredSize,
+                shardSize,
+                aggregationContext,
+                parent,
+                cardinality,
+                metadata) -> {
+                if (GEO_HEX_AGG_FEATURE.check(getLicenseState())) {
+                    GeoHexCellIdSource cellIdSource = new GeoHexCellIdSource(
+                        (ValuesSource.GeoPoint) valuesSource,
+                        precision,
+                        geoBoundingBox
+                    );
+                    return new GeoHexGridAggregator(
+                        name,
+                        factories,
+                        cellIdSource,
+                        requiredSize,
+                        shardSize,
+                        aggregationContext,
+                        parent,
+                        cardinality,
+                        metadata
+                    );
+                }
+
+                throw LicenseUtils.newComplianceException("geohex_grid aggregation on geo_point fields");
+            },
+            true
+        );
+    }
+
     private void registerGeoShapeGridAggregators(ValuesSourceRegistry.Builder builder) {
         builder.register(
             GeoHashGridAggregationBuilder.REGISTRY_KEY,

+ 133 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexCellIdSource.java

@@ -0,0 +1,133 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.SortedNumericDocValues;
+import org.elasticsearch.common.geo.GeoBoundingBox;
+import org.elasticsearch.h3.CellBoundary;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.index.fielddata.MultiGeoPointValues;
+import org.elasticsearch.index.fielddata.SortedBinaryDocValues;
+import org.elasticsearch.index.fielddata.SortedNumericDoubleValues;
+import org.elasticsearch.search.aggregations.bucket.geogrid.CellValues;
+import org.elasticsearch.search.aggregations.support.ValuesSource;
+
+/**
+ * Class to help convert {@link MultiGeoPointValues}
+ * to GeoHex bucketing.
+ */
+public class GeoHexCellIdSource extends ValuesSource.Numeric {
+    private final GeoPoint valuesSource;
+    private final int precision;
+    private final GeoBoundingBox geoBoundingBox;
+
+    public GeoHexCellIdSource(GeoPoint valuesSource, int precision, GeoBoundingBox geoBoundingBox) {
+        this.valuesSource = valuesSource;
+        this.precision = precision;
+        this.geoBoundingBox = geoBoundingBox;
+    }
+
+    public int precision() {
+        return precision;
+    }
+
+    @Override
+    public boolean isFloatingPoint() {
+        return false;
+    }
+
+    @Override
+    public SortedNumericDocValues longValues(LeafReaderContext ctx) {
+        return geoBoundingBox.isUnbounded()
+            ? new UnboundedCellValues(valuesSource.geoPointValues(ctx), precision)
+            : new BoundedCellValues(valuesSource.geoPointValues(ctx), precision, geoBoundingBox);
+    }
+
+    @Override
+    public SortedNumericDoubleValues doubleValues(LeafReaderContext ctx) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public SortedBinaryDocValues bytesValues(LeafReaderContext ctx) {
+        throw new UnsupportedOperationException();
+    }
+
+    private static class UnboundedCellValues extends CellValues {
+
+        UnboundedCellValues(MultiGeoPointValues geoValues, int precision) {
+            super(geoValues, precision);
+        }
+
+        @Override
+        protected int advanceValue(org.elasticsearch.common.geo.GeoPoint target, int valuesIdx) {
+            values[valuesIdx] = H3.geoToH3(target.getLat(), target.getLon(), precision);
+            return valuesIdx + 1;
+        }
+    }
+
+    private static class BoundedCellValues extends CellValues {
+
+        private final boolean crossesDateline;
+        private final GeoBoundingBox bbox;
+
+        protected BoundedCellValues(MultiGeoPointValues geoValues, int precision, GeoBoundingBox bbox) {
+            super(geoValues, precision);
+            this.crossesDateline = bbox.right() < bbox.left();
+            this.bbox = bbox;
+        }
+
+        @Override
+        public int advanceValue(org.elasticsearch.common.geo.GeoPoint target, int valuesIdx) {
+            final double lat = target.getLat();
+            final double lon = target.getLon();
+            final long hex = H3.geoToH3(lat, lon, precision);
+            // validPoint is a fast check, validHex is slow
+            if (validPoint(lat, lon) || validHex(hex)) {
+                values[valuesIdx] = hex;
+                return valuesIdx + 1;
+            }
+            return valuesIdx;
+        }
+
+        private boolean validPoint(double lat, double lon) {
+            if (bbox.top() >= lat && bbox.bottom() <= lat) {
+                if (crossesDateline) {
+                    return bbox.left() <= lon || bbox.right() >= lon;
+                } else {
+                    return bbox.left() <= lon && bbox.right() >= lon;
+                }
+            }
+            return false;
+        }
+
+        private boolean validHex(long hex) {
+            CellBoundary boundary = H3.h3ToGeoBoundary(hex);
+            double minLat = Double.POSITIVE_INFINITY;
+            double minLon = Double.POSITIVE_INFINITY;
+            double maxLat = Double.NEGATIVE_INFINITY;
+            double maxLon = Double.NEGATIVE_INFINITY;
+            for (int i = 0; i < boundary.numPoints(); i++) {
+                double boundaryLat = boundary.getLatLon(i).getLatDeg();
+                double boundaryLon = boundary.getLatLon(i).getLonDeg();
+                minLon = Math.min(minLon, boundaryLon);
+                maxLon = Math.max(maxLon, boundaryLon);
+                minLat = Math.min(minLat, boundaryLat);
+                maxLat = Math.max(maxLat, boundaryLat);
+            }
+            if (bbox.top() > minLat && bbox.bottom() < maxLat) {
+                if (crossesDateline) {
+                    return bbox.left() < maxLon || bbox.right() > minLon;
+                } else {
+                    return bbox.left() < maxLon && bbox.right() > minLon;
+                }
+            }
+            return false;
+        }
+    }
+}

+ 123 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregationBuilder.java

@@ -0,0 +1,123 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.common.geo.GeoBoundingBox;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.xcontent.support.XContentMapValues;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.search.aggregations.AggregationBuilder;
+import org.elasticsearch.search.aggregations.AggregatorFactories;
+import org.elasticsearch.search.aggregations.AggregatorFactory;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregationBuilder;
+import org.elasticsearch.search.aggregations.metrics.GeoGridAggregatorSupplier;
+import org.elasticsearch.search.aggregations.support.AggregationContext;
+import org.elasticsearch.search.aggregations.support.ValuesSourceAggregatorFactory;
+import org.elasticsearch.search.aggregations.support.ValuesSourceConfig;
+import org.elasticsearch.search.aggregations.support.ValuesSourceRegistry;
+import org.elasticsearch.xcontent.ObjectParser;
+import org.elasticsearch.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Map;
+
+public class GeoHexGridAggregationBuilder extends GeoGridAggregationBuilder {
+    public static final String NAME = "geohex_grid";
+    private static final int DEFAULT_PRECISION = 5;
+    private static final int DEFAULT_MAX_NUM_CELLS = 10000;
+    public static final ValuesSourceRegistry.RegistryKey<GeoGridAggregatorSupplier> REGISTRY_KEY = new ValuesSourceRegistry.RegistryKey<>(
+        NAME,
+        GeoGridAggregatorSupplier.class
+    );
+
+    public static final ObjectParser<GeoHexGridAggregationBuilder, String> PARSER = createParser(
+        NAME,
+        GeoHexGridAggregationBuilder::parsePrecision,
+        GeoHexGridAggregationBuilder::new
+    );
+
+    static int parsePrecision(XContentParser parser) throws IOException, ElasticsearchParseException {
+        final Object node = parser.currentToken().equals(XContentParser.Token.VALUE_NUMBER)
+            ? Integer.valueOf(parser.intValue())
+            : parser.text();
+        return XContentMapValues.nodeIntegerValue(node);
+    }
+
+    public GeoHexGridAggregationBuilder(String name) {
+        super(name);
+        precision(DEFAULT_PRECISION);
+        size(DEFAULT_MAX_NUM_CELLS);
+        shardSize = -1;
+    }
+
+    public GeoHexGridAggregationBuilder(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @Override
+    public GeoGridAggregationBuilder precision(int precision) {
+        if (precision < 0 || precision > H3.MAX_H3_RES) {
+            throw new IllegalArgumentException(
+                "Invalid geohex aggregation precision of " + precision + "" + ". Must be between 0 and " + H3.MAX_H3_RES
+            );
+        }
+        this.precision = precision;
+        return this;
+    }
+
+    @Override
+    protected ValuesSourceAggregatorFactory createFactory(
+        String name,
+        ValuesSourceConfig config,
+        int precision,
+        int requiredSize,
+        int shardSize,
+        GeoBoundingBox geoBoundingBox,
+        AggregationContext context,
+        AggregatorFactory parent,
+        AggregatorFactories.Builder subFactoriesBuilder,
+        Map<String, Object> metadata
+    ) throws IOException {
+        return new GeoHexGridAggregatorFactory(
+            name,
+            config,
+            precision,
+            requiredSize,
+            shardSize,
+            geoBoundingBox,
+            context,
+            parent,
+            subFactoriesBuilder,
+            metadata
+        );
+    }
+
+    private GeoHexGridAggregationBuilder(
+        GeoHexGridAggregationBuilder clone,
+        AggregatorFactories.Builder factoriesBuilder,
+        Map<String, Object> metadata
+    ) {
+        super(clone, factoriesBuilder, metadata);
+    }
+
+    @Override
+    protected AggregationBuilder shallowCopy(AggregatorFactories.Builder factoriesBuilder, Map<String, Object> metadata) {
+        return new GeoHexGridAggregationBuilder(this, factoriesBuilder, metadata);
+    }
+
+    @Override
+    public String getType() {
+        return NAME;
+    }
+
+    @Override
+    protected ValuesSourceRegistry.RegistryKey<?> getRegistryKey() {
+        return REGISTRY_KEY;
+    }
+}

+ 62 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregator.java

@@ -0,0 +1,62 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.elasticsearch.search.aggregations.Aggregator;
+import org.elasticsearch.search.aggregations.AggregatorFactories;
+import org.elasticsearch.search.aggregations.CardinalityUpperBound;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregator;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGridBucket;
+import org.elasticsearch.search.aggregations.support.AggregationContext;
+import org.elasticsearch.search.aggregations.support.ValuesSource;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Aggregates data expressed as h3 longs (for efficiency's sake)
+ * but formats results as h3 strings.
+ */
+public class GeoHexGridAggregator extends GeoGridAggregator<InternalGeoHexGrid> {
+
+    public GeoHexGridAggregator(
+        String name,
+        AggregatorFactories factories,
+        ValuesSource.Numeric valuesSource,
+        int requiredSize,
+        int shardSize,
+        AggregationContext context,
+        Aggregator parent,
+        CardinalityUpperBound cardinality,
+        Map<String, Object> metadata
+    ) throws IOException {
+        super(name, factories, valuesSource, requiredSize, shardSize, context, parent, cardinality, metadata);
+    }
+
+    @Override
+    protected InternalGeoHexGrid buildAggregation(
+        String name,
+        int requiredSize,
+        List<InternalGeoGridBucket> buckets,
+        Map<String, Object> metadata
+    ) {
+        return new InternalGeoHexGrid(name, requiredSize, buckets, metadata);
+    }
+
+    @Override
+    public InternalGeoHexGrid buildEmptyAggregation() {
+        return new InternalGeoHexGrid(name, requiredSize, Collections.emptyList(), metadata());
+    }
+
+    @Override
+    protected InternalGeoGridBucket newEmptyBucket() {
+        return new InternalGeoHexGridBucket(0, 0, null);
+    }
+}

+ 81 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregatorFactory.java

@@ -0,0 +1,81 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.geo.GeoBoundingBox;
+import org.elasticsearch.search.aggregations.Aggregator;
+import org.elasticsearch.search.aggregations.AggregatorFactories;
+import org.elasticsearch.search.aggregations.AggregatorFactory;
+import org.elasticsearch.search.aggregations.CardinalityUpperBound;
+import org.elasticsearch.search.aggregations.InternalAggregation;
+import org.elasticsearch.search.aggregations.NonCollectingAggregator;
+import org.elasticsearch.search.aggregations.support.AggregationContext;
+import org.elasticsearch.search.aggregations.support.ValuesSourceAggregatorFactory;
+import org.elasticsearch.search.aggregations.support.ValuesSourceConfig;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+
+public class GeoHexGridAggregatorFactory extends ValuesSourceAggregatorFactory {
+
+    private final int precision;
+    private final int requiredSize;
+    private final int shardSize;
+    private final GeoBoundingBox geoBoundingBox;
+
+    GeoHexGridAggregatorFactory(
+        String name,
+        ValuesSourceConfig config,
+        int precision,
+        int requiredSize,
+        int shardSize,
+        GeoBoundingBox geoBoundingBox,
+        AggregationContext context,
+        AggregatorFactory parent,
+        AggregatorFactories.Builder subFactoriesBuilder,
+        Map<String, Object> metadata
+    ) throws IOException {
+        super(name, config, context, parent, subFactoriesBuilder, metadata);
+        this.precision = precision;
+        this.requiredSize = requiredSize;
+        this.shardSize = shardSize;
+        this.geoBoundingBox = geoBoundingBox;
+    }
+
+    @Override
+    protected Aggregator createUnmapped(Aggregator parent, Map<String, Object> metadata) throws IOException {
+        final InternalAggregation aggregation = new InternalGeoHexGrid(name, requiredSize, Collections.emptyList(), metadata);
+        return new NonCollectingAggregator(name, context, parent, factories, metadata) {
+            @Override
+            public InternalAggregation buildEmptyAggregation() {
+                return aggregation;
+            }
+        };
+    }
+
+    @Override
+    protected Aggregator doCreateInternal(Aggregator parent, CardinalityUpperBound cardinality, Map<String, Object> metadata)
+        throws IOException {
+        return context.getValuesSourceRegistry()
+            .getAggregator(GeoHexGridAggregationBuilder.REGISTRY_KEY, config)
+            .build(
+                name,
+                factories,
+                config.getValuesSource(),
+                precision,
+                geoBoundingBox,
+                requiredSize,
+                shardSize,
+                context,
+                parent,
+                cardinality,
+                metadata
+            );
+    }
+}

+ 67 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/InternalGeoHexGrid.java

@@ -0,0 +1,67 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.search.aggregations.InternalAggregations;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGrid;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGridBucket;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Represents a grid of cells where each cell's location is determined by a h3 cell.
+ * All cells in a grid are of the same precision and held internally as a single long
+ * for efficiency's sake.
+ */
+public class InternalGeoHexGrid extends InternalGeoGrid<InternalGeoHexGridBucket> {
+
+    InternalGeoHexGrid(String name, int requiredSize, List<InternalGeoGridBucket> buckets, Map<String, Object> metadata) {
+        super(name, requiredSize, buckets, metadata);
+    }
+
+    public InternalGeoHexGrid(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @Override
+    public InternalGeoGrid<InternalGeoHexGridBucket> create(List<InternalGeoGridBucket> buckets) {
+        return new InternalGeoHexGrid(name, requiredSize, buckets, metadata);
+    }
+
+    @Override
+    public InternalGeoGridBucket createBucket(InternalAggregations aggregations, InternalGeoGridBucket prototype) {
+        return new InternalGeoHexGridBucket(prototype.hashAsLong(), prototype.getDocCount(), aggregations);
+    }
+
+    @Override
+    protected InternalGeoGrid<InternalGeoHexGridBucket> create(
+        String name,
+        int requiredSize,
+        List<InternalGeoGridBucket> buckets,
+        Map<String, Object> metadata
+    ) {
+        return new InternalGeoHexGrid(name, requiredSize, buckets, metadata);
+    }
+
+    @Override
+    protected InternalGeoHexGridBucket createBucket(long hashAsLong, long docCount, InternalAggregations aggregations) {
+        return new InternalGeoHexGridBucket(hashAsLong, docCount, aggregations);
+    }
+
+    @Override
+    protected Reader<InternalGeoHexGridBucket> getBucketReader() {
+        return InternalGeoHexGridBucket::new;
+    }
+
+    @Override
+    public String getWriteableName() {
+        return GeoHexGridAggregationBuilder.NAME;
+    }
+}

+ 42 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/InternalGeoHexGridBucket.java

@@ -0,0 +1,42 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.geo.GeoPoint;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.h3.LatLng;
+import org.elasticsearch.search.aggregations.InternalAggregations;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGridBucket;
+
+import java.io.IOException;
+
+public class InternalGeoHexGridBucket extends InternalGeoGridBucket {
+
+    InternalGeoHexGridBucket(long hashAsLong, long docCount, InternalAggregations aggregations) {
+        super(hashAsLong, docCount, aggregations);
+    }
+
+    /**
+     * Read from a stream.
+     */
+    public InternalGeoHexGridBucket(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @Override
+    public String getKeyAsString() {
+        return H3.h3ToString(hashAsLong);
+    }
+
+    @Override
+    public GeoPoint getKey() {
+        LatLng latLng = H3.h3ToLatLng(hashAsLong);
+        return new GeoPoint(latLng.getLatDeg(), latLng.getLonDeg());
+    }
+}

+ 34 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/ParsedGeoHexGrid.java

@@ -0,0 +1,34 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.elasticsearch.search.aggregations.bucket.geogrid.ParsedGeoGrid;
+import org.elasticsearch.xcontent.ObjectParser;
+import org.elasticsearch.xcontent.XContentParser;
+
+import java.io.IOException;
+
+public class ParsedGeoHexGrid extends ParsedGeoGrid {
+
+    private static final ObjectParser<ParsedGeoGrid, Void> PARSER = createParser(
+        ParsedGeoHexGrid::new,
+        ParsedGeoHexGridBucket::fromXContent,
+        ParsedGeoHexGridBucket::fromXContent
+    );
+
+    public static ParsedGeoGrid fromXContent(XContentParser parser, String name) throws IOException {
+        ParsedGeoGrid aggregation = PARSER.parse(parser, null);
+        aggregation.setName(name);
+        return aggregation;
+    }
+
+    @Override
+    public String getType() {
+        return GeoHexGridAggregationBuilder.NAME;
+    }
+}

+ 34 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/ParsedGeoHexGridBucket.java

@@ -0,0 +1,34 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.geo.GeoPoint;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.h3.LatLng;
+import org.elasticsearch.search.aggregations.bucket.geogrid.ParsedGeoGridBucket;
+import org.elasticsearch.xcontent.XContentParser;
+
+import java.io.IOException;
+
+public class ParsedGeoHexGridBucket extends ParsedGeoGridBucket {
+
+    @Override
+    public GeoPoint getKey() {
+        LatLng latLng = H3.h3ToLatLng(hashAsString);
+        return new GeoPoint(latLng.getLatDeg(), latLng.getLonDeg());
+    }
+
+    @Override
+    public String getKeyAsString() {
+        return hashAsString;
+    }
+
+    static ParsedGeoHexGridBucket fromXContent(XContentParser parser) throws IOException {
+        return parseXContent(parser, false, ParsedGeoHexGridBucket::new, (p, bucket) -> bucket.hashAsString = p.text());
+    }
+}

+ 41 - 0
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialPluginTests.java

@@ -10,15 +10,19 @@ import org.elasticsearch.ElasticsearchSecurityException;
 import org.elasticsearch.license.License;
 import org.elasticsearch.license.TestUtils;
 import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.plugins.SearchPlugin;
+import org.elasticsearch.search.aggregations.AggregatorFactories;
 import org.elasticsearch.search.aggregations.CardinalityUpperBound;
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoHashGridAggregationBuilder;
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileGridAggregationBuilder;
 import org.elasticsearch.search.aggregations.metrics.GeoCentroidAggregationBuilder;
 import org.elasticsearch.search.aggregations.metrics.GeoGridAggregatorSupplier;
 import org.elasticsearch.search.aggregations.metrics.MetricAggregatorSupplier;
+import org.elasticsearch.search.aggregations.support.CoreValuesSourceType;
 import org.elasticsearch.search.aggregations.support.ValuesSourceConfig;
 import org.elasticsearch.search.aggregations.support.ValuesSourceRegistry;
 import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexGridAggregationBuilder;
 import org.elasticsearch.xpack.spatial.search.aggregations.support.GeoShapeValuesSourceType;
 
 import java.util.Arrays;
@@ -54,6 +58,43 @@ public class SpatialPluginTests extends ESTestCase {
         }
     }
 
+    public void testGeoHexLicenseCheck() {
+        for (License.OperationMode operationMode : License.OperationMode.values()) {
+            SpatialPlugin plugin = getPluginWithOperationMode(operationMode);
+            ValuesSourceRegistry.Builder registryBuilder = new ValuesSourceRegistry.Builder();
+            List<SearchPlugin.AggregationSpec> specs = plugin.getAggregations();
+            specs.forEach(c -> c.getAggregatorRegistrar().accept(registryBuilder));
+            ValuesSourceRegistry registry = registryBuilder.build();
+            GeoGridAggregatorSupplier hexSupplier = registry.getAggregator(
+                GeoHexGridAggregationBuilder.REGISTRY_KEY,
+                new ValuesSourceConfig(CoreValuesSourceType.GEOPOINT, null, true, null, null, null, null, null, null)
+            );
+            if (License.OperationMode.TRIAL != operationMode
+                && License.OperationMode.compare(operationMode, License.OperationMode.GOLD) < 0) {
+                ElasticsearchSecurityException exception = expectThrows(
+                    ElasticsearchSecurityException.class,
+                    () -> hexSupplier.build(
+                        null,
+                        AggregatorFactories.EMPTY,
+                        null,
+                        0,
+                        null,
+                        0,
+                        0,
+                        null,
+                        null,
+                        CardinalityUpperBound.NONE,
+                        null
+                    )
+                );
+                assertThat(
+                    exception.getMessage(),
+                    equalTo("current license is non-compliant for [geohex_grid aggregation on geo_point fields]")
+                );
+            }
+        }
+    }
+
     public void testGeoGridLicenseCheck() {
         for (ValuesSourceRegistry.RegistryKey<GeoGridAggregatorSupplier> registryKey : Arrays.asList(
             GeoHashGridAggregationBuilder.REGISTRY_KEY,

+ 65 - 0
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexAggregationBuilderTests.java

@@ -0,0 +1,65 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.test.AbstractSerializingTestCase;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xpack.spatial.util.GeoTestUtils;
+
+import java.io.IOException;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class GeoHexAggregationBuilderTests extends AbstractSerializingTestCase<GeoHexGridAggregationBuilder> {
+
+    @Override
+    protected GeoHexGridAggregationBuilder doParseInstance(XContentParser parser) throws IOException {
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.START_OBJECT));
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.FIELD_NAME));
+        String name = parser.currentName();
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.START_OBJECT));
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.FIELD_NAME));
+        assertThat(parser.currentName(), equalTo(GeoHexGridAggregationBuilder.NAME));
+        GeoHexGridAggregationBuilder parsed = GeoHexGridAggregationBuilder.PARSER.apply(parser, name);
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.END_OBJECT));
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.END_OBJECT));
+        return parsed;
+    }
+
+    @Override
+    protected Writeable.Reader<GeoHexGridAggregationBuilder> instanceReader() {
+        return GeoHexGridAggregationBuilder::new;
+    }
+
+    @Override
+    protected GeoHexGridAggregationBuilder createTestInstance() {
+        GeoHexGridAggregationBuilder geoHexGridAggregationBuilder = new GeoHexGridAggregationBuilder("_name");
+        geoHexGridAggregationBuilder.field("field");
+        if (randomBoolean()) {
+            geoHexGridAggregationBuilder.precision(randomIntBetween(0, H3.MAX_H3_RES));
+        }
+        if (randomBoolean()) {
+            geoHexGridAggregationBuilder.size(randomIntBetween(0, 256 * 256));
+        }
+        if (randomBoolean()) {
+            geoHexGridAggregationBuilder.shardSize(randomIntBetween(0, 256 * 256));
+        }
+        if (randomBoolean()) {
+            geoHexGridAggregationBuilder.setGeoBoundingBox(GeoTestUtils.randomBBox());
+        }
+        return geoHexGridAggregationBuilder;
+    }
+
+    public void testInvalidPrecision() {
+        GeoHexGridAggregationBuilder geoHexGridAggregationBuilder = new GeoHexGridAggregationBuilder("_name");
+        expectThrows(IllegalArgumentException.class, () -> geoHexGridAggregationBuilder.precision(16));
+        expectThrows(IllegalArgumentException.class, () -> geoHexGridAggregationBuilder.precision(-1));
+    }
+}

+ 87 - 0
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexAggregatorTests.java

@@ -0,0 +1,87 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.geo.GeoBoundingBox;
+import org.elasticsearch.geo.GeometryTestUtils;
+import org.elasticsearch.geometry.Point;
+import org.elasticsearch.geometry.Rectangle;
+import org.elasticsearch.h3.CellBoundary;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.index.mapper.MappedFieldType;
+import org.elasticsearch.plugins.SearchPlugin;
+import org.elasticsearch.search.aggregations.AggregationBuilder;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregationBuilder;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregatorTestCase;
+import org.elasticsearch.search.aggregations.support.CoreValuesSourceType;
+import org.elasticsearch.search.aggregations.support.ValuesSourceType;
+import org.elasticsearch.xpack.spatial.LocalStateSpatialPlugin;
+import org.elasticsearch.xpack.spatial.search.aggregations.support.GeoShapeValuesSourceType;
+import org.elasticsearch.xpack.spatial.util.GeoTestUtils;
+
+import java.util.List;
+
+public class GeoHexAggregatorTests extends GeoGridAggregatorTestCase<InternalGeoHexGridBucket> {
+
+    @Override
+    protected List<SearchPlugin> getSearchPlugins() {
+        return List.of(new LocalStateSpatialPlugin());
+    }
+
+    @Override
+    protected List<ValuesSourceType> getSupportedValuesSourceTypes() {
+        return List.of(GeoShapeValuesSourceType.instance(), CoreValuesSourceType.GEOPOINT);
+    }
+
+    @Override
+    protected int randomPrecision() {
+        return randomIntBetween(0, H3.MAX_H3_RES);
+    }
+
+    @Override
+    protected String hashAsString(double lng, double lat, int precision) {
+        return H3.geoToH3Address(lat, lng, precision);
+    }
+
+    @Override
+    protected GeoGridAggregationBuilder createBuilder(String name) {
+        return new GeoHexGridAggregationBuilder(name);
+    }
+
+    @Override
+    protected Point randomPoint() {
+        return GeometryTestUtils.randomPoint();
+    }
+
+    @Override
+    protected GeoBoundingBox randomBBox() {
+        return GeoTestUtils.randomBBox();
+    }
+
+    @Override
+    protected Rectangle getTile(double lng, double lat, int precision) {
+        CellBoundary boundary = H3.h3ToGeoBoundary(hashAsString(lng, lat, precision));
+        double minLat = Double.POSITIVE_INFINITY;
+        double minLon = Double.POSITIVE_INFINITY;
+        double maxLat = Double.NEGATIVE_INFINITY;
+        double maxLon = Double.NEGATIVE_INFINITY;
+        for (int i = 0; i < boundary.numPoints(); i++) {
+            double boundaryLat = boundary.getLatLon(i).getLatDeg();
+            double boundaryLon = boundary.getLatLon(i).getLonDeg();
+            minLon = Math.min(minLon, boundaryLon);
+            maxLon = Math.max(maxLon, boundaryLon);
+            minLat = Math.min(minLat, boundaryLat);
+            maxLat = Math.max(maxLat, boundaryLat);
+        }
+        return new Rectangle(minLon, maxLon, maxLat, minLat);
+    }
+
+    @Override
+    protected AggregationBuilder createAggBuilderForTypeTest(MappedFieldType fieldType, String fieldName) {
+        return createBuilder("foo").field(fieldName);
+    }
+}

+ 66 - 0
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridTests.java

@@ -0,0 +1,66 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.util.CollectionUtils;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.plugins.SearchPlugin;
+import org.elasticsearch.search.aggregations.Aggregation;
+import org.elasticsearch.search.aggregations.InternalAggregations;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridTestCase;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGridBucket;
+import org.elasticsearch.xcontent.NamedXContentRegistry;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xpack.spatial.LocalStateSpatialPlugin;
+
+import java.util.List;
+import java.util.Map;
+
+public class GeoHexGridTests extends GeoGridTestCase<InternalGeoHexGridBucket, InternalGeoHexGrid> {
+
+    @Override
+    protected SearchPlugin registerPlugin() {
+        return new LocalStateSpatialPlugin();
+    }
+
+    @Override
+    protected List<NamedXContentRegistry.Entry> getNamedXContents() {
+        return CollectionUtils.appendToCopy(
+            super.getNamedXContents(),
+            new NamedXContentRegistry.Entry(
+                Aggregation.class,
+                new ParseField(GeoHexGridAggregationBuilder.NAME),
+                (p, c) -> ParsedGeoHexGrid.fromXContent(p, (String) c)
+            )
+        );
+    }
+
+    @Override
+    protected InternalGeoHexGrid createInternalGeoGrid(
+        String name,
+        int size,
+        List<InternalGeoGridBucket> buckets,
+        Map<String, Object> metadata
+    ) {
+        return new InternalGeoHexGrid(name, size, buckets, metadata);
+    }
+
+    @Override
+    protected InternalGeoHexGridBucket createInternalGeoGridBucket(Long key, long docCount, InternalAggregations aggregations) {
+        return new InternalGeoHexGridBucket(key, docCount, aggregations);
+    }
+
+    @Override
+    protected long longEncode(double lng, double lat, int precision) {
+        return H3.geoToH3(lat, lng, precision);
+    }
+
+    @Override
+    protected int randomPrecision() {
+        return randomIntBetween(0, H3.MAX_H3_RES);
+    }
+}

+ 143 - 0
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/spatial/80_geohex_grid.yml

@@ -0,0 +1,143 @@
+setup:
+  - do:
+      indices.create:
+        index: locations
+        body:
+          settings:
+            number_of_shards: 3
+          mappings:
+            properties:
+              location:
+                type: geo_point
+
+  - do:
+      bulk:
+        refresh: true
+        body:
+          - index:
+              _index: locations
+              _id: 1
+          - '{"location": "POINT(4.912350 52.374081)", "city": "Amsterdam", "name": "NEMO Science Museum"}'
+          - index:
+              _index: locations
+              _id: 2
+          - '{"location": "POINT(4.901618 52.369219)", "city": "Amsterdam", "name": "Museum Het Rembrandthuis"}'
+          - index:
+              _index: locations
+              _id: 3
+          - '{"location": "POINT(4.914722 52.371667)", "city": "Amsterdam", "name": "Nederlands Scheepvaartmuseum"}'
+          - index:
+              _index: locations
+              _id: 4
+          - '{"location": "POINT(4.405200 51.222900)", "city": "Antwerp", "name": "Letterenhuis"}'
+          - index:
+              _index: locations
+              _id: 5
+          - '{"location": "POINT(2.336389 48.861111)", "city": "Paris", "name": "Musée du Louvre"}'
+          - index:
+              _index: locations
+              _id: 6
+          - '{"location": "POINT(2.327000 48.860000)", "city": "Paris", "name": "Musée dOrsay"}'
+  - do:
+      indices.refresh: {}
+
+---
+"Test geohex_grid with defaults":
+
+  - do:
+      search:
+        index: locations
+        size: 0
+        body:
+          aggs:
+            grid:
+              geohex_grid:
+                field: location
+  - match: {hits.total.value:      6    }
+  - length: { aggregations.grid.buckets: 3 }
+  - match: { aggregations.grid.buckets.0.key: "85196953fffffff" }
+  - match: { aggregations.grid.buckets.0.doc_count: 3 }
+  - match: { aggregations.grid.buckets.1.key: "851fb467fffffff" }
+  - match: { aggregations.grid.buckets.1.doc_count: 2 }
+  - match: { aggregations.grid.buckets.2.key: "851fa4c7fffffff" }
+  - match: { aggregations.grid.buckets.2.doc_count: 1 }
+
+---
+"Test geohex_grid with precision":
+
+  - do:
+      search:
+        index: locations
+        size: 0
+        body:
+          aggs:
+            grid:
+              geohex_grid:
+                field: location
+                precision: 0
+  - match: { hits.total.value:      6    }
+  - length: { aggregations.grid.buckets: 2 }
+  - match: { aggregations.grid.buckets.0.key: "801ffffffffffff" }
+  - match: { aggregations.grid.buckets.0.doc_count: 4 }
+  - match: { aggregations.grid.buckets.1.key: "8019fffffffffff" }
+  - match: { aggregations.grid.buckets.1.doc_count: 2 }
+
+---
+"Test geohex_grid with size":
+
+  - do:
+      search:
+        index: locations
+        size: 0
+        body:
+          aggs:
+            grid:
+              geohex_grid:
+                field: location
+                size: 1
+  - match: {hits.total.value:      6    }
+  - length: { aggregations.grid.buckets: 1 }
+  - match: { aggregations.grid.buckets.0.key: "85196953fffffff" }
+  - match: { aggregations.grid.buckets.0.doc_count: 3 }
+
+---
+"Test geohex_grid with shard size":
+
+  - do:
+      search:
+        index: locations
+        size: 0
+        body:
+          aggs:
+            grid:
+              geohex_grid:
+                field: location
+                shard_size: 10
+  - match: {hits.total.value:      6    }
+  - length: { aggregations.grid.buckets: 3 }
+  - match: { aggregations.grid.buckets.0.key: "85196953fffffff" }
+  - match: { aggregations.grid.buckets.0.doc_count: 3 }
+  - match: { aggregations.grid.buckets.1.key: "851fb467fffffff" }
+  - match: { aggregations.grid.buckets.1.doc_count: 2 }
+  - match: { aggregations.grid.buckets.2.key: "851fa4c7fffffff" }
+  - match: { aggregations.grid.buckets.2.doc_count: 1 }
+
+---
+"Test geohex_grid with bounds":
+
+  - do:
+      search:
+        index: locations
+        size: 0
+        body:
+          aggs:
+            grid:
+              geohex_grid:
+                field: location
+                bounds:
+                  top_left: "52.4, 4.9"
+                  bottom_right: "52.3, 5.0"
+  - match: {hits.total.value:      6    }
+  - length: { aggregations.grid.buckets: 1 }
+  - match: { aggregations.grid.buckets.0.key: "85196953fffffff" }
+  - match: { aggregations.grid.buckets.0.doc_count: 3 }