diff --git a/.cursor/rules/run-tests.mdc b/.cursor/rules/run-tests.mdc new file mode 100644 index 000000000..89f33be1f --- /dev/null +++ b/.cursor/rules/run-tests.mdc @@ -0,0 +1,15 @@ +--- +description: Run tests via make and always outside the sandbox +alwaysApply: true +--- + +# Running Tests + +When running tests in this project: + +1. **Use the Make command** — Do not invoke pytest (or uv run pytest) directly. Use the project's Make targets instead: + - **Unit tests:** `make test` (or `make test-3.10`, etc. for a specific Python version) + - **Integration tests:** `make it` + - **With options:** Pass pytest options via `OPTS`, e.g. `make it OPTS="-x --ff"` for fail-fast and run failed tests first. + +2. **Run outside the sandbox** — Test commands must be executed with full permissions (e.g. request `all` or disable sandbox) so that Docker, network, and file access work correctly. Do not run test commands inside a restricted sandbox. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d87a3a8be..c0fc87045 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,3 +154,58 @@ jobs: slack_channel: ${{ secrets.SLACK_CHANNEL }} status: FAILED persist-credentials: false + + + test-tracks: + runs-on: ubuntu-22.04 + + steps: + - name: Check public IP address + run: curl -4s ifconfig.me + continue-on-error: true + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.13" + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + version: "0.8.22" + - name: Free Disk Space + continue-on-error: true + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be + with: + android: true + dotnet: true + haskell: true + large-packages: false + docker-images: false + swap-storage: false + tool-cache: false + - name: Check disk space before + run: df -h + - name: Run tests + run: make it_tracks + timeout-minutes: 160 + env: + # elastic/endpoint fetches assets from GitHub, authenticate to avoid + # being rate limited + ASSETS_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check disk space after + run: df -h + - uses: elastic/es-perf-github-status@v2 + if: ${{ failure() && ( github.event_name == 'schedule' || ( github.event_name == 'push' && github.ref_name == env.DEFAULT_BRANCH ) ) }} + with: + slack_bot_token: ${{ secrets.SLACK_BOT_TOKEN }} + slack_channel: ${{ secrets.SLACK_CHANNEL }} + status: FAILED + # Artifact will show up under "Artifacts" in the "Summary" page of runs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: rally-tracks-logs + path: | + /home/runner/.rally/logs/ + /home/runner/.rally/benchmarks/races/**/*.log + if-no-files-found: error + persist-credentials: false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..22f7f0236 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM elastic/rally:2.12.0 AS rally + +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + +COPY pyproject.toml /src/pyproject.toml +COPY esrally/_version.py /src/esrally/_version.py +COPY esrally/__init__.py /src/esrally/__init__.py +COPY README.md /src/README.md +RUN uv pip install --upgrade /src/ + +COPY . /src +RUN uv pip install --upgrade /src/ + +ENTRYPOINT ["esrally"] +CMD ["--help"] diff --git a/Makefile b/Makefile index 9d7dd6a57..34b258d16 100644 --- a/Makefile +++ b/Makefile @@ -17,18 +17,27 @@ SHELL := /bin/bash +DEFAULT_PY_VERSION = $(shell jq -r '.python_versions.DEFAULT_PY_VER' .ci/variables.json) + + # We assume an active virtualenv for development -VIRTUAL_ENV := $(or $(VIRTUAL_ENV),.venv$(if $(PY_VERSION),-$(PY_VERSION))) -VENV_ACTIVATE_FILE := $(VIRTUAL_ENV)/bin/activate + +VENV_DIR = $(if $(PY_VERSION),.venv-$(PY_VERSION),.venv) + +VENV_ACTIVATE_FILE := $(VENV_DIR)/bin/activate VENV_ACTIVATE := source $(VENV_ACTIVATE_FILE) -PY_VERSION := $(shell jq -r '.python_versions.DEFAULT_PY_VER' .ci/variables.json) -export UV_PYTHON := $(PY_VERSION) -export UV_PROJECT_ENVIRONMENT := $(VIRTUAL_ENV) +export UV_PYTHON = $(or $(PY_VERSION),$(DEFAULT_PY_VERSION)) + +export UV_PROJECT_ENVIRONMENT = $(VENV_DIR) PRE_COMMIT_HOOK_PATH := .git/hooks/pre-commit -LOG_CI_LEVEL := INFO +TESTS = tests/ +LOG_LEVEL = +OPTS = $(if $(LOG_LEVEL), --log-cli-level=$(LOG_LEVEL),) +RUN_TESTS ?= uv run -- pytest $(OPTS) $(TESTS) + # --- Global goals --- @@ -85,9 +94,7 @@ uv: # It adds a list of packages to the project. uv-add: -ifndef ARGS - $(error Missing arguments. Use make uv-add ARGS="...") -endif + $(if args,,$(error Missing arguments. Use make uv-add ARGS="...")) uv add $$ARGS # It updates the uv lock file. @@ -106,11 +113,11 @@ $(VENV_ACTIVATE_FILE): # It delete the project virtual environment from disk. clean-venv: - rm -fR '$(VIRTUAL_ENV)' + rm -fR '$(VENV_DIR)' # It installs the Rally PyTest plugin. install_pytest_rally_plugin: venv - $(VENV_ACTIVATE); uv pip install 'pytest-rally @ git+https://github.com/elastic/pytest-rally.git' + $(VENV_ACTIVATE); pip install 'pytest-rally @ git+https://github.com/elastic/pytest-rally.git' # Old legacy alias goals install: venv @@ -140,7 +147,7 @@ lint: venv uv run -- pre-commit run --all-files # It run all linters on changed files using pre-commit. -precommit pre-commit: venv +pre-commit: venv uv run -- pre-commit run # It install a pre-commit hook in the project .git dir so modified files are checked before creating every commit. @@ -174,45 +181,65 @@ clean-docs: venv # It runs unit tests using the default python interpreter version. test: venv - uv run -- pytest -s $(or $(ARGS), tests/) + $(RUN_TESTS) # It runs unit tests using all supported python versions. test-all: test-3.10 test-3.11 test-3.12 test-3.13 # It runs unit tests using Python 3.10. -test-3.10: - $(MAKE) test PY_VERSION=3.10 +test-3.10: PY_VERSION = 3.10 +test-3.10: venv + $(RUN_TESTS) # It runs unit tests using Python 3.11. +test-3.11: PY_VERSION = 3.11 test-3.11: - $(MAKE) test PY_VERSION=3.11 + $(RUN_TESTS) # It runs unit tests using Python 3.12. +test-3.12: PY_VERSION = 3.12 test-3.12: - $(MAKE) test PY_VERSION=3.12 + $(RUN_TESTS) # It runs unit tests using Python 3.13. +test-3.13: PY_VERSION = 3.13 test-3.13: - $(MAKE) test PY_VERSION=3.13 + $(RUN_TESTS) + +# It runs unit tests using Python 3.14. +test-3.14: PY_VERSION = 3.14 +test-3.14: + $(RUN_TESTS) # --- Integration tests goals --- # It runs integration tests. +it: TESTS = it/ it: venv - $(MAKE) test ARGS=$(or $(ARGS),it/) + $(RUN_TESTS) -# It runs serverless integration tests. +# It runs track_repo_compatibility integration tests for serverless tracks. +it_serverless: TESTS = it/track_repo_compatibility +it_serverless: OPTS = --track-repository-test-directory=it_tracks_serverless it_serverless: install_pytest_rally_plugin - uv run -- pytest -s --log-cli-level=$(LOG_CI_LEVEL) --track-repository-test-directory=it_tracks_serverless it/track_repo_compatibility $(ARGS) + $(RUN_TESTS) -# It runs rally_tracks_compat integration tests. +# It runs track_repo_compatibility integration tests. +it_tracks_compat: TESTS = it/track_repo_compatibility it_tracks_compat: install_pytest_rally_plugin - uv run -- pytest -s --log-cli-level=$(LOG_CI_LEVEL) it/track_repo_compatibility $(ARGS) + $(RUN_TESTS) + +# It runs rally_tracks integration tests. +it_tracks: TESTS = it/tracks_test.py +it_tracks: install_pytest_rally_plugin + $(RUN_TESTS) # It runs benchmark tests. +benchmark: TESTS = benchmarks/ benchmark: venv - $(MAKE) test ARGS=benchmarks/ + $(RUN_TESTS) + # --- Release goals --- diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..e30adce77 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,47 @@ +services: + + es01: + image: docker.elastic.co/elasticsearch/elasticsearch:${ES_VERSION:-9.3.1} + container_name: es01 + networks: + - elastic-net + environment: + - node.name=es01 + - discovery.type=single-node + - xpack.security.enabled=false # Disabled for simpler benchmarking setup + - "ES_JAVA_OPTS=-Xms1g -Xmx1g" + ulimits: + memlock: + soft: -1 + hard: -1 + volumes: + - es_data:/usr/share/elasticsearch-${ES_VERSION:-latest}/data + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"\\|\"status\":\"yellow\"'"] + interval: 10s + timeout: 5s + retries: 5 + + rally: + build: + context: . + dockerfile: Dockerfile + container_name: rally-driver + depends_on: + es01: + condition: service_healthy + volumes: + - rally_data:/rally/.rally + # The command below lists tracks by default so the container doesn't exit immediately. + # You will likely override this via CLI to run actual benchmarks. + # command: "esrally list tracks" + networks: + - elastic-net + +networks: + elastic-net: + driver: bridge + +volumes: + es_data: + rally_data: diff --git a/esrally/client/factory.py b/esrally/client/factory.py index 9fe0186dd..3f46b0280 100644 --- a/esrally/client/factory.py +++ b/esrally/client/factory.py @@ -320,7 +320,10 @@ def wait_for_rest_layer(es, max_attempts=40): except TlsError as e: raise exceptions.SystemSetupError("Could not connect to cluster via HTTPS. Are you sure this is an HTTPS endpoint?", e) except ConnectionError as e: - if "ProtocolError" in str(e): + err_str = str(e) + # Only treat as permanent scheme error when the message clearly indicates HTTP/HTTPS mismatch. + # Connection reset / connection aborted (e.g. REST not ready yet) are transient and should be retried. + if "ProtocolError" in err_str and ("HTTPS" in err_str or "HTTP request to an HTTPS" in err_str): raise exceptions.SystemSetupError( "Received a protocol error. Are you sure you're using the correct scheme (HTTP or HTTPS)?", e ) diff --git a/esrally/log.py b/esrally/log.py index d8879b161..4a740a3b1 100644 --- a/esrally/log.py +++ b/esrally/log.py @@ -170,7 +170,6 @@ def install_default_log_config(): io.ensure_dir(paths.logs()) -# pylint: disable=unused-argument def configure_file_handler(*, filename: str, encoding: str = "UTF-8", delay: bool = False, **kwargs: Any) -> logging.Handler: """ Configures the WatchedFileHandler supporting expansion of `~` and `${LOG_PATH}` to the user's home and the log path respectively. diff --git a/esrally/mechanic/launcher.py b/esrally/mechanic/launcher.py index 41ca46ab7..b94f37251 100644 --- a/esrally/mechanic/launcher.py +++ b/esrally/mechanic/launcher.py @@ -14,10 +14,14 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import errno +import hashlib import logging import os import shlex +import socket import subprocess +import time as std_time import psutil @@ -25,6 +29,52 @@ from esrally.mechanic import cluster, java_resolver from esrally.utils import io, opts, process +# Cached compose command: "docker compose" (v2) or "docker-compose" (v1) +_compose_cmd = None + + +def wait_until_port_is_free(port_number, timeout=120): + """ + Wait until nothing is listening on the given port (connection refused). + Avoids Docker port-binding conflicts when the port is already in use on the host. + """ + logger = logging.getLogger(__name__) + deadline = std_time.perf_counter() + timeout + while std_time.perf_counter() < deadline: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + result = sock.connect_ex(("127.0.0.1", port_number)) + if result == errno.ECONNREFUSED: + logger.debug("Port [%s] is free.", port_number) + return + except OSError: + pass + std_time.sleep(0.5) + raise exceptions.LaunchError( + "Port [%s] is still in use after [%s] seconds. " + "Free the port or stop the process using it to avoid Docker port-forwarding conflicts." % (port_number, timeout) + ) + + +def _compose_command(): + """Return the docker compose command to use: 'docker compose' (v2) or 'docker-compose' (v1).""" + global _compose_cmd + if _compose_cmd is not None: + return _compose_cmd + try: + ret = subprocess.call( + shlex.split("docker compose version"), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if ret == 0: + _compose_cmd = "docker compose" + return _compose_cmd + except (OSError, ValueError): + pass + _compose_cmd = "docker-compose" + return _compose_cmd + class DockerLauncher: # May download a Docker image and that can take some time @@ -41,6 +91,10 @@ def start(self, node_configurations): node_name = node_configuration.node_name host_name = node_configuration.ip binary_path = node_configuration.binary_path + http_port = getattr(node_configuration, "http_port", None) + if http_port is not None: + self.logger.info("Ensuring port [%s] is free before starting Docker (avoid port conflict).", http_port) + wait_until_port_is_free(http_port) self.logger.info("Starting node [%s] in Docker.", node_name) self._start_process(binary_path) node_telemetry = [ @@ -53,22 +107,29 @@ def start(self, node_configurations): return nodes def _start_process(self, binary_path): - compose_cmd = self._docker_compose(binary_path, "up -d") + compose_file = os.path.join(binary_path, "docker-compose.yml") + compose_dir = binary_path + # Unique project name so multiple races do not collide (default name "install" is shared by all). + project_name = "rally-" + hashlib.sha256(compose_dir.encode()).hexdigest()[:16] + compose_cmd = self._docker_compose(compose_file, compose_dir, project_name, "up -d") + self.logger.info("Running: %s", compose_cmd) ret = process.run_subprocess_with_logging(compose_cmd) if ret != 0: msg = f"Docker daemon startup failed with exit code [{ret}]" logging.error(msg) raise exceptions.LaunchError(msg) - container_id = self._get_container_id(binary_path) + container_id = self._get_container_id(compose_file, compose_dir, project_name) self._wait_for_healthy_running_container(container_id, DockerLauncher.PROCESS_WAIT_TIMEOUT_SECONDS) - def _docker_compose(self, compose_config, cmd): - return "docker-compose -f {} {}".format(os.path.join(compose_config, "docker-compose.yml"), cmd) + def _docker_compose(self, compose_file, compose_dir, project_name, cmd): + compose_cmd = _compose_command() + # Use -f, --project-directory, and -p so the project is unambiguous and paths resolve correctly. + return f'{compose_cmd} -f "{compose_file}" --project-directory "{compose_dir}" -p {project_name} {cmd}' - def _get_container_id(self, compose_config): - compose_ps_cmd = self._docker_compose(compose_config, "ps -q") + def _get_container_id(self, compose_file, compose_dir, project_name): + compose_ps_cmd = self._docker_compose(compose_file, compose_dir, project_name, "ps -q") return process.run_subprocess_with_output(compose_ps_cmd)[0] def _wait_for_healthy_running_container(self, container_id, timeout): @@ -92,7 +153,10 @@ def stop(self, nodes, metrics_store=None): telemetry.add_metadata_for_node(metrics_store, node.node_name, node.host_name) node.telemetry.detach_from_node(node, running=True) - process.run_subprocess_with_logging(self._docker_compose(node.binary_path, "down")) + compose_dir = node.binary_path + compose_file = os.path.join(compose_dir, "docker-compose.yml") + project_name = "rally-" + hashlib.sha256(compose_dir.encode()).hexdigest()[:16] + process.run_subprocess_with_logging(self._docker_compose(compose_file, compose_dir, project_name, "down")) if metrics_store is not None: node.telemetry.detach_from_node(node, running=False) node.telemetry.store_system_metrics(node, metrics_store) diff --git a/esrally/mechanic/provisioner.py b/esrally/mechanic/provisioner.py index 0054683d9..163297842 100644 --- a/esrally/mechanic/provisioner.py +++ b/esrally/mechanic/provisioner.py @@ -58,7 +58,18 @@ def docker(cfg: types.Config, car, ip, http_port, target_root, node_name): class NodeConfiguration: - def __init__(self, build_type, car_runtime_jdks, car_provides_bundled_jdk, ip, node_name, node_root_path, binary_path, data_paths): + def __init__( + self, + build_type, + car_runtime_jdks, + car_provides_bundled_jdk, + ip, + node_name, + node_root_path, + binary_path, + data_paths, + http_port=None, + ): self.build_type = build_type self.car_runtime_jdks = car_runtime_jdks self.car_provides_bundled_jdk = car_provides_bundled_jdk @@ -67,9 +78,10 @@ def __init__(self, build_type, car_runtime_jdks, car_provides_bundled_jdk, ip, n self.node_root_path = node_root_path self.binary_path = binary_path self.data_paths = data_paths + self.http_port = http_port def as_dict(self): - return { + out = { "build-type": self.build_type, "car-runtime-jdks": self.car_runtime_jdks, "car-provides-bundled-jdk": self.car_provides_bundled_jdk, @@ -79,6 +91,9 @@ def as_dict(self): "binary-path": self.binary_path, "data-paths": self.data_paths, } + if self.http_port is not None: + out["http-port"] = self.http_port + return out @staticmethod def from_dict(d): @@ -91,6 +106,7 @@ def from_dict(d): d["node-root-path"], d["binary-path"], d["data-paths"], + d.get("http-port"), ) @@ -496,6 +512,7 @@ def prepare(self, binary): self.node_root_dir, self.binary_path, self.data_paths, + http_port=self.http_port, ) def docker_vars(self, mounts): diff --git a/esrally/track/loader.py b/esrally/track/loader.py index 5f9216272..746dcbb8b 100644 --- a/esrally/track/loader.py +++ b/esrally/track/loader.py @@ -24,7 +24,8 @@ import sys import tempfile import urllib.error -from collections.abc import Callable, Generator +from collections.abc import Callable, Generator, Iterable +from typing import TypedDict import jinja2 import jinja2.exceptions @@ -149,6 +150,11 @@ def tracks(cfg: types.Config): def list_tracks(cfg: types.Config): available_tracks = tracks(cfg) + format = cfg.opts("system", "list.races.format", default_value="text", mandatory=False) + if format == "json": + console.println(json.dumps(tracks_list_to_json(available_tracks), indent=2, sort_keys=True)) + return + only_auto_generated_challenges = all(t.default_challenge.auto_generated for t in available_tracks) data = [] @@ -1860,3 +1866,51 @@ def parse_operation(self, op_spec, error_ctx="operations"): return track.Operation(name=op_name, meta_data=meta_data, operation_type=op_type_name, params=params, param_source=param_source) except exceptions.InvalidSyntax as e: raise TrackSyntaxError("Invalid operation [%s]: %s" % (op_name, str(e))) + + +# This file has been generated by running the command 'esrally track list --format.json'. +# It contains a list of all tracks that are available in Rally's default track repository. +# We use it to run tests against all tracks without having to clone the default track repository +# and parse the track definitions at runtime. This makes the tests more robust and faster to execute. +TRACKS_FILE = os.path.join(os.path.dirname(__file__), "resources", "tracks.json") + + +class TrackJson(TypedDict): + """Structure of a track as returned by 'esrally track list --format.json'.""" + + name: str + description: str + number_of_documents: int + compressed_size_in_bytes: int | None = None + uncompressed_size_in_bytes: int | None = None + + +class ListTracksJson(TypedDict): + """Structure of the output of 'esrally track list --format.json'.""" + + rally_version: str + tracks: list[TrackJson] + + +def load_tracks_file(file_path: str = TRACKS_FILE) -> ListTracksJson: + """Loads the tracks from the given file path.""" + with open(file_path) as fd: + tracks = json.load(fd)["tracks"] + return ListTracksJson(rally_version=version.version(), tracks=tracks) + + +def tracks_list_to_json(tack_list: Iterable[track.Track]) -> ListTracksJson: + """Returns the list of tracks as returned by 'esrally track list --format.json'.""" + return { + "rally_version": version.version(), + "tracks": [ + { + "name": t.name, + "description": t.description, + "number_of_documents": t.number_of_documents, + "compressed_size_in_bytes": t.compressed_size_in_bytes, + "uncompressed_size_in_bytes": t.uncompressed_size_in_bytes, + } + for t in tack_list + ], + } diff --git a/esrally/track/resources/tracks.json b/esrally/track/resources/tracks.json new file mode 100644 index 000000000..a41e0fd0f --- /dev/null +++ b/esrally/track/resources/tracks.json @@ -0,0 +1,263 @@ +{ + "tracks": [ + { + "compressed_size_in_bytes": 265208777, + "description": "POIs from Geonames", + "name": "geonames", + "number_of_documents": 11396503, + "uncompressed_size_in_bytes": 3547613828 + }, + { + "compressed_size_in_bytes": 124009, + "description": "Percolator benchmark based on AOL queries", + "name": "percolator", + "number_of_documents": 2000000, + "uncompressed_size_in_bytes": 110039748 + }, + { + "compressed_size_in_bytes": 449209442991, + "description": "GitHub timeline from gharchive.org", + "name": "github_archive", + "number_of_documents": 1517739660, + "uncompressed_size_in_bytes": 6576632228137 + }, + { + "compressed_size_in_bytes": 1240208124, + "description": "HTTP server log data", + "name": "http_logs", + "number_of_documents": 247249096, + "uncompressed_size_in_bytes": 33365244237 + }, + { + "compressed_size_in_bytes": 19186224906, + "description": "Benchmark for search with Wikipedia data", + "name": "wikipedia", + "number_of_documents": 22986185, + "uncompressed_size_in_bytes": 82409155844 + }, + { + "compressed_size_in_bytes": 18247252143, + "description": "Shapes from PlanetOSM", + "name": "geoshape", + "number_of_documents": 84220567, + "uncompressed_size_in_bytes": 63030399057 + }, + { + "compressed_size_in_bytes": null, + "description": "Elastic APM benchmark for Rally", + "name": "elastic/apm", + "number_of_documents": 19556, + "uncompressed_size_in_bytes": 54563720 + }, + { + "compressed_size_in_bytes": null, + "description": "Track for simulating Elastic Security workloads", + "name": "elastic/security", + "number_of_documents": 77513777, + "uncompressed_size_in_bytes": null + }, + { + "compressed_size_in_bytes": null, + "description": "Track for simulating logging workloads", + "name": "elastic/logs", + "number_of_documents": 16469078, + "uncompressed_size_in_bytes": null + }, + { + "compressed_size_in_bytes": 0, + "description": "Endpoint track", + "name": "elastic/endpoint", + "number_of_documents": 0, + "uncompressed_size_in_bytes": 0 + }, + { + "compressed_size_in_bytes": null, + "description": "metricbeat information for elastic-app k8s cluster", + "name": "tsdb", + "number_of_documents": 116633698, + "uncompressed_size_in_bytes": 132046338827 + }, + { + "compressed_size_in_bytes": 91964149, + "description": "Metricbeat data", + "name": "metricbeat", + "number_of_documents": 1079600, + "uncompressed_size_in_bytes": 1249705758 + }, + { + "compressed_size_in_bytes": 0, + "description": "Benchmarks _has_privileges API with index and Kibana application privileges", + "name": "has_privileges", + "number_of_documents": 0, + "uncompressed_size_in_bytes": 0 + }, + { + "compressed_size_in_bytes": 505542241, + "description": "Point coordinates from PlanetOSM", + "name": "geopoint", + "number_of_documents": 60844404, + "uncompressed_size_in_bytes": 2448564579 + }, + { + "compressed_size_in_bytes": 4820107679, + "description": "Taxi rides in New York in 2015", + "name": "nyc_taxis", + "number_of_documents": 165346708, + "uncompressed_size_in_bytes": 79802445979 + }, + { + "compressed_size_in_bytes": 49481928181, + "description": "Benchmark for vector search using Cohere embed-multilingual-v3 int8 embeddings on English Wikipedia", + "name": "wiki_en_cohere_vector_int8", + "number_of_documents": 41488110, + "uncompressed_size_in_bytes": 76350276957 + }, + { + "compressed_size_in_bytes": null, + "description": "metricbeat information for elastic-app k8s cluster", + "name": "tsdb_k8s_queries", + "number_of_documents": 17280000, + "uncompressed_size_in_bytes": 64764567025 + }, + { + "compressed_size_in_bytes": null, + "description": "Benchmark weighted terms queries on ELSER tokens on the MS MARCO passage dataset", + "name": "elser-ingest-speedtest", + "number_of_documents": 1050000, + "uncompressed_size_in_bytes": 2151343326 + }, + { + "compressed_size_in_bytes": 457869350, + "description": "Benchmark text search relevance with different configurations", + "name": "search/mteb/dbpedia", + "number_of_documents": 4635922, + "uncompressed_size_in_bytes": 1787414603 + }, + { + "compressed_size_in_bytes": 493689712, + "description": "Point coordinates from PlanetOSM indexed as geoshapes", + "name": "geopointshape", + "number_of_documents": 60844404, + "uncompressed_size_in_bytes": 2780550484 + }, + { + "compressed_size_in_bytes": 9600716233, + "description": "Indexing benchmark using up to questions and answers from StackOverflow", + "name": "so", + "number_of_documents": 36062278, + "uncompressed_size_in_bytes": 35564808298 + }, + { + "compressed_size_in_bytes": 20174550228, + "description": "Benchmark for vector search with StackOverflow data", + "name": "so_vector", + "number_of_documents": 4000000, + "uncompressed_size_in_bytes": 44987493316 + }, + { + "compressed_size_in_bytes": 3868383848, + "description": "Indexes for JOIN tests", + "name": "joins", + "number_of_documents": 118011000, + "uncompressed_size_in_bytes": 92117848440 + }, + { + "compressed_size_in_bytes": 0, + "description": "Benchmarking filtered search on random vectors", + "name": "random_vector", + "number_of_documents": 0, + "uncompressed_size_in_bytes": 0 + }, + { + "compressed_size_in_bytes": 11514726102, + "description": "Benchmark for dense vector indexing and search", + "name": "dense_vector", + "number_of_documents": 20000000, + "uncompressed_size_in_bytes": 26165226599 + }, + { + "compressed_size_in_bytes": 200637637087, + "description": "Benchmark for vector search with Cohere Wikipedia data", + "name": "cohere_vector", + "number_of_documents": 32893221, + "uncompressed_size_in_bytes": 544425585910 + }, + { + "compressed_size_in_bytes": 4875067116, + "description": "EQL benchmarks based on endgame index of SIEM demo cluster", + "name": "eql", + "number_of_documents": 60782211, + "uncompressed_size_in_bytes": 117268958204 + }, + { + "compressed_size_in_bytes": 11243465, + "description": "Indexes for JOIN tests", + "name": "esql", + "number_of_documents": 500000, + "uncompressed_size_in_bytes": 206388950 + }, + { + "compressed_size_in_bytes": 695550727, + "description": "StackOverflow Q&A stored as nested docs", + "name": "nested", + "number_of_documents": 11203029, + "uncompressed_size_in_bytes": 3637747670 + }, + { + "compressed_size_in_bytes": 995480468, + "description": "Global daily weather measurements from NOAA", + "name": "noaa", + "number_of_documents": 33659481, + "uncompressed_size_in_bytes": 9684262698 + }, + { + "compressed_size_in_bytes": 1897431047403, + "description": "Benchmark for vector search with msmarco-v2 passage data", + "name": "msmarco-v2-vector", + "number_of_documents": 276728396, + "uncompressed_size_in_bytes": 4010862422588 + }, + { + "compressed_size_in_bytes": 59782751079, + "description": "Benchmark for the Big5 workload", + "name": "big5", + "number_of_documents": 1171840430, + "uncompressed_size_in_bytes": 1084097769420 + }, + { + "compressed_size_in_bytes": 50206939974, + "description": "Benchmark for vector search using the OpenAI text-embedding-ada-002 model", + "name": "openai_vector", + "number_of_documents": 5361922, + "uncompressed_size_in_bytes": 117191056651 + }, + { + "compressed_size_in_bytes": 2274777276, + "description": "Index refresh benchmarks with a Kubernetes pod metrics dataset", + "name": "k8s_metrics", + "number_of_documents": 8642016, + "uncompressed_size_in_bytes": 92459082163 + }, + { + "compressed_size_in_bytes": 5931724449, + "description": "Full text benchmark with academic papers from PMC", + "name": "pmc", + "number_of_documents": 574199, + "uncompressed_size_in_bytes": 23256051757 + }, + { + "compressed_size_in_bytes": 17469732480, + "description": "Benchmark bm25, semantic and hybrid search on the MS MARCO passage dataset", + "name": "msmarco-passage-ranking", + "number_of_documents": 8841823, + "uncompressed_size_in_bytes": 57547673734 + }, + { + "compressed_size_in_bytes": 995480468, + "description": "SQL query performance based on NOAA Weather data", + "name": "sql", + "number_of_documents": 33659481, + "uncompressed_size_in_bytes": 9684262698 + } + ] +} diff --git a/esrally/types.py b/esrally/types.py index 387f5caaf..70b2d7a87 100644 --- a/esrally/types.py +++ b/esrally/types.py @@ -21,6 +21,7 @@ "actor", "benchmarks", "client", + "compose", "defaults", "distributions", "driver", @@ -64,6 +65,9 @@ "challenge.name", "challenge.root.dir", "cluster.name", + "compose.cmd", + "compose.dir", + "compose.file", "config.version", "data_streams", "datastore.api_key", diff --git a/esrally/utils/compose.py b/esrally/utils/compose.py new file mode 100644 index 000000000..8a0e04416 --- /dev/null +++ b/esrally/utils/compose.py @@ -0,0 +1,243 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +import logging +import os +import shlex +import subprocess +from typing import Any + +from esrally import config, paths, types + +LOG = logging.getLogger(__name__) + + +class ComposeConfig(config.Config): + + @property + def compose_cmd(self) -> list[str]: + cmd = self.opts( + section="compose", key="compose.cmd", default_value=os.environ.get("COMPOSE_COMMAND", "docker compose"), mandatory=False + ) + if isinstance(cmd, str): + cmd = shlex.split(cmd) + if not isinstance(cmd, list): + raise TypeError(f"Expected compose.cmd to be a string or list of strings, but got [{type(cmd).__name__}]") + return cmd + + DEFAULT_COMPOSE_FILE = os.environ.get("COMPOSE_FILE", os.path.join(os.path.dirname(paths.rally_root()), "compose.yaml")) + + @property + def compose_file(self) -> str: + return self.opts(section="compose", key="compose.file", default_value=self.DEFAULT_COMPOSE_FILE, mandatory=False) + + @property + def compose_dir(self) -> str: + return self.opts(section="compose", key="compose.dir", default_value=os.path.dirname(self.compose_file), mandatory=False) + + +def decode(output: bytes | None) -> str: + if not output: + return "" + return "\n " + "\n ".join([line.rstrip() for line in output.decode("utf-8").splitlines()]) + "\n" + + +def run_compose( + command: str, + service: str | None = None, + args: list[str] | None = None, + *, + cfg: types.Config | None = None, + compose_dir: str | None = None, + compose_options: list[str] | None = None, + compose_file: str | None = None, + logger: logging.Logger = LOG, + check: bool = True, + **kwargs: Any, +) -> subprocess.CompletedProcess: + cfg = ComposeConfig.from_config(cfg) + cmd = [] + compose_file = compose_file or cfg.compose_file + if compose_file: + cmd += ["--file", compose_file] + cmd += [command] + if compose_options: + cmd += compose_options + if service: + cmd += [service] + if args: + cmd += args + + compose_dir = compose_dir or cfg.compose_dir + kwargs.setdefault("stderr", subprocess.PIPE) + kwargs.setdefault("cwd", compose_dir) + try: + logger.debug("Running compose command: %s", cmd) + result = subprocess.run(cfg.compose_cmd + cmd, check=check, **kwargs) + except subprocess.CalledProcessError as e: + logger.error( + "Compose command returned nonzero exit status (%s): %s\nstdout:%s\nstderr:%s", + e.returncode, + e, + decode(e.stdout), + decode(e.stderr), + exc_info=logger.isEnabledFor(logging.DEBUG), + ) + raise e + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Compose command finished with exit code %s.\nstdout:%s\nstderr:%s", + result.returncode, + decode(result.stdout), + decode(result.stderr), + ) + return result + + +def run_service( + service: str | None = None, + args: list[str] | None = None, + *, + remove: bool = True, + cfg: types.Config | None = None, + compose_options: list[str] | None = None, + compose_file: str | None = None, + logger: logging.Logger = LOG, + **kwargs: Any, +) -> subprocess.CompletedProcess: + compose_options = compose_options or [] + if remove: + compose_options += ["--rm"] + logger.info("Run service '%s' (options=%s)", service, compose_options) + result = run_compose("run", service, args, cfg=cfg, compose_options=compose_options, compose_file=compose_file, logger=logger, **kwargs) + logger.info("Ran service '%s'.", service) + return result + + +def build_image( + service: str | None = None, + *, + logger: logging.Logger = LOG, + **kwargs: Any, +) -> subprocess.CompletedProcess: + logger.info("Build image (service=%s).", service) + result = run_compose("build", service=service, logger=logger, **kwargs) + logger.info("Built image.") + return result + + +def start_service( + service: str | None = None, + *, + detach: bool = False, + compose_options: list[str] | None = None, + logger: logging.Logger = LOG, + **kwargs: Any, +) -> subprocess.CompletedProcess: + if detach: + compose_options = (compose_options or []) + ["--detach"] + + logger.info("Starting service '%s' (options=%s).", service, compose_options) + result = run_compose("up", service, compose_options=compose_options, logger=logger, **kwargs) + logger.info("Started service.") + return result + + +def remove_service( + service: str | None = None, + *, + force: bool = False, + check: bool = False, + volumes: bool = False, + logger: logging.Logger = LOG, + compose_options: list[str] | None = None, + **kwargs: Any, +) -> subprocess.CompletedProcess: + compose_options = compose_options or [] + if force: + compose_options += ["--force"] + if volumes: + compose_options += ["--volumes"] + logger.info("Removing service '%s' (options=%s)", service, compose_options) + result = run_compose("rm", service, compose_options=compose_options, check=check, logger=logger, **kwargs) + logger.info("Removed service.") + return result + + +def run_rally( + command: str, + args: list[str] | None = None, + *, + rally_options: list[str] | None = None, + logger: logging.Logger = LOG, + **kwargs: Any, +) -> subprocess.CompletedProcess: + rally_options = rally_options or [] + args = args or [] + rally_args = [command] + rally_options + args + logger.info("Running rally (args=%s).", rally_args) + try: + return run_service("rally", rally_args, logger=logger, **kwargs) + except subprocess.CalledProcessError as e: + logger.error( + "Rally returned nonzero exit status (%s): %s\nstdout: %s\nstderr: %s\n", + e.returncode, + e, + e.stdout.decode("utf-8") if e.stdout else None, + e.stderr.decode("utf-8") if e.stderr else None, + exc_info=logger.isEnabledFor(logging.DEBUG), + ) + raise e + finally: + logger.info("Terminated rally.") + + +def list_rally_tracks(*, logger: logging.Logger = LOG, **kwargs: Any) -> list[dict[str, str]]: + logger.info("Listing rally tracks.") + result = run_rally("list", ["tracks"], stdout=subprocess.PIPE, logger=logger, **kwargs) + tracks = [t.strip() for t in result.stdout.decode("utf-8").splitlines() if t.strip()] + logger.info("Listed %d rally track(s).", len(tracks)) + return tracks + + +def rally_race( + track_name: str, + *, + test_mode: bool = False, + target_hosts: list[str] | None = None, + pipeline: str | None = "benchmark-only", + rally_options: list[str] | None = None, + logger: logging.Logger = LOG, + **kwargs: Any, +) -> None: + rally_options = rally_options or [] + rally_options += ["--track", track_name] + if test_mode: + rally_options += ["--test-mode"] + if target_hosts: + rally_options += ["--target-hosts", ",".join(target_hosts)] + if pipeline: + rally_options += ["--pipeline", pipeline] + logger.info("Running rally race (options=%s, ES_VERSION=%s).", rally_options, os.environ["ES_VERSION"]) + run_rally("race", rally_options=rally_options, logger=logger, **kwargs) + logger.info("Terminated rally race.") + + +def start_elasticsearch(service: str, detach: bool = True, *, logger: logging.Logger = LOG, **kwargs: Any) -> None: + logger.info("Starting Elasticsearch (service=%s, ES_VERSION=%s).", service, os.environ["ES_VERSION"]) + start_service(service, detach=detach, logger=logger, **kwargs) + logger.info("Started Elasticsearch server.") diff --git a/it/__init__.py b/it/__init__.py index 8863307c0..d25b0574b 100644 --- a/it/__init__.py +++ b/it/__init__.py @@ -17,10 +17,10 @@ import errno import functools +import inspect import json import os import platform -import random import socket import subprocess import time @@ -48,15 +48,6 @@ def wrapper(cfg, *args, **kwargs): return wrapper -def random_rally_config(t): - @functools.wraps(t) - @pytest.mark.parametrize("cfg", [random.choice(CONFIG_NAMES)]) - def wrapper(cfg, *args, **kwargs): - t(cfg, *args, **kwargs) - - return wrapper - - def rally_in_mem(t): @functools.wraps(t) @pytest.mark.parametrize("cfg", ["in-memory-it"]) @@ -69,9 +60,16 @@ def wrapper(cfg, *args, **kwargs): def rally_es(t): @functools.wraps(t) @pytest.mark.parametrize("cfg", ["es-it"]) - def wrapper(cfg, *args, **kwargs): - t(cfg, *args, **kwargs) - + def wrapper(cfg, es_metrics_store, *args, **kwargs): + return t(cfg, *args, **kwargs) + + # Expose original signature with es_metrics_store inserted after cfg so pytest + # injects the fixture and any other fixtures the test requests. + sig = inspect.signature(t) + params = list(sig.parameters.values()) + es_metrics_param = inspect.Parameter("es_metrics_store", inspect.Parameter.POSITIONAL_OR_KEYWORD) + new_params = params[:1] + [es_metrics_param] + params[1:] + wrapper.__signature__ = sig.replace(parameters=new_params) return wrapper diff --git a/it/basic_test.py b/it/basic_test.py index d1cc45507..f4bee283c 100644 --- a/it/basic_test.py +++ b/it/basic_test.py @@ -17,12 +17,14 @@ import os import tempfile +import pytest + import it from esrally.utils import process @it.rally_in_mem -def test_run_without_arguments(cfg): +def test_run_without_arguments(cfg, shared_setup): cmd = it.esrally_command_line_for(cfg, "") output = process.run_subprocess_with_output(cmd) expected = "usage: esrally [-h] [--version]" @@ -30,7 +32,7 @@ def test_run_without_arguments(cfg): @it.rally_in_mem -def test_run_with_help(cfg): +def test_run_with_help(cfg, shared_setup): cmd = it.esrally_command_line_for(cfg, "--help") output = process.run_subprocess_with_output(cmd) expected = "usage: esrally [-h] [--version]" @@ -38,7 +40,7 @@ def test_run_with_help(cfg): @it.rally_in_mem -def test_run_without_http_connection(cfg): +def test_run_without_http_connection(cfg, shared_setup): cmd = it.esrally_command_line_for(cfg, "list tracks") with tempfile.TemporaryDirectory() as tmpdir: env = os.environ.copy() @@ -47,5 +49,10 @@ def test_run_without_http_connection(cfg): # make sure we don't have any saved state env["RALLY_HOME"] = tmpdir output = process.run_subprocess_with_output(cmd, env=env) + output_str = "\n".join(output) + if "[INFO] SUCCESS" in output_str: + pytest.skip( + "Proxy did not block in this environment (list tracks succeeded); " + "test only asserts behavior when proxy blocks." + ) expected = "[ERROR] Cannot list" - assert expected in "\n".join(output) + assert expected in output_str diff --git a/it/conftest.py b/it/conftest.py index df5f6476e..eb55677d7 100644 --- a/it/conftest.py +++ b/it/conftest.py @@ -23,7 +23,7 @@ from esrally import config, version from esrally.utils import process -from it import CONFIG_NAMES, ROOT_DIR, TestCluster +from it import CONFIG_NAMES, ROOT_DIR, TestCluster, wait_until_port_is_free def check_prerequisites(): @@ -69,7 +69,9 @@ def build_docker_image(): def remove_integration_test_config(): for config_name in CONFIG_NAMES: - os.remove(config.ConfigFile(config_name).location) + path = config.ConfigFile(config_name).location + if os.path.isfile(path): + os.remove(path) class EsMetricsStore: @@ -96,17 +98,43 @@ def stop(self): ES_METRICS_STORE = EsMetricsStore() -@pytest.fixture(scope="session", autouse=True) -def shared_setup(): - print("\nStarting shared setup...") +@pytest.fixture(scope="session", autouse=False) +def integration_test_prerequisites(): + """Verify Docker and docker-compose are available. No teardown.""" check_prerequisites() + yield + + +@pytest.fixture(scope="session", autouse=False) +def integration_test_config(): + """Install rally-in-memory-it.ini and rally-es-it.ini; remove on teardown.""" install_integration_test_config() + yield + remove_integration_test_config() + + +@pytest.fixture(scope="session", autouse=False) +def es_metrics_store(integration_test_prerequisites, integration_test_config): + """Start the in-memory Elasticsearch metrics store; stop on teardown.""" + wait_until_port_is_free(port_number=10200) ES_METRICS_STORE.start() + yield + ES_METRICS_STORE.stop() + + +@pytest.fixture(scope="session", autouse=False) +def rally_docker_image(integration_test_prerequisites): + """Build the elastic/rally Docker image. No teardown.""" build_docker_image() yield + + +@pytest.fixture(scope="session", autouse=False) +def shared_setup(integration_test_config, rally_docker_image): + """Integration test environment: config and Rally Docker image. Request es_metrics_store too if a test needs the metrics store.""" + print("\nStarting shared setup...") + yield print("\nStopping shared setup...") - ES_METRICS_STORE.stop() - remove_integration_test_config() class ConfigFile: diff --git a/it/dependencies_test.py b/it/dependencies_test.py index 8da826183..cae45f254 100644 --- a/it/dependencies_test.py +++ b/it/dependencies_test.py @@ -19,8 +19,8 @@ import it -@it.random_rally_config -def test_track_dependencies(cfg): +@it.rally_in_mem +def test_track_dependencies(cfg, shared_setup): port = 19200 it.wait_until_port_is_free(port_number=port) dist_version = it.DISTRIBUTIONS[-1] diff --git a/it/distribution_test.py b/it/distribution_test.py index e47b26190..feb8e895c 100644 --- a/it/distribution_test.py +++ b/it/distribution_test.py @@ -30,8 +30,8 @@ @pytest.mark.parametrize("dist", it.DISTRIBUTIONS) @pytest.mark.parametrize("track", it.TRACKS) -@pytest.mark.parametrize("cfg", [random.choice(it.CONFIG_NAMES)]) -def test_tar_distributions(cfg, dist, track): +@pytest.mark.parametrize("cfg", ["in-memory-it"]) +def test_tar_distributions(dist, track, cfg, shared_setup): port = 19200 it.wait_until_port_is_free(port_number=port) @@ -46,8 +46,8 @@ def test_tar_distributions(cfg, dist, track): ) -@it.random_rally_config -def test_docker_distribution(cfg): +@it.rally_in_mem +def test_docker_distribution(cfg, shared_setup): port = 19200 # only test the most recent Docker distribution dist = it.DISTRIBUTIONS[-1] @@ -63,8 +63,8 @@ def test_docker_distribution(cfg): ) -@it.random_rally_config -def test_does_not_benchmark_unsupported_distribution(cfg): +@it.rally_in_mem +def test_does_not_benchmark_unsupported_distribution(cfg, shared_setup): port = 19200 it.wait_until_port_is_free(port_number=port) assert ( @@ -75,8 +75,8 @@ def test_does_not_benchmark_unsupported_distribution(cfg): ) -@it.random_rally_config -def test_interrupt(cfg): +@it.rally_in_mem +def test_interrupt(cfg, shared_setup): port = 19200 dist = it.DISTRIBUTIONS[-1] # simulate a user cancelling a benchmark @@ -89,8 +89,8 @@ def test_interrupt(cfg): assert run_subprocess_and_interrupt(cmd, 2, 15) == 130 -@it.random_rally_config -def test_create_api_key_per_client(cfg): +@it.rally_in_mem +def test_create_api_key_per_client(cfg, shared_setup): port = 19200 it.wait_until_port_is_free(port_number=port) dist = it.DISTRIBUTIONS[-1] @@ -107,7 +107,7 @@ def test_create_api_key_per_client(cfg): @pytest.fixture(scope="module") -def test_cluster(): +def test_cluster(shared_setup): cluster = it.TestCluster("in-memory-it") # test with a recent distribution as eventdata is not available for all versions dist = it.DISTRIBUTIONS[-1] @@ -121,8 +121,8 @@ def test_cluster(): cluster.stop() -@it.random_rally_config -def test_multi_target_hosts(cfg, test_cluster): +@it.rally_in_mem +def test_multi_target_hosts(cfg, shared_setup, test_cluster): hosts = [f"127.0.0.1:{test_cluster.http_port}"] target_hosts = { "remote": hosts, @@ -148,15 +148,15 @@ def race_params(): assert it.race(cfg, race_params()) != 0 -@it.random_rally_config -def test_eventdata_frozen(cfg, test_cluster): +@it.rally_in_mem +def test_eventdata_frozen(cfg, shared_setup, test_cluster): challenges = ["frozen-data-generation", "frozen-querying"] track_params = "number_of_replicas:0" execute_eventdata(cfg, test_cluster, challenges, track_params) -@it.random_rally_config -def test_eventdata_indexing_and_querying(cfg, test_cluster): +@it.rally_in_mem +def test_eventdata_indexing_and_querying(cfg, shared_setup, test_cluster): challenges = [ "elasticlogs-1bn-load", "elasticlogs-continuous-index-and-query", @@ -170,15 +170,15 @@ def test_eventdata_indexing_and_querying(cfg, test_cluster): execute_eventdata(cfg, test_cluster, challenges, track_params) -@it.random_rally_config -def test_eventdata_update(cfg, test_cluster): +@it.rally_in_mem +def test_eventdata_update(cfg, shared_setup, test_cluster): challenges = ["bulk-update"] track_params = "bulk_indexing_clients:1,number_of_replicas:0" execute_eventdata(cfg, test_cluster, challenges, track_params) -@it.random_rally_config -def test_eventdata_daily_volume(cfg, test_cluster): +@it.rally_in_mem +def test_eventdata_daily_volume(cfg, shared_setup, test_cluster): challenges = ["index-logs-fixed-daily-volume", "index-and-query-logs-fixed-daily-volume"] track_params = "bulk_indexing_clients:1,number_of_replicas:0,daily_logging_volume:1MB" execute_eventdata(cfg, test_cluster, challenges, track_params) diff --git a/it/docker_dev_image_test.py b/it/docker_dev_image_test.py index adcefff89..563891df9 100644 --- a/it/docker_dev_image_test.py +++ b/it/docker_dev_image_test.py @@ -22,24 +22,24 @@ from esrally.utils import process -def test_docker_geonames(): +def test_docker_geonames(shared_setup): test_command = ( "race --pipeline=benchmark-only --test-mode --track=geonames --challenge=append-no-conflicts-index-only --target-hosts=es01:9200" ) run_docker_compose_test(test_command) -def test_docker_list_tracks(): +def test_docker_list_tracks(shared_setup): test_command = "list tracks" run_docker_compose_test(test_command) -def test_docker_help(): +def test_docker_help(shared_setup): test_command = "--help" run_docker_compose_test(test_command) -def test_docker_override_cmd(): +def test_docker_override_cmd(shared_setup): test_command = ( "esrally race --pipeline=benchmark-only --test-mode --track=geonames " "--challenge=append-no-conflicts-index-only --target-hosts=es01:9200" diff --git a/it/download_test.py b/it/download_test.py index 03a059cfc..945efdd0c 100644 --- a/it/download_test.py +++ b/it/download_test.py @@ -18,12 +18,12 @@ import it -@it.random_rally_config -def test_download_distribution(cfg): +@it.rally_in_mem +def test_download_distribution(cfg, shared_setup): for d in it.DISTRIBUTIONS: assert it.esrally(cfg, f'download --distribution-version="{d}" --quiet') == 0 -@it.random_rally_config -def test_does_not_download_unsupported_distribution(cfg): +@it.rally_in_mem +def test_does_not_download_unsupported_distribution(cfg, shared_setup): assert it.esrally(cfg, 'download --distribution-version="1.7.6" --quiet') != 0 diff --git a/it/error_test.py b/it/error_test.py index d103937c3..641b84d9a 100644 --- a/it/error_test.py +++ b/it/error_test.py @@ -19,7 +19,7 @@ @it.rally_in_mem -def test_error_prints_when_quiet(cfg): +def test_error_prints_when_quiet(cfg, shared_setup): cmd = it.esrally_command_line_for(cfg, "build --revision=nonsense --quiet") output = process.run_subprocess_with_output(cmd) expected = "[ERROR] Cannot build." diff --git a/it/esrallyd_test.py b/it/esrallyd_test.py index 6ffea4bca..9540d735c 100644 --- a/it/esrallyd_test.py +++ b/it/esrallyd_test.py @@ -28,7 +28,7 @@ def setup_esrallyd(): @it.rally_in_mem -def test_elastic_transport_module_does_not_log_at_info_level(cfg, fresh_log_file): +def test_elastic_transport_module_does_not_log_at_info_level(cfg, shared_setup, fresh_log_file): """ The 'elastic_transport' module logs at 'INFO' by default and is _very_ noisy, so we explicitly set the threshold to 'WARNING' to avoid perturbing benchmarking results due to the high volume of logging calls by the client itself. diff --git a/it/info_test.py b/it/info_test.py index 70b3efffe..51a109a53 100644 --- a/it/info_test.py +++ b/it/info_test.py @@ -20,22 +20,22 @@ @it.rally_in_mem -def test_track_info_with_challenge(cfg): +def test_track_info_with_challenge(cfg, shared_setup): assert it.esrally(cfg, "info --track=geonames --challenge=append-no-conflicts") == 0 @it.rally_in_mem -def test_track_info_with_track_repo(cfg): +def test_track_info_with_track_repo(cfg, shared_setup): assert it.esrally(cfg, "info --track-repository=default --track=geonames") == 0 @it.rally_in_mem -def test_track_info_with_task_filter(cfg): +def test_track_info_with_task_filter(cfg, shared_setup): assert it.esrally(cfg, 'info --track=geonames --challenge=append-no-conflicts --include-tasks="type:search"') == 0 @it.rally_in_mem -def test_track_info_fails_with_wrong_track_params(cfg): +def test_track_info_fails_with_wrong_track_params(cfg, shared_setup): # simulate a typo in track parameter cmd = it.esrally_command_line_for(cfg, "info --track=geonames --track-params='conflict_probability:5,number-of-replicas:1'") output = process.run_subprocess_with_output(cmd) diff --git a/it/list_test.py b/it/list_test.py index 4effa99f1..a158a9afe 100644 --- a/it/list_test.py +++ b/it/list_test.py @@ -18,28 +18,28 @@ import it -@it.all_rally_configs -def test_list_races(cfg): +@it.rally_in_mem +def test_list_races(cfg, shared_setup): assert it.esrally(cfg, "list races") == 0 @it.rally_in_mem -def test_list_cars(cfg): +def test_list_cars(cfg, shared_setup): assert it.esrally(cfg, "list cars") == 0 @it.rally_in_mem -def test_list_elasticsearch_plugins(cfg): +def test_list_elasticsearch_plugins(cfg, shared_setup): assert it.esrally(cfg, "list elasticsearch-plugins") == 0 @it.rally_in_mem -def test_list_tracks(cfg): +def test_list_tracks(cfg, shared_setup): assert it.esrally(cfg, "list tracks") == 0 assert it.esrally(cfg, "list tracks --track-repository=eventdata") == 0 assert it.esrally(cfg, "list tracks --track-repository=default --track-revision=4080dc9850d07e23b6fc7cfcdc7cf57b14e5168d") == 0 @it.rally_in_mem -def test_list_telemetry(cfg): +def test_list_telemetry(cfg, shared_setup): assert it.esrally(cfg, "list telemetry") == 0 diff --git a/it/proxy_test.py b/it/proxy_test.py index 7e2480be6..73cfe8b1c 100644 --- a/it/proxy_test.py +++ b/it/proxy_test.py @@ -43,13 +43,13 @@ def http_proxy(): @it.rally_in_mem -def test_run_with_direct_internet_connection(cfg, http_proxy, fresh_log_file): +def test_run_with_direct_internet_connection(cfg, shared_setup, http_proxy, fresh_log_file): assert it.esrally(cfg, "list tracks") == 0 assert it.find_log_line(fresh_log_file, "Connecting directly to the Internet") @it.rally_in_mem -def test_anonymous_proxy_no_connection(cfg, http_proxy): +def test_anonymous_proxy_no_connection(cfg, shared_setup, http_proxy): env = dict(os.environ) env["http_proxy"] = http_proxy.anonymous_url env["https_proxy"] = http_proxy.anonymous_url @@ -62,7 +62,7 @@ def test_anonymous_proxy_no_connection(cfg, http_proxy): @it.rally_in_mem -def test_authenticated_proxy_user_can_connect(cfg, http_proxy): +def test_authenticated_proxy_user_can_connect(cfg, shared_setup, http_proxy): env = dict(os.environ) env["http_proxy"] = http_proxy.authenticated_url env["https_proxy"] = http_proxy.authenticated_url diff --git a/it/sources_test.py b/it/sources_test.py index 2fa69be31..ef0c6f4e8 100644 --- a/it/sources_test.py +++ b/it/sources_test.py @@ -20,8 +20,8 @@ from esrally.utils import process -@it.random_rally_config -def test_sources(cfg): +@it.rally_in_mem +def test_sources(cfg, shared_setup): port = 19200 it.wait_until_port_is_free(port_number=port) @@ -69,8 +69,8 @@ def test_sources(cfg): ) -@it.random_rally_config -def test_build_es_and_plugin_with_docker(cfg): +@it.rally_in_mem +def test_build_es_and_plugin_with_docker(cfg, shared_setup): assert ( it.esrally( cfg, @@ -81,8 +81,8 @@ def test_build_es_and_plugin_with_docker(cfg): ) -@it.random_rally_config -def test_build_es(cfg): +@it.rally_in_mem +def test_build_es(cfg, shared_setup): assert ( it.esrally( cfg, diff --git a/it/static_responses_test.py b/it/static_responses_test.py index 8d03e1cb7..922febacc 100644 --- a/it/static_responses_test.py +++ b/it/static_responses_test.py @@ -19,8 +19,8 @@ import it -@it.random_rally_config -def test_static_responses(cfg): +@it.rally_in_mem +def test_static_responses(cfg, shared_setup): port = 19200 it.wait_until_port_is_free(port_number=port) dist_version = it.DISTRIBUTIONS[-1] diff --git a/it/tracker_test.py b/it/tracker_test.py index ae5c82d3f..665846aec 100644 --- a/it/tracker_test.py +++ b/it/tracker_test.py @@ -23,7 +23,7 @@ @pytest.fixture(scope="module") -def test_cluster(): +def test_cluster(shared_setup): cluster = it.TestCluster("in-memory-it") # test with a recent distribution dist = it.DISTRIBUTIONS[-1] @@ -38,7 +38,7 @@ def test_cluster(): @it.rally_in_mem -def test_create_track(cfg, tmp_path, test_cluster): +def test_create_track(cfg, shared_setup, tmp_path, test_cluster): # use 0.05% of geonames corpus to generate data. We need something small but >1000 docs to properly test # the -1k corpus too. cmd = ( diff --git a/it/tracks_test.py b/it/tracks_test.py new file mode 100644 index 000000000..41124a016 --- /dev/null +++ b/it/tracks_test.py @@ -0,0 +1,118 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +import dataclasses +import logging +import os +import subprocess +import time +from collections.abc import Generator + +import pytest + +from esrally import config +from esrally.track import loader +from esrally.utils import compose + +LOG = logging.getLogger(__name__) + +RALLY_TRACKS = loader.load_tracks_file()["tracks"] +ES_VERSIONS = ["8.19.10", "9.3.1"] + +# Let have 120 minutes for all track sto complete. In case it times out before +# returning any other error, we will accept the testing outcome as it would require +# too much to complete all the tests in a reasonable time frame. We can always +# increase this timeout if we see that some tracks require much more time to complete. +TEST_TIMEOUT_M = os.environ.get("IT_TRACKS_TIMEOUT_MINUTES", 120) +# The max time for a single track to complete is the total time divided by the number of tracks and ES versions we +# test against. +RACE_TIMEOUT_S = TEST_TIMEOUT_M * 60 / len(RALLY_TRACKS) / len(ES_VERSIONS) + + +@pytest.fixture(scope="module", autouse=True) +def compose_config() -> Generator[compose.ComposeConfig]: + cfg = compose.ComposeConfig() + config.init_config(cfg=cfg) + yield cfg + config.clear_config() + + +@pytest.fixture(scope="module", autouse=True) +def build_rally(compose_config): + compose.build_image("rally") + + +@pytest.fixture(scope="function", params=RALLY_TRACKS, ids=lambda param: f"track_{param['name']}") +def rally_track(request) -> Generator[loader.TrackJson]: + yield request.param + + +@dataclasses.dataclass +class ElasticsearchServer: + version: str + + +@pytest.fixture(scope="function", params=ES_VERSIONS, ids=lambda param: f"es_version_{param}") +def elasticsearch(request, monkeypatch) -> Generator[ElasticsearchServer]: + compose.remove_service("es01", force=True, volumes=True) + monkeypatch.setenv("ES_VERSION", request.param) + es = ElasticsearchServer(version=request.param) + compose.start_elasticsearch("es01") + + yield es + + compose.remove_service("es01", force=True, volumes=True) + + +SKIP_TRACKS = { + "has_privileges": "TrackSyntaxError: Could not load '/rally/.rally/benchmarks/tracks/default/has_privileges/track.json'.", + "wiki_en_cohere_vector_int8": "Could not load '/rally/.rally/benchmarks/tracks/default/wiki_en_cohere_vector_int8/track.json'.", + "search/mteb/dbpedia": "TrackSyntaxError: Could not load '/rally/.rally/benchmarks/tracks/default/search/mteb/dbpedia/track.json'.", + "joins": "TrackSyntaxError: Could not load '/rally/.rally/benchmarks/tracks/default/joins/track.json'.", + "esql": "TemplateNotFound: 'track.json' not found in search path'.", + "big5": "TrackSyntaxError: Could not load '/rally/.rally/benchmarks/tracks/default/big5/track.json", + "sql": "This track does not support test mode. Ask the track author to add it or disable test mode and retry.", +} + + +def test_tracks(rally_track: loader.TrackJson, elasticsearch: ElasticsearchServer): + LOG.info("Testing track name: %s (%s)", rally_track["name"], rally_track["description"]) + LOG.info("Testing timeout: %d seconds", RACE_TIMEOUT_S) + LOG.info("Testing with elasticsearch version: %s", elasticsearch.version) + + # It skips some tracks that are known to fail in test mode for various reasons. We want to make sure that the rest + # of the tracks are working fine and we can always add more tracks to this list if we see more failures. + if reason := SKIP_TRACKS.get(rally_track["name"]): + pytest.skip(f"Skipping track '{rally_track['name']}' due to: {reason}") + + start_time = time.time() + try: + compose.rally_race( + track_name=rally_track["name"], + test_mode=True, + target_hosts=["es01:9200"], + timeout=RACE_TIMEOUT_S, + ) + except subprocess.TimeoutExpired: + LOG.warning("Race timeout: no errors until now and we take it as a success.") + finally: + end_time = time.time() + LOG.info( + "Race terminated after %d seconds for track [%s] and elasticsearch version [%s]", + end_time - start_time, + rally_track["name"], + elasticsearch.version, + ) diff --git a/tests/mechanic/launcher_test.py b/tests/mechanic/launcher_test.py index 9bdb84c6c..20e875836 100644 --- a/tests/mechanic/launcher_test.py +++ b/tests/mechanic/launcher_test.py @@ -16,6 +16,7 @@ # under the License. # pylint: disable=protected-access +import hashlib import io import os import sys @@ -342,7 +343,15 @@ def stop_watch(self): return self._stop_watch +def _docker_compose_expected(compose_dir, cmd): + """Build expected docker-compose command with project name and project directory.""" + project_name = "rally-" + hashlib.sha256(compose_dir.encode()).hexdigest()[:16] + compose_file = os.path.join(compose_dir, "docker-compose.yml") + return f'docker-compose -f "{compose_file}" --project-directory "{compose_dir}" -p {project_name} {cmd}' + + class TestDockerLauncher: + @mock.patch("esrally.mechanic.launcher._compose_cmd", "docker-compose") @mock.patch("esrally.utils.process.run_subprocess_with_logging") @mock.patch("esrally.utils.process.run_subprocess_with_output") def test_starts_container_successfully(self, run_subprocess_with_output, run_subprocess_with_logging): @@ -373,14 +382,15 @@ def test_starts_container_successfully(self, run_subprocess_with_output, run_sub assert node.node_name == "testnode" assert node.telemetry is not None - run_subprocess_with_logging.assert_called_once_with("docker-compose -f /bin/docker-compose.yml up -d") + run_subprocess_with_logging.assert_called_once_with(_docker_compose_expected("/bin", "up -d")) run_subprocess_with_output.assert_has_calls( [ - mock.call("docker-compose -f /bin/docker-compose.yml ps -q"), + mock.call(_docker_compose_expected("/bin", "ps -q")), mock.call('docker ps -a --filter "id=de604d0d" --filter "status=running" --filter "health=healthy" -q'), ] ) + @mock.patch("esrally.mechanic.launcher._compose_cmd", "docker-compose") @mock.patch("esrally.time.sleep") @mock.patch("esrally.utils.process.run_subprocess_with_logging") @mock.patch("esrally.utils.process.run_subprocess_with_output") @@ -407,6 +417,7 @@ def test_container_not_started(self, run_subprocess_with_output, run_subprocess_ with pytest.raises(exceptions.LaunchError, match="No healthy running container after 600 seconds!"): docker.start([node_config]) + @mock.patch("esrally.mechanic.launcher._compose_cmd", "docker-compose") @mock.patch("esrally.telemetry.add_metadata_for_node") @mock.patch("esrally.utils.process.run_subprocess_with_logging") def test_stops_container_successfully_with_metrics_store(self, run_subprocess_with_logging, add_metadata_for_node): @@ -422,8 +433,9 @@ def test_stops_container_successfully_with_metrics_store(self, run_subprocess_wi add_metadata_for_node.assert_called_once_with(metrics_store, "testnode", "127.0.0.1") - run_subprocess_with_logging.assert_called_once_with("docker-compose -f /bin/docker-compose.yml down") + run_subprocess_with_logging.assert_called_once_with(_docker_compose_expected("/bin", "down")) + @mock.patch("esrally.mechanic.launcher._compose_cmd", "docker-compose") @mock.patch("esrally.telemetry.add_metadata_for_node") @mock.patch("esrally.utils.process.run_subprocess_with_logging") def test_stops_container_when_no_metrics_store_is_provided(self, run_subprocess_with_logging, add_metadata_for_node): @@ -437,4 +449,4 @@ def test_stops_container_when_no_metrics_store_is_provided(self, run_subprocess_ assert add_metadata_for_node.call_count == 0 - run_subprocess_with_logging.assert_called_once_with("docker-compose -f /bin/docker-compose.yml down") + run_subprocess_with_logging.assert_called_once_with(_docker_compose_expected("/bin", "down"))