Repository: elastic/rally

Issue:
Extract total hits as either structured object or number
With https://github.com/elastic/elasticsearch/pull/35849, Elasticsearch will return total hits as an object, e.g.:

```json
{
  "hits": {
    "total": {
      "value": 100,
      "relation": "eq"
    }
  }
}
```

but so far, Rally assumes the following structure:

```json
{
  "hits": {
    "total": 100
  }
}
```

Rally needs to be able to extract the information about the total hits in both scenarios (hits as int and as structured object). For backwards compatibility we should store meta-data in Rally in two properties: 

* `hits` (as is) which contains the actual value (extracted either from `hits.total` or `hits.total.value`)
* `hits_relation` (new) which contains either `"eq"` if `hits.total` is a number or the value of `hits.total.relation`.

Implementation note: This requires changes in `esrally.driver.runner.Query#request_body_query()`.

Discussion:
ebadyano: lgtm

Patch:
diff --git a/esrally/driver/runner.py b/esrally/driver/runner.py
index d9044ae8..b8007edc 100644
--- a/esrally/driver/runner.py
+++ b/esrally/driver/runner.py
@@ -551,6 +551,7 @@ class Query(Runner):
                   Always 1 for normal queries and the number of retrieved pages for scroll queries.
     * ``unit``: The unit in which to interpret ``weight``. Always "ops".
     * ``hits``: Total number of hits for this operation.
+    * ``hits_relation``: whether ``hits`` is accurate (``eq``) or a lower bound of the actual hit count (``gte``).
     * ``timed_out``: Whether the search has timed out. For scroll queries, this flag is ``True`` if the flag was ``True`` for any of the
                      queries issued.
 
@@ -580,10 +581,17 @@ class Query(Runner):
             body=mandatory(params, "body", self),
             **request_params)
         hits = r["hits"]["total"]
+        if isinstance(hits, dict):
+            hits_total = hits["value"]
+            hits_relation = hits["relation"]
+        else:
+            hits_total = hits
+            hits_relation = "eq"
         return {
             "weight": 1,
             "unit": "ops",
-            "hits": hits,
+            "hits": hits_total,
+            "hits_relation": hits_relation,
             "timed_out": r["timed_out"],
             "took": r["took"]
         }
@@ -633,6 +641,8 @@ class Query(Runner):
             "weight": retrieved_pages,
             "pages": retrieved_pages,
             "hits": hits,
+            # as Rally determines the number of hits in a scroll, the result is always accurate.
+            "hits_relation": "eq",
             "unit": "pages",
             "timed_out": timed_out,
             "took": took


Test patch:
diff --git a/tests/driver/runner_test.py b/tests/driver/runner_test.py
index a1754993..2aa031fd 100644
--- a/tests/driver/runner_test.py
+++ b/tests/driver/runner_test.py
@@ -555,6 +555,48 @@ class BulkIndexRunnerTests(TestCase):
 class QueryRunnerTests(TestCase):
     @mock.patch("elasticsearch.Elasticsearch")
     def test_query_match_only_request_body_defined(self, es):
+        es.search.return_value = {
+            "timed_out": False,
+            "took": 5,
+            "hits": {
+                "total": {
+                    "value": 1,
+                    "relation": "gte"
+                },
+                "hits": [
+                    {
+                        "some-doc-1"
+                    },
+                    {
+                        "some-doc-2"
+                    }
+                ]
+            }
+        }
+
+        query_runner = runner.Query()
+
+        params = {
+            "body": {
+                "query": {
+                    "match_all": {}
+                }
+            }
+        }
+
+        with query_runner:
+            result = query_runner(es, params)
+
+        self.assertEqual(1, result["weight"])
+        self.assertEqual("ops", result["unit"])
+        self.assertEqual(1, result["hits"])
+        self.assertEqual("gte", result["hits_relation"])
+        self.assertFalse(result["timed_out"])
+        self.assertEqual(5, result["took"])
+        self.assertFalse("error-type" in result)
+
+    @mock.patch("elasticsearch.Elasticsearch")
+    def test_query_hits_total_as_number(self, es):
         es.search.return_value = {
             "timed_out": False,
             "took": 5,
@@ -587,6 +629,7 @@ class QueryRunnerTests(TestCase):
         self.assertEqual(1, result["weight"])
         self.assertEqual("ops", result["unit"])
         self.assertEqual(2, result["hits"])
+        self.assertEqual("eq", result["hits_relation"])
         self.assertFalse(result["timed_out"])
         self.assertEqual(5, result["took"])
         self.assertFalse("error-type" in result)
@@ -597,7 +640,10 @@ class QueryRunnerTests(TestCase):
             "timed_out": False,
             "took": 5,
             "hits": {
-                "total": 2,
+                "total": {
+                    "value": 2,
+                    "relation": "eq"
+                },
                 "hits": [
                     {
                         "some-doc-1"
@@ -628,6 +674,7 @@ class QueryRunnerTests(TestCase):
         self.assertEqual(1, result["weight"])
         self.assertEqual("ops", result["unit"])
         self.assertEqual(2, result["hits"])
+        self.assertEqual("eq", result["hits_relation"])
         self.assertFalse(result["timed_out"])
         self.assertEqual(5, result["took"])
         self.assertFalse("error-type" in result)
@@ -678,6 +725,7 @@ class QueryRunnerTests(TestCase):
         self.assertEqual(1, results["weight"])
         self.assertEqual(1, results["pages"])
         self.assertEqual(2, results["hits"])
+        self.assertEqual("eq", results["hits_relation"])
         self.assertEqual(4, results["took"])
         self.assertEqual("pages", results["unit"])
         self.assertFalse(results["timed_out"])
@@ -726,6 +774,7 @@ class QueryRunnerTests(TestCase):
         self.assertEqual(1, results["weight"])
         self.assertEqual(1, results["pages"])
         self.assertEqual(2, results["hits"])
+        self.assertEqual("eq", results["hits_relation"])
         self.assertEqual(4, results["took"])
         self.assertEqual("pages", results["unit"])
         self.assertFalse(results["timed_out"])
@@ -790,6 +839,7 @@ class QueryRunnerTests(TestCase):
         self.assertEqual(2, results["weight"])
         self.assertEqual(2, results["pages"])
         self.assertEqual(3, results["hits"])
+        self.assertEqual("eq", results["hits_relation"])
         self.assertEqual(79, results["took"])
         self.assertEqual("pages", results["unit"])
         self.assertTrue(results["timed_out"])
@@ -847,6 +897,7 @@ class QueryRunnerTests(TestCase):
         self.assertEqual(2, results["weight"])
         self.assertEqual(2, results["pages"])
         self.assertEqual(1, results["hits"])
+        self.assertEqual("eq", results["hits_relation"])
         self.assertEqual("pages", results["unit"])
         self.assertEqual(55, results["took"])
         self.assertFalse("error-type" in results)
@@ -902,6 +953,7 @@ class QueryRunnerTests(TestCase):
         self.assertEqual(2, results["weight"])
         self.assertEqual(2, results["pages"])
         self.assertEqual(1, results["hits"])
+        self.assertEqual("eq", results["hits_relation"])
         self.assertEqual("pages", results["unit"])
         self.assertEqual(55, results["took"])
         self.assertFalse("error-type" in results)
@@ -967,6 +1019,7 @@ class QueryRunnerTests(TestCase):
         self.assertEqual(2, results["weight"])
         self.assertEqual(2, results["pages"])
         self.assertEqual(4, results["hits"])
+        self.assertEqual("eq", results["hits_relation"])
         self.assertEqual(900, results["took"])
         self.assertEqual("pages", results["unit"])
         self.assertFalse(results["timed_out"])