ebadyano: lgtm
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

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()`.
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"])

pip install -e .
parse_log_pytest
pytest
pytest pytest-benchmark
apt-get update apt-get install -y gcc python3-pip
3.6
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs @ file:///opt/conda/conda-bld/attrs_1642510447205/work
certifi==2021.5.30
elasticsearch==6.2.0
-e git+https://github.com/elastic/rally.git@ddc1e7a8873e5336809b7e6cfe3f434175a08ff3#egg=esrally
importlib-metadata @ file:///tmp/build/80754af9/importlib-metadata_1631916693255/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
Jinja2==2.9.5
jsonschema==2.5.1
MarkupSafe==2.0.1
more-itertools @ file:///tmp/build/80754af9/more-itertools_1637733554872/work
packaging @ file:///tmp/build/80754af9/packaging_1637314298585/work
pluggy @ file:///tmp/build/80754af9/pluggy_1615976315926/work
psutil==5.4.0
py @ file:///opt/conda/conda-bld/py_1644396412707/work
py-cpuinfo==3.2.0
pyparsing @ file:///tmp/build/80754af9/pyparsing_1635766073266/work
pytest==6.2.4
pytest-benchmark==3.4.1
tabulate==0.8.1
thespian==3.9.3
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
typing_extensions @ file:///opt/conda/conda-bld/typing_extensions_1647553014482/work
urllib3==1.22
zipp @ file:///tmp/build/80754af9/zipp_1633618647012/work

name: rally
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=21.4.0=pyhd3eb1b0_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - importlib-metadata=4.8.1=py36h06a4308_0
  - importlib_metadata=4.8.1=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - more-itertools=8.12.0=pyhd3eb1b0_0
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=21.3=pyhd3eb1b0_0
  - pip=21.2.2=py36h06a4308_0
  - pluggy=0.13.1=py36h06a4308_0
  - py=1.11.0=pyhd3eb1b0_0
  - pyparsing=3.0.4=pyhd3eb1b0_0
  - pytest=6.2.4=py36h06a4308_2
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - typing_extensions=4.1.1=pyh06a4308_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.6.0=pyhd3eb1b0_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - elasticsearch==6.2.0
      - jinja2==2.9.5
      - jsonschema==2.5.1
      - markupsafe==2.0.1
      - psutil==5.4.0
      - py-cpuinfo==3.2.0
      - pytest-benchmark==3.4.1
      - tabulate==0.8.1
      - thespian==3.9.3
      - urllib3==1.22
prefix: /opt/conda/envs/rally

tests/driver/runner_test.py::QueryRunnerTests::test_query_hits_total_as_number tests/driver/runner_test.py::QueryRunnerTests::test_query_match_all tests/driver/runner_test.py::QueryRunnerTests::test_query_match_only_request_body_defined tests/driver/runner_test.py::QueryRunnerTests::test_scroll_query_cannot_clear_scroll tests/driver/runner_test.py::QueryRunnerTests::test_scroll_query_early_termination tests/driver/runner_test.py::QueryRunnerTests::test_scroll_query_only_one_page tests/driver/runner_test.py::QueryRunnerTests::test_scroll_query_only_one_page_only_request_body_defined tests/driver/runner_test.py::QueryRunnerTests::test_scroll_query_request_all_pages tests/driver/runner_test.py::QueryRunnerTests::test_scroll_query_with_explicit_number_of_pages
tests/driver/runner_test.py::RegisterRunnerTests::test_multi_cluster_runner_class_should_be_wrapped tests/driver/runner_test.py::RegisterRunnerTests::test_multi_cluster_runner_class_with_context_manager_should_be_wrapped_with_context_manager_enabled tests/driver/runner_test.py::RegisterRunnerTests::test_runner_function_should_be_wrapped tests/driver/runner_test.py::RegisterRunnerTests::test_single_cluster_runner_class_should_be_wrapped tests/driver/runner_test.py::RegisterRunnerTests::test_single_cluster_runner_class_with_context_manager_should_be_wrapped_with_context_manager_enabled tests/driver/runner_test.py::BulkIndexRunnerTests::test_bulk_index_error tests/driver/runner_test.py::BulkIndexRunnerTests::test_bulk_index_missing_params tests/driver/runner_test.py::BulkIndexRunnerTests::test_bulk_index_success_with_metadata tests/driver/runner_test.py::BulkIndexRunnerTests::test_bulk_index_success_without_metadata tests/driver/runner_test.py::BulkIndexRunnerTests::test_mixed_bulk_with_detailed_stats tests/driver/runner_test.py::BulkIndexRunnerTests::test_mixed_bulk_with_simple_stats tests/driver/runner_test.py::PutPipelineRunnerTests::test_create_pipeline tests/driver/runner_test.py::PutPipelineRunnerTests::test_param_body_mandatory tests/driver/runner_test.py::PutPipelineRunnerTests::test_param_id_mandatory tests/driver/runner_test.py::ClusterHealthRunnerTests::test_accepts_better_cluster_status tests/driver/runner_test.py::ClusterHealthRunnerTests::test_rejects_relocating_shards tests/driver/runner_test.py::ClusterHealthRunnerTests::test_rejects_unknown_cluster_status tests/driver/runner_test.py::ClusterHealthRunnerTests::test_waits_for_expected_cluster_status tests/driver/runner_test.py::CreateIndexRunnerTests::test_creates_multiple_indices tests/driver/runner_test.py::CreateIndexRunnerTests::test_param_indices_mandatory tests/driver/runner_test.py::DeleteIndexRunnerTests::test_deletes_all_indices tests/driver/runner_test.py::DeleteIndexRunnerTests::test_deletes_existing_indices tests/driver/runner_test.py::CreateIndexTemplateRunnerTests::test_create_index_templates tests/driver/runner_test.py::CreateIndexTemplateRunnerTests::test_param_templates_mandatory tests/driver/runner_test.py::DeleteIndexTemplateRunnerTests::test_deletes_all_index_templates tests/driver/runner_test.py::DeleteIndexTemplateRunnerTests::test_deletes_only_existing_index_templates tests/driver/runner_test.py::DeleteIndexTemplateRunnerTests::test_param_templates_mandatory tests/driver/runner_test.py::CreateMlDatafeedTests::test_create_ml_datafeed tests/driver/runner_test.py::DeleteMlDatafeedTests::test_delete_ml_datafeed tests/driver/runner_test.py::StartMlDatafeedTests::test_start_ml_datafeed_with_body tests/driver/runner_test.py::StartMlDatafeedTests::test_start_ml_datafeed_with_params tests/driver/runner_test.py::StopMlDatafeedTests::test_stop_ml_datafeed tests/driver/runner_test.py::CreateMlJobTests::test_create_ml_job tests/driver/runner_test.py::DeleteMlJobTests::test_delete_ml_job tests/driver/runner_test.py::OpenMlJobTests::test_open_ml_job tests/driver/runner_test.py::CloseMlJobTests::test_close_ml_job tests/driver/runner_test.py::RawRequestRunnerTests::test_issue_create_index tests/driver/runner_test.py::RawRequestRunnerTests::test_issue_delete_index tests/driver/runner_test.py::RawRequestRunnerTests::test_issue_msearch tests/driver/runner_test.py::RawRequestRunnerTests::test_issue_request_with_defaults tests/driver/runner_test.py::ShrinkIndexTests::test_shrink_index_derives_shrink_node tests/driver/runner_test.py::ShrinkIndexTests::test_shrink_index_with_shrink_node tests/driver/runner_test.py::RetryTests::test_assumes_success_if_runner_returns_non_dict tests/driver/runner_test.py::RetryTests::test_does_not_retry_on_application_error_if_not_wanted tests/driver/runner_test.py::RetryTests::test_does_not_retry_on_timeout_if_not_wanted tests/driver/runner_test.py::RetryTests::test_is_does_not_retry_on_success tests/driver/runner_test.py::RetryTests::test_is_transparent_on_application_error_when_no_retries tests/driver/runner_test.py::RetryTests::test_is_transparent_on_exception_when_no_retries tests/driver/runner_test.py::RetryTests::test_is_transparent_on_success_when_no_retries tests/driver/runner_test.py::RetryTests::test_retries_mixed_timeout_and_application_errors tests/driver/runner_test.py::RetryTests::test_retries_on_application_error_if_wanted tests/driver/runner_test.py::RetryTests::test_retries_on_timeout_if_wanted_and_raises_if_no_recovery tests/driver/runner_test.py::RetryTests::test_retries_on_timeout_if_wanted_and_returns_first_call
esrally/driver/runner.py
esrally/driver/runner.py